[LOADED_DOCS: 6 files, no new loads]

Refactor benchmarks; clarify AcBinary doc warnings

Refactored serializer benchmark infra for richer, structured results and added fresh/reused buffer writer scenarios for AcBinary and MemoryPack. Disabled AcBinary SGen for all test models to ensure runtime/reflection-only benchmarks. Updated documentation to clarify and cross-link all silent corruption risks (hash collisions, MaxDepth, PropertyFilter), harmonized warnings, and referenced relevant issue IDs for traceability.
This commit is contained in:
Loretta 2026-04-30 12:36:37 +02:00
parent 4e91d24fdb
commit 294a3e9609
5 changed files with 454 additions and 168 deletions

View File

@ -34,16 +34,28 @@ public static class Program
#endif
// Serializer name constants
private const string SerializerMessagePack = "MessagePack";
private const string SerializerAcBinaryDefault = "AcBinary (Default)";
private const string SerializerAcBinaryDefaultNoSgen = "AcBinary (Def, NoSgen)";
private const string SerializerAcBinaryNoRef = "AcBinary (NoRef)";
private const string SerializerAcBinaryFastMode = "AcBinary (FastMode)";
private const string SerializerAcBinaryFastNoSgen = "AcBinary (Fast, NoSgen)";
private const string SerializerAcBinaryNoIntern = "AcBinary (NoIntern)";
private const string SerializerMemoryPack = "MemoryPack";
//private const string SerializerAcBinaryBufferWriter = "AcBinary (BufferWriter)";
//private const string SerializerSystemTextJson = "System.Text.Json";
// Engine identifiers (used in Engine column + comparison logic)
private const string EngineAcBinary = "AcBinary";
private const string EngineMemoryPack = "MemoryPack";
private const string EngineMessagePack = "MessagePack";
private const string EngineSystemTextJson = "System.Text.Json";
// IO mode identifiers (used in IO column + comparison logic)
private const string IoByteArray = "Byte[]";
private const string IoBufWrReuse = "BufWr reuse";
private const string IoBufWrNew = "BufWr new";
private const string IoString = "String";
// Dispatch mode identifiers — describes how property access / type dispatch happens for a given run.
// SGen = compile-time source generator path (Unsafe.As<T> direct fields, slot-array wrapper lookup).
// Runtime= reflection / compiled-delegate path.
// Hybrid = SGen root with non-SGen child types reached via bridge methods. See docs/BINARY/BINARY_SGEN.md.
private const string ModeSGen = "SGen";
private const string ModeRuntime = "Runtime";
private const string ModeHybrid = "Hybrid";
// OptionsPreset values are passed per-instance (constructor argument), not constants —
// each CreateSerializers call line specifies its own preset name (e.g. "FastMode", "NoIntern").
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
@ -131,6 +143,31 @@ public static class Program
System.Console.WriteLine($"Build: {BuildConfiguration} | .NET: {Environment.Version} | Test Type: {testDataSets.FirstOrDefault()?.TypeName ?? "unknown"} | Test Cells: {testDataSets.Count}/{allTestDataSets.Count}");
System.Console.WriteLine();
// Global JIT pre-warmup — touches every (testdata × serializer) code path BEFORE any timing happens.
// Without this, the FIRST test data measured carries JIT-tier-promotion latency: the per-cell warmup
// alone doesn't ensure that every Serialize<T>/IBufferWriter overload is fully Tier 1 by the time we
// start measuring. Symptom: first cell's BufferWriter variants run ~2x slower than the SAME variants
// on later cells (e.g. Small BufWr reuse 9ms vs Medium BufWr reuse 4ms — even though Medium is bigger).
// Pre-warmup runs every overload at least once with each data shape so .NET 9's tiered JIT promotes
// them all in the background; the per-cell warmup that follows then locks in cache + branch state.
if (BenchmarkSamples > 1) // skip in DEBUG (single-sample fast iteration)
{
System.Console.WriteLine($"Global JIT pre-warmup ({testDataSets.Count} cells × all serializers, light pass)...");
foreach (var testData in testDataSets)
{
var preSerializers = CreateSerializers(testData);
foreach (var s in preSerializers)
{
// Light warmup just to trigger Tier 0 → Tier 1 promotion. The per-cell 5000-iter warmup
// inside RunBenchmarksForTestData still runs afterwards for cache/BTB warming.
s.Warmup(2000);
}
}
// Let background tiered-JIT compilation drain before we begin measuring.
Thread.Sleep(3000);
System.Console.WriteLine("✓ Global pre-warmup complete.\n");
}
foreach (var testData in testDataSets)
{
System.Console.WriteLine($"\n{'═'.ToString().PadRight(70, '═')}");
@ -242,9 +279,13 @@ public static class Program
var result = new BenchmarkResult
{
TestDataName = testData.DisplayName, // Use DisplayName for IId% info
SerializerName = serializer.Name,
Engine = serializer.Engine,
IoMode = serializer.IoMode,
DispatchMode = serializer.DispatchMode,
OptionsPreset = serializer.OptionsPreset,
OptionsDescription = serializer.OptionsDescription,
SerializedSize = serializer.SerializedSize
SerializedSize = serializer.SerializedSize,
SetupAllocBytes = serializer.SetupAllocBytes
};
if (mode is "all" or "serialize" or "ser")
@ -280,34 +321,42 @@ public static class Program
return new List<ISerializerBenchmark>
{
// AcBinary variants
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode),
////new AcBinaryBenchmark(testData.Order, binaryFastModeNoSgenOption, SerializerAcBinaryFastNoSgen),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.Default, SerializerAcBinaryDefault),
////new AcBinaryBenchmark(testData.Order, binaryDefaultNoSgenOption, SerializerAcBinaryDefaultNoSgen),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.WithoutReferenceHandling, SerializerAcBinaryNoRef),
//new AcBinaryBenchmark(testData.Order, binaryNoInternOption, SerializerAcBinaryNoIntern),
// ============================================================
// AcBinary — Byte[] API (uncomment to compare option presets side-by-side)
// ============================================================
// Fastest Byte[] — SGen path (UseGeneratedCode=true, default).
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, "FastMode"),
// Fastest Byte[] — Runtime path (UseGeneratedCode=false). Same wire/options, no source-generated dispatch.
// Always paired with the SGen variant so every layer can compare the SGen speed-up apples-to-apples.
new AcBinaryBenchmark(testData.Order, binaryFastModeNoSgenOption, "FastMode"),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.Default, "Default"),
//new AcBinaryBenchmark(testData.Order, binaryDefaultNoSgenOption, "Default"),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.WithoutReferenceHandling, "NoRef"),
//new AcBinaryBenchmark(testData.Order, binaryNoInternOption, "NoIntern"),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastNoSgen),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryDefault),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryDefaultNoSgen),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoRef),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoIntern),
// AcBinary via IBufferWriter (reused ArrayBufferWriter — long-running service / batch scenario)
new AcBinaryBufferWriterBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, "FastMode"),
// AcBinary via IBufferWriter (FRESH ArrayBufferWriter per call — one-shot scenario).
// BufferWriterChunkSize=4096 → AcBinary advances every 4 KB (smaller internal buffer = sooner Advance/GetMemory cycle,
// matches Kestrel slab + TCP MTU). Despite the property name "ChunkSize", in the IBufferWriter path this is just the
// internal buffer size; wire-format "chunks" only exist in AsyncPipeWriterOutput's chunked-framing mode.
new AcBinaryFreshBufferWriterBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, "FastMode (4KB buffer)"),
// MemoryPack
new MemoryPackBenchmark(testData.Order, SerializerMemoryPack),
// MessagePack
new MessagePackBenchmark(testData.Order, SerializerMessagePack),
// AcBinary BufferWriter
//new AcBinaryBufferWriterBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryBufferWriter),
// System.Text.Json
//new SystemTextJsonBenchmark(testData.Order, SerializerSystemTextJson)
// ============================================================
// MemoryPack — three I/O modes for apples-to-apples comparison
// ============================================================
new MemoryPackBenchmark(testData.Order, "Default"),
new MemoryPackBufferWriterBenchmark(testData.Order, "Default"),
new MemoryPackFreshBufferWriterBenchmark(testData.Order, "Default"),
// ============================================================
// MessagePack — for legacy comparison
// ============================================================
new MessagePackBenchmark(testData.Order, "ContractBased"),
// System.Text.Json (commented — JSON serializer for reference; not in active suite)
//new SystemTextJsonBenchmark(testData.Order, "Default")
};
}
@ -459,9 +508,20 @@ public static class Program
private interface ISerializerBenchmark
{
string Name { get; }
/// <summary>Serializer engine — e.g. "AcBinary", "MemoryPack", "MessagePack".</summary>
string Engine { get; }
/// <summary>I/O mode — e.g. "Byte[]", "BufWr reuse", "BufWr new", "NamedPipe", "FileStream".</summary>
string IoMode { get; }
/// <summary>Dispatch mode — "SGen", "Runtime", or "Hybrid". For AcBinary derived from <c>UseGeneratedCode</c> + child-type SGen coverage; non-AcBinary engines report their own native dispatch model.</summary>
string DispatchMode { get; }
/// <summary>Options preset name — e.g. "FastMode", "Default", "NoIntern", "WithCompression".</summary>
string OptionsPreset { get; }
/// <summary>Synthesized display name from Engine + IoMode + OptionsPreset.</summary>
string Name => $"{Engine} ({IoMode}, {OptionsPreset})";
int SerializedSize { get; }
string? OptionsDescription => null;
/// <summary>One-time setup allocation cost (e.g., pre-allocated ArrayBufferWriter with internal buffer). Captured in constructor; 0 for byte[] API and Fresh-BufWriter variants.</summary>
long SetupAllocBytes { get; }
void Warmup(int iterations);
void Serialize();
void Deserialize();
@ -475,15 +535,19 @@ public static class Program
private readonly AcBinarySerializerOptions _options;
private readonly byte[] _serialized;
public string Name { get; }
public string Engine => EngineAcBinary;
public string IoMode => IoByteArray;
public string DispatchMode => _options.UseGeneratedCode ? ModeSGen : ModeRuntime;
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupAllocBytes => 0;
public string OptionsDescription => $"WireMode={_options.WireMode}, RefHandling={_options.ReferenceHandling}, Interning={_options.UseStringInterning}, Metadata={_options.UseMetadata}, SGen={_options.UseGeneratedCode}, Compression={_options.UseCompression}";
public AcBinaryBenchmark(TestOrder order, AcBinarySerializerOptions options, string name)
public AcBinaryBenchmark(TestOrder order, AcBinarySerializerOptions options, string optionsPreset)
{
_order = order;
_options = options;
Name = name;
OptionsPreset = optionsPreset;
_serialized = AcBinarySerializer.Serialize(order, options);
//_options.UseCompression = Lz4CompressionMode.Block;
@ -526,13 +590,17 @@ public static class Program
private readonly TestOrder _order;
private readonly byte[] _serialized;
public string Name { get; }
public string Engine => EngineMemoryPack;
public string IoMode => IoByteArray;
public string DispatchMode => ModeSGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupAllocBytes => 0;
public MemoryPackBenchmark(TestOrder order, string name)
public MemoryPackBenchmark(TestOrder order, string optionsPreset)
{
_order = order;
Name = name;
OptionsPreset = optionsPreset;
_serialized = MemoryPackSerializer.Serialize(order);
}
@ -565,14 +633,18 @@ public static class Program
private readonly MessagePackSerializerOptions _options;
private readonly byte[] _serialized;
public string Name { get; }
public string Engine => EngineMessagePack;
public string IoMode => IoByteArray;
public string DispatchMode => ModeSGen; // MessagePack uses [MessagePackObject] source-generated formatters (StandardResolver)
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupAllocBytes => 0;
public string OptionsDescription { get; }
public MessagePackBenchmark(TestOrder order, string name)
public MessagePackBenchmark(TestOrder order, string optionsPreset)
{
_order = order;
Name = name;
OptionsPreset = optionsPreset;
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.Lz4Block);
@ -607,24 +679,42 @@ public static class Program
}
}
private sealed class AcBinaryBufferWriterBenchmark : ISerializerBenchmark
/// <summary>
/// Benchmarks AcBinary via the IBufferWriter overload with a pre-allocated, reused ArrayBufferWriter.
/// Realistic IBufferWriter usage pattern: caller owns + reuses the writer (zero alloc per call after warmup).
/// </summary>
/// <summary>
/// Benchmarks AcBinary via the IBufferWriter overload, allocating a FRESH ArrayBufferWriter on EVERY call.
/// One-shot scenario — represents code that doesn't reuse a writer across calls.
/// Uses BufferWriterChunkSize=4096 (production-realistic, SignalR-aligned) instead of the 65535 default —
/// otherwise AcBinary would request 64KB upfront via GetSpan(), forcing the fresh ABW to allocate 64KB
/// regardless of payload size (heavy over-allocation for small payloads).
/// </summary>
private sealed class AcBinaryFreshBufferWriterBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly AcBinarySerializerOptions _options;
private readonly byte[] _serialized;
private ArrayBufferWriter<byte> _bufferWriter;
public string Name { get; }
public string Engine => EngineAcBinary;
public string IoMode => IoBufWrNew;
public string DispatchMode => _options.UseGeneratedCode ? ModeSGen : ModeRuntime;
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public string OptionsDescription => $"WireMode={_options.WireMode}, RefHandling={_options.ReferenceHandling}, Interning={_options.UseStringInterning}, Metadata={_options.UseMetadata}, SGen={_options.UseGeneratedCode}, Compression={_options.UseCompression}";
public long SetupAllocBytes => 0;
public string OptionsDescription => $"WireMode={_options.WireMode}, RefHandling={_options.ReferenceHandling}, Interning={_options.UseStringInterning}, Metadata={_options.UseMetadata}, SGen={_options.UseGeneratedCode}, Compression={_options.UseCompression}, BufferSize={_options.BufferWriterChunkSize}B";
public AcBinaryBufferWriterBenchmark(TestOrder order, AcBinarySerializerOptions options, string name)
public AcBinaryFreshBufferWriterBenchmark(TestOrder order, AcBinarySerializerOptions options, string optionsPreset)
{
_order = order;
_options = options;
Name = name;
_serialized = AcBinarySerializer.Serialize(order, options);
//_bufferWriter = new ArrayBufferWriter<byte>();
// Override: 4 KB internal buffer instead of 65535 default — controls how often AcBinary advances
// (Advance + GetMemory) on the underlying IBufferWriter. Smaller buffer = sooner advance = matches
// Kestrel slab + TCP MTU for streaming. NOT a wire-format chunk size (that exists only in
// AsyncPipeWriterOutput's chunked-framing mode); on ArrayBufferWriter this is purely the grow step.
_options.BufferWriterChunkSize = 4096;
OptionsPreset = optionsPreset;
_serialized = AcBinarySerializer.Serialize(order, _options);
}
public void Warmup(int iterations)
@ -639,8 +729,116 @@ public static class Program
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
//_bufferWriter.ResetWrittenCount();
_bufferWriter = new ArrayBufferWriter<byte>();
var abw = new ArrayBufferWriter<byte>(); // FRESH every call — alloc + grow as needed
AcBinarySerializer.Serialize(_order, abw, _options);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => AcBinaryDeserializer.Deserialize<TestOrder>(_serialized, _options);
public bool VerifyRoundTrip()
{
var abw = new ArrayBufferWriter<byte>();
AcBinarySerializer.Serialize(_order, abw, _options);
var roundTripped = AcBinaryDeserializer.Deserialize<TestOrder>(abw.WrittenSpan.ToArray(), _options);
return DeepEqualsViaJson(_order, roundTripped);
}
}
/// <summary>
/// Benchmarks MemoryPack via the IBufferWriter overload, allocating a FRESH ArrayBufferWriter on EVERY call.
/// Apples-to-apples counterpart to AcBinaryFreshBufferWriterBenchmark.
/// </summary>
private sealed class MemoryPackFreshBufferWriterBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly byte[] _serialized;
public string Engine => EngineMemoryPack;
public string IoMode => IoBufWrNew;
public string DispatchMode => ModeSGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupAllocBytes => 0;
public MemoryPackFreshBufferWriterBenchmark(TestOrder order, string optionsPreset)
{
_order = order;
OptionsPreset = optionsPreset;
_serialized = MemoryPackSerializer.Serialize(order);
}
public void Warmup(int iterations)
{
for (var i = 0; i < iterations; i++)
{
Serialize();
Deserialize();
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
var abw = new ArrayBufferWriter<byte>();
MemoryPackSerializer.Serialize(abw, _order);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => MemoryPackSerializer.Deserialize<TestOrder>(_serialized);
public bool VerifyRoundTrip()
{
var abw = new ArrayBufferWriter<byte>();
MemoryPackSerializer.Serialize(abw, _order);
var roundTripped = MemoryPackSerializer.Deserialize<TestOrder>(abw.WrittenSpan.ToArray());
return DeepEqualsViaJson(_order, roundTripped);
}
}
private sealed class AcBinaryBufferWriterBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly AcBinarySerializerOptions _options;
private readonly byte[] _serialized;
private readonly ArrayBufferWriter<byte> _bufferWriter;
public string Engine => EngineAcBinary;
public string IoMode => IoBufWrReuse;
public string DispatchMode => _options.UseGeneratedCode ? ModeSGen : ModeRuntime;
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupAllocBytes { get; }
public string OptionsDescription => $"WireMode={_options.WireMode}, RefHandling={_options.ReferenceHandling}, Interning={_options.UseStringInterning}, Metadata={_options.UseMetadata}, SGen={_options.UseGeneratedCode}, Compression={_options.UseCompression}";
public AcBinaryBufferWriterBenchmark(TestOrder order, AcBinarySerializerOptions options, string optionsPreset)
{
_order = order;
_options = options;
OptionsPreset = optionsPreset;
_serialized = AcBinarySerializer.Serialize(order, options);
// Measure ONLY the BufferWriter infrastructure setup (excluding the helper Serialize above)
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
var beforeSetup = GC.GetAllocatedBytesForCurrentThread();
_bufferWriter = new ArrayBufferWriter<byte>(_serialized.Length * 2);
var afterSetup = GC.GetAllocatedBytesForCurrentThread();
SetupAllocBytes = afterSetup - beforeSetup;
}
public void Warmup(int iterations)
{
for (var i = 0; i < iterations; i++)
{
Serialize();
Deserialize();
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
_bufferWriter.ResetWrittenCount(); // reuse — no alloc, no zeroing
AcBinarySerializer.Serialize(_order, _bufferWriter, _options);
}
@ -649,9 +847,67 @@ public static class Program
public bool VerifyRoundTrip()
{
var bw = new ArrayBufferWriter<byte>();
AcBinarySerializer.Serialize(_order, bw, _options);
var roundTripped = AcBinaryDeserializer.Deserialize<TestOrder>(bw.WrittenSpan.ToArray(), _options);
_bufferWriter.ResetWrittenCount();
AcBinarySerializer.Serialize(_order, _bufferWriter, _options);
var roundTripped = AcBinaryDeserializer.Deserialize<TestOrder>(_bufferWriter.WrittenSpan.ToArray(), _options);
return DeepEqualsViaJson(_order, roundTripped);
}
}
/// <summary>
/// Benchmarks MemoryPack via the IBufferWriter overload with a pre-allocated, reused ArrayBufferWriter.
/// Apples-to-apples counterpart to AcBinaryBufferWriterBenchmark — MemoryPack's IBufferWriter is the path it's designed for.
/// </summary>
private sealed class MemoryPackBufferWriterBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly byte[] _serialized;
private readonly ArrayBufferWriter<byte> _bufferWriter;
public string Engine => EngineMemoryPack;
public string IoMode => IoBufWrReuse;
public string DispatchMode => ModeSGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupAllocBytes { get; }
public MemoryPackBufferWriterBenchmark(TestOrder order, string optionsPreset)
{
_order = order;
OptionsPreset = optionsPreset;
_serialized = MemoryPackSerializer.Serialize(order);
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
var beforeSetup = GC.GetAllocatedBytesForCurrentThread();
_bufferWriter = new ArrayBufferWriter<byte>(_serialized.Length * 2);
var afterSetup = GC.GetAllocatedBytesForCurrentThread();
SetupAllocBytes = afterSetup - beforeSetup;
}
public void Warmup(int iterations)
{
for (var i = 0; i < iterations; i++)
{
Serialize();
Deserialize();
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
_bufferWriter.ResetWrittenCount();
MemoryPackSerializer.Serialize(_bufferWriter, _order);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => MemoryPackSerializer.Deserialize<TestOrder>(_serialized);
public bool VerifyRoundTrip()
{
_bufferWriter.ResetWrittenCount();
MemoryPackSerializer.Serialize(_bufferWriter, _order);
var roundTripped = MemoryPackSerializer.Deserialize<TestOrder>(_bufferWriter.WrittenSpan.ToArray());
return DeepEqualsViaJson(_order, roundTripped);
}
}
@ -663,13 +919,17 @@ public static class Program
private readonly string _serialized;
private readonly byte[] _serializedUtf8;
public string Name { get; }
public string Engine => EngineSystemTextJson;
public string IoMode => IoString;
public string DispatchMode => ModeRuntime; // System.Text.Json default uses reflection-based metadata (no source generator opt-in here)
public string OptionsPreset { get; }
public int SerializedSize => _serializedUtf8.Length;
public long SetupAllocBytes => 0;
public SystemTextJsonBenchmark(TestOrder order, string name)
public SystemTextJsonBenchmark(TestOrder order, string optionsPreset)
{
_order = order;
Name = name;
OptionsPreset = optionsPreset;
_options = new JsonSerializerOptions
{
WriteIndented = false,
@ -710,13 +970,19 @@ public static class Program
private sealed class BenchmarkResult
{
public string TestDataName { get; set; } = "";
public string SerializerName { get; set; } = "";
public string Engine { get; set; } = "";
public string IoMode { get; set; } = "";
public string DispatchMode { get; set; } = "";
public string OptionsPreset { get; set; } = "";
/// <summary>Synthesized display name for backwards compatibility / single-string-row scenarios. Includes DispatchMode so SGen and Runtime variants of the same preset don't collide in grouping (e.g. SUMMARY: WINNERS).</summary>
public string SerializerName => $"{Engine} ({IoMode}, {OptionsPreset}, {DispatchMode})";
public string? OptionsDescription { get; set; }
public int SerializedSize { get; set; }
public double SerializeTimeMs { get; set; }
public double DeserializeTimeMs { get; set; }
public long SerializeAllocBytesPerOp { get; set; }
public long DeserializeAllocBytesPerOp { get; set; }
public long SetupAllocBytes { get; set; }
public double RoundTripTimeMs => SerializeTimeMs + DeserializeTimeMs;
}
@ -726,7 +992,7 @@ public static class Program
var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs,8:F2} ms" : " N/A";
var serAlloc = result.SerializeTimeMs > 0 ? $"{result.SerializeAllocBytesPerOp,8:N0} B/op" : " N/A";
var desAlloc = result.DeserializeTimeMs > 0 ? $"{result.DeserializeAllocBytesPerOp,8:N0} B/op" : " N/A";
System.Console.WriteLine($" {result.SerializerName,-25} | Size: {result.SerializedSize,8:N0} | Ser: {ser} ({serAlloc}) | Des: {des} ({desAlloc})");
System.Console.WriteLine($" {result.SerializerName,-40} | Size: {result.SerializedSize,8:N0} | Ser: {ser} ({serAlloc}) | Des: {des} ({desAlloc})");
}
private static void PrintGroupedResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
@ -754,30 +1020,37 @@ public static class Program
{
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.RoundTripTimeMs).ToList();
// Baseline switched MessagePack → MemoryPack: MemoryPack is the SOTA performance leader.
var memPackResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerMemoryPack);
var acBinaryResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerAcBinaryDefault);
var memPackResult = testResults.FirstOrDefault(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray));
// Pin the comparison to AcBinary's SGen variant — apples-to-apples vs MemoryPack (also source-generated).
// The Runtime variant is shown alongside in the table for context, not used as the headline number.
var acBinaryResult = testResults.FirstOrDefault(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen));
System.Console.WriteLine($"\n┌─ {testData.DisplayName} ─".PadRight(98, '─') + "┐");
System.Console.WriteLine($"│ {"#",-4} │ {"Serializer",-25} │ {"Size",-10} │ {"Serialize",-12} │ {"Deserialize",-12} │ {"Round-trip",-12} │");
System.Console.WriteLine($"├{"".PadRight(6, '─')}┼{"".PadRight(27, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(14, '─')}┼{"".PadRight(14, '─')}┼{"".PadRight(14, '─')}┤");
System.Console.WriteLine($"\n┌─ {testData.DisplayName} ─".PadRight(159, '─') + "┐");
System.Console.WriteLine($"│ {"#",-4} │ {"Engine",-11} │ {"Options",-22} │ {"IO",-12} │ {"Mode",-8} │ {"Setup",-8} │ {"Size",-8} │ {"Ser ms",-10} │ {"SerAlloc",-10} │ {"Des ms",-10} │ {"DesAlloc",-10} │ {"RT ms",-10} │");
System.Console.WriteLine($"├{"".PadRight(6, '─')}┼{"".PadRight(13, '─')}┼{"".PadRight(24, '─')}┼{"".PadRight(14, '─')}┼{"".PadRight(10, '─')}┼{"".PadRight(10, '─')}┼{"".PadRight(10, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┤");
var rank = 1;
foreach (var result in testResults)
{
var size = $"{result.SerializedSize:N0}";
var setup = result.SetupAllocBytes > 0 ? $"{result.SetupAllocBytes:N0}" : "0";
var ser = result.SerializeTimeMs > 0 ? $"{result.SerializeTimeMs:F2} ms" : "N/A";
var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs:F2} ms" : "N/A";
var rt = result.RoundTripTimeMs > 0 ? $"{result.RoundTripTimeMs:F2} ms" : "N/A";
var serAlloc = result.SerializeTimeMs > 0 ? $"{result.SerializeAllocBytesPerOp:N0} B" : "N/A";
var desAlloc = result.DeserializeTimeMs > 0 ? $"{result.DeserializeAllocBytesPerOp:N0} B" : "N/A";
// Highlight MemoryPack (baseline) and AcBinary (Default) with win/lose colors
var isHighlighted = result.SerializerName is SerializerMemoryPack or SerializerAcBinaryDefault;
// Highlight MemoryPack baseline (any Byte[]) and AcBinary headline contender (Byte[] + SGen) with win/lose colors.
// The AcBinary Byte[]+Runtime variant is shown unhighlighted — it's contextual (SGen speed-up reference), not the headline.
var isHighlighted = (result.Engine == EngineMemoryPack && result.IoMode == IoByteArray)
|| (result.Engine == EngineAcBinary && result.IoMode == IoByteArray && result.DispatchMode == ModeSGen);
var prefix = isHighlighted ? "│►" : "│ ";
var suffix = isHighlighted ? "◄│" : " │";
// Color logic: Green = winner (faster), Red = loser (slower)
if (isHighlighted && memPackResult != null && acBinaryResult != null)
{
var isMemPack = result.SerializerName == SerializerMemoryPack;
var isMemPack = (result.Engine == EngineMemoryPack && result.IoMode == IoByteArray);
var memPackFaster = memPackResult.RoundTripTimeMs < acBinaryResult.RoundTripTimeMs;
if (isMemPack)
@ -790,7 +1063,7 @@ public static class Program
}
}
System.Console.WriteLine($"{prefix}{rank++,4} │ {result.SerializerName,-25} │ {size,10} │ {ser,12} │ {des,12} │ {rt,12}{suffix}");
System.Console.WriteLine($"{prefix}{rank++,4} │ {result.Engine,-11} │ {result.OptionsPreset,-22} │ {result.IoMode,-12} │ {result.DispatchMode,-8} │ {setup,8} │ {size,8} │ {ser,10} │ {serAlloc,10} │ {des,10} │ {desAlloc,10} │ {rt,10}{suffix}");
if (isHighlighted)
{
@ -798,53 +1071,65 @@ public static class Program
}
}
// Allocation summary row (per-op allocation in bytes; lower is better)
System.Console.WriteLine($"├{"".PadRight(6, '─')}┼{"".PadRight(27, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(14, '─')}┼{"".PadRight(14, '─')}┼{"".PadRight(14, '─')}┤");
foreach (var result in testResults)
{
if (result.SerializerName is not (SerializerMemoryPack or SerializerAcBinaryDefault)) continue;
var serAlloc = result.SerializeTimeMs > 0 ? $"{result.SerializeAllocBytesPerOp:N0} B/op" : "N/A";
var desAlloc = result.DeserializeTimeMs > 0 ? $"{result.DeserializeAllocBytesPerOp:N0} B/op" : "N/A";
System.Console.WriteLine($"│ alloc │ {result.SerializerName,-25} │ {"",10} │ {serAlloc,12} │ {desAlloc,12} │ {"",12} │");
}
// Footer row: AcBinary (Default) vs MemoryPack comparison per column
// Footer row: AcBinary (Byte[]) vs MemoryPack (Byte[]) comparison per column
if (memPackResult != null && acBinaryResult != null)
{
var sizePct = (acBinaryResult.SerializedSize / (double)memPackResult.SerializedSize - 1) * 100;
var serPct = memPackResult.SerializeTimeMs > 0 ? (acBinaryResult.SerializeTimeMs / memPackResult.SerializeTimeMs - 1) * 100 : 0;
var desPct = memPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / memPackResult.DeserializeTimeMs - 1) * 100 : 0;
var rtPct = memPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / memPackResult.RoundTripTimeMs - 1) * 100 : 0;
var serAllocPct = memPackResult.SerializeAllocBytesPerOp > 0 ? (acBinaryResult.SerializeAllocBytesPerOp / (double)memPackResult.SerializeAllocBytesPerOp - 1) * 100 : 0;
var desAllocPct = memPackResult.DeserializeAllocBytesPerOp > 0 ? (acBinaryResult.DeserializeAllocBytesPerOp / (double)memPackResult.DeserializeAllocBytesPerOp - 1) * 100 : 0;
System.Console.WriteLine($"├{"".PadRight(6, '─')}┴{"".PadRight(27, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(14, '─')}┼{"".PadRight(14, '─')}┼{"".PadRight(13, '─')}┤");
System.Console.Write($"│ ► Default vs {SerializerMemoryPack,-19} │ ");
// Footer separator: merge first 5 cols (#, Engine, Options, IO, Mode) → comparison label;
// remaining 7 cols stay aligned (Setup, Size, Ser ms, SerAlloc, Des ms, DesAlloc, RT ms).
System.Console.WriteLine($"├{"".PadRight(6, '─')}┴{"".PadRight(13, '─')}┴{"".PadRight(24, '─')}┴{"".PadRight(14, '─')}┴{"".PadRight(10, '─')}┼{"".PadRight(10, '─')}┼{"".PadRight(10, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┤");
// Merged label cell width = 4 + 11 + 22 + 12 + 8 + 4*3 (dropped separators) = 69
System.Console.Write($"│ {" AcBinary (Byte[]) vs MemoryPack (Byte[])",-69} │ ");
// Setup (n/a for Byte[] vs Byte[] — neither pre-allocates)
System.Console.Write($"{"",8}");
System.Console.Write(" │ ");
// Size
System.Console.ForegroundColor = sizePct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{sizePct,+9:+0;-0}%");
System.Console.Write($"{sizePct,+7:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Serialize
System.Console.ForegroundColor = serPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{serPct,+11:+0;-0}%");
System.Console.Write($"{serPct,+9:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Serialize Alloc
System.Console.ForegroundColor = serAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{serAllocPct,+9:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Deserialize
System.Console.ForegroundColor = desPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{desPct,+11:+0;-0}%");
System.Console.Write($"{desPct,+9:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Deserialize Alloc
System.Console.ForegroundColor = desAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{desAllocPct,+9:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Round-trip
System.Console.ForegroundColor = rtPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{rtPct,+10:+0;-0}%");
System.Console.Write($"{rtPct,+9:+0;-0}%");
System.Console.ResetColor();
System.Console.WriteLine(" │");
}
System.Console.WriteLine($"└{"".PadRight(6, '─')}─{"".PadRight(27, '─')}┴{"".PadRight(12, '─')}┴{"".PadRight(14, '─')}┴{"".PadRight(14, '─')}┴{"".PadRight(13, '─')}┘");
// Closing line: merged on left (─ between cols 1-5), ┴ on the right (cols 6-12 boundary).
System.Console.WriteLine($"└{"".PadRight(6, '─')}─{"".PadRight(13, '─')}─{"".PadRight(24, '─')}─{"".PadRight(14, '─')}─{"".PadRight(10, '─')}┴{"".PadRight(10, '─')}┴{"".PadRight(10, '─')}┴{"".PadRight(12, '─')}┴{"".PadRight(12, '─')}┴{"".PadRight(12, '─')}┴{"".PadRight(12, '─')}┴{"".PadRight(12, '─')}┘");
//System.Console.WriteLine($"GrowBufferCount: {AcBinarySerializer.GrowBufferCount}");
//System.Console.WriteLine($"GrowBufferTotalBytes: {AcBinarySerializer.GrowBufferTotalBytes:N0} bytes");
}
@ -855,8 +1140,8 @@ public static class Program
System.Console.WriteLine("║ SUMMARY: WINNERS ║");
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
System.Console.WriteLine($"\n{"Category",-20} │ {"Winner",-25} │ {"Avg Value",-18}");
System.Console.WriteLine($"{"".PadRight(20, '─')}─┼─{"".PadRight(25, '─')}─┼─{"".PadRight(18, '─')}");
System.Console.WriteLine($"\n{"Category",-20} │ {"Winner",-40} │ {"Avg Value",-18}");
System.Console.WriteLine($"{"".PadRight(20, '─')}─┼─{"".PadRight(40, '─')}─┼─{"".PadRight(18, '─')}");
// Fastest Serialize
var fastestSer = results.Where(r => r.SerializeTimeMs > 0)
@ -865,7 +1150,7 @@ public static class Program
.OrderBy(x => x.AvgTime)
.FirstOrDefault();
if (fastestSer != null)
System.Console.WriteLine($"{"Fastest Serialize",-20} │ {fastestSer.Name,-25} │ {fastestSer.AvgTime,15:F2} ms");
System.Console.WriteLine($"{"Fastest Serialize",-20} │ {fastestSer.Name,-40} │ {fastestSer.AvgTime,15:F2} ms");
// Fastest Deserialize
var fastestDes = results.Where(r => r.DeserializeTimeMs > 0)
@ -874,7 +1159,7 @@ public static class Program
.OrderBy(x => x.AvgTime)
.FirstOrDefault();
if (fastestDes != null)
System.Console.WriteLine($"{"Fastest Deserialize",-20} │ {fastestDes.Name,-25} │ {fastestDes.AvgTime,15:F2} ms");
System.Console.WriteLine($"{"Fastest Deserialize",-20} │ {fastestDes.Name,-40} │ {fastestDes.AvgTime,15:F2} ms");
// Smallest Size
var smallestSize = results
@ -883,7 +1168,7 @@ public static class Program
.OrderBy(x => x.AvgSize)
.FirstOrDefault();
if (smallestSize != null)
System.Console.WriteLine($"{"Smallest Size",-20} │ {smallestSize.Name,-25} │ {smallestSize.AvgSize,15:F0} B");
System.Console.WriteLine($"{"Smallest Size",-20} │ {smallestSize.Name,-40} │ {smallestSize.AvgSize,15:F0} B");
// Fastest Round-trip
var fastestRt = results.Where(r => r.RoundTripTimeMs > 0)
@ -892,22 +1177,24 @@ public static class Program
.OrderBy(x => x.AvgTime)
.FirstOrDefault();
if (fastestRt != null)
System.Console.WriteLine($"{"Fastest Round-trip",-20} │ {fastestRt.Name,-25} │ {fastestRt.AvgTime,15:F2} ms");
System.Console.WriteLine($"{"Fastest Round-trip",-20} │ {fastestRt.Name,-40} │ {fastestRt.AvgTime,15:F2} ms");
// Overall AcBinary Default vs MemoryPack comparison (baseline switched MessagePack → MemoryPack as SOTA reference)
var memPackSerResults = results.Where(r => r.SerializerName == SerializerMemoryPack && r.SerializeTimeMs > 0).ToList();
var memPackDesResults = results.Where(r => r.SerializerName == SerializerMemoryPack && r.DeserializeTimeMs > 0).ToList();
var memPackRtResults = results.Where(r => r.SerializerName == SerializerMemoryPack && r.RoundTripTimeMs > 0).ToList();
// Overall AcBinary (SGen) vs MemoryPack comparison (baseline switched MessagePack → MemoryPack as SOTA reference).
// AcBinary side is restricted to DispatchMode == SGen — apples-to-apples vs MemoryPack which is also source-generated.
// The Runtime variant is shown side-by-side in each per-test fancy table for SGen-speedup context, but excluded from this headline.
var memPackSerResults = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray) && r.SerializeTimeMs > 0).ToList();
var memPackDesResults = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray) && r.DeserializeTimeMs > 0).ToList();
var memPackRtResults = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray) && r.RoundTripTimeMs > 0).ToList();
var acBinarySerResults = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.SerializeTimeMs > 0).ToList();
var acBinaryDesResults = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.DeserializeTimeMs > 0).ToList();
var acBinaryRtResults = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.RoundTripTimeMs > 0).ToList();
var acBinarySerResults = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen) && r.SerializeTimeMs > 0).ToList();
var acBinaryDesResults = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen) && r.DeserializeTimeMs > 0).ToList();
var acBinaryRtResults = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen) && r.RoundTripTimeMs > 0).ToList();
// Skip comparison if no data available
if (memPackRtResults.Count == 0 || acBinaryRtResults.Count == 0)
{
System.Console.WriteLine();
System.Console.WriteLine($"── {SerializerAcBinaryDefault} vs {SerializerMemoryPack} (Overall) ──");
System.Console.WriteLine($"── {"AcBinary (Byte[], SGen)"} vs {"MemoryPack (Byte[])"} (Overall) ──");
System.Console.WriteLine(" (Comparison requires both serialize and deserialize data)");
return;
}
@ -915,19 +1202,19 @@ public static class Program
var memPackAvgSer = memPackSerResults.Count > 0 ? memPackSerResults.Average(r => r.SerializeTimeMs) : 0;
var memPackAvgDes = memPackDesResults.Average(r => r.DeserializeTimeMs);
var memPackAvgRt = memPackRtResults.Average(r => r.RoundTripTimeMs);
var memPackAvgSize = results.Where(r => r.SerializerName == SerializerMemoryPack).Average(r => r.SerializedSize);
var memPackAvgSize = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray)).Average(r => r.SerializedSize);
var memPackAvgSerAlloc = memPackSerResults.Count > 0 ? memPackSerResults.Average(r => r.SerializeAllocBytesPerOp) : 0;
var memPackAvgDesAlloc = memPackDesResults.Count > 0 ? memPackDesResults.Average(r => r.DeserializeAllocBytesPerOp) : 0;
var acBinaryAvgSer = acBinarySerResults.Count > 0 ? acBinarySerResults.Average(r => r.SerializeTimeMs) : 0;
var acBinaryAvgDes = acBinaryDesResults.Average(r => r.DeserializeTimeMs);
var acBinaryAvgRt = acBinaryRtResults.Average(r => r.RoundTripTimeMs);
var acBinaryAvgSize = results.Where(r => r.SerializerName == SerializerAcBinaryDefault).Average(r => r.SerializedSize);
var acBinaryAvgSize = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen)).Average(r => r.SerializedSize);
var acBinaryAvgSerAlloc = acBinarySerResults.Count > 0 ? acBinarySerResults.Average(r => r.SerializeAllocBytesPerOp) : 0;
var acBinaryAvgDesAlloc = acBinaryDesResults.Count > 0 ? acBinaryDesResults.Average(r => r.DeserializeAllocBytesPerOp) : 0;
System.Console.WriteLine();
System.Console.WriteLine($"── {SerializerAcBinaryDefault} vs {SerializerMemoryPack} (Overall) ──");
System.Console.WriteLine($"── {"AcBinary (Byte[], SGen)"} vs {"MemoryPack (Byte[])"} (Overall) ──");
// Only show serialize comparison if data available
if (memPackAvgSer > 0 && acBinaryAvgSer > 0)
@ -1030,37 +1317,39 @@ public static class Program
// CSV-like data for easy import (now includes per-op allocation columns)
sb.AppendLine("=== RAW DATA (CSV) ===");
sb.AppendLine("TestData,Serializer,Size,SerializeMs,DeserializeMs,RoundTripMs,SerializeAllocBytesPerOp,DeserializeAllocBytesPerOp");
sb.AppendLine("TestData,Engine,IO,Mode,Options,Size,SerializeMs,DeserializeMs,RoundTripMs,SerializeAllocBytesPerOp,DeserializeAllocBytesPerOp,SetupAllocBytes");
foreach (var testData in testDataSets)
{
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).ToList();
foreach (var result in testResults)
{
sb.AppendLine($"{result.TestDataName},{result.SerializerName},{result.SerializedSize},{result.SerializeTimeMs:F2},{result.DeserializeTimeMs:F2},{result.RoundTripTimeMs:F2},{result.SerializeAllocBytesPerOp},{result.DeserializeAllocBytesPerOp}");
sb.AppendLine($"{result.TestDataName},{result.Engine},{result.IoMode},{result.DispatchMode},{result.OptionsPreset},{result.SerializedSize},{result.SerializeTimeMs:F2},{result.DeserializeTimeMs:F2},{result.RoundTripTimeMs:F2},{result.SerializeAllocBytesPerOp},{result.DeserializeAllocBytesPerOp},{result.SetupAllocBytes}");
}
}
sb.AppendLine();
// Formatted results
sb.AppendLine("=== FORMATTED RESULTS BY TEST DATA ===");
sb.AppendLine($"(►) = Highlighted: {SerializerMemoryPack} (baseline) and {SerializerAcBinaryDefault}");
sb.AppendLine($"(►) = Highlighted: {"MemoryPack (Byte[])"} (baseline) and {"AcBinary (Byte[])"}");
sb.AppendLine();
foreach (var testData in testDataSets)
{
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.RoundTripTimeMs).ToList();
var memPackResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerMemoryPack);
var acBinaryResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerAcBinaryDefault);
var memPackResult = testResults.FirstOrDefault(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray));
// Pin the comparison to AcBinary's SGen variant — apples-to-apples vs MemoryPack (also source-generated).
// The Runtime variant is shown alongside in the table for context, not used as the headline number.
var acBinaryResult = testResults.FirstOrDefault(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen));
sb.AppendLine();
sb.AppendLine($"--- {testData.DisplayName} ---");
sb.AppendLine($"{"#",-4} {"Serializer",-26} {"Size",-12} {"Serialize",-14} {"Deserialize",-14} {"Round-trip",-14} {"SerAlloc",-12} {"DesAlloc",-12}");
sb.AppendLine(new string('-', 110));
sb.AppendLine($"{"#",-4} {"Serializer",-42} {"Size",-12} {"Serialize",-14} {"Deserialize",-14} {"Round-trip",-14} {"SerAlloc",-12} {"DesAlloc",-12}");
sb.AppendLine(new string('-', 130));
var rank = 1;
foreach (var result in testResults)
{
var isHighlighted = result.SerializerName is SerializerMemoryPack or SerializerAcBinaryDefault;
var isHighlighted = ((result.Engine == EngineMemoryPack || result.Engine == EngineAcBinary) && result.IoMode == IoByteArray);
var prefix = isHighlighted ? "► " : " ";
var size = $"{result.SerializedSize:N0}";
@ -1070,7 +1359,7 @@ public static class Program
var serAlloc = result.SerializeTimeMs > 0 ? $"{result.SerializeAllocBytesPerOp:N0} B" : "N/A";
var desAlloc = result.DeserializeTimeMs > 0 ? $"{result.DeserializeAllocBytesPerOp:N0} B" : "N/A";
sb.AppendLine($"{rank++,2} {prefix}{result.SerializerName,-24} {size,-12} {ser,-14} {des,-14} {rt,-14} {serAlloc,-12} {desAlloc,-12}");
sb.AppendLine($"{rank++,2} {prefix}{result.SerializerName,-40} {size,-12} {ser,-14} {des,-14} {rt,-14} {serAlloc,-12} {desAlloc,-12}");
}
// Summary row for this test data (vs MemoryPack — baseline switched MessagePack → MemoryPack)
@ -1081,7 +1370,7 @@ public static class Program
var desPct = memPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / memPackResult.DeserializeTimeMs - 1) * 100 : 0;
var rtPct = memPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / memPackResult.RoundTripTimeMs - 1) * 100 : 0;
sb.AppendLine($" {SerializerAcBinaryDefault} vs {SerializerMemoryPack}: Size {sizePct:+0;-0}% │ Ser {serPct:+0;-0}% │ Des {desPct:+0;-0}% │ RT {rtPct:+0;-0}%");
sb.AppendLine($" {"AcBinary (Byte[])"} vs {"MemoryPack (Byte[])"}: Size {sizePct:+0;-0}% │ Ser {serPct:+0;-0}% │ Des {desPct:+0;-0}% │ RT {rtPct:+0;-0}%");
}
//sb.AppendLine($"GrowBufferCount: {AcBinarySerializer.GrowBufferCount}");
@ -1090,16 +1379,18 @@ public static class Program
// Summary comparison (vs MemoryPack)
// Restrict AcBinary side to SGen — the SGen vs Runtime variants are shown side-by-side
// in the per-test fancy table; the headline should compare apples-to-apples (both source-generated).
sb.AppendLine();
sb.AppendLine($"=== {SerializerAcBinaryDefault} vs {SerializerMemoryPack} (Overall) ===");
sb.AppendLine($"=== {"AcBinary (Byte[], SGen)"} vs {"MemoryPack (Byte[])"} (Overall) ===");
var memPackSerResults2 = results.Where(r => r.SerializerName == SerializerMemoryPack && r.SerializeTimeMs > 0).ToList();
var memPackDesResults2 = results.Where(r => r.SerializerName == SerializerMemoryPack && r.DeserializeTimeMs > 0).ToList();
var memPackRtResults2 = results.Where(r => r.SerializerName == SerializerMemoryPack && r.RoundTripTimeMs > 0).ToList();
var memPackSerResults2 = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray) && r.SerializeTimeMs > 0).ToList();
var memPackDesResults2 = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray) && r.DeserializeTimeMs > 0).ToList();
var memPackRtResults2 = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray) && r.RoundTripTimeMs > 0).ToList();
var acBinarySerResults2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.SerializeTimeMs > 0).ToList();
var acBinaryDesResults2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.DeserializeTimeMs > 0).ToList();
var acBinaryRtResults2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.RoundTripTimeMs > 0).ToList();
var acBinarySerResults2 = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen) && r.SerializeTimeMs > 0).ToList();
var acBinaryDesResults2 = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen) && r.DeserializeTimeMs > 0).ToList();
var acBinaryRtResults2 = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen) && r.RoundTripTimeMs > 0).ToList();
if (memPackSerResults2.Count > 0 && acBinarySerResults2.Count > 0)
{
@ -1130,8 +1421,8 @@ public static class Program
sb.AppendLine($" Round-trip: {((acBinaryAvgRt2 / memPackAvgRt2 - 1) * 100):+0;-0}% ({acBinaryAvgRt2:F2} ms vs {memPackAvgRt2:F2} ms)");
}
var memPackAvgSize2 = results.Where(r => r.SerializerName == SerializerMemoryPack).Average(r => r.SerializedSize);
var acBinaryAvgSize2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault).Average(r => r.SerializedSize);
var memPackAvgSize2 = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray)).Average(r => r.SerializedSize);
var acBinaryAvgSize2 = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen)).Average(r => r.SerializedSize);
sb.AppendLine($" Size: {((acBinaryAvgSize2 / memPackAvgSize2 - 1) * 100):+0;-0}% ({acBinaryAvgSize2:F0} B vs {memPackAvgSize2:F0} B)");
File.WriteAllText(logFilePath, sb.ToString(), Utf8NoBom);
@ -1148,7 +1439,7 @@ public static class Program
var testTypeName = testDataSets.FirstOrDefault()?.TypeName ?? "unknown";
sb.AppendLine($"# AcBinary Benchmark {BuildConfiguration} {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
sb.AppendLine($"Iterations: {TestIterations} | Warmup: {WarmupIterations} | Samples: {BenchmarkSamples} (median) | .NET: {Environment.Version} | TestType: {testTypeName}");
sb.AppendLine($"Baseline: {SerializerMemoryPack} (SOTA reference) | Verified: round-trip correctness checked once per cell before warmup");
sb.AppendLine($"Baseline: {"MemoryPack (Byte[])"} (SOTA reference) | Verified: round-trip correctness checked once per cell before warmup");
// Options summary
var optionsMap = results
@ -1169,8 +1460,8 @@ public static class Program
sb.AppendLine();
sb.AppendLine("## Results");
sb.AppendLine();
sb.AppendLine("TestData | Serializer | Size(B) | Ser(ms) | Deser(ms) | RT(ms) | SerAlloc(B/op) | DesAlloc(B/op)");
sb.AppendLine("---|---|---|---|---|---|---|---");
sb.AppendLine("TestData | Engine | IO | Mode | Options | Size(B) | Ser(ms) | Deser(ms) | RT(ms) | SerAlloc(B/op) | DesAlloc(B/op) | SetupAlloc(B)");
sb.AppendLine("---|---|---|---|---|---|---|---|---|---|---|---");
foreach (var testData in testDataSets)
{
@ -1187,7 +1478,8 @@ public static class Program
var rt = r.RoundTripTimeMs > 0 ? r.RoundTripTimeMs.ToString("F2", inv) : "-";
var serAlloc = r.SerializeTimeMs > 0 ? r.SerializeAllocBytesPerOp.ToString(inv) : "-";
var desAlloc = r.DeserializeTimeMs > 0 ? r.DeserializeAllocBytesPerOp.ToString(inv) : "-";
sb.AppendLine($"{r.TestDataName} | {r.SerializerName} | {r.SerializedSize} | {ser} | {des} | {rt} | {serAlloc} | {desAlloc}");
var setupAlloc = r.SetupAllocBytes.ToString(inv);
sb.AppendLine($"{r.TestDataName} | {r.Engine} | {r.IoMode} | {r.DispatchMode} | {r.OptionsPreset} | {r.SerializedSize} | {ser} | {des} | {rt} | {serAlloc} | {desAlloc} | {setupAlloc}");
}
}

View File

@ -55,7 +55,7 @@ public enum TestUserRole
/// Implements IId&lt;int&gt; for semantic $id/$ref serialization.
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
[MessagePackObject]
public partial class SharedTag : IId<int>
{
@ -80,7 +80,7 @@ public partial class SharedTag : IId<int>
/// Shared category - for hierarchical cross-reference testing.
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
[MessagePackObject]
public partial class SharedCategory : IId<int>
{
@ -106,7 +106,7 @@ public partial class SharedCategory : IId<int>
/// Shared user reference - appears in many places to test $ref deduplication.
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
[MessagePackObject]
public partial class SharedUser : IId<int>
{
@ -136,7 +136,7 @@ public partial class SharedUser : IId<int>
/// User preferences - non-IId nested object
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
[MessagePackObject]
public partial class UserPreferences
{
@ -162,7 +162,7 @@ public partial class UserPreferences
/// Does NOT implement IId, so uses standard Newtonsoft reference tracking.
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
[MessagePackObject]
public partial class MetadataInfo
{
@ -190,7 +190,7 @@ public partial class MetadataInfo
/// Level 1: Main order - root of the hierarchy
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
[MessagePackObject]
public partial class TestOrder : IId<int>
{
@ -249,7 +249,7 @@ public partial class TestOrder : IId<int>
/// <summary>
/// Level 1: Main order - root of the hierarchy
/// </summary>
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
public partial class TestOrder_Circ_Ref : IId<int>
{
public int Id { get; set; }
@ -284,7 +284,7 @@ public partial class TestOrder_Circ_Ref : IId<int>
/// Level 2: Order item with pallets
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
[MessagePackObject]
public partial class TestOrderItem : IId<int>
{
@ -323,7 +323,7 @@ public partial class TestOrderItem : IId<int>
/// <summary>
/// Level 2: Order item with pallets
/// </summary>
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
public partial class TestOrderItem_Circ_Ref : IId<int>
{
public int Id { get; set; }
@ -347,7 +347,7 @@ public partial class TestOrderItem_Circ_Ref : IId<int>
/// Level 3: Pallet containing measurements
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
[MessagePackObject]
public partial class TestPallet : IId<int>
{
@ -390,7 +390,7 @@ public partial class TestPallet : IId<int>
/// Level 4: Measurement with multiple points
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
[MessagePackObject]
public partial class TestMeasurement : IId<int>
{
@ -425,7 +425,7 @@ public partial class TestMeasurement : IId<int>
/// Level 5: Deepest level - measurement point
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
[MessagePackObject]
public partial class TestMeasurementPoint : IId<int>
{
@ -459,7 +459,7 @@ public partial class TestMeasurementPoint : IId<int>
/// <summary>
/// Order with Guid Id - for testing Guid-based IId
/// </summary>
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
public class TestGuidOrder : IId<Guid>
{
public Guid Id { get; set; }
@ -471,7 +471,7 @@ public class TestGuidOrder : IId<Guid>
/// <summary>
/// Item with Guid Id
/// </summary>
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
public class TestGuidItem : IId<Guid>
{
public Guid Id { get; set; }
@ -487,7 +487,7 @@ public class TestGuidItem : IId<Guid>
/// Simulates NopCommerce GenericAttribute - stores key-value pairs where DateTime values
/// are stored as strings in the database.
/// </summary>
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
public class TestGenericAttribute
{
public int Id { get; set; }
@ -499,7 +499,7 @@ public class TestGenericAttribute
/// DTO with GenericAttributes collection - simulates OrderDto with string-stored DateTime values.
/// This reproduces the production bug where Binary serialization was thought to corrupt DateTime strings.
/// </summary>
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
public class TestDtoWithGenericAttributes : IId<int>
{
public int Id { get; set; }
@ -510,7 +510,7 @@ public class TestDtoWithGenericAttributes : IId<int>
/// <summary>
/// Order with nullable collections for null vs empty testing
/// </summary>
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
public class TestOrderWithNullableCollections
{
public int Id { get; set; }
@ -523,7 +523,7 @@ public class TestOrderWithNullableCollections
/// Class with all primitive types for WASM/serialization testing
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
public partial class PrimitiveTestClass
{
public int IntValue { get; set; }
@ -546,7 +546,7 @@ public partial class PrimitiveTestClass
/// Class with extended primitive types for full serializer coverage.
/// Includes DateTimeOffset, TimeSpan, Dictionary, null properties.
/// </summary>
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
public class ExtendedPrimitiveTestClass
{
public int Id { get; set; }
@ -576,7 +576,7 @@ public class ExtendedPrimitiveTestClass
/// <summary>
/// Class with array of objects containing null items for WriteNull coverage
/// </summary>
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
public class ObjectWithNullItems
{
public int Id { get; set; }
@ -591,7 +591,7 @@ public class ObjectWithNullItems
/// "Server-side" DTO with extra properties that the "client" doesn't know about.
/// Used to test SkipValue functionality when deserializing unknown properties.
/// </summary>
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
public class ServerCustomerDto : IId<int>
{
public int Id { get; set; }
@ -624,7 +624,7 @@ public class ServerCustomerDto : IId<int>
/// the deserializer must skip unknown properties correctly
/// while still maintaining string intern table consistency.
/// </summary>
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
public class ClientCustomerDto : IId<int>
{
public int Id { get; set; }
@ -638,7 +638,7 @@ public class ClientCustomerDto : IId<int>
/// Server DTO with nested objects that client doesn't know about.
/// Tests skipping complex nested structures.
/// </summary>
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
public class ServerOrderWithExtras : IId<int>
{
public int Id { get; set; }
@ -659,7 +659,7 @@ public class ServerOrderWithExtras : IId<int>
/// <summary>
/// Client version of the order - doesn't have Customer/RelatedCustomers properties.
/// </summary>
[AcBinarySerializable(true)]
[AcBinarySerializable(false)]
public class ClientOrderSimple : IId<int>
{
public int Id { get; set; }

View File

@ -101,6 +101,6 @@ When `UseMetadata=true`, property name hashes (FNV-1a via `FnvHash.ComputeString
When `UseMetadata=false`, properties are matched by **positional index only** — source and destination must have identical property layouts.
**Edge cases:**
- **Hash collision** (`CheckDuplicatePropName=true`, default): throws `InvalidOperationException`. When `false`: collision silently ignored — risk of data corruption.
- **Hash collision** (`CheckDuplicatePropName=true`, default): throws `InvalidOperationException`. ⚠️ When `false`: collision silently ignored — **risk of data corruption**, see [`BINARY_ISSUES.md#accore-bin-i-c5r7`](BINARY_ISSUES.md#accore-bin-i-c5r7-checkduplicatepropnamefalse-silently-corrupts-on-fnv-1a-hash-collision).
- **Source has unknown property** (not in destination): silently skipped via `SkipValue()`, no error.
- **Destination has extra property** (not in source): left at default value (new instance) or unchanged (populate mode).

View File

@ -167,11 +167,8 @@ A `volatile` field on the holder side (e.g. `_options`) only protects reference
**Status:** Open
**Affects:** `AcBinarySerializerOptions.CheckDuplicatePropName` when set to `false`
**Path:** `BINARY_FEATURES.md` sor 104, `BINARY_OPTIONS.md` sor 43
The default value (`true`) throws `InvalidOperationException` on FNV-1a property-name hash collision within a type. When set to `false` (the docs explicitly recommend this for production performance), collisions are silently accepted — the second property's hash overwrites the first in the lookup table, and the wrong property setter is invoked during deserialization. Result: **silent data corruption** between the colliding properties.
The `BINARY_OPTIONS.md` doc gives two contradictory recommendations on the same flag (*"risk of data corruption"* + *"Disable in production for performance"*) without a single decision rule. NuGet consumers reading either passage in isolation can reach opposite conclusions about safety.
The default value (`true`) throws `InvalidOperationException` on FNV-1a property-name hash collision within a type. When set to `false` (a doc-suggested production-performance optimization), collisions are silently accepted — the second property's hash overwrites the first in the lookup table, and the wrong property setter is invoked during deserialization. Result: **silent data corruption** between the colliding properties.
**Impact:** Latent — FNV-1a + typical property names rarely collide, but applications with many SGen types eventually hit one. Detection requires a separate property-by-property comparison after round-trip; the serializer surfaces no signal.
@ -184,7 +181,6 @@ The `BINARY_OPTIONS.md` doc gives two contradictory recommendations on the same
**Status:** Open
**Affects:** `AcBinarySerializerOptions.MaxDepth` (and any preset using a non-default value, e.g. `ShallowCopy` preset has `MaxDepth=0`)
**Path:** `BINARY_OPTIONS.md` sor 67
When the object graph exceeds `MaxDepth`, deeper objects/collections are written as `Null(76)`**the same byte as a genuine null value**. The deserializer cannot distinguish "depth-cut-off null" from "real null" → silent data loss without any signal at the receive side.
@ -198,7 +194,6 @@ When the object graph exceeds `MaxDepth`, deeper objects/collections are written
**Status:** Open
**Affects:** `AcBinarySerializerOptions.PropertyFilter` combined with `UseMetadata=false`
**Path:** `BINARY_OPTIONS.md` sor 93
When the serializer applies a `PropertyFilter`, excluded properties are completely absent from the stream — no marker, no placeholder. The deserializer must apply an **identical** filter, OR rely on `UseMetadata=true` property-name hash matching. If neither condition holds, positional indices on the receive side mis-match: property A's value lands in property B's setter → silent data corruption.
@ -213,7 +208,6 @@ When the serializer applies a `PropertyFilter`, excluded properties are complete
**Status:** Open
**Affects:** `AcBinarySerializerOptions.ThrowOnCircularReference=false` combined with `ReferenceHandling != None`
**Path:** `BINARY_OPTIONS.md` sor 30
With `ThrowOnCircularReference=false` + reference handling enabled, **only `IId`-implementing types are tracked for cycle detection**. Non-`IId` circular references hit `MaxDepth` before being detected → silent truncation at the depth boundary, no exception, no log.

View File

@ -27,7 +27,7 @@ Configuration options, presets, and option interactions for `AcBinarySerializerO
**Interaction with `ThrowOnCircularReference` (default: `true`):**
- `true` + ref handling enabled: all objects tracked for cycle detection, throws `InvalidOperationException` on circular reference
- `false` + ref handling enabled: only IId types tracked for deduplication, non-IId circular refs silently truncated at `MaxDepth`
- `false` + ref handling enabled: only IId types tracked for deduplication, ⚠️ non-IId circular refs silently truncated at `MaxDepth` — see [`BINARY_ISSUES.md#accore-bin-i-j6t9`](BINARY_ISSUES.md#accore-bin-i-j6t9-non-iid-circular-references-silently-truncated-when-throwoncircularreferencefalse).
## UseMetadata
@ -40,7 +40,7 @@ Configuration options, presets, and option interactions for `AcBinarySerializerO
**Code branch:** `context.UseMetadata` controls whether `ObjectWithMetadata(69)` or plain `Object(64)` markers are used. When `false`, `IsDirectObjectWrite=true` allows source-generated writers to bypass `WriteObject` entirely and inline property writes.
**Related:** `CheckDuplicatePropName` (default: `true`) — throws if FNV-1a hash collision detected between property names of the same type. Disable in production for performance.
**Related:** `CheckDuplicatePropName` (default: `true`) — throws if FNV-1a hash collision detected between property names of the same type. ⚠️ Disabling for performance trades correctness for speed — see [`BINARY_ISSUES.md#accore-bin-i-c5r7`](BINARY_ISSUES.md#accore-bin-i-c5r7-checkduplicatepropnamefalse-silently-corrupts-on-fnv-1a-hash-collision) before turning off.
## UseStringInterning
@ -64,7 +64,7 @@ Configuration options, presets, and option interactions for `AcBinarySerializerO
| `0` | Root level only — nested objects/collections written as `Null(76)` |
| `N` | Objects deeper than N levels written as `Null(76)` |
**Format impact:** Depth-exceeded values appear as `Null(76)` in the stream — indistinguishable from actual null values. No special marker.
**Format impact:** Depth-exceeded values appear as `Null(76)` in the stream — ⚠️ indistinguishable from actual null values, no special marker — see [`BINARY_ISSUES.md#accore-bin-i-p2h8`](BINARY_ISSUES.md#accore-bin-i-p2h8-maxdepth-cut-off-null-indistinguishable-from-real-null) for round-trip data-loss implications.
**Code branch:** Checked at entry of every object/collection write: `if (depth > MaxDepth) { WriteByte(Null); return; }`.
@ -90,7 +90,7 @@ delegate bool BinaryPropertyFilter(in BinaryPropertyFilterContext context);
**BinaryPropertyFilterContext fields:** `DeclaringType`, `PropertyName`, `PropertyType`, `Instance` (null during metadata phase), `IsMetadataPhase`, `GetValue()` (lazy).
**Format impact:** Excluded properties are completely absent from the stream — no marker, no placeholder. The deserializer must use `UseMetadata=true` or identical filter to correctly match property indices.
**Format impact:** Excluded properties are completely absent from the stream — no marker, no placeholder. ⚠️ The deserializer must use `UseMetadata=true` or identical filter to correctly match property indices, otherwise positional drift causes silent corruption — see [`BINARY_ISSUES.md#accore-bin-i-w3f4`](BINARY_ISSUES.md#accore-bin-i-w3f4-propertyfilter--usemetadatafalse-silently-corrupts-via-index-drift).
**Code branch:** `context.HasPropertyFilter` checked in `ShouldSerializeProperty()`. Called twice: once during metadata registration (`Instance=null`), once during write phase.