AyCode.Core/AyCode.Core.Serializers.Con.../Program.cs

1888 lines
102 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using AyCode.Core.Compression;
using AyCode.Core.Serializers.Attributes;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using MemoryPack;
using MessagePack;
using MessagePack.Resolvers;
using Microsoft.Extensions.Options;
using System.Buffers;
using System.Diagnostics;
using System.IO.Pipelines;
using System.IO.Pipes;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
namespace AyCode.Core.Serializers.Console;
/// <summary>
/// Comprehensive benchmark application for all serializers.
/// Compares: AcBinary (all options), MemoryPack, MessagePack, Newtonsoft.Json, System.Text.Json
///
/// Usage:
/// dotnet run # Run all benchmarks
/// dotnet run -- quick # Quick mode (fewer iterations)
/// dotnet run -- serialize # Serialize only
/// dotnet run -- deserialize # Deserialize only
/// </summary>
public static class Program
{
private const string ResultsDirectory = @"H:\Applications\Aycode\Source\AyCode.Core\Test_Benchmark_Results\Benchmark";
#if DEBUG
private const string BuildConfiguration = "Debug";
#else
private const string BuildConfiguration = "Release";
#endif
// Serializer name constants
// 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";
private const string IoNamedPipe = "NamedPipe";
// 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";
private const int JitSleep = 3000;
// 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);
/// <summary>
/// Aggregated <see cref="AcBinarySerializableAttribute"/> feature flags across every type tagged with
/// the attribute in the loaded assemblies. Cached on first access (single reflection scan at startup).
/// Used by <see cref="BuildAcBinaryOptionsDescription"/> so the per-row Options column shows BOTH the
/// configured options-level value AND the effective attribute-level enable flag — a feature flagged
/// off at the type level overrides the options regardless of preset, and that asymmetry must surface
/// in the log to avoid misreading a "RefHandling=OnlyId" / "Interning=All" line as actually active.
/// Aggregation rule: if ALL tagged types have the feature enabled → <c>true</c>; if any tagged type
/// disables it → <c>false</c> (a single disabling type suppresses the feature on the type-graph).
/// </summary>
private static readonly (bool refHandling, bool internString, bool metadata, bool idTracking) _attrFlags
= ScanAcBinaryAttributeFlags();
private static (bool refHandling, bool internString, bool metadata, bool idTracking) ScanAcBinaryAttributeFlags()
{
var attrs = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => { try { return a.GetTypes(); } catch { return Array.Empty<Type>(); } })
.Select(t => t.GetCustomAttribute<AcBinarySerializableAttribute>())
.Where(a => a != null)
.ToList();
if (attrs.Count == 0) return (false, false, false, false);
return (
refHandling: attrs.All(a => a!.EnableRefHandlingFeature),
internString: attrs.All(a => a!.EnableInternStringFeature),
metadata: attrs.All(a => a!.EnableMetadataFeature),
idTracking: attrs.All(a => a!.EnableIdTrackingFeature));
}
/// <summary>
/// Common Options-column formatter for every AcBinary serializer benchmark row. Renders the
/// configured options-level value AND the effective attribute-level enable flag side-by-side
/// (e.g. <c>Interning=All(opt) | False (attr)</c>) so attribute-suppressed features cannot
/// silently mislead. Pass any benchmark-specific extras (e.g. <c>", BufferSize=4096B"</c>)
/// in <paramref name="extra"/> — they are appended after the common fields.
/// </summary>
private static string BuildAcBinaryOptionsDescription(AcBinarySerializerOptions options, string extra = "")
{
return $"WireMode={options.WireMode}, " +
$"RefHandling={options.ReferenceHandling}(opt) | {_attrFlags.refHandling} (attr), " +
$"Interning={options.UseStringInterning}(opt) | {_attrFlags.internString} (attr), " +
$"Metadata={options.UseMetadata}(opt) | {_attrFlags.metadata} (attr), " +
$"SGen={options.UseGeneratedCode}, " +
$"Compression={options.UseCompression}{extra}";
}
#if DEBUG
private const int WarmupIterations = 0;
private const int TestIterations = 1;
private const int BenchmarkSamples = 1; // Debug: single sample, fast iteration
#else
private static int WarmupIterations = 5000; //5000
private static int TestIterations = 1000; //1000
private static int BenchmarkSamples = 3;
#endif
public static void Main(string[] args)
{
// Set console encoding to UTF-8 for proper Unicode character display
System.Console.OutputEncoding = Encoding.UTF8;
// Setup validation — abort BEFORE any benchmark logic if MemoryPack baseline is invalid.
// Done early so user is told immediately, not after warmup.
ValidateMemoryPackSetup();
// Determine layer (which test data to run), opMode (ser/des/all), and serializerMode (standard/asyncpipe).
// CLI args take precedence; if no args, show interactive menu.
// serializerMode: "standard" = all serializers EXCEPT AsyncPipe; "asyncpipe" = ONLY the AsyncPipe streaming benchmark.
// The two are mutually exclusive — AsyncPipe never runs alongside the standard set, so its long-lived pipe
// setup / kernel-buffer overhead does not skew the steady-state Byte[] / IBufferWriter measurements.
string layer;
var opMode = "all";
var serializerMode = "standard";
if (args.Length == 0)
{
var selection = ShowInteractiveMenu();
if (selection == null) return; // user pressed Q
layer = selection.Value.layer;
serializerMode = selection.Value.serializerMode;
}
else
{
var arg = args[0].ToLower();
// Profiler mode: warmup only, then exit (for memory profiler analysis)
if (arg == "profiler")
{
RunProfilerMode();
return;
}
// Quick mode: short warmup, few iterations, small sample count
if (arg == "quick")
{
WarmupIterations = 5;
TestIterations = 100;
BenchmarkSamples = 3;
layer = "all";
}
else if (arg is "core" or "comprehensive" or "edge" or "all")
{
layer = arg;
}
else if (arg is "asyncpipe" or "pipe")
{
// AsyncPipe-only mode: streaming I/O isolation across all test data.
layer = "all";
serializerMode = "asyncpipe";
}
else if (arg is "ser" or "serialize")
{
opMode = "serialize";
layer = "all";
}
else if (arg is "des" or "deserialize")
{
opMode = "deserialize";
layer = "all";
}
else
{
// Backwards compat: unknown arg → treat as layer keyword
layer = arg;
}
}
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
System.Console.WriteLine("║ COMPREHENSIVE SERIALIZER BENCHMARK SUITE ║");
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
var allResults = new List<BenchmarkResult>();
var allTestDataSets = BenchmarkTestDataProvider.CreateTestDataSets();
var testDataSets = FilterByLayer(allTestDataSets, layer);
System.Console.WriteLine($"Layer: {layer} | OpMode: {opMode} | SerializerMode: {serializerMode} | Iterations: {TestIterations} | Warmup: {WarmupIterations} | Samples: {BenchmarkSamples} (median)");
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, serializerMode);
try
{
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);
}
}
finally
{
// Dispose any IDisposable serializers (NamedPipe / FileStream variants own OS resources).
foreach (var s in preSerializers) (s as IDisposable)?.Dispose();
}
}
// Let background tiered-JIT compilation drain before we begin measuring.
Thread.Sleep(JitSleep);
System.Console.WriteLine("✓ Global pre-warmup complete.\n");
}
foreach (var testData in testDataSets)
{
System.Console.WriteLine($"\n{'═'.ToString().PadRight(70, '═')}");
System.Console.WriteLine($"TEST DATA: {testData.DisplayName}");
System.Console.WriteLine($"{'═'.ToString().PadRight(70, '═')}");
var results = RunBenchmarksForTestData(testData, opMode, serializerMode);
allResults.AddRange(results);
}
// Print grouped results
PrintGroupedResults(allResults, testDataSets);
// Save results to file
SaveResults(allResults, testDataSets);
System.Console.WriteLine("\n✓ Benchmark complete!");
}
/// <summary>
/// Profiler mode: warmup only, then EXIT immediately.
/// Usage: dotnet run -- profiler
/// </summary>
private static void RunProfilerMode()
{
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
System.Console.WriteLine("║ PROFILER MODE (AcBinary only) ║");
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
System.Console.WriteLine($"Build: {BuildConfiguration} | .NET: {Environment.Version}");
System.Console.WriteLine();
var order = BenchmarkTestDataProvider.CreateProfilerOrder();
var options = AcBinarySerializerOptions.WithoutReferenceHandling;
options.UseStringInterning = StringInterningMode.None;
var bytes = AcBinarySerializer.Serialize(order, options);
// Warmup (fills caches)
System.Console.WriteLine("Warming up (1000 iterations)...");
for (var i = 0; i < 1000; i++)
{
_ = AcBinarySerializer.Serialize(order, options);
_ = AcBinaryDeserializer.Deserialize<TestOrder>(bytes);
}
Thread.Sleep(2000);
System.Console.WriteLine("Warmup complete. Caches are now populated.");
System.Console.WriteLine();
// HOT PATH - this is what the profiler should capture!
System.Console.WriteLine("Running hot path serialization (1000 iterations for profiling)...");
for (var i = 0; i < 1000; i++)
{
_ = AcBinarySerializer.Serialize(order, options);
//_ = AcBinaryDeserializer.Deserialize<TestOrder>(bytes);
}
System.Console.WriteLine("Running hot path deserialization (1000 iterations for profiling)...");
for (var i = 0; i < 1000; i++)
{
_ = AcBinaryDeserializer.Deserialize<TestOrder>(bytes);
}
System.Console.WriteLine("Hot path complete.");
System.Console.WriteLine();
System.Console.WriteLine(">>> ATTACH MEMORY PROFILER NOW <<<");
System.Console.WriteLine("Press any key to exit...");
System.Console.ReadKey(intercept: true);
System.Console.WriteLine();
System.Console.WriteLine("✓ Profiler mode complete. Exiting now.");
}
#region Benchmark Execution
private static List<BenchmarkResult> RunBenchmarksForTestData(TestDataSet testData, string mode, string serializerMode)
{
var results = new List<BenchmarkResult>();
var serializers = CreateSerializers(testData, serializerMode);
// Round-trip correctness check — once per (cell × serializer), BEFORE warmup. Aborts the entire benchmark on failure.
System.Console.WriteLine("Verifying round-trip correctness...");
foreach (var serializer in serializers)
{
if (!serializer.VerifyRoundTrip())
{
System.Console.Error.WriteLine($"❌ FATAL: Round-trip verification FAILED for {serializer.Name} on {testData.DisplayName}");
System.Console.Error.WriteLine("Benchmark numbers from a serializer with broken round-trip would be meaningless. Aborting.");
Environment.Exit(1);
}
}
System.Console.WriteLine("✓ All serializers passed round-trip verification.");
// Warmup all serializers
System.Console.WriteLine($"Warming up ({WarmupIterations} iterations)...");
foreach (var serializer in serializers)
{
serializer.Warmup(WarmupIterations);
}
// Wait for tiered JIT background compilation to complete
Thread.Sleep(JitSleep);
// Run benchmarks
System.Console.WriteLine($"Running benchmarks ({TestIterations} iterations × {BenchmarkSamples} samples median)...\n");
foreach (var serializer in serializers)
{
var result = new BenchmarkResult
{
TestDataName = testData.DisplayName, // Use DisplayName for IId% info
Engine = serializer.Engine,
IoMode = serializer.IoMode,
DispatchMode = serializer.DispatchMode,
OptionsPreset = serializer.OptionsPreset,
OptionsDescription = serializer.OptionsDescription,
SerializedSize = serializer.SerializedSize,
SetupAllocBytes = serializer.SetupAllocBytes,
IsRoundTripOnly = serializer.IsRoundTripOnly
};
if (serializer.IsRoundTripOnly)
{
// Round-trip-only benchmarks (NamedPipe etc.): measure the full pipe round-trip directly into the RT
// columns. Ser ms / SerAlloc / Des ms / DesAlloc stay 0 → display as "N/A". Allocation uses the
// process-wide measurement so the server-drain-thread allocations (e.g. server-side new byte[len])
// also show up — otherwise current-thread alloc would only count the client side and look ~halved.
if (mode is "all" or "serialize" or "ser")
{
result.RoundTripTimeMs = RunTimed(() => serializer.Serialize(), TestIterations);
result.RoundTripAllocBytesPerOp = MeasureAllocationTotal(() => serializer.Serialize(), TestIterations);
}
// mode == "deserialize" alone is meaningless for a round-trip-only benchmark; skip silently.
}
else
{
if (mode is "all" or "serialize" or "ser")
{
result.SerializeTimeMs = RunTimed(() => serializer.Serialize(), TestIterations);
// Dedicated alloc-only sample (separate from timing samples; keeps timing pure)
result.SerializeAllocBytesPerOp = MeasureAllocation(() => serializer.Serialize(), TestIterations);
}
if (mode is "all" or "deserialize" or "des")
{
result.DeserializeTimeMs = RunTimed(() => serializer.Deserialize(), TestIterations);
result.DeserializeAllocBytesPerOp = MeasureAllocation(() => serializer.Deserialize(), TestIterations);
}
// Compose RT from Ser+Des (the previously computed property's behavior, now explicit since RT is settable).
result.RoundTripTimeMs = result.SerializeTimeMs + result.DeserializeTimeMs;
result.RoundTripAllocBytesPerOp = result.SerializeAllocBytesPerOp + result.DeserializeAllocBytesPerOp;
}
results.Add(result);
PrintResult(result);
}
// Dispose any IDisposable serializers (NamedPipe / FileStream variants own OS resources that must be released
// before the next test data builds new ones — otherwise pipes / handles leak across test cells).
foreach (var s in serializers) (s as IDisposable)?.Dispose();
return results;
}
private static List<ISerializerBenchmark> CreateSerializers(TestDataSet testData, string serializerMode)
{
// AsyncPipe-only mode — return ONLY the AsyncPipe streaming benchmark (no other serializer).
// Streaming I/O has long-lived pipe setup + kernel-buffer overhead that, when interleaved with
// the standard byte-array / IBufferWriter measurements, masks the steady-state numbers. Run it
// in isolation so the timing numbers reflect ONLY the streaming path.
if (serializerMode == "asyncpipe")
{
// NamedPipe — pipe-aligned chunk size for the long-lived IPC scenario. The chunkSize here
// drives the AsyncPipeWriterOutput's chunk-on-wire size (header + data, page-aligned thanks to
// the AcquireChunk fix) AND the kernel pipe buffer size (inBufferSize/outBufferSize on the
// NamedPipeServerStream ctor). Same value across both layers = one WriteFile(chunkSize) syscall
// fits blocking-free in one kernel pipe-buffer slot. Single source of truth for both app-level
// wire chunk AND kernel transfer unit; change ONLY this line when tuning.
var binaryFastModePipeChunkOnly = AcBinarySerializerOptions.FastMode;
binaryFastModePipeChunkOnly.BufferWriterChunkSize = 16_384; //AsyncPipeWriterOutput.MaxChunkSize;
return new List<ISerializerBenchmark>
{
new AcBinaryNamedPipeBenchmark(testData.Order, binaryFastModePipeChunkOnly, "FastMode (PipeChunk)"),
};
}
// Standard mode — all serializers EXCEPT AsyncPipe (the streaming benchmark is opt-in via the
// AsyncPipe menu / CLI mode, never bundled with the steady-state suite).
var binaryNoInternOption = AcBinarySerializerOptions.Default;
binaryNoInternOption.UseStringInterning = StringInterningMode.None;
var binaryDefaultNoSgenOption = AcBinarySerializerOptions.Default;
binaryDefaultNoSgenOption.UseGeneratedCode = false;
var binaryFastModeNoSgenOption = AcBinarySerializerOptions.FastMode;
binaryFastModeNoSgenOption.UseGeneratedCode = false;
// BufWr new — 4 KB chunk size for the FRESH ArrayBufferWriter scenario. The chunkSize here drives
// the serializer's GetSpan(N) request → the ArrayBufferWriter's internal allocation per call.
// Small chunk = small per-call allocation, optimum for one-shot serialization where each iteration
// allocates a fresh ABW. Independent of the AsyncPipe profile (different mechanism: alloc overhead
// vs syscall count).
var binaryFastModeBufWrChunk = AcBinarySerializerOptions.FastMode;
binaryFastModeBufWrChunk.BufferWriterChunkSize = 4096;
var defaultOptions = AcBinarySerializerOptions.Default;
defaultOptions.UseStringInterning = StringInterningMode.None;
defaultOptions.ReferenceHandling = ReferenceHandlingMode.OnlyId;
return new List<ISerializerBenchmark>
{
// ============================================================
// 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"),
// Default preset Byte[] — RefHandling=OnlyId (deduplicates IId-shared references on the wire) +
// UseStringInterning=All (deduplicates repeated strings). Showcases the Default preset's wire-size
// and CPU trade-off vs FastMode on the ~20% IId-ref / repeated-string test data.
new AcBinaryBenchmark(testData.Order, defaultOptions, "Default"),
//new AcBinaryBenchmark(testData.Order, binaryDefaultNoSgenOption, "Default"),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.WithoutReferenceHandling, "NoRef"),
//new AcBinaryBenchmark(testData.Order, binaryNoInternOption, "NoIntern"),
// 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).
// 4 KB chunk size from binaryFastModeBufWrChunk — minimises the per-call ArrayBufferWriter
// allocation. Optimum for this scenario.
new AcBinaryFreshBufferWriterBenchmark(testData.Order, binaryFastModeBufWrChunk, "FastMode (4KB BufWr)"),
// AsyncPipe streaming (AcBinaryNamedPipeBenchmark) is intentionally OMITTED here — run it via
// the dedicated AsyncPipe menu / CLI mode for isolated streaming-I/O measurements.
// ============================================================
// 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")
};
}
/// <summary>
/// Runs the action <paramref name="iterations"/> times for <see cref="BenchmarkSamples"/> independent samples,
/// returning the median elapsed time. Multi-sample design reduces single-run variance from ~±15% to ~±5%
/// by smoothing transient effects (background activity, thermal/turbo state, JIT tier-promotion timing).
/// When <see cref="BenchmarkSamples"/> &lt;= 1, falls back to single-sample timing (Debug / quick mode).
/// </summary>
private static double RunTimed(Action action, int iterations)
{
var samples = BenchmarkSamples;
if (samples <= 1)
{
// Single-sample fast path (Debug or trivial run) — no allocation, no sort.
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++) action();
sw.Stop();
return sw.Elapsed.TotalMilliseconds;
}
var times = new double[samples];
for (var s = 0; s < samples; s++)
{
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++) action();
sw.Stop();
times[s] = sw.Elapsed.TotalMilliseconds;
}
Array.Sort(times);
// Median: middle value for odd sample counts, average of two middles for even counts.
return samples % 2 == 1 ? times[samples / 2] : (times[samples / 2 - 1] + times[samples / 2]) / 2.0;
}
/// <summary>
/// Measures per-call allocation in bytes after a clean GC. Single dedicated sample (no median) — keeps timing samples pure.
/// </summary>
private static long MeasureAllocation(Action action, int iterations)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var before = GC.GetAllocatedBytesForCurrentThread();
for (var i = 0; i < iterations; i++) action();
var after = GC.GetAllocatedBytesForCurrentThread();
return (after - before) / iterations;
}
/// <summary>
/// Process-wide allocation measurement — needed for round-trip-only benchmarks (NamedPipe etc.) where
/// the work happens across multiple threads. <see cref="GC.GetAllocatedBytesForCurrentThread"/> would
/// only count the caller-thread allocations, missing the server-side <c>new byte[len]</c> buffers and
/// any drain-pump-thread allocations. <see cref="GC.GetTotalAllocatedBytes"/> covers the entire process.
/// Slightly noisier than the per-thread variant (background threads / GC bookkeeping leak in), but
/// over 1000 iterations the signal dominates.
/// </summary>
private static long MeasureAllocationTotal(Action action, int iterations)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var before = GC.GetTotalAllocatedBytes(precise: true);
for (var i = 0; i < iterations; i++) action();
var after = GC.GetTotalAllocatedBytes(precise: true);
return (after - before) / iterations;
}
private static readonly JsonSerializerOptions VerifyJsonOpts = new()
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
};
/// <summary>
/// Round-trip equality check: serialize both via System.Text.Json (canonical form) and compare strings.
/// Slower than property-by-property compare, but universal — works for any object graph without custom comparer.
/// </summary>
private static bool DeepEqualsViaJson(object? a, object? b)
{
if (a == null && b == null) return true;
if (a == null || b == null) return false;
var jsonA = JsonSerializer.Serialize(a, VerifyJsonOpts);
var jsonB = JsonSerializer.Serialize(b, VerifyJsonOpts);
return jsonA == jsonB;
}
/// <summary>
/// Validates MemoryPack setup at startup. Aborts the benchmark if TestOrder is not [MemoryPackable].
/// Without this attribute, MemoryPack falls back to runtime resolver (slower) — comparison would be INVALID.
/// </summary>
private static void ValidateMemoryPackSetup()
{
var typesToCheck = new[] { typeof(TestOrder) };
foreach (var type in typesToCheck)
{
var hasAttr = type.GetCustomAttributes(typeof(MemoryPackableAttribute), inherit: true).Any();
if (!hasAttr)
{
System.Console.Error.WriteLine($"❌ FATAL: {type.FullName} is not [MemoryPackable] — MemoryPack would fall back to runtime resolver, comparison is INVALID for SGen-vs-SGen claim.");
System.Console.Error.WriteLine("Add [MemoryPackable] to the type and any nested types referenced from it.");
Environment.Exit(1);
}
}
}
/// <summary>
/// Interactive menu shown when no CLI args. Returns the layer keyword (core/comprehensive/edge/all) or null on Quit.
/// </summary>
private static (string layer, string serializerMode)? ShowInteractiveMenu()
{
System.Console.WriteLine();
System.Console.WriteLine("╔══════════════════════════════════════════════════════════╗");
System.Console.WriteLine("║ AcBinary Benchmark Suite ║");
System.Console.WriteLine("╚══════════════════════════════════════════════════════════╝");
System.Console.WriteLine();
System.Console.WriteLine("Select benchmark layer:");
System.Console.WriteLine();
System.Console.WriteLine(" [1] Core — daily iteration");
System.Console.WriteLine(" [2] Comprehensive — release validation");
System.Console.WriteLine(" [3] Edge cases — refactor verification");
System.Console.WriteLine(" [A] All layers");
System.Console.WriteLine(" [P] AsyncPipe — streaming I/O isolation (only AsyncPipe, all test data)");
System.Console.WriteLine(" [Q] Quit");
System.Console.Write("\nSelection: ");
var key = System.Console.ReadKey(intercept: false).KeyChar;
System.Console.WriteLine();
return char.ToLower(key) switch
{
'1' => ("core", "standard"),
'2' => ("comprehensive", "standard"),
'3' => ("edge", "standard"),
'a' => ("all", "standard"),
'p' => ("all", "asyncpipe"),
'q' => null,
_ => ("all", "standard")
};
}
/// <summary>
/// Filters test data sets by layer keyword. Layered approach lets you run only what's needed for the iteration cadence.
/// P1: only "Core" data exists (Small/Medium/Large/Repeated/Deep). Comprehensive and Edge layers will be expanded in P2.
/// </summary>
private static List<TestDataSet> FilterByLayer(List<TestDataSet> all, string layer)
{
if (layer == "all") return all.ToList();
var coreNames = new[] { "Small", "Medium", "Large", "Repeated", "Deep" };
// P2 will add: "Flat", "Polymorphic", "Collection", "Numeric", "NonAscii", etc.
var comprehensiveExtras = new string[] { /* P2 */ };
// P3 will add: "ColdStart", "VeryLarge", "PathologicalString", etc.
var edgeExtras = new string[] { /* P3 */ };
return layer switch
{
"core" => all.Where(t => StartsWithAny(t.Name, coreNames)).ToList(),
"comprehensive" => all.Where(t => StartsWithAny(t.Name, coreNames) || StartsWithAny(t.Name, comprehensiveExtras)).ToList(),
"edge" => all.Where(t => StartsWithAny(t.Name, coreNames) || StartsWithAny(t.Name, comprehensiveExtras) || StartsWithAny(t.Name, edgeExtras)).ToList(),
_ => all.ToList()
};
static bool StartsWithAny(string name, string[] prefixes) => prefixes.Any(name.StartsWith);
}
#endregion
#region Serializer Implementations
private interface ISerializerBenchmark
{
/// <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; }
/// <summary>True when Serialize() does a full round-trip (e.g. NamedPipe) and Deserialize() is a no-op.
/// Used by the SUMMARY: WINNERS section to skip such cells from "Fastest Serialize" and "Fastest Deserialize"
/// rankings (because both metrics are misleading there) — they still participate in "Fastest Round-trip".
/// Default false for in-memory IO modes which measure Ser and Des separately.</summary>
bool IsRoundTripOnly => false;
void Warmup(int iterations);
void Serialize();
void Deserialize();
/// <summary>Round-trip correctness check — called once per cell before warmup. Returns true if Serialize+Deserialize preserves data.</summary>
bool VerifyRoundTrip();
}
private sealed class AcBinaryBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly AcBinarySerializerOptions _options;
private readonly byte[] _serialized;
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 => BuildAcBinaryOptionsDescription(_options);
public AcBinaryBenchmark(TestOrder order, AcBinarySerializerOptions options, string optionsPreset)
{
_order = order;
_options = options;
OptionsPreset = optionsPreset;
_serialized = AcBinarySerializer.Serialize(order, options);
//_options.UseCompression = Lz4CompressionMode.Block;
}
public void Warmup(int iterations)
{
for (var i = 0; i < iterations; i++)
{
Serialize();
Deserialize();
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
AcBinarySerializer.Serialize(_order, _options);
//if (_options.ReferenceHandling != ReferenceHandlingMode.None || _options.UseStringInterning != StringInterningMode.None)
//{
// AcBinarySerializer.ScanOnly(_order, _options);
//}
//else AcBinarySerializer.Serialize(_order, _options);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => AcBinaryDeserializer.Deserialize<TestOrder>(_serialized, _options);
public bool VerifyRoundTrip()
{
var bytes = AcBinarySerializer.Serialize(_order, _options);
var roundTripped = AcBinaryDeserializer.Deserialize<TestOrder>(bytes, _options);
return DeepEqualsViaJson(_order, roundTripped);
}
}
private sealed class MemoryPackBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly byte[] _serialized;
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 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() => MemoryPackSerializer.Serialize(_order);
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => MemoryPackSerializer.Deserialize<TestOrder>(_serialized);
public bool VerifyRoundTrip()
{
var bytes = MemoryPackSerializer.Serialize(_order);
var roundTripped = MemoryPackSerializer.Deserialize<TestOrder>(bytes);
return DeepEqualsViaJson(_order, roundTripped);
}
}
private sealed class MessagePackBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly MessagePackSerializerOptions _options;
private readonly byte[] _serialized;
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 optionsPreset)
{
_order = order;
OptionsPreset = optionsPreset;
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.Lz4Block);
_options = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.None);
var isContractless = _options.Resolver is ContractlessStandardResolver;
OptionsDescription = $"Mode={( isContractless ? "Contractless" : "ContractBased")}, Compression={_options.Compression}";
_serialized = MessagePackSerializer.Serialize(order, _options);
}
public void Warmup(int iterations)
{
for (var i = 0; i < iterations; i++)
{
Serialize();
Deserialize();
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize() => MessagePackSerializer.Serialize(_order, _options);
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => MessagePackSerializer.Deserialize<TestOrder>(_serialized, _options);
public bool VerifyRoundTrip()
{
var bytes = MessagePackSerializer.Serialize(_order, _options);
var roundTripped = MessagePackSerializer.Deserialize<TestOrder>(bytes, _options);
return DeepEqualsViaJson(_order, roundTripped);
}
}
/// <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;
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 long SetupAllocBytes => 0;
public string OptionsDescription => BuildAcBinaryOptionsDescription(_options, $", BufferSize={_options.BufferWriterChunkSize}B");
public AcBinaryFreshBufferWriterBenchmark(TestOrder order, AcBinarySerializerOptions options, string optionsPreset)
{
_order = order;
// BufferWriterChunkSize comes from the caller (central source of truth in CreateSerializers
// — the binaryFastMode4KbChunk options instance). Do NOT mutate _options here; tune the chunk
// size in CreateSerializers only.
_options = options;
OptionsPreset = optionsPreset;
_serialized = AcBinarySerializer.Serialize(order, _options);
}
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>(); // 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 AcBinary over a long-lived NamedPipe IPC connection using the AcBinary native streaming API
/// (<see cref="AcBinarySerializer.SerializeChunked{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>
/// + <see cref="AsyncPipeReaderInput"/> + <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/>).
/// Mirrors what a real consumer (e.g. <c>DeserializeFromPipeReaderAsync</c>) does per message:
/// long-lived <see cref="AsyncPipeReaderInput"/> with multi-message wire framing on top of a long-lived NamedPipe.
///
/// <para><b>Architecture</b>:</para>
/// <list type="bullet">
/// <item>Constructor (NOT timed): sets up <see cref="NamedPipeServerStream"/> + <see cref="NamedPipeClientStream"/>,
/// waits for connection, creates one long-lived <see cref="System.IO.Pipelines.PipeWriter"/> /
/// <see cref="System.IO.Pipelines.PipeReader"/> pair, ONE long-lived <see cref="AsyncPipeReaderInput"/>
/// in <c>multiMessage = true</c> mode, ONE drain Task that pumps <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/>
/// forever, and ONE deserialize Task that loops <c>AcBinaryDeserializer.Deserialize&lt;T&gt;(input, opts)</c>
/// producing into a <see cref="System.Threading.Channels.Channel{T}"/>.</item>
/// <item>Per-iteration <see cref="Serialize"/> (timed): sender writes via
/// <see cref="AcBinarySerializer.SerializeChunkedFramed{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>
/// — multi-message wire (<c>[201][UINT16][data]...[202]</c>); the <c>[202]</c> end marker arms the input's
/// <c>_readPos = -1</c> sentinel, so the next message's first <c>AppendToBuffer</c> recycles the buffer to 0.
/// Then receiver awaits the channel for the deserialized result.</item>
/// <item><see cref="Deserialize"/> is a no-op (full round-trip captured in <see cref="Serialize"/>);
/// <see cref="IsRoundTripOnly"/>=true → Ser ms / SerAlloc oszlopok N/A, RT ms = full round-trip.</item>
/// </list>
///
/// <para><b>Per-iter overhead</b>: 0 new <c>Task.Run</c>, 0 new <c>AsyncPipeReaderInput</c>, 0 new <c>CancellationTokenSource</c>.
/// Pure cost = <c>SerializeChunkedFramed</c> (CPU + chunk-onkénti flush) + kernel write/read syscalls + 1 sync barrier
/// (channel) + deserialized graph alloc. The "multi-message reuse" pattern enabled by Q4T8 fix (R5K2 minimum: <c>_readPos = -1</c>
/// sentinel + <c>AppendToBuffer</c> sliding-window cycling).</para>
///
/// <para><b>Approximation note</b>: single-process loopback NamedPipe. Real cross-process / cross-machine SignalR
/// adds further transport latency (TCP, WebSocket framing) on top. The benchmark gives a lower bound.</para>
/// </summary>
private sealed class AcBinaryNamedPipeBenchmark : ISerializerBenchmark, IDisposable
{
private readonly TestOrder _order;
private readonly AcBinarySerializerOptions _options;
private readonly byte[] _serialized; // for SerializedSize reporting only
// Long-lived pipe lifecycle (set up once in ctor — NOT timed).
private readonly NamedPipeServerStream _pipeServer;
private readonly NamedPipeClientStream _pipeClient;
private readonly PipeWriter _pipeWriter;
private readonly PipeReader _pipeReader;
// Long-lived multi-message receive infrastructure (set up once in ctor).
private readonly AsyncPipeReaderInput _input;
private readonly CancellationTokenSource _cts;
private readonly Task _drainTask;
private bool _disposed;
public string Engine => EngineAcBinary;
public string IoMode => IoNamedPipe;
public string DispatchMode => _options.UseGeneratedCode ? ModeSGen : ModeRuntime;
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupAllocBytes => 0;
public bool IsRoundTripOnly => true;
public string OptionsDescription => BuildAcBinaryOptionsDescription(_options, $", BufferSize={_options.BufferWriterChunkSize}B, Transport=NamedPipe(long-lived,multiMessage)");
public AcBinaryNamedPipeBenchmark(TestOrder order, AcBinarySerializerOptions options, string optionsPreset)
{
_order = order;
// BufferWriterChunkSize comes from the caller (central source of truth in CreateSerializers
// — the binaryFastMode4KbChunk options instance). Do NOT mutate _options here; tune the chunk
// size in CreateSerializers only.
_options = options;
OptionsPreset = optionsPreset;
_serialized = AcBinarySerializer.Serialize(order, _options);
// 1× pipe setup. Kernel-side pipe buffer (inBufferSize / outBufferSize on the server ctor — the
// client inherits the server-defined buffer size at connect time) matches BufferWriterChunkSize
// exactly: AsyncPipeWriterOutput now treats chunkSize as the chunk-on-wire total size (header +
// data), so one WriteFile(chunkSize) syscall lands in exactly one kernel-page slot — page-aligned,
// no fragmentation, no IRP reordering. _options.BufferWriterChunkSize is the single tunable source.
var pipeName = $"AcBinaryBench-{Guid.NewGuid():N}";
_pipeServer = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte,
System.IO.Pipes.PipeOptions.Asynchronous,
inBufferSize: _options.BufferWriterChunkSize,
outBufferSize: _options.BufferWriterChunkSize);
_pipeClient = new NamedPipeClientStream(".", pipeName, PipeDirection.Out, System.IO.Pipes.PipeOptions.Asynchronous);
var serverWait = _pipeServer.WaitForConnectionAsync();
_pipeClient.Connect();
serverWait.GetAwaiter().GetResult();
_pipeWriter = PipeWriter.Create(_pipeClient);
_pipeReader = PipeReader.Create(_pipeServer);
// 1× multi-message receive infrastructure: long-lived input + 1 background drain task.
// Per-iter Serialize() does its own Deserialize<T>(input, opts) call on the calling thread —
// strictly sequential per the calling thread's loop, so the producer (drain) and consumer
// (deserialiser, on the calling thread) cannot race on the buffer.
_input = new AsyncPipeReaderInput(_options.BufferWriterChunkSize * 2, multiMessage: true);
_cts = new CancellationTokenSource();
// Drain task: pumps PipeReader → input.Feed forever (or until cancel). Single Task.Run for
// the full benchmark lifetime (NOT per iteration) — its overhead is amortised across all messages.
_drainTask = Task.Run(() => _input.DrainFromAsync(_pipeReader, _cts.Token));
}
public void Warmup(int iterations)
{
for (var i = 0; i < iterations; i++)
{
Serialize();
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
// Sender: multi-message wire framing — [201][UINT16][data]...[202]. The Flush() inside
// SerializeChunkedFramed writes the [202] CHUNK_END marker and flushes the kernel buffer.
AcBinarySerializer.SerializeChunkedFramed(_order, _pipeWriter, _options);
// Receiver: synchronous Deserialize<T> on the calling thread. Blocks (via TryAdvanceSegment's
// MRES.Wait) until the drain task has fed enough bytes for the structurally-complete graph.
// Returns when the graph is complete; finally block calls input.MessageDone() which arms
// _readPos = -1 sentinel for the next Append-cycle. Strictly sequential on the calling thread:
// the next Serialize() call's SerializeChunkedFramed only runs after this Deserialize<T> returns.
_ = AcBinaryDeserializer.Deserialize<TestOrder>(_input, _options);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize()
{
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
}
public bool VerifyRoundTrip()
{
// Round-trip one message synchronously on the calling thread.
AcBinarySerializer.SerializeChunkedFramed(_order, _pipeWriter, _options);
var result = AcBinaryDeserializer.Deserialize<TestOrder>(_input, _options);
return result != null && DeepEqualsViaJson(_order, result);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// Cancel drain task → DrainFromAsync exits → input.Complete() in its finally.
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
try { _drainTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
// Complete writer + dispose pipe lifecycle.
try { _pipeWriter.CompleteAsync().AsTask().Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
try { _pipeReader.Complete(); } catch { /* swallow on teardown */ }
try { _pipeClient.Dispose(); } catch { /* swallow on teardown */ }
try { _pipeServer.Dispose(); } catch { /* swallow on teardown */ }
try { _input.Dispose(); } catch { /* swallow on teardown */ }
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
}
}
/// <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 => BuildAcBinaryOptionsDescription(_options);
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);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => AcBinaryDeserializer.Deserialize<TestOrder>(_serialized, _options);
public bool VerifyRoundTrip()
{
_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);
}
}
private sealed class SystemTextJsonBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly JsonSerializerOptions _options;
private readonly string _serialized;
private readonly byte[] _serializedUtf8;
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 optionsPreset)
{
_order = order;
OptionsPreset = optionsPreset;
_options = new JsonSerializerOptions
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
};
_serialized = JsonSerializer.Serialize(order, _options);
_serializedUtf8 = Utf8NoBom.GetBytes(_serialized);
}
public void Warmup(int iterations)
{
for (var i = 0; i < iterations; i++)
{
Serialize();
Deserialize();
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize() => JsonSerializer.Serialize(_order, _options);
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => JsonSerializer.Deserialize<TestOrder>(_serialized, _options);
public bool VerifyRoundTrip()
{
var json = JsonSerializer.Serialize(_order, _options);
var roundTripped = JsonSerializer.Deserialize<TestOrder>(json, _options);
return DeepEqualsViaJson(_order, roundTripped);
}
}
#endregion
#region Results
private sealed class BenchmarkResult
{
public string TestDataName { get; set; } = "";
public string Engine { get; set; } = "";
public string IoMode { get; set; } = "";
public string DispatchMode { get; set; } = "";
public string OptionsPreset { get; set; } = "";
/// <summary>True if Serialize() captures a full round-trip and Deserialize() is a no-op
/// (single-use streaming transports like NamedPipe). Excluded from "Fastest Serialize" / "Fastest Deserialize"
/// winners rankings; still ranked in "Fastest Round-trip". Display-side: Ser ms / SerAlloc / Des ms / DesAlloc
/// all show "N/A" since they were never measured separately; RT ms / RT Alloc carry the full round-trip values.</summary>
public bool IsRoundTripOnly { 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; }
/// <summary>Total round-trip time. For in-memory benchmarks: <c>Serialize + Deserialize</c> (set explicitly in
/// <c>RunBenchmarksForTestData</c>). For round-trip-only benchmarks (NamedPipe etc.): the directly-measured
/// pipe round-trip time, since Ser and Des are not separately measurable there.</summary>
public double RoundTripTimeMs { get; set; }
/// <summary>Total round-trip allocation per op. For in-memory benchmarks: <c>SerializeAlloc + DeserializeAlloc</c>.
/// For round-trip-only benchmarks: process-wide allocation measured via <see cref="GC.GetTotalAllocatedBytes"/>
/// (covers ALL threads — client, server-drain, channel internals — not just the caller).</summary>
public long RoundTripAllocBytesPerOp { get; set; }
}
private static void PrintResult(BenchmarkResult result)
{
var ser = result.SerializeTimeMs > 0 ? $"{result.SerializeTimeMs,8:F2} ms" : " N/A";
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,-40} | Size: {result.SerializedSize,8:N0} | Ser: {ser} ({serAlloc}) | Des: {des} ({desAlloc})");
}
private static void PrintGroupedResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
{
System.Console.WriteLine("\n");
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
System.Console.WriteLine("║ GROUPED RESULTS BY TEST DATA ║");
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
// Print serializer options
var optionsMap = results
.Where(r => r.OptionsDescription != null)
.Select(r => (r.SerializerName, r.OptionsDescription!))
.Distinct()
.ToList();
if (optionsMap.Count > 0)
{
System.Console.WriteLine();
System.Console.WriteLine(" Serializer Options:");
foreach (var (name, opts) in optionsMap)
System.Console.WriteLine($" {name}: {opts}");
}
foreach (var testData in testDataSets)
{
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.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(172, '─') + "┐");
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} │ {"RT Alloc",-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, '─')}┼{"".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";
var rtAlloc = result.RoundTripAllocBytesPerOp > 0 ? $"{result.RoundTripAllocBytesPerOp:N0} B" : "N/A";
// 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.Engine == EngineMemoryPack && result.IoMode == IoByteArray);
var memPackFaster = memPackResult.RoundTripTimeMs < acBinaryResult.RoundTripTimeMs;
if (isMemPack)
{
System.Console.ForegroundColor = memPackFaster ? ConsoleColor.Green : ConsoleColor.Red;
}
else
{
System.Console.ForegroundColor = memPackFaster ? ConsoleColor.Red : ConsoleColor.Green;
}
}
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} │ {rtAlloc,10}{suffix}");
if (isHighlighted)
{
System.Console.ResetColor();
}
}
// 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;
var rtAllocPct = memPackResult.RoundTripAllocBytesPerOp > 0 ? (acBinaryResult.RoundTripAllocBytesPerOp / (double)memPackResult.RoundTripAllocBytesPerOp - 1) * 100 : 0;
// Footer separator: merge first 5 cols (#, Engine, Options, IO, Mode) → comparison label;
// remaining 8 cols stay aligned (Setup, Size, Ser ms, SerAlloc, Des ms, DesAlloc, RT ms, RT Alloc).
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, '─')}┼{"".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,+7:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Serialize
System.Console.ForegroundColor = serPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
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,+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,+9:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Round-trip Alloc
System.Console.ForegroundColor = rtAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{rtAllocPct,+9:+0;-0}%");
System.Console.ResetColor();
System.Console.WriteLine(" │");
}
// Closing line: merged on left (─ between cols 1-5), ┴ on the right (cols 6-13 boundary, 8 unmerged cells).
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, '─')}┴{"".PadRight(12, '─')}┘");
//System.Console.WriteLine($"GrowBufferCount: {AcBinarySerializer.GrowBufferCount}");
//System.Console.WriteLine($"GrowBufferTotalBytes: {AcBinarySerializer.GrowBufferTotalBytes:N0} bytes");
}
// Summary: Best serializer for each category
System.Console.WriteLine("\n");
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
System.Console.WriteLine("║ SUMMARY: WINNERS ║");
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
System.Console.WriteLine($"\n{"Category",-20} │ {"Winner",-40} │ {"Avg Value",-18}");
System.Console.WriteLine($"{"".PadRight(20, '─')}─┼─{"".PadRight(40, '─')}─┼─{"".PadRight(18, '─')}");
// Fastest Serialize — round-trip-only serializers (NamedPipe etc.) excluded:
// their Serialize() captures the full round-trip and isn't comparable to a pure Ser metric.
var fastestSer = results.Where(r => r.SerializeTimeMs > 0 && !r.IsRoundTripOnly)
.GroupBy(r => r.SerializerName)
.Select(g => new { Name = g.Key, AvgTime = g.Average(r => r.SerializeTimeMs) })
.OrderBy(x => x.AvgTime)
.FirstOrDefault();
if (fastestSer != null)
System.Console.WriteLine($"{"Fastest Serialize",-20} │ {fastestSer.Name,-40} │ {fastestSer.AvgTime,15:F2} ms");
// Fastest Deserialize — round-trip-only serializers excluded (their Deserialize() is a no-op).
var fastestDes = results.Where(r => r.DeserializeTimeMs > 0 && !r.IsRoundTripOnly)
.GroupBy(r => r.SerializerName)
.Select(g => new { Name = g.Key, AvgTime = g.Average(r => r.DeserializeTimeMs) })
.OrderBy(x => x.AvgTime)
.FirstOrDefault();
if (fastestDes != null)
System.Console.WriteLine($"{"Fastest Deserialize",-20} │ {fastestDes.Name,-40} │ {fastestDes.AvgTime,15:F2} ms");
// Smallest Size
var smallestSize = results
.GroupBy(r => r.SerializerName)
.Select(g => new { Name = g.Key, AvgSize = g.Average(r => r.SerializedSize) })
.OrderBy(x => x.AvgSize)
.FirstOrDefault();
if (smallestSize != null)
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)
.GroupBy(r => r.SerializerName)
.Select(g => new { Name = g.Key, AvgTime = g.Average(r => r.RoundTripTimeMs) })
.OrderBy(x => x.AvgTime)
.FirstOrDefault();
if (fastestRt != null)
System.Console.WriteLine($"{"Fastest Round-trip",-20} │ {fastestRt.Name,-40} │ {fastestRt.AvgTime,15:F2} ms");
// 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.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($"── {"AcBinary (Byte[], SGen)"} vs {"MemoryPack (Byte[])"} (Overall) ──");
System.Console.WriteLine(" (Comparison requires both serialize and deserialize data)");
return;
}
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.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.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($"── {"AcBinary (Byte[], SGen)"} vs {"MemoryPack (Byte[])"} (Overall) ──");
// Only show serialize comparison if data available
if (memPackAvgSer > 0 && acBinaryAvgSer > 0)
{
var serPctAll = (acBinaryAvgSer / memPackAvgSer - 1) * 100;
System.Console.ForegroundColor = serPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Serialize: {serPctAll:+0;-0}% ({acBinaryAvgSer:F2} ms vs {memPackAvgSer:F2} ms)");
System.Console.ResetColor();
}
var desPctAll = (acBinaryAvgDes / memPackAvgDes - 1) * 100;
var rtPctAll = (acBinaryAvgRt / memPackAvgRt - 1) * 100;
var sizePctAll = (acBinaryAvgSize / memPackAvgSize - 1) * 100;
System.Console.ForegroundColor = desPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Deserialize: {desPctAll:+0;-0}% ({acBinaryAvgDes:F2} ms vs {memPackAvgDes:F2} ms)");
System.Console.ResetColor();
System.Console.ForegroundColor = rtPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Round-trip: {rtPctAll:+0;-0}% ({acBinaryAvgRt:F2} ms vs {memPackAvgRt:F2} ms)");
System.Console.ResetColor();
System.Console.ForegroundColor = sizePctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Size: {sizePctAll:+0;-0}% ({acBinaryAvgSize:F0} B vs {memPackAvgSize:F0} B)");
System.Console.ResetColor();
// Allocation comparison: byte[] API allocates the output array on both sides — delta shows serializer-overhead diff.
if (memPackAvgSerAlloc > 0 && acBinaryAvgSerAlloc > 0)
{
var serAllocPct = (acBinaryAvgSerAlloc / memPackAvgSerAlloc - 1) * 100;
System.Console.ForegroundColor = serAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Ser Alloc: {serAllocPct:+0;-0}% ({acBinaryAvgSerAlloc:F0} B/op vs {memPackAvgSerAlloc:F0} B/op)");
System.Console.ResetColor();
}
if (memPackAvgDesAlloc > 0 && acBinaryAvgDesAlloc > 0)
{
var desAllocPct = (acBinaryAvgDesAlloc / memPackAvgDesAlloc - 1) * 100;
System.Console.ForegroundColor = desAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Des Alloc: {desAllocPct:+0;-0}% ({acBinaryAvgDesAlloc:F0} B/op vs {memPackAvgDesAlloc:F0} B/op)");
System.Console.ResetColor();
}
}
private static void SaveResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
{
Directory.CreateDirectory(ResultsDirectory);
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
var baseFileName = $"Console.FullBenchmark_{BuildConfiguration}_{timestamp}";
var logFilePath = Path.Combine(ResultsDirectory, $"{baseFileName}.log");
var outputFilePath = Path.Combine(ResultsDirectory, $"{baseFileName}.output");
// Save binary output to separate .output file
var largeTestData = testDataSets.FirstOrDefault(t => t.Name.StartsWith("Large"));
if (largeTestData != null)
{
var outputSb = new StringBuilder();
outputSb.AppendLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
outputSb.AppendLine("║ SERIALIZED BINARY OUTPUT ║");
outputSb.AppendLine($"║ Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}".PadRight(100) + "║");
outputSb.AppendLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
outputSb.AppendLine();
outputSb.AppendLine("=== SERIALIZED BYTES: Large (5x5x5x10) - AcBinary (Default) ===");
var serializedBytes = AcBinarySerializer.Serialize(largeTestData.Order, AcBinarySerializerOptions.Default);
outputSb.AppendLine($"Size: {serializedBytes.Length:N0} bytes");
outputSb.AppendLine();
outputSb.AppendLine("Hex dump:");
outputSb.AppendLine(FormatHexDump(serializedBytes));
File.WriteAllText(outputFilePath, outputSb.ToString(), Utf8NoBom);
System.Console.WriteLine($"✓ Binary output saved to: {outputFilePath}");
}
// Save benchmark results to .log file
var sb = new StringBuilder();
sb.AppendLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
sb.AppendLine("║ SERIALIZER BENCHMARK RESULTS ║");
sb.AppendLine($"║ Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}".PadRight(100) + "║");
sb.AppendLine($"║ Build: {BuildConfiguration}".PadRight(100) + "║");
sb.AppendLine($"║ Iterations: {TestIterations}".PadRight(100) + "║");
sb.AppendLine($"║ Samples: {BenchmarkSamples} (median)".PadRight(100) + "║");
sb.AppendLine($"║ Test Type: {testDataSets.FirstOrDefault()?.TypeName ?? "unknown"}".PadRight(100) + "║");
sb.AppendLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
sb.AppendLine();
// Serializer options summary
var optionsMap = results
.Where(r => r.OptionsDescription != null)
.Select(r => (r.SerializerName, r.OptionsDescription!))
.Distinct()
.ToList();
if (optionsMap.Count > 0)
{
sb.AppendLine("=== SERIALIZER OPTIONS ===");
foreach (var (name, opts) in optionsMap)
sb.AppendLine($" {name}: {opts}");
sb.AppendLine();
}
// CSV-like data for easy import (now includes per-op allocation columns)
sb.AppendLine("=== RAW DATA (CSV) ===");
sb.AppendLine("TestData,Engine,IO,Mode,Options,Size,SerializeMs,DeserializeMs,RoundTripMs,SerializeAllocBytesPerOp,DeserializeAllocBytesPerOp,RoundTripAllocBytesPerOp,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.Engine},{result.IoMode},{result.DispatchMode},{result.OptionsPreset},{result.SerializedSize},{result.SerializeTimeMs:F2},{result.DeserializeTimeMs:F2},{result.RoundTripTimeMs:F2},{result.SerializeAllocBytesPerOp},{result.DeserializeAllocBytesPerOp},{result.RoundTripAllocBytesPerOp},{result.SetupAllocBytes}");
}
}
sb.AppendLine();
// Formatted results
sb.AppendLine("=== FORMATTED RESULTS BY TEST DATA ===");
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.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",-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.Engine == EngineMemoryPack || result.Engine == EngineAcBinary) && result.IoMode == IoByteArray);
var prefix = isHighlighted ? "► " : " ";
var size = $"{result.SerializedSize:N0}";
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";
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)
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;
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}");
//sb.AppendLine($"GrowBufferTotalBytes: {AcBinarySerializer.GrowBufferTotalBytes:N0} bytes");
}
// 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($"=== {"AcBinary (Byte[], SGen)"} vs {"MemoryPack (Byte[])"} (Overall) ===");
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.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)
{
var memPackAvgSer2 = memPackSerResults2.Average(r => r.SerializeTimeMs);
var acBinaryAvgSer2 = acBinarySerResults2.Average(r => r.SerializeTimeMs);
var memPackAvgSerAlloc2 = memPackSerResults2.Average(r => r.SerializeAllocBytesPerOp);
var acBinaryAvgSerAlloc2 = acBinarySerResults2.Average(r => r.SerializeAllocBytesPerOp);
sb.AppendLine($" Serialize: {((acBinaryAvgSer2 / memPackAvgSer2 - 1) * 100):+0;-0}% ({acBinaryAvgSer2:F2} ms vs {memPackAvgSer2:F2} ms)");
if (memPackAvgSerAlloc2 > 0)
sb.AppendLine($" Ser Alloc: {((acBinaryAvgSerAlloc2 / memPackAvgSerAlloc2 - 1) * 100):+0;-0}% ({acBinaryAvgSerAlloc2:F0} B/op vs {memPackAvgSerAlloc2:F0} B/op)");
}
if (memPackDesResults2.Count > 0 && acBinaryDesResults2.Count > 0)
{
var memPackAvgDes2 = memPackDesResults2.Average(r => r.DeserializeTimeMs);
var acBinaryAvgDes2 = acBinaryDesResults2.Average(r => r.DeserializeTimeMs);
var memPackAvgDesAlloc2 = memPackDesResults2.Average(r => r.DeserializeAllocBytesPerOp);
var acBinaryAvgDesAlloc2 = acBinaryDesResults2.Average(r => r.DeserializeAllocBytesPerOp);
sb.AppendLine($" Deserialize: {((acBinaryAvgDes2 / memPackAvgDes2 - 1) * 100):+0;-0}% ({acBinaryAvgDes2:F2} ms vs {memPackAvgDes2:F2} ms)");
if (memPackAvgDesAlloc2 > 0)
sb.AppendLine($" Des Alloc: {((acBinaryAvgDesAlloc2 / memPackAvgDesAlloc2 - 1) * 100):+0;-0}% ({acBinaryAvgDesAlloc2:F0} B/op vs {memPackAvgDesAlloc2:F0} B/op)");
}
if (memPackRtResults2.Count > 0 && acBinaryRtResults2.Count > 0)
{
var memPackAvgRt2 = memPackRtResults2.Average(r => r.RoundTripTimeMs);
var acBinaryAvgRt2 = acBinaryRtResults2.Average(r => r.RoundTripTimeMs);
sb.AppendLine($" Round-trip: {((acBinaryAvgRt2 / memPackAvgRt2 - 1) * 100):+0;-0}% ({acBinaryAvgRt2:F2} ms vs {memPackAvgRt2:F2} ms)");
}
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);
System.Console.WriteLine($"✓ Results saved to: {logFilePath}");
// Save LLM-optimized results
var llmFilePath = Path.Combine(ResultsDirectory, $"{baseFileName}.LLM");
SaveLlmResults(llmFilePath, results, testDataSets);
}
private static void SaveLlmResults(string filePath, List<BenchmarkResult> results, List<TestDataSet> testDataSets)
{
var sb = new StringBuilder();
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: {"MemoryPack (Byte[])"} (SOTA reference) | Verified: round-trip correctness checked once per cell before warmup");
// Options summary
var optionsMap = results
.Where(r => r.OptionsDescription != null)
.Select(r => (r.SerializerName, r.OptionsDescription!))
.Distinct()
.ToList();
if (optionsMap.Count > 0)
{
sb.AppendLine();
sb.AppendLine("## Options");
sb.AppendLine();
foreach (var (name, opts) in optionsMap)
sb.AppendLine($"- **{name}**: {opts}");
}
// Flat results table sorted by test data then round-trip (now includes Alloc columns)
sb.AppendLine();
sb.AppendLine("## Results");
sb.AppendLine();
sb.AppendLine("TestData | Engine | IO | Mode | Options | Size(B) | Ser(ms) | Deser(ms) | RT(ms) | SerAlloc(B/op) | DesAlloc(B/op) | RTAlloc(B/op) | SetupAlloc(B)");
sb.AppendLine("---|---|---|---|---|---|---|---|---|---|---|---|---");
foreach (var testData in testDataSets)
{
var testResults = results
.Where(r => r.TestDataName == testData.DisplayName)
.OrderBy(r => r.RoundTripTimeMs)
.ToList();
foreach (var r in testResults)
{
var inv = System.Globalization.CultureInfo.InvariantCulture;
var ser = r.SerializeTimeMs > 0 ? r.SerializeTimeMs.ToString("F2", inv) : "-";
var des = r.DeserializeTimeMs > 0 ? r.DeserializeTimeMs.ToString("F2", inv) : "-";
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) : "-";
var rtAlloc = r.RoundTripAllocBytesPerOp > 0 ? r.RoundTripAllocBytesPerOp.ToString(inv) : "-";
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} | {rtAlloc} | {setupAlloc}");
}
}
File.WriteAllText(filePath, sb.ToString(), Utf8NoBom);
System.Console.WriteLine($"✓ LLM results saved to: {filePath}");
}
/// <summary>
/// Formats byte array as hex dump with offset, hex values, and ASCII representation.
/// </summary>
private static string FormatHexDump(byte[] bytes, int bytesPerLine = 16)
{
var sb = new StringBuilder();
for (var i = 0; i < bytes.Length; i += bytesPerLine)
{
// Offset
sb.Append($"{i:X8} ");
// Hex bytes
for (var j = 0; j < bytesPerLine; j++)
{
if (i + j < bytes.Length)
sb.Append($"{bytes[i + j]:X2} ");
else
sb.Append(" ");
if (j == 7) sb.Append(' '); // Extra space in middle
}
sb.Append(" |");
// ASCII representation
for (var j = 0; j < bytesPerLine && i + j < bytes.Length; j++)
{
var b = bytes[i + j];
sb.Append(b is >= 32 and < 127 ? (char)b : '.');
}
sb.AppendLine("|");
}
return sb.ToString();
}
#endregion
}