Cách tạo .NET Serializer nhanh nhất với .NET 7 / C# 11, trường hợp MemoryPack

Nov 29 2022
Tôi đã phát hành một bộ xê-ri hóa mới có tên là MemoryPack, một bộ xê-ri hóa dành riêng cho C# hoạt động nhanh hơn nhiều so với các bộ xê-ri hóa khác. So với MessagePack cho C#, một bộ nối tiếp nhị phân nhanh, hiệu suất nhanh hơn nhiều lần đối với các đối tượng tiêu chuẩn và thậm chí nhanh hơn 50~100 lần khi dữ liệu tối ưu.

Tôi đã phát hành một bộ xê-ri hóa mới có tên là MemoryPack , một bộ xê-ri hóa dành riêng cho C# hoạt động nhanh hơn nhiều so với các bộ xê-ri hóa khác.

So với MessagePack cho C# , một bộ nối tiếp nhị phân nhanh, hiệu suất nhanh hơn nhiều lần đối với các đối tượng tiêu chuẩn và thậm chí nhanh hơn 50~100 lần khi dữ liệu tối ưu. Hỗ trợ tốt nhất là .NET 7, nhưng hiện hỗ trợ .NET Standard 2.1 (.NET 5, 6), Unity và thậm chí cả TypeScript. Nó cũng hỗ trợ Đa hình (Union), dung sai phiên bản đầy đủ, tham chiếu vòng tròn và API I/O hiện đại mới nhất (IBufferWriter, ReadOnlySeqeunce, Pipelines).

Hiệu suất của serializer dựa trên cả “đặc tả định dạng dữ liệu” và “việc triển khai trong mỗi ngôn ngữ”. Ví dụ: mặc dù các định dạng nhị phân thường có lợi thế hơn các định dạng văn bản (chẳng hạn như JSON), nhưng có thể có bộ tuần tự hóa JSON nhanh hơn bộ tuần tự hóa nhị phân (như đã minh họa với Utf8Json ). Vì vậy, serializer nhanh nhất là gì? Khi bạn đi sâu vào cả thông số kỹ thuật và cách triển khai, bộ nối tiếp thực sự nhanh nhất sẽ ra đời.

Tôi đã, đang và vẫn đang phát triển và duy trì MessagePack cho C# trong nhiều năm và MessagePack cho C# là một bộ nối tiếp rất thành công trong thế giới .NET, với hơn 4000 Ngôi sao GitHub. Nó cũng đã được áp dụng bởi các sản phẩm tiêu chuẩn của Microsoft như Visual Studio 2022, SignalR MessagePack Hub Protocol và giao thức Blazor Server (blazorpack).

Trong 5 năm qua, tôi cũng đã xử lý gần 1000 vấn đề. Tôi đã làm việc về hỗ trợ AOT với trình tạo mã sử dụng Roslyn từ 5 năm trước và đã chứng minh điều đó, đặc biệt là trong Unity, môi trường AOT (IL2CPP) và nhiều trò chơi di động Unity sử dụng nó.

Ngoài MessagePack cho C#, tôi đã tạo các bộ tuần tự hóa như ZeroFormatter (định dạng riêng) và Utf8Json (JSON), đã nhận được nhiều Ngôi sao GitHub, vì vậy tôi hiểu sâu sắc về đặc điểm hiệu suất của các định dạng khác nhau. Ngoài ra, tôi đã tham gia vào việc tạo khuôn khổ RPC MagicOnion , cơ sở dữ liệu trong bộ nhớ MasterMemory , ứng dụng khách PubSub AlterNats và triển khai cả ứng dụng khách (Unity)/máy chủ của một số tựa trò chơi.

Mục tiêu của MemoryPack là trở thành bộ lập sê-ri nhanh nhất, thiết thực và linh hoạt nhất. Và tôi nghĩ rằng tôi đã đạt được nó.

Trình tạo nguồn gia tăng

MemoryPack hoàn toàn sử dụng Trình tạo nguồn gia tăng được cải tiến trong .NET 6. Về cách sử dụng, nó không quá khác biệt so với MessagePack cho C#, ngoại trừ việc thay đổi loại mục tiêu thành một phần.

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);

Trình tạo nguồn cũng hoạt động như một bộ phân tích, vì vậy nó có thể phát hiện xem nó có thể tuần tự hóa an toàn hay không bằng cách đưa ra lỗi biên dịch tại thời điểm chỉnh sửa.

Lưu ý rằng phiên bản Unity sử dụng Trình tạo nguồn cũ thay vì Trình tạo nguồn gia tăng do lý do phiên bản ngôn ngữ/trình biên dịch.

Đặc tả nhị phân cho C#

Khẩu hiệu của MemoryPack là “Không mã hóa”. Đây không phải là một câu chuyện đặc biệt; Ví dụ, bộ nối tiếp nhị phân chính của Rust, bincode , có thông số kỹ thuật tương tự. FlatBuffers cũng đọc và ghi nội dung tương tự như dữ liệu bộ nhớ mà không cần thực hiện phân tích cú pháp.

Tuy nhiên, không giống như FlatBuffers và những thứ khác, MemoryPack là bộ tuần tự hóa có mục đích chung không yêu cầu loại đặc biệt và tuần tự hóa/giải tuần tự hóa đối với POCO. Nó cũng có khả năng chịu phiên bản đối với các bổ sung thành viên lược đồ và hỗ trợ đa hình (Union).

mã hóa varint so với cố định

Int32 là 4 byte, nhưng trong JSON chẳng hạn, các số được mã hóa dưới dạng chuỗi có mã hóa độ dài thay đổi từ 1~11 byte (ví dụ: 1 hoặc -2147483648). Nhiều định dạng nhị phân cũng có thông số kỹ thuật mã hóa độ dài thay đổi từ 1 đến 5 byte để tiết kiệm kích thước. Ví dụ: loại số của Bộ đệm giao thức có mã hóa số nguyên có độ dài thay đổi lưu trữ giá trị trong 7 bit và cờ cho sự hiện diện hoặc vắng mặt của phần tiếp theo trong 1 bit (varint). Điều này có nghĩa là số càng nhỏ thì càng cần ít byte. Ngược lại, trong trường hợp xấu nhất, số lượng sẽ tăng lên 5 byte, lớn hơn 4 byte ban đầu. MessagePack và CBORđược xử lý tương tự bằng cách sử dụng mã hóa có độ dài thay đổi, với tối thiểu 1 byte cho số nhỏ và tối đa 5 byte cho số lớn.

Điều này có nghĩa là varint được xử lý nhiều hơn so với trường hợp độ dài cố định. Hãy so sánh hai trong mã cụ thể. Độ dài biến là mã hóa varint + ZigZag (số âm và số dương được kết hợp) được sử dụng trong 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);
}

Điều này thậm chí còn rõ ràng hơn khi áp dụng cho mảng.

// 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);
    }
}

Mảng trong C# không chỉ là các kiểu nguyên thủy như int, điều này cũng đúng với các cấu trúc có nhiều kiểu nguyên thủy, ví dụ, một mảng Vector3 với (float x, float y, float z) sẽ có cách bố trí bộ nhớ như sau.

Một float (4 byte) có độ dài cố định là 5 byte trong MessagePack. 1 byte bổ sung được bắt đầu bằng một mã định danh cho biết loại giá trị là gì (Int, Float, String…). Cụ thể là [0xca, x, x, x, x, x]. Định dạng MemoryPack không có mã định danh, vì vậy 4 byte được ghi nguyên trạng.

Hãy xem xét Vector3[10000], tốt hơn 50 lần so với điểm chuẩn.

// 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;
        }
    }
}

Với MemoryPack, chỉ có một bản sao bộ nhớ duy nhất. Điều này thực sự sẽ thay đổi thời gian xử lý theo một mức độ lớn và là lý do cho tốc độ tăng 50x~100x trong biểu đồ ở đầu bài viết này.

Tất nhiên, quá trình deserialization cũng là một bản duy nhất.

// 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;
}

Tuy nhiên, hầu hết mọi người có thể không sử dụng nó và không ai sử dụng tùy chọn độc quyền khiến MessagePack không tương thích.

Vì vậy, với MemoryPack, tôi muốn có một thông số kỹ thuật mang lại hiệu suất tốt nhất như C# theo mặc định.

Tối ưu hóa chuỗi

MemoryPack có hai thông số kỹ thuật cho Chuỗi: UTF8 hoặc UTF16. vì chuỗi C# là UTF16, nên việc tuần tự hóa nó thành UTF16 sẽ tiết kiệm chi phí mã hóa/giải mã thành 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);
}

Tuy nhiên, ngay cả với UTF8, MemoryPack có một số tối ưu hóa mà các bộ nối tiếp khác không có.

// 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);
}

Thông thường, các bộ nối tiếp được phép dự trữ một bộ đệm hào phóng. Do đó, MemoryPack phân bổ độ dài chuỗi gấp ba lần, đây là trường hợp xấu nhất đối với mã hóa UTF8, để tránh truyền tải kép.

Trong trường hợp giải mã, các tối ưu hóa đặc biệt hơn nữa được áp dụng.

// 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);

Tuy nhiên, MemoryPack ghi cả UTF16-Length và UTF8-Length trong tiêu đề. Do đó, sự kết hợp của String.Create<TState>(Int32, TState, SpanAction<Char,TState>) và Utf8.ToUtf16 cung cấp giải mã hiệu quả nhất cho Chuỗi C#.

Về kích thước tải trọng

Mã hóa số nguyên có độ dài cố định có thể tăng kích thước so với mã hóa có độ dài thay đổi. Tuy nhiên, trong kỷ nguyên hiện đại, việc sử dụng mã hóa có độ dài thay đổi chỉ để giảm kích thước nhỏ của số nguyên là một bất lợi.

Vì dữ liệu không chỉ là số nguyên nên nếu bạn thực sự muốn giảm kích thước, bạn nên xem xét việc nén ( LZ4 , ZStandard , Brotli , v.v.) và nếu bạn nén dữ liệu, thì hầu như không có điểm nào trong mã hóa độ dài thay đổi. Nếu bạn muốn chuyên biệt hơn và nhỏ hơn, nén hướng cột sẽ cho bạn kết quả lớn hơn (ví dụ: Apache Parquet ).

Để nén hiệu quả được tích hợp với triển khai MemoryPack, tôi hiện có các lớp phụ trợ cho BrotliEncode/Decode làm tiêu chuẩn.

Tôi cũng có một số thuộc tính áp dụng nén đặc biệt cho một số cột nguyên thủy, chẳng hạn như nén cột.

[MemoryPackable]
public partial class Sample
{
    public int Id { get; set; }

    [BitPackFormatter]
    public bool[] Data { get; set; }

    [BrotliFormatter]
    public byte[] Payload { get; set; }
}

BrotliFormatter áp dụng trực tiếp thuật toán nén. Điều này thực sự hoạt động tốt hơn so với nén toàn bộ tệp.

Điều này là do không cần bản sao trung gian và quy trình nén có thể được áp dụng trực tiếp cho dữ liệu được đăng nhiều kỳ.

Phương pháp trích xuất hiệu suất và tỷ lệ nén bằng cách áp dụng xử lý theo cách tùy chỉnh tùy thuộc vào dữ liệu, thay vì nén tổng thể đơn giản, được trình bày chi tiết trong bài viết Giảm chi phí ghi nhật ký theo hai bậc độ lớn bằng cách sử dụng CLP trên Blog kỹ thuật của Uber.

Sử dụng các tính năng mới của .NET 7/C#11

MemoryPack có các chữ ký phương thức hơi khác nhau trong quá trình triển khai cho .NET Standard 2.1 và triển khai cho .NET 7. .NET 7 là một triển khai định hướng hiệu suất, tích cực hơn, tận dụng các tính năng ngôn ngữ mới nhất.

Đầu tiên, giao diện serializer sử dụng các thành viên trừu tượng tĩnh như sau

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>

Ví dụ: các bộ sưu tập có thể được tuần tự hóa/giải tuần tự hóa thành IEnumerable<T> để triển khai chung, nhưng MemoryPack cung cấp triển khai riêng cho tất cả các loại. Để đơn giản, List<T> có thể được xử lý như

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);
    }
}

Tuy nhiên, MemoryPack đã tối ưu hóa nó hơn nữa.

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);
}

Trong trường hợp Deserialize, cũng có một số tối ưu hóa thú vị. Đầu tiên, MemoryPack's Deserialize chấp nhận tham chiếu T? giá trị và nếu giá trị là null, nó sẽ ghi đè lên đối tượng được tạo bên trong (giống như một bộ nối tiếp bình thường), nếu giá trị được truyền. Điều này cho phép không phân bổ việc tạo đối tượng mới trong quá trình Deserialize. Các bộ sưu tập cũng được tái sử dụng bằng cách gọi Clear() trong trường hợp List<T>.

Sau đó, bằng cách thực hiện một lệnh gọi Span đặc biệt, tất cả sẽ được xử lý dưới dạng Span, tránh được chi phí bổ sung của 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]);
        }
    }
}

Nhưng CollectionsMarshal.AsSpan, bạn sẽ nhận được một khoảng có độ dài bằng 0, vì kích thước bên trong không thay đổi. Nếu chúng ta có CollectionMarshals.AsMemory, chúng ta có thể lấy mảng thô từ đó bằng tổ hợp MemoryMarshal.TryGetArray, nhưng tiếc là không có cách nào để lấy mảng ban đầu từ Span. Vì vậy, tôi buộc cấu trúc kiểu khớp với Unsafe.As và thay đổi List<T>._size, tôi có thể nhận được mảng bên trong mở rộng.

Bằng cách đó, chúng tôi có thể tối ưu hóa loại không được quản lý để chỉ sao chép nó và tránh List<T>.Add (kiểm tra kích thước mảng mỗi lần) và đóng gói các giá trị qua Span<T>[index], cao hơn nhiều so với quá trình khử lưu huỳnh của một serializer thông thường. màn biểu diễn.

Mặc dù tối ưu hóa cho Danh sách<T> mang tính đại diện, nhưng có quá nhiều loại khác để giới thiệu, tất cả các loại đã được xem xét kỹ lưỡng và tối ưu hóa tốt nhất có thể đã được áp dụng cho từng loại.

Nối tiếp chấp nhận IBufferWriter<byte> làm cấu trúc gốc của nó và Deserialize chấp nhận ReadOnlySpan<byte> và ReadOnlySequence<byte>.

Điều này là do các loại này được yêu cầu bởi System.IO.Pipelines . Nói cách khác, vì nó là nền tảng của Máy chủ ASP .NET Core (Kestrel), bạn có thể mong đợi quá trình tuần tự hóa hiệu suất cao hơn bằng cách kết nối trực tiếp với nó.

IBufferWriter<byte> đặc biệt quan trọng vì nó có thể ghi trực tiếp vào bộ đệm, do đó đạt được bản sao bằng 0 trong quá trình tuần tự hóa. Hỗ trợ cho IBufferWriter<byte> là điều kiện tiên quyết cho các bộ nối tiếp hiện đại, vì nó mang lại hiệu suất cao hơn so với sử dụng byte[] hoặc Stream. Bộ nối tiếp cho biểu đồ lúc đầu (System.Text.Json, protobuf-net, Microsoft.Orleans.Serialization, MessagePack cho C# và MemoryPack) hỗ trợ nó.

MessagePack so với MemoryPack

MessagePack for C# rất dễ sử dụng và có hiệu suất tuyệt vời. Cụ thể những điểm sau hơn MemoryPack

  • Khả năng tương thích liên ngôn ngữ tuyệt vời
  • Khả năng tương thích JSON (đặc biệt đối với các khóa chuỗi) và khả năng đọc của con người
  • Phiên bản hoàn hảo chịu đựng theo mặc định
  • Tuần tự hóa các loại đối tượng và ẩn danh
  • khử lưu huỳnh động
  • nén LZ4 nhúng
  • Sự ổn định lâu dài đã được chứng minh

Tuy nhiên, nó vượt trội hơn MessagePack ở những điểm sau

  • Hiệu suất, đặc biệt đối với mảng loại không được quản lý
  • Hỗ trợ AOT dễ sử dụng
  • Phương pháp xây dựng đa hình mở rộng (Union)
  • Hỗ trợ tham chiếu vòng tròn
  • Ghi đè giải tuần tự hóa
  • Tạo mã TypeScript
  • Trình định dạng tùy chỉnh dựa trên thuộc tính linh hoạt

MemoryPack không phải là một bộ lập sê-ri thử nghiệm chỉ tập trung vào hiệu suất, mà còn nhằm mục đích trở thành một bộ lập sê-ri thực tế. Cuối cùng, tôi cũng đã xây dựng kinh nghiệm của mình với MessagePack cho C# để cung cấp một số tính năng.

  • Hỗ trợ API I/O hiện đại(`IBufferWriter<byte>`, `ReadOnlySpan<byte>`, `ReadOnlySequence<byte>`)
  • Tạo mã dựa trên Trình tạo nguồn thân thiện với AOT gốc, không có Dynamic CodeGen (IL.Emit)
  • Reflectionless non-generics APIs
  • Deserialize vào ví dụ hiện có
  • Tuần tự hóa đa hình (Union)
  • hỗ trợ dung sai phiên bản giới hạn (nhanh/mặc định) và hỗ trợ dung sai phiên bản đầy đủ
  • Lập sê-ri tham chiếu vòng
  • Tuần tự hóa phát trực tuyến dựa trên PipeWriter/Reader
  • Tạo mã TypeScript và ASP.NET Core Formatter
  • Hỗ trợ Unity(2021.3) IL2CPP thông qua Trình tạo nguồn .NET