Como fazer o serializador .NET mais rápido com .NET 7 / C# 11, caso do MemoryPack

Nov 29 2022
Eu lancei um novo serializador chamado MemoryPack, um novo serializador específico para C# que executa muito mais rápido do que outros serializadores. Comparado ao MessagePack for C#, um serializador binário rápido, o desempenho é várias vezes mais rápido para objetos padrão e até 50 a 100 vezes mais rápido quando os dados são ideais.

Eu lancei um novo serializador chamado MemoryPack , um novo serializador específico para C# que executa muito mais rápido do que outros serializadores.

Comparado ao MessagePack for C# , um serializador binário rápido, o desempenho é várias vezes mais rápido para objetos padrão e até 50 a 100 vezes mais rápido quando os dados são ideais. O melhor suporte é .NET 7, mas agora suporta .NET Standard 2.1 (.NET 5, 6), Unity e até mesmo TypeScript. Ele também oferece suporte a polimorfismo (União), tolerância total à versão, referências circulares e as mais recentes APIs de E/S modernas (IBufferWriter, ReadOnlySeqeunce, Pipelines).

O desempenho do serializador é baseado na “especificação do formato de dados” e na “implementação em cada idioma”. Por exemplo, embora os formatos binários geralmente tenham uma vantagem sobre os formatos de texto (como JSON), é possível ter um serializador JSON mais rápido que um serializador binário (conforme demonstrado com Utf8Json ). Então, qual é o serializador mais rápido? Quando você chega à especificação e à implementação, nasce o verdadeiro serializador mais rápido.

Eu tenho, e ainda estou, desenvolvendo e mantendo o MessagePack para C# por muitos anos, e o MessagePack para C# é um serializador de muito sucesso no mundo .NET, com mais de 4.000 GitHub Stars. Ele também foi adotado por produtos padrão da Microsoft, como o Visual Studio 2022, SignalR MessagePack Hub Protocol e o protocolo Blazor Server (blazorpack).

Nos últimos 5 anos, também processei quase 1000 edições. Trabalho no suporte AOT com geradores de código usando Roslyn há 5 anos e o demonstrei, especialmente no Unity, um ambiente AOT (IL2CPP) e em muitos jogos móveis Unity que o utilizam.

Além do MessagePack for C#, criei serializadores como ZeroFormatter (formato próprio) e Utf8Json (JSON), que receberam muitas GitHub Stars, por isso tenho um profundo conhecimento das características de desempenho de diferentes formatos. Além disso, estive envolvido na criação da estrutura RPC MagicOnion , do banco de dados em memória MasterMemory , do cliente PubSub AlterNats e de ambas as implementações de cliente (Unity)/servidor de vários títulos de jogos.

O objetivo do MemoryPack é ser o melhor serializador rápido, prático e versátil. E acho que consegui.

Gerador de Fonte Incremental

O MemoryPack adota totalmente o Incremental Source Generator aprimorado no .NET 6. Em termos de uso, não é tão diferente do MessagePack para C#, exceto por alterar o tipo de destino para parcial.

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

O Source Generator também serve como um analisador, para que possa detectar se é serializável com segurança, emitindo um erro de compilação no momento da edição.

Observe que a versão do Unity usa o antigo gerador de código-fonte em vez do gerador de código incremental devido a motivos de versão de idioma/compilador.

Especificação Binária para C#

O slogan do MemoryPack é “codificação zero”. Esta não é uma história especial; O principal serializador binário do Rust, bincode , por exemplo, tem uma especificação semelhante. FlatBuffers também lê e grava conteúdo semelhante aos dados da memória sem analisar a implementação.

No entanto, ao contrário de FlatBuffers e outros, MemoryPack é um serializador de uso geral que não requer um tipo especial e serializa/deserializa contra POCO. Ele também tem versão tolerante a adições de membros do esquema e suporte a polimorfismo (União).

codificação varint vs fixo

Int32 tem 4 bytes, mas em JSON, por exemplo, os números são codificados como strings com codificação de comprimento variável de 1 a 11 bytes (por exemplo, 1 ou -2147483648). Muitos formatos binários também têm especificações de codificação de comprimento variável de 1 a 5 bytes para economizar tamanho. Por exemplo, o tipo numérico de Protocol Buffers tem codificação inteira de comprimento variável que armazena o valor em 7 bits e o sinalizador para a presença ou ausência de um seguinte em 1 bit (varint). Isso significa que quanto menor o número, menos bytes são necessários. Por outro lado, no pior caso, o número aumentará para 5 bytes, que é maior que os 4 bytes originais. MessagePack e CBORsão processados ​​de forma semelhante usando codificação de comprimento variável, com um mínimo de 1 byte para números pequenos e um máximo de 5 bytes para números grandes.

Isso significa que varint é executado processamento extra do que no caso de comprimento fixo. Vamos comparar os dois em código concreto. O comprimento variável é varint + codificação ZigZag (números negativos e positivos são combinados) usado em 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);
}

Isso é ainda mais pronunciado quando aplicado a arrays.

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

Arrays em C# não são apenas tipos primitivos como int, isso também é verdade para structs com vários primitivos, por exemplo, um array Vector3 com (float x, float y, float z) teria o seguinte layout de memória.

Um float (4 bytes) é um comprimento fixo de 5 bytes em MessagePack. O 1 byte adicional é prefixado por um identificador que indica o tipo do valor (Int, Float, String…). Especificamente, [0xca, x, x, x, x, x]. O formato MemoryPack não tem identificador, então 4 bytes são escritos como estão.

Considere o Vector3[10000], que foi 50 vezes melhor que o benchmark.

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

Com MemoryPack, apenas uma única cópia de memória. Isso literalmente alteraria o tempo de processamento em uma ordem de grandeza e é o motivo da aceleração de 50x~100x no gráfico no início deste artigo.

Claro, o processo de desserialização também é uma única cópia.

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

No entanto, a maioria das pessoas provavelmente não o usa e ninguém usaria uma opção proprietária que tornaria o MessagePack incompatível.

Então, com o MemoryPack, eu queria uma especificação que desse o melhor desempenho como C# por padrão.

Otimização de strings

MemoryPack tem duas especificações para String: UTF8 ou UTF16. como a string C# é UTF16, serializá-la como UTF16 economiza o custo de codificação/decodificação para 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);
}

No entanto, mesmo com UTF8, o MemoryPack possui algumas otimizações que outros serializadores não possuem.

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

Normalmente, os serializadores podem reservar um buffer generoso. Portanto, MemoryPack aloca três vezes o comprimento da string, que é o pior caso para a codificação UTF8, para evitar passagem dupla.

No caso da decodificação, outras otimizações especiais são aplicadas.

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

No entanto, MemoryPack registra UTF16-Length e UTF8-Length no cabeçalho. Portanto, a combinação de String.Create<TState>(Int32, TState, SpanAction<Char,TState>) e Utf8.ToUtf16 fornece a decodificação mais eficiente para C# String.

Sobre o tamanho da carga útil

A codificação de comprimento fixo de números inteiros pode ser inflada em tamanho em comparação com a codificação de comprimento variável. No entanto, na era moderna, usar a codificação de comprimento variável apenas para reduzir o tamanho pequeno dos números inteiros é mais uma desvantagem.

Como os dados não são apenas números inteiros, se você realmente deseja reduzir o tamanho, considere a compactação ( LZ4 , ZStandard , Brotli , etc.) e, se você compactar os dados, quase não há sentido na codificação de comprimento variável. Se você quiser ser mais especializado e menor, a compactação orientada a colunas fornecerá melhores resultados (por exemplo, Apache Parquet ).

Para compactação eficiente integrada à implementação MemoryPack, atualmente tenho classes auxiliares para BrotliEncode/Decode como padrão.

Também tenho vários atributos que aplicam compactação especial a certas colunas primitivas, como compactação de coluna.

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

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

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

BrotliFormatter aplica diretamente o algoritmo de compressão. Na verdade, isso funciona melhor do que compactar o arquivo inteiro.

Isso ocorre porque nenhuma cópia intermediária é necessária e o processo de compactação pode ser aplicado diretamente aos dados serializados.

O método de extrair o desempenho e a taxa de compactação aplicando o processamento de maneira personalizada, dependendo dos dados, em vez da compactação geral simples, é detalhado no artigo Reducing Logging Cost by Two Orders of Magnitude using CLP no Uber Engineering Blog.

Usando os novos recursos do .NET 7/C#11

MemoryPack tem assinaturas de método ligeiramente diferentes na implementação do .NET Standard 2.1 e na implementação do .NET 7. O .NET 7 é uma implementação mais agressiva e orientada para o desempenho que aproveita os recursos de linguagem mais recentes.

Primeiro, a interface do serializador utiliza membros abstratos estáticos da seguinte maneira

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>

Por exemplo, as coleções podem ser serializadas/desserializadas como IEnumerable<T> para uma implementação comum, mas MemoryPack fornece uma implementação separada para todos os tipos. Para simplificar, um List<T> pode ser processado como

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

No entanto, o MemoryPack o otimizou ainda mais.

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

No caso de Deserialize, também existem algumas otimizações interessantes. Primeiro, o Deserialize do MemoryPack aceita uma referência T? value, e se o valor for nulo, ele substituirá o objeto gerado internamente (assim como um serializador normal), se o valor for passado. Isso permite a alocação zero da criação de novos objetos durante Deserialize. As coleções também são reutilizadas chamando Clear() no caso de List<T>.

Em seguida, fazendo uma chamada Span especial, tudo é tratado como Spans, evitando a sobrecarga adicional de 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]);
        }
    }
}

Mas CollectionsMarshal.AsSpan, você obterá um span de comprimento 0, pois o tamanho interno não é alterado. Se tivéssemos CollectionMarshals.AsMemory, poderíamos obter o array bruto de lá com um combo MemoryMarshal.TryGetArray, mas infelizmente não há como obter o array original do Span. Então, eu forço a estrutura de tipo a combinar com Unsafe.As e altero List<T>._size, consegui obter o array interno expandido.

Dessa forma, poderíamos otimizar o tipo não gerenciado para apenas copiá-lo e evitar List<T>.Add (que verifica o tamanho do array a cada vez) e empacotar os valores via Span<T>[index], que é muito maior do que a desserialização de um serializador convencional. atuação.

Embora a otimização para List<T> seja representativa, há muitas outras a serem introduzidas, todos os tipos foram examinados e a melhor otimização possível foi aplicada a cada um.

Serialize aceita IBufferWriter<byte> como sua estrutura nativa e Deserialize aceita ReadOnlySpan<byte> e ReadOnlySequence<byte>.

Isso ocorre porque esses tipos são exigidos por System.IO.Pipelines . Em outras palavras, como é a base do servidor do ASP .NET Core (Kestrel), você pode esperar uma serialização de maior desempenho conectando-se diretamente a ele.

IBufferWriter<byte> é particularmente importante porque pode gravar diretamente no buffer, obtendo cópia zero no processo de serialização. O suporte para IBufferWriter<byte> é um pré-requisito para serializadores modernos, pois oferece desempenho superior ao uso de byte[] ou Stream. O serializador para o gráfico no início (System.Text.Json, protobuf-net, Microsoft.Orleans.Serialization, MessagePack for C# e MemoryPack) oferece suporte a ele.

MessagePack vs MemoryPack

MessagePack for C# é muito fácil de usar e tem excelente desempenho. Em particular, os seguintes pontos são melhores que o MemoryPack

  • Excelente compatibilidade entre idiomas
  • Compatibilidade JSON (especialmente para chaves de string) e legibilidade humana
  • Versão perfeita tolerante por padrão
  • Serialização de objetos e tipos anônimos
  • desserialização dinâmica
  • compactação LZ4 incorporada
  • Longa estabilidade comprovada

No entanto, é superior ao MessagePack nas seguintes maneiras

  • Desempenho, especialmente para matrizes de tipo não gerenciado
  • Suporte AOT fácil de usar
  • Método de construção de Polimorfismo Estendido (União)
  • Suporte para referências circulares
  • Substituir desserialização
  • Geração de código TypeScript
  • Formatador personalizado baseado em atributo flexível

MemoryPack não é um serializador experimental que se concentra apenas no desempenho, mas também se destina a ser um serializador prático. Para esse fim, também desenvolvi minha experiência com o MessagePack for C# para fornecer vários recursos.

  • Suporte a APIs de E/S modernas (`IBufferWriter<byte>`, `ReadOnlySpan<byte>`, `ReadOnlySequence<byte>`)
  • Geração de código baseada em gerador de fonte compatível com AOT nativo, sem Dynamic CodeGen(IL.Emit)
  • APIs não genéricas reflexivas
  • Desserializar na instância existente
  • Serialização de Polimorfismo(União)
  • versão tolerante limitada (rápida/padrão) e suporte tolerante a versão completa
  • Serialização de referência circular
  • Serialização de streaming baseada em PipeWriter/Reader
  • Geração de código TypeScript e ASP.NET Core Formatter
  • Unity(2021.3) Suporte IL2CPP via .NET Source Generator