Как сделать самый быстрый .NET Serializer с .NET 7/C# 11, пример MemoryPack
Я выпустил новый сериализатор под названием MemoryPack , новый сериализатор для C#, который работает намного быстрее, чем другие сериализаторы.

По сравнению с MessagePack для C# , быстрым двоичным сериализатором, производительность в несколько раз выше для стандартных объектов и даже в 50–100 раз быстрее, когда данные оптимальны. Лучшая поддержка — .NET 7, но теперь поддерживается .NET Standard 2.1 (.NET 5, 6), Unity и даже TypeScript. Он также поддерживает полиморфизм (Union), полную устойчивость к версиям, циклические ссылки и новейшие современные API-интерфейсы ввода-вывода (IBufferWriter, ReadOnlySeqeunce, Pipelines).
Производительность сериализатора основана как на «спецификации формата данных», так и на «реализации на каждом языке». Например, хотя двоичные форматы обычно имеют преимущество перед текстовыми форматами (такими как JSON), возможно иметь сериализатор JSON, который быстрее, чем двоичный сериализатор (как показано на примере Utf8Json ). Итак, какой самый быстрый сериализатор? Когда вы приступите как к спецификации, так и к реализации, рождается самый быстрый сериализатор.
Я много лет разрабатываю и поддерживаю MessagePack для C#, и MessagePack для C# — очень успешный сериализатор в мире .NET с более чем 4000 звезд GitHub. Он также был принят стандартными продуктами Microsoft, такими как Visual Studio 2022, SignalR MessagePack Hub Protocol и протокол Blazor Server (blazorpack).
За последние 5 лет я также обработал около 1000 вопросов. Я работаю над поддержкой AOT с помощью генераторов кода с использованием Roslyn уже 5 лет назад и продемонстрировал это, особенно в Unity, среде AOT (IL2CPP) и многих мобильных играх Unity, использующих ее.
В дополнение к MessagePack для C# я создал сериализаторы, такие как ZeroFormatter (собственный формат) и Utf8Json (JSON), которые получили множество звезд GitHub, поэтому я хорошо понимаю характеристики производительности разных форматов. Кроме того, я принимал участие в создании фреймворка RPC MagicOnion , базы данных в оперативной памяти MasterMemory , клиента PubSub AlterNats и клиентских (Unity)/серверных реализаций нескольких игр.
Цель MemoryPack — стать максимально быстрым, практичным и универсальным сериализатором. И я думаю, что добился этого.
Инкрементный генератор исходного кода
MemoryPack полностью использует добавочный генератор исходного кода , улучшенный в .NET 6. С точки зрения использования он не сильно отличается от MessagePack для C#, за исключением изменения целевого типа на частичный.
using MemoryPack;
// Source Generator makes serialize/deserialize code
[MemoryPackable]
public partial class Person
{
public int Age { get; set; }
public string Name { get; set; }
}
// usage
var v = new Person { Age = 40, Name = "John" };
var bin = MemoryPackSerializer.Serialize(v);
var val = MemoryPackSerializer.Deserialize<Person>(bin);

Генератор исходного кода также служит в качестве анализатора, поэтому он может определить, можно ли его безопасно сериализовать, выдав ошибку компиляции во время редактирования.
Обратите внимание, что версия Unity использует старый генератор исходного кода вместо добавочного генератора исходного кода из-за причин версии языка/компилятора.
Двоичная спецификация для C#
Слоган MemoryPack — «Нулевое кодирование». Это не особая история; Например, основной бинарный сериализатор Rust, bincode , имеет аналогичную спецификацию. FlatBuffers также читает и записывает содержимое, аналогичное данным памяти, без разбора реализации.
Однако, в отличие от FlatBuffers и других, MemoryPack — это сериализатор общего назначения, который не требует специального типа и сериализует/десериализует для POCO. Он также имеет версии, устойчивые к добавлениям элементов схемы, и поддержку полиморфизма (Union).
переменная кодировка против фиксированной
Int32 составляет 4 байта, но в JSON, например, числа закодированы как строки с кодировкой переменной длины от 1 до 11 байт (например, 1 или -2147483648). Многие двоичные форматы также имеют спецификации кодирования переменной длины от 1 до 5 байтов для экономии размера. Например, числовой тип Protocol Buffers имеет целочисленное кодирование переменной длины, которое хранит значение в 7 битах и флаг наличия или отсутствия следующего в 1 бите (varint). Это означает, что чем меньше число, тем меньше требуется байтов. И наоборот, в худшем случае число вырастет до 5 байтов, что больше исходных 4 байтов. MessagePack и CBORобрабатываются аналогичным образом с использованием кодирования переменной длины, минимум 1 байт для небольших чисел и максимум 5 байт для больших чисел.
Это означает, что varint выполняет дополнительную обработку, чем в случае с фиксированной длиной. Давайте сравним их в конкретном коде. Переменная длина — это кодировка varint + ZigZag (объединяются отрицательные и положительные числа), используемая в protobuf.
// Fixed encoding
static void WriteFixedInt32(Span<byte> buffer, int value)
{
ref byte p = ref MemoryMarshal.GetReference(buffer);
Unsafe.WriteUnaligned(ref p, value);
}
// Varint encoding
static void WriteVarInt32(Span<byte> buffer, int value) => WriteVarInt64(buffer, (long)value);
static void WriteVarInt64(Span<byte> buffer, long value)
{
ref byte p = ref MemoryMarshal.GetReference(buffer);
ulong n = (ulong)((value << 1) ^ (value >> 63));
while ((n & ~0x7FUL) != 0)
{
Unsafe.WriteUnaligned(ref p, (byte)((n & 0x7f) | 0x80));
p = ref Unsafe.Add(ref p, 1);
n >>= 7;
}
Unsafe.WriteUnaligned(ref p, (byte)n);
}
Это еще более заметно применительно к массивам.
// https://sharplab.io/
Inspect.Heap(new int[]{ 1, 2, 3, 4, 5 });
// Fixed-length(MemoryPack)
void Serialize(int[] value)
{
// Size can be calculated and allocate in advance
var size = (sizeof(int) * value.Length) + 4;
EnsureCapacity(size);
// MemoryCopy once
MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(buffer);
}
// Variable-length(MessagePack)合
void Serialize(int[] value)
{
foreach (var item in value)
{
// Unknown size, so check size each times
EnsureCapacity(); // if (buffer.Length < writeLength) Resize();
// Variable length encoding per element
WriteVarInt32(item);
}
}
Массивы в C# — это не только примитивные типы, такие как int, это также верно для структур с несколькими примитивами, например, массив Vector3 с (float x, float y, float z) будет иметь следующую структуру памяти.

Число с плавающей запятой (4 байта) — это фиксированная длина 5 байтов в MessagePack. Дополнительный 1 байт имеет префикс идентификатора, указывающего тип значения (Int, Float, String…). В частности, [0xca, x, x, x, x, x]. Формат MemoryPack не имеет идентификатора, поэтому 4 байта записываются как есть.
Возьмем Vector3[10000], который оказался в 50 раз лучше эталонного теста.
// these fields exists in type
// byte[] buffer
// int offset
void SerializeMemoryPack(Vector3[] value)
{
// only do copy once
var size = Unsafe.SizeOf<Vector3>() * value.Length;
if ((buffer.Length - offset) < size)
{
Array.Resize(ref buffer, buffer.Length * 2);
}
MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(buffer.AsSpan(0, offset))
}
void SerializeMessagePack(Vector3[] value)
{
// Repeat for array length x number of fields
foreach (var item in value)
{
// X
{
// EnsureCapacity
// (Actually, create buffer-linked-list with bufferWriter.Advance, not Resize)
if ((buffer.Length - offset) < 5)
{
Array.Resize(ref buffer, buffer.Length * 2);
}
var p = MemoryMarshal.GetArrayDataReference(buffer);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset), (byte)0xca);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset + 1), item.X);
offset += 5;
}
// Y
{
if ((buffer.Length - offset) < 5)
{
Array.Resize(ref buffer, buffer.Length * 2);
}
var p = MemoryMarshal.GetArrayDataReference(buffer);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset), (byte)0xca);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset + 1), item.Y);
offset += 5;
}
// Z
{
if ((buffer.Length - offset) < 5)
{
Array.Resize(ref buffer, buffer.Length * 2);
}
var p = MemoryMarshal.GetArrayDataReference(buffer);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset), (byte)0xca);
Unsafe.WriteUnaligned(ref Unsafe.Add(ref p, offset + 1), item.Z);
offset += 5;
}
}
}
С MemoryPack только одна копия памяти. Это буквально на порядок изменило бы время обработки и является причиной ускорения в 50-100 раз на графике в начале этой статьи.
Конечно, процесс десериализации тоже в единственном экземпляре.
// Deserialize of MemoryPack, only copy
Vector3[] DeserializeMemoryPack(ReadOnlySpan<byte> buffer, int size)
{
var dest = new Vector3[size];
MemoryMarshal.Cast<byte, Vector3>(buffer).CopyTo(dest);
return dest;
}
// Require read float many times in loop
Vector3[] DeserializeMessagePack(ReadOnlySpan<byte> buffer, int size)
{
var dest = new Vector3[size];
for (int i = 0; i < size; i++)
{
var x = ReadSingle(buffer);
buffer = buffer.Slice(5);
var y = ReadSingle(buffer);
buffer = buffer.Slice(5);
var z = ReadSingle(buffer);
buffer = buffer.Slice(5);
dest[i] = new Vector3(x, y, z);
}
return dest;
}
Однако большинство людей, вероятно, не используют его, и никто не станет использовать проприетарную опцию, которая сделает MessagePack несовместимым.
Так что с MemoryPack мне нужна была спецификация, которая по умолчанию давала бы лучшую производительность, чем C#.
Оптимизация строк
MemoryPack имеет две спецификации для String: UTF8 или UTF16. поскольку строка C# представляет собой UTF16, сериализация ее как UTF16 экономит затраты на кодирование/декодирование до UTF8.
void EncodeUtf16(string value)
{
var size = value.Length * 2;
EnsureCapacity(size);
// Span<char> -> Span<byte> -> Copy
MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(buffer);
}
string DecodeUtf16(ReadOnlySpan<byte> buffer, int length)
{
ReadOnlySpan<char> src = MemoryMarshal.Cast<byte, char>(buffer).Slice(0, length);
return new string(src);
}
Однако даже с UTF8 в MemoryPack есть некоторые оптимизации, которых нет в других сериализаторах.
// fast
void WriteUtf8MemoryPack(string value)
{
var source = value.AsSpan();
var maxByteCount = (source.Length + 1) * 3;
EnsureCapacity(maxByteCount);
Utf8.FromUtf16(source, dest, out var _, out var bytesWritten, replaceInvalidSequences: false);
}
// slow
void WriteUtf8StandardSerializer(string value)
{
var maxByteCount = Encoding.UTF8.GetByteCount(value);
EnsureCapacity(maxByteCount);
Encoding.UTF8.GetBytes(value, dest);
}
Обычно сериализаторам разрешается резервировать щедрый буфер. Поэтому MemoryPack выделяет в три раза больше длины строки, что является наихудшим случаем для кодировки UTF8, чтобы избежать двойного обхода.
В случае декодирования применяются дополнительные специальные оптимизации.
// fast
string ReadUtf8MemoryPack(int utf16Length, int utf8Length)
{
unsafe
{
fixed (byte* p = &buffer)
{
return string.Create(utf16Length, ((IntPtr)p, utf8Length), static (dest, state) =>
{
var src = MemoryMarshal.CreateSpan(ref Unsafe.AsRef<byte>((byte*)state.Item1), state.Item2);
Utf8.ToUtf16(src, dest, out var bytesRead, out var charsWritten, replaceInvalidSequences: false);
});
}
}
}
// slow
string ReadStandardSerialzier(int utf8Length)
{
return Encoding.UTF8.GetString(buffer.AsSpan(0, utf8Length));
}
var length = CalcUtf16Length(utf8data);
var str = String.Create(length);
Encoding.Utf8.DecodeToString(utf8data, str);
Однако MemoryPack записывает в заголовке как UTF16-Length, так и UTF8-Length. Таким образом, комбинация String.Create<TState>(Int32, TState, SpanAction<Char,TState>) и Utf8.ToUtf16 обеспечивает наиболее эффективное декодирование C# String.
О размере полезной нагрузки
Кодирование целых чисел фиксированной длины может быть увеличено по размеру по сравнению с кодированием переменной длины. Однако в современную эпоху использование кодирования с переменной длиной только для уменьшения небольшого размера целых чисел является скорее недостатком.
Так как данные не только целые, то если очень хочется уменьшить размер, то стоит подумать о сжатии ( LZ4 , ZStandard , Brotli и т.д.), а если сжимать данные, то смысла в кодировании переменной длины почти нет. Если вы хотите быть более специализированным и меньшим, сжатие, ориентированное на столбцы, даст вам лучшие результаты (например, Apache Parquet ).
Для эффективного сжатия, интегрированного с реализацией MemoryPack, в настоящее время у меня есть стандартные вспомогательные классы для BrotliEncode/Decode.
У меня также есть несколько атрибутов, которые применяют специальное сжатие к определенным примитивным столбцам, например сжатие столбцов.
[MemoryPackable]
public partial class Sample
{
public int Id { get; set; }
[BitPackFormatter]
public bool[] Data { get; set; }
[BrotliFormatter]
public byte[] Payload { get; set; }
}
BrotliFormatter напрямую применяет алгоритм сжатия. На самом деле это работает лучше, чем сжатие всего файла.

Это связано с тем, что промежуточная копия не требуется, а процесс сжатия можно применять непосредственно к сериализованным данным.
Метод извлечения производительности и коэффициента сжатия путем применения обработки в зависимости от данных, а не простого общего сжатия, подробно описан в статье Снижение стоимости ведения журнала на два порядка с помощью CLP в блоге инженеров Uber.
Использование новых функций .NET 7/C#11
MemoryPack имеет немного разные сигнатуры методов в реализации для .NET Standard 2.1 и в реализации для .NET 7. .NET 7 — более агрессивная, ориентированная на производительность реализация, использующая преимущества новейших языковых функций.
Во-первых, интерфейс сериализатора использует статические абстрактные члены следующим образом.
public interface IMemoryPackable<T>
{
// note: serialize parameter should be `ref readonly` but current lang spec can not.
// see proposal https://github.com/dotnet/csharplang/issues/6010
static abstract void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref T? value)
where TBufferWriter : IBufferWriter<byte>;
static abstract void Deserialize(ref MemoryPackReader reader, scoped ref T? value);
}
[MemortyPackable]
partial class Foo : IMemoryPackable
{
static void IMemoryPackable<Foo>.Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref Foo? value)
{
}
static void IMemoryPackable<Foo>.Deserialize(ref MemoryPackReader reader, scoped ref Foo? value)
{
}
}
public void WritePackable<T>(scoped in T? value)
where T : IMemoryPackable<T>
{
// If T is IMemoryPackable, call static method directly
T.Serialize(ref this, ref Unsafe.AsRef(value));
}
//
public void WriteValue<T>(scoped in T? value)
{
// call Serialize from interface virtual method
IMemoryPackFormatter<T> formatter = MemoryPackFormatterProvider.GetFormatter<T>();
formatter.Serialize(ref this, ref Unsafe.AsRef(value));
}
public ref struct MemoryPackWriter<TBufferWriter>
where TBufferWriter : IBufferWriter<byte>
{
ref TBufferWriter bufferWriter;
ref byte bufferReference;
int bufferLength;
// internally MemoryPack uses some struct buffer-writers
struct BrotliCompressor : IBufferWriter<byte>
struct FixedArrayBufferWriter : IBufferWriter<byte>
Например, коллекции могут быть сериализованы/десериализованы как IEnumerable<T> для общей реализации, но MemoryPack предоставляет отдельную реализацию для всех типов. Для простоты List<T> можно обрабатывать как
public void Serialize(ref MemoryPackWriter writer, IEnumerable<T> value)
{
foreach(var item in source)
{
writer.WriteValue(item);
}
}
public void Serialize(ref MemoryPackWriter writer, List<T> value)
{
foreach(var item in source)
{
writer.WriteValue(item);
}
}
Однако MemoryPack еще больше оптимизировал его.
public sealed class ListFormatter<T> : MemoryPackFormatter<List<T?>>
{
public override void Serialize<TBufferWriter>(ref MemoryPackWriter<TBufferWriter> writer, scoped ref List<T?>? value)
{
if (value == null)
{
writer.WriteNullCollectionHeader();
return;
}
writer.WriteSpan(CollectionsMarshal.AsSpan(value));
}
}
// MemoryPackWriter.WriteSpan
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void WriteSpan<T>(scoped Span<T?> value)
{
if (!RuntimeHelpers.IsReferenceOrContainsReferences<T>())
{
DangerousWriteUnmanagedSpan(value);
return;
}
var formatter = GetFormatter<T>();
WriteCollectionHeader(value.Length);
for (int i = 0; i < value.Length; i++)
{
formatter.Serialize(ref this, ref value[i]);
}
}
// MemoryPackWriter.DangerousWriteUnmanagedSpan
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void DangerousWriteUnmanagedSpan<T>(scoped Span<T> value)
{
if (value.Length == 0)
{
WriteCollectionHeader(0);
return;
}
var srcLength = Unsafe.SizeOf<T>() * value.Length;
var allocSize = srcLength + 4;
ref var dest = ref GetSpanReference(allocSize);
ref var src = ref Unsafe.As<T, byte>(ref MemoryMarshal.GetReference(value));
Unsafe.WriteUnaligned(ref dest, value.Length);
Unsafe.CopyBlockUnaligned(ref Unsafe.Add(ref dest, 4), ref src, (uint)srcLength);
Advance(allocSize);
}
В случае с Deserialize также есть несколько интересных оптимизаций. Во-первых, Deserialize MemoryPack принимает ссылку T? value, и если значение равно null, оно перезапишет внутренне сгенерированный объект (точно так же, как обычный сериализатор), если значение будет передано. Это позволяет нулевое выделение для создания нового объекта во время Deserialize. Коллекции также повторно используются при вызове Clear() в случае List<T>.
Затем, выполняя специальный вызов Span, все это обрабатывается как Span, что позволяет избежать дополнительных накладных расходов, связанных с List<T>.Add.
public sealed class ListFormatter<T> : MemoryPackFormatter<List<T?>>
{
public override void Deserialize(ref MemoryPackReader reader, scoped ref List<T?>? value)
{
if (!reader.TryReadCollectionHeader(out var length))
{
value = null;
return;
}
if (value == null)
{
value = new List<T?>(length);
}
else if (value.Count == length)
{
value.Clear();
}
var span = CollectionsMarshalEx.CreateSpan(value, length);
reader.ReadSpanWithoutReadLengthHeader(length, ref span);
}
}
internal static class CollectionsMarshalEx
{
/// <summary>
/// similar as AsSpan but modify size to create fixed-size span.
/// </summary>
public static Span<T?> CreateSpan<T>(List<T?> list, int length)
{
list.EnsureCapacity(length);
ref var view = ref Unsafe.As<List<T?>, ListView<T?>>(ref list);
view._size = length;
return view._items.AsSpan(0, length);
}
// NOTE: These structure depndent on .NET 7, if changed, require to keep same structure.
internal sealed class ListView<T>
{
public T[] _items;
public int _size;
public int _version;
}
}
// MemoryPackReader.ReadSpanWithoutReadLengthHeader
public void ReadSpanWithoutReadLengthHeader<T>(int length, scoped ref Span<T?> value)
{
if (length == 0)
{
value = Array.Empty<T>();
return;
}
if (!RuntimeHelpers.IsReferenceOrContainsReferences<T>())
{
if (value.Length != length)
{
value = AllocateUninitializedArray<T>(length);
}
var byteCount = length * Unsafe.SizeOf<T>();
ref var src = ref GetSpanReference(byteCount);
ref var dest = ref Unsafe.As<T, byte>(ref MemoryMarshal.GetReference(value)!);
Unsafe.CopyBlockUnaligned(ref dest, ref src, (uint)byteCount);
Advance(byteCount);
}
else
{
if (value.Length != length)
{
value = new T[length];
}
var formatter = GetFormatter<T>();
for (int i = 0; i < length; i++)
{
formatter.Deserialize(ref this, ref value[i]);
}
}
}
Но CollectionsMarshal.AsSpan вы получите диапазон длины 0, потому что внутренний размер не изменяется. Если бы у нас был CollectionMarshals.AsMemory, мы могли бы получить оттуда необработанный массив комбинацией MemoryMarshal.TryGetArray, но, к сожалению, нет способа получить исходный массив из Span. Итак, я заставил структуру типа соответствовать Unsafe.As и изменил List<T>._size, я смог получить расширенный внутренний массив.
Таким образом, мы могли бы оптимизировать неуправляемый тип, чтобы просто скопировать его, и избежать List<T>.Add (который каждый раз проверяет размер массива), и упаковать значения через Span<T>[index], что намного выше, чем десериализация обычного сериализатора. производительность.
Хотя оптимизация для List<T> является репрезентативной, существует слишком много других, чтобы представить их, все типы были тщательно изучены, и к каждому применена наилучшая возможная оптимизация.
Serialize принимает IBufferWriter<byte> в качестве собственной структуры, а Deserialize принимает ReadOnlySpan<byte> и ReadOnlySequence<byte>.
Это связано с тем, что эти типы требуются для System.IO.Pipelines . Другими словами, поскольку он является основой сервера ASP .NET Core (Kestrel), можно ожидать более высокой производительности сериализации при прямом подключении к нему.
IBufferWriter<byte> особенно важен, поскольку он может записывать непосредственно в буфер, таким образом обеспечивая нулевое копирование в процессе сериализации. Поддержка IBufferWriter<byte> является необходимым условием для современных сериализаторов, поскольку обеспечивает более высокую производительность, чем использование byte[] или Stream. Сериализатор для графа в начале (System.Text.Json, protobuf-net, Microsoft.Orleans.Serialization, MessagePack для C# и MemoryPack) поддерживает его.
MessagePack против MemoryPack
MessagePack для C# очень прост в использовании и обладает отличной производительностью. В частности, следующие пункты лучше, чем у MemoryPack
- Отличная межъязыковая совместимость
- Совместимость с JSON (особенно для строковых ключей) и удобочитаемость для человека
- Идеальная версия толерантна по умолчанию
- Сериализация объектных и анонимных типов
- динамическая десериализация
- встроенное сжатие LZ4
- Давно проверенная стабильность
Однако он превосходит MessagePack по следующим параметрам:
- Производительность, особенно для массивов неуправляемых типов
- Простая в использовании поддержка AOT
- Метод построения расширенного полиморфизма (объединения)
- Поддержка циклических ссылок
- Перезаписать десериализацию
- Генерация кода TypeScript
- Гибкое пользовательское форматирование на основе атрибутов
MemoryPack — это не экспериментальный сериализатор, ориентированный только на производительность, но и предназначенный для практического использования. С этой целью я также использовал свой опыт работы с MessagePack для C#, чтобы предоставить ряд функций.
- Поддержка современных API-интерфейсов ввода-вывода (`IBufferWriter<byte>`, `ReadOnlySpan<byte>`, `ReadOnlySequence<byte>`)
- Нативная генерация кода на основе генератора исходного кода, дружественного к AOT, без Dynamic CodeGen (IL.Emit)
- Безотражательные неуниверсальные API
- Десериализовать в существующий экземпляр
- Сериализация полиморфизма (союза)
- ограниченная версионность (быстро/по умолчанию) и полная версионность
- Циклическая сериализация ссылок
- Потоковая сериализация на основе PipeWriter/Reader
- Генерация кода TypeScript и ASP.NET Core Formatter
- Unity (2021.3) Поддержка IL2CPP через генератор исходного кода .NET