Comment créer le sérialiseur .NET le plus rapide avec .NET 7 / C# 11, cas de MemoryPack
J'ai publié un nouveau sérialiseur appelé MemoryPack , un nouveau sérialiseur spécifique à C # qui fonctionne beaucoup plus rapidement que les autres sérialiseurs.

Par rapport à MessagePack pour C# , un sérialiseur binaire rapide, les performances sont plusieurs fois plus rapides pour les objets standard, et même 50 à 100 fois plus rapides lorsque les données sont optimales. Le meilleur support est .NET 7, mais prend désormais en charge .NET Standard 2.1 (.NET 5, 6), Unity et même TypeScript. Il prend également en charge le polymorphisme (Union), la tolérance de version complète, les références circulaires et les dernières API d'E/S modernes (IBufferWriter, ReadOnlySeqeunce, Pipelines).
Les performances du sérialiseur reposent à la fois sur la "spécification du format de données" et sur "l'implémentation dans chaque langage". Par exemple, alors que les formats binaires ont généralement un avantage sur les formats texte (tels que JSON), il est possible d'avoir un sérialiseur JSON plus rapide qu'un sérialiseur binaire (comme démontré avec Utf8Json ). Alors, quel est le sérialiseur le plus rapide ? Lorsque vous en arrivez à la fois aux spécifications et à la mise en œuvre, le véritable sérialiseur le plus rapide est né.
Je développe et maintiens MessagePack pour C# depuis de nombreuses années, et je le suis toujours, et MessagePack pour C# est un sérialiseur très réussi dans le monde .NET, avec plus de 4000 GitHub Stars. Il a également été adopté par les produits standard de Microsoft tels que Visual Studio 2022, SignalR MessagePack Hub Protocol et le protocole Blazor Server (blazorpack).
Au cours des 5 dernières années, j'ai également traité près de 1000 problèmes. Je travaille sur le support AOT avec des générateurs de code utilisant Roslyn depuis 5 ans, et je l'ai démontré, en particulier dans Unity, un environnement AOT (IL2CPP), et de nombreux jeux mobiles Unity l'utilisant.
En plus de MessagePack pour C#, j'ai créé des sérialiseurs tels que ZeroFormatter (propre format) et Utf8Json (JSON), qui ont reçu de nombreuses étoiles GitHub, j'ai donc une compréhension approfondie des caractéristiques de performance des différents formats. De plus, j'ai participé à la création du framework RPC MagicOnion , de la base de données en mémoire MasterMemory , du client PubSub AlterNats et des implémentations client (Unity)/serveur de plusieurs titres de jeux.
L'objectif de MemoryPack est d'être le nec plus ultra des sérialiseurs rapides, pratiques et polyvalents. Et je pense y être parvenu.
Générateur de source incrémental
MemoryPack adopte pleinement le générateur de source incrémentiel amélioré dans .NET 6. En termes d'utilisation, il n'est pas si différent de MessagePack pour C #, sauf pour changer le type de cible en partiel.
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);

Le générateur de source sert également d'analyseur, de sorte qu'il peut détecter s'il est sérialisable en toute sécurité en émettant une erreur de compilation au moment de l'édition.
Notez que la version Unity utilise l' ancien générateur de source au lieu du générateur de source incrémentiel pour des raisons de version de langage/compilateur.
Spécification binaire pour C#
Le slogan de MemoryPack est "Zéro encodage". Ce n'est pas une histoire spéciale; Le principal sérialiseur binaire de Rust, bincode , par exemple, a une spécification similaire. FlatBuffers lit et écrit également du contenu similaire aux données de la mémoire sans implémentation d'analyse.
Cependant, contrairement à FlatBuffers et autres, MemoryPack est un sérialiseur à usage général qui ne nécessite pas de type spécial et sérialise/désérialise contre POCO. Il a également une gestion des versions tolérante aux ajouts de membres de schéma et à la prise en charge du polymorphisme (Union).
encodage varint vs fixe
Int32 est de 4 octets, mais dans JSON, par exemple, les nombres sont codés sous forme de chaînes avec un codage de longueur variable de 1 à 11 octets (par exemple, 1 ou -2147483648). De nombreux formats binaires ont également des spécifications de codage de longueur variable de 1 à 5 octets pour économiser de la taille. Par exemple, le type numérique de Protocol Buffers a un codage entier de longueur variable qui stocke la valeur sur 7 bits et le drapeau de présence ou d'absence d'un suivant sur 1 bit (varint). Cela signifie que plus le nombre est petit, moins il faut d'octets. Inversement, dans le pire des cas, le nombre passera à 5 octets, ce qui est supérieur aux 4 octets d'origine. MessagePack et CBORsont traités de la même manière en utilisant un codage de longueur variable, avec un minimum de 1 octet pour les petits nombres et un maximum de 5 octets pour les grands nombres.
Cela signifie que varint subit un traitement supplémentaire par rapport au cas de longueur fixe. Comparons les deux dans un code concret. La longueur variable est le codage varint + ZigZag (les nombres négatifs et positifs sont combinés) utilisé dans 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);
}
Ceci est encore plus prononcé lorsqu'il est appliqué aux tableaux.
// 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);
}
}
Les tableaux en C # ne sont pas seulement des types primitifs comme int, cela est également vrai pour les structures avec plusieurs primitives, par exemple, un tableau Vector3 avec (float x, float y, float z) aurait la disposition de mémoire suivante.

Un flottant (4 octets) est une longueur fixe de 5 octets dans MessagePack. Le 1 octet supplémentaire est préfixé par un identifiant indiquant le type de la valeur (Int, Float, String…). Plus précisément, [0xca, x, x, x, x, x]. Le format MemoryPack n'a pas d'identifiant, donc 4 octets sont écrits tels quels.
Considérez Vector3[10000], qui était 50 fois meilleur que la référence.
// 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;
}
}
}
Avec MemoryPack, une seule copie de la mémoire. Cela changerait littéralement le temps de traitement d'un ordre de grandeur et est la raison de l'accélération de 50x ~ 100x dans le graphique au début de cet article.
Bien sûr, le processus de désérialisation est également un exemplaire unique.
// 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;
}
Cependant, la plupart des gens ne l'utilisent probablement pas et personne n'utiliserait une option propriétaire qui rendrait MessagePack incompatible.
Donc, avec MemoryPack, je voulais une spécification qui donnerait les meilleures performances en C# par défaut.
Optimisation des chaînes
MemoryPack a deux spécifications pour String : UTF8 ou UTF16. puisque la chaîne C # est UTF16, la sérialisation en UTF16 permet d'économiser le coût d'encodage/décodage en 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);
}
Cependant, même avec UTF8, MemoryPack a quelques optimisations que d'autres sérialiseurs n'ont pas.
// 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);
}
Habituellement, les sérialiseurs sont autorisés à réserver un tampon généreux. Par conséquent, MemoryPack alloue trois fois la longueur de la chaîne, ce qui est le pire des cas pour le codage UTF8, pour éviter le double parcours.
Dans le cas du décodage, d'autres optimisations spéciales sont appliquées.
// 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);
Cependant, MemoryPack enregistre à la fois UTF16-Length et UTF8-Length dans l'en-tête. Par conséquent, la combinaison de String.Create<TState>(Int32, TState, SpanAction<Char,TState>) et Utf8.ToUtf16 fournit le décodage le plus efficace en C# String.
À propos de la taille de la charge utile
Le codage à longueur fixe des nombres entiers peut être gonflé en taille par rapport au codage à longueur variable. Cependant, à l'ère moderne, l'utilisation d'un codage à longueur variable uniquement pour réduire la petite taille des entiers est plus un inconvénient.
Puisque les données ne sont pas seulement des entiers, si vous voulez vraiment réduire la taille, vous devriez envisager la compression ( LZ4 , ZStandard , Brotli , etc.), et si vous compressez les données, il n'y a presque aucun intérêt à encoder à longueur variable. Si vous souhaitez être plus spécialisé et plus petit, la compression orientée colonne vous donnera de meilleurs résultats (par exemple, Apache Parquet ).
Pour une compression efficace intégrée à l'implémentation de MemoryPack, j'ai actuellement des classes auxiliaires pour BrotliEncode/Decode en standard.
J'ai également plusieurs attributs qui appliquent une compression spéciale à certaines colonnes primitives, telles que la compression de colonne.
[MemoryPackable]
public partial class Sample
{
public int Id { get; set; }
[BitPackFormatter]
public bool[] Data { get; set; }
[BrotliFormatter]
public byte[] Payload { get; set; }
}
BrotliFormatter applique directement l'algorithme de compression. Cela fonctionne mieux que de compresser le fichier entier.

En effet, aucune copie intermédiaire n'est nécessaire et le processus de compression peut être appliqué directement aux données sérialisées.
La méthode d'extraction des performances et du taux de compression en appliquant un traitement personnalisé en fonction des données, plutôt qu'une simple compression globale, est détaillée dans l' article Réduire le coût de journalisation de deux ordres de grandeur à l'aide de CLP sur le blog Uber Engineering.
Utiliser les nouvelles fonctionnalités de .NET 7 / C#11
MemoryPack a des signatures de méthode légèrement différentes dans l'implémentation pour .NET Standard 2.1 et l'implémentation pour .NET 7. .NET 7 est une implémentation plus agressive, axée sur les performances, qui tire parti des dernières fonctionnalités du langage.
Tout d'abord, l'interface du sérialiseur utilise des membres abstraits statiques comme suit
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>
Par exemple, les collections peuvent être sérialisées/désérialisées en tant que IEnumerable<T> pour une implémentation commune, mais MemoryPack fournit une implémentation distincte pour tous les types. Pour plus de simplicité, un List<T> peut être traité comme
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);
}
}
Cependant, MemoryPack l'a encore optimisé.
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);
}
Dans le cas de Deserialize, il existe également des optimisations intéressantes. Tout d'abord, la désérialisation de MemoryPack accepte une référence T ? valeur, et si la valeur est nulle, il écrasera l'objet généré en interne (tout comme un sérialiseur normal), si la valeur est transmise. Cela permet une allocation nulle de la création de nouveaux objets pendant la désérialisation. Les collections sont également réutilisées en appelant Clear() dans le cas de List<T>.
Ensuite, en effectuant un appel Span spécial, tout est traité comme des étendues, évitant ainsi la surcharge supplémentaire 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]);
}
}
}
Mais CollectionsMarshal.AsSpan, vous obtiendrez une étendue de longueur 0, car la taille interne n'est pas modifiée. Si nous avions CollectionMarshals.AsMemory, nous pourrions obtenir le tableau brut à partir de là avec un combo MemoryMarshal.TryGetArray, mais malheureusement, il n'y a aucun moyen d'obtenir le tableau d'origine à partir de Span. Donc, je force la structure de type à correspondre à Unsafe.As et change List<T>._size, j'ai pu obtenir le tableau interne étendu.
De cette façon, nous pourrions optimiser le type non géré pour simplement le copier et éviter List<T>.Add (qui vérifie la taille du tableau à chaque fois) et emballer les valeurs via Span<T>[index], qui est beaucoup plus élevé que la désérialisation d'un sérialiseur classique. performance.
Bien que l'optimisation de List<T> soit représentative, il y en a trop d'autres à introduire, tous les types ont été examinés et la meilleure optimisation possible a été appliquée à chacun.
Serialize accepte IBufferWriter<byte> comme structure native et Deserialize accepte ReadOnlySpan<byte> et ReadOnlySequence<byte>.
En effet, ces types sont requis par System.IO.Pipelines . En d'autres termes, puisqu'il s'agit de la base du serveur ASP .NET Core (Kestrel), vous pouvez vous attendre à une sérialisation plus performante en vous y connectant directement.
IBufferWriter<byte> est particulièrement important car il peut écrire directement dans le tampon, réalisant ainsi zéro copie dans le processus de sérialisation. La prise en charge de IBufferWriter<byte> est une condition préalable pour les sérialiseurs modernes, car elle offre des performances supérieures à l'utilisation de byte[] ou Stream. Le sérialiseur pour le graphique au début (System.Text.Json, protobuf-net, Microsoft.Orleans.Serialization, MessagePack pour C# et MemoryPack) le prend en charge.
MessagePack contre MemoryPack
MessagePack pour C# est très facile à utiliser et offre d'excellentes performances. En particulier, les points suivants sont meilleurs que MemoryPack
- Excellente compatibilité inter-langue
- Compatibilité JSON (en particulier pour les clés de chaîne) et lisibilité humaine
- Version parfaite tolérante par défaut
- Sérialisation d'objets et de types anonymes
- désérialisation dynamique
- compression LZ4 intégrée
- Stabilité éprouvée depuis longtemps
Cependant, il est supérieur à MessagePack de la manière suivante
- Performances, en particulier pour les baies de type non géré
- Prise en charge AOT facile à utiliser
- Méthode de construction du polymorphisme étendu (Union)
- Prise en charge des références circulaires
- Remplacer la désérialisation
- Génération de code TypeScript
- Formateur personnalisé flexible basé sur des attributs
MemoryPack n'est pas un sérialiseur expérimental qui se concentre uniquement sur les performances, mais est également destiné à être un sérialiseur pratique. À cette fin, j'ai également mis à profit mon expérience avec MessagePack pour C # pour fournir un certain nombre de fonctionnalités.
- Prise en charge des API d'E/S modernes (`IBufferWriter<byte>`, `ReadOnlySpan<byte>`, `ReadOnlySequence<byte>`)
- Génération de code basée sur le générateur de source compatible AOT natif, pas de Dynamic CodeGen (IL.Emit)
- API non génériques sans réflexion
- Désérialiser dans une instance existante
- Sérialisation du polymorphisme (Union)
- support tolérant à la version limitée (rapide/par défaut) et tolérant à la version complète
- Sérialisation de référence circulaire
- Sérialisation de streaming basée sur PipeWriter/Reader
- Génération de code TypeScript et formateur ASP.NET Core
- Unity (2021.3) Prise en charge IL2CPP via le générateur de source .NET