Требования
В данной статье мы рассмотрим задачи переноса сложных объектов между процессами и машинами. В наших системах было много мест, где требовалось перемещать большое кол-во бизнес объектов различной структуры, например:
- самозацикленные графы объектов (деревья с back-references)
- массивы структур (value types)
- классы/структуры с readonly полями
- инстансы существующих .Net коллекций (Dictionary, List), которые внутренне используют custom-сериализацию
- большое кол-во инстансов типов, специализированных для конкретной задачи
Речь пойдёт о трёх аспектах, которые очень важны в распределённых кластерных системах:
- скорость сериализации/десериализации
- объём объектов в сериализированном виде
- возможность использовать существующие объекты без надобности “украшения” этих объектов и их полей вспомогательными атрибутами для сериализации
Кратко рассмотрим три вышеперечисленных аспекта.
Первое — скорость. Это очень важно для обеспечения общего быстродействия системы в распределённой среде, когда для выполнения задачи (например, запрос от одного пользователя) требуется исполнить пять-десять запросов на другие машины бэк-энда.
Второе — объём. При перекачке/репликации большого кол-ва данных бюджет канала связи между датацентрами не должен “раздуваться”.
Третье — удобство. Очень неудобно, когда только для сериализации/маршалинга требуется создание “лишних” объектов, ктр. переносят данные. Также неудобно заставлять программиста конкретного бизнес-типа писать низкоуровневый код по записи инстанса в массив байт. Может это и можно сделать, когда у вас 5-6 классов, но что делать, если в вашей системе 30 базовых generic классов (i.e. DeliveryStrategy), каждый из которых комбинируется с десятками других классов (это даёт сотни конкретных типов, i.e.: DeliveryStrategy, DeliveryStrategy, DeliveryStrategy etc.). Очень хотелось бы иметь прозрачную систему, которая может сериализировать практически все классы предметной области без надобности дополнительной разметки, кода и т.д. Конечно, есть вещи, которые не нужно сериализировать, например, какие-то unmanaged ресурсы или делегаты, но всё остальное обычно нужно, даже такие элементы как readonly поля структур и классов.
Данная статья освещает тему именно бинарной сериализации. Мы не будем говорить о JSON и прочих форматах, так как они не предназначены для эффективного решения вышеупомянутых задач.
Проблемы существующих сериализаторов
Сразу оговоримся, всё, что тут написано, относительно — смотря что с чем сравнивать. Если вы пишите/читаете сотни объектов в секунду, то проблем нет. Другое дело, когда нужно обрабатывать десятки или даже сотни тысяч объектов в секунду.
BinaryFormatter — ветеран .Net. Отличается простотой использования и подходит к требованиям лучше, чем DataContractSerializer. Хорошо поддерживает все встроенные типы коллекций и прочих BCL классов. Поддерживает версионность объектов. Не интероперабилен между платформами. Имеет очень большие недостатки связанные с производительностью. Он очень медленный и сериализация производит очень массивные потоки.
DataContractSerializer — движок WCF. Работает быстрее BinaryFormatter’а во многих случаях. Поддерживает интероперабильность и версионность. Однако этот сериализатор не предназначен для решения general-purpose проблем сериализации как таковой. Он требует специализированной декорации классов и полей атрибутами, также имеются проблемы с полиморфизмом и поддержкой сложных типов. Это очень объяснимо. Дело в том, что DataContractSerializer не предназначен по определению для работы с произвольными типами (отсюда и название).
Protobuf — суперскорость! Использует гугловский формат, позволяет менять версию объектов и супербыстрый. Интероперабилен между платформами. Имеет большой существенный недостаток — не “понимает” все типы автоматически и не поддерживает сложных графов.
Thrift — фэйсбуковская разработка. Использует свой IDL, интероперабилен между языками, позволяет менять версию. Недостатки: достаточно медленно работает, расходует много памяти, не поддерживает циклические графы.
Исходя из вышеперечисленных характеристик, если не учитывать производительность, самый подходящий для нас сериализатор — это BinaryFormatter. Он наиболее “прозрачен”. То, что он не поддерживает интероперабельность между платформами, для нас не важно, т.к. у нас одна платформа — Unistack. Но вот скорость его работы просто ужасная. Очень медленно и большой объём на выходе.
NFX.Serialization.Slim.SlimSerializer
github.com/aumcode/nfx/blob/master/Source/NFX/Serialization/Slim/SlimSerializer.cs
SlimSerializer является гибридным сериализатором с динамической генерацией ser/deser кода в рантайме для каждого конкретного типа.
Мы не пытались сделать абсолютно универсальное решение, ибо тогда пришлось бы жертвовать чем-то. Мы не делали вещи, которые. для нас неважны, а именно:
- кросс-платформенность
- object version upgrade
Исходя из вышесказанного, SlimSerializer не подходит для таких задач, где:
- данные хранятся в storage (например, на диске)
- данные генерируются/принимаются процессами не на CLR-платформе, однако Windows.NET — to — Linux.MONO и Linux.MONO — to — Windows.NET работают великолепно
SlimSerializer предназначен для ситуаций, когда:
- нужна большая скорость (сотни тысяч операций в секунду)
- требуется экономить объём передаваемых данных
- специализированная разметка для сериализации нереальна по разным причинам (например, очень много классов)
SlimSerializer поддерживает всевозможные edge-case’ы, например:
- прямая сериализация примитивных структур и их Nullable эквивалентов (DateTime, Timespan, Amount, GDID, FID, GUID, MethodSpec, TypeSpec etc.)
- прямая сериализация основных reference-типов (byte[], char[], string[])
- поддержка классов и структур с read-only полями
- поддержка custom-сериализации ISerializable, OnSerializing, OnSerialized… etc.
- каскадно-вложенная сериализация (например, какой-то тип делает custom-сериализацию себя и должен вызвать SlimSerializer для какого-то поля)
- позволяет сериализировать любые поддерживаемые типы (кроме делегатов) в корень
- нормализует графы любой сложности и вложенности
- детекция buffer-overflow в десериализации (это нужно, когда стрим корраптается и возможно непреднамеренное выделение большого куска памяти)
Разработка непростая и уже претерпела множество оптимизаций. Результаты, которых нам удалось достичь, не конечны, можно ещё ускорить, но это вызовет усложнение уже и без того нетривиального кода.
Как это работает?
SlimSeralizer использует стриммер, который берётся из injectable формата github.com/aumcode/nfx/blob/master/Source/NFX/IO/StreamerFormats.cs. Стриммер-форматы нужны для того, чтобы сериализировать определённые типы напрямую в поток. Например, мы по умолчанию поддерживаем такие типы как FID, GUID, GDID, MetaHandle etc. Дело в том, что определённые типы можно хитро паковать variable-bit энкодингом. Это даёт очень большой прирост в скорости и экономит место. Все integer-примитивы пишутся variable-bit энкодингом. Таким образом, в случаях, когда нужна супербыстрая поддержка специального типа, можно унаследовать StreamerFormat и добавить WriteX/ReadX методы. Система сама собирает и превращает их в лямбда-функторы, которые нужны для быстрой сериализации/десериализации.
Для каждого типа строится TypeDescriptor github.com/aumcode/nfx/blob/master/Source/NFX/Serialization/Slim/TypeSchema.cs., который динамически компилирует пару функторов для сериализации и десериализации.
SlimSerializer построен на идее TypeRegistry и это главная изюминка всего сериализатора github.com/aumcode/nfx/blob/master/Source/NFX/Serialization/Slim/TypeRegistry.cs. Типы пишутся как строка — полное имя типа, но если такой тип уже встречался ранее, то пишется type handle вида “$123”. Это обозначает типа, находящийся в регистратуре за номером 123.
Когда мы встречаем reference, то заменяем его на MetaHandle github.com/aumcode/nfx/blob/master/Source/NFX/IO/MetaHandle.cs, который эффективно инлайнает либо строку, если reference на string, либо integer, который является номером инстанса объекта в графе объектов, т.е. своеобразный псевдо-поинтер-хэндл. При десериализации всё реконструируется в обратном порядке.
Производительность
Все нижеприведённые тесты производились на Intel Core I7 3.2 GHz на одном потоке.
Производительность SlimSerializer масштабируется пропорционально кол-ву потоков. Мы применяем специализированные thread-static оптимизации, дабы не копировать буфера.
Возьмём следующий тип в качестве “подопытного”. Обратите внимание на всевозможные атрибуты, которые нужны для DataContractSerializer:
[DataContract(IsReference=true)]
[Serializable]
public class Perzon
{
[DataMember]public string FirstName;
[DataMember]public string MiddleName;
[DataMember]public string LastName;
[DataMember]public Perzon Parent;
[DataMember]public int Age1;
[DataMember]public int Age2;
[DataMember]public int? Age3;
[DataMember]public int? Age4;
[DataMember]public double Salary1;
[DataMember]public double? Salary2;
[DataMember]public Guid ID1;
[DataMember]public Guid? ID2;
[DataMember]public Guid? ID3;
[DataMember]public List<string> Names1;
[DataMember]public List<string> Names2;
[DataMember]public int O1 = 1;
[DataMember]public bool O2 = true;
[DataMember]public DateTime O3 = App.LocalizedTime;
[DataMember]public TimeSpan O4 = TimeSpan.FromHours(12);
[DataMember]public decimal O5 = 123.23M;
}
А теперь делаем много раз по 500 000 объектов:
- Slim serialize: 464 252 ops/sec; size: 94 bytes
- Slim deser: 331 564 ops/sec
- BinFormatter serialize: 34 702 ops/sec: size: 1188 bytes
- BinFormatter deser: 42 702 ops/sec
- DataContract serialize: 108 932 ops/sec: size: 773 bytes
- DataContract deser: 41 985 ops/sec
Скорость сериализации Slim к BinFormatter: в 13.37 раз быстрее.
Скорость десериализации Slim к BinFormatter: в 7.76 раз быстрее.
Объём Slim к BinFormatter: в 12.63 раз меньше.
Скорость сериализации Slim к DataContract: в 4.26 раз быстрее.
Скорость десериализации Slim к DataContract: в 7.89 раз быстрее.
Объём Slim к DataContract: в 8.22 раз меньше.
А теперь пробуем сложный object-граф из нескольких десятков взаимно ссылающихся объектов, включая массивы и листы (много раз по 50 000 объектов):
- Slim serialize: 12 036 ops/sec; size: 4 466 bytes
- Slim deser: 11 322 ops/sec
- BinFormatter serialize: 2 055 ops/sec: size: 7 393 bytes
- BinFormatter deser: 2 277 ops/sec
- DataContract serialize: 3 943 ops/sec: size: 20 246 bytes
- DataContract deser: 1 510 ops/sec
Скорость сериализации Slim к BinFormatter: в 5.85 раз быстрее.
Скорость десериализации Slim к BinFormatter: в 4.97 раз быстрее.
Объём Slim к BinFormatter: в 1.65 раз меньше.
Скорость сериализации Slim к DataContract: в 3.05 раз быстрее.
Скорость десериализации Slim к DataContract: в 7.49 раз быстрее.
Объём Slim к DataContract: в 4.53 раз меньше.
Обратите внимание на разницу при сериализации типизированного класса (первый случай “Perzon”) и второй (много объектов). Во втором случае есть сложный граф с циклическими взаимосвязями объектов и поэтому Slim начинает приближаться (замедляться) по скорости к Microsoft’у. Однако всё равно превосходит последний минимум в 4 раза по скорости и в полтора раза по объёму. Код на этот тест: github.com/aumcode/nfx/blob/master/Source/Testing/Manual/WinFormsTest/SerializerForm2.cs#L51-104
А вот здесь сравнение с Apache.Thrift: blog.aumcode.com/2015/03/apache-thrift-vs-nfxglue-benchmark.html.
Хоть эти цифры и не по чистой сериализации, а по всему NFX.Glue (который включает в себя мэссаджинг, TCP networking, security etc), скорость очень сильно зависит от SlimSerializer, на котором построены “родные” байндинги NFX.Glue.
Each test is:
64,000 calls each returning a set of 10 rows each having 10 fields
640,000 total rows pumped
Glue: took 1982 msec @ 32290 calls/sec
Thrift1: took 65299 msec @ 980 calls/sec 32x slower than Glue
Thrift2: took 44925 msec @ 1424 calls/sec 22x slower than Glue
=================================================================
Glue is:
32 times faster than Thrift BinaryProtocol
22 times faster than Thrift CompactProtocol
Итоги
NFX SlimSerializer даёт исключительно высокий и предсказуемо устойчивый перформанс, экономя ресурсы процессора и памяти. Именно это открывает возможности для технологий высокой нагруженности на CLR платформе, позволяя обрабатывать сотни тысяч запросов в секунду на каждом узле distributed систем.
У SlimSerializer’а есть несколько ограничений, обусловленных невозможностью создать практическую систему “one size fits all”. Эти ограничения: отсутствие версионности структур данных, сериализации делегатов, интероперабильности с другими платформами кроме CLR. Однако стоит заметить, что в концепции Unistack (унифицированный стэк software для всех узлов системы) эти ограничения вообще незаметны кроме отсутствия версионности, т.е. SlimSerializer не предназначен для длительного хранения данных на диске, если структура данных может поменяться.
Ультра-эффективные native байндинги NFX.Glue позволяют обслуживать 100 тысяч + двусторонних вызовов (two-way calls) в секунду благодаря специализированным оптимизациям, применяемым в сериализаторе, при этом не требуя от программиста лишней работы по созданию extra data-transfer типов
youtu.be/m5zckEbXAaA
youtu.be.com/KyhYwaxg2xc
SlimSerializer значительно обгоняет встроенные в .NET средства, позволяя эффективно обрабатывать сложные графы взаимосвязанных объектов (чего ни Protobuf, ни Thrift делать не умеют).