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; /// /// 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 /// 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 #if DEBUG private static int WarmupIterations = 0; private static int TestIterations = 1; private static 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 // 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"; private const string IoNamedPipeRaw = "NamedPipe"; // Dispatch mode identifiers — describes how property access / type dispatch happens for a given run. // SGen = compile-time source generator path (Unsafe.As 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); /// /// Aggregated feature flags across every type tagged with /// the attribute in the loaded assemblies. Cached on first access (single reflection scan at startup). /// Used by 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 → true; if any tagged type /// disables it → false (a single disabling type suppresses the feature on the type-graph). /// 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(); } }) .Select(t => t.GetCustomAttribute()) .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)); } /// /// 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. Interning=All(opt) | False (attr)) so attribute-suppressed features cannot /// silently mislead. Pass any benchmark-specific extras (e.g. ", BufferSize=4096B") /// in — they are appended after the common fields. /// 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}"; } /// /// Converts a total-time (in ms across ) into per-operation microseconds. /// Formula: totalMs / iterations × 1000. The benchmark stores *TimeMs as the cumulative /// median over the timing run; the display layer renders per-op µs to make numbers iteration-count /// independent (e.g. switching TestIterations 1000 → 100 leaves the displayed µs/op unchanged /// — only its sample noise grows). Symmetric with the already-per-op *AllocBytesPerOp fields. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static double ToPerOpMicros(double totalMs) => totalMs / TestIterations * 1000.0; /// /// Converts a byte count to KB (1 KB = 1024 B). Display-only helper so allocation columns can /// render compact F2 KB values (e.g. 4.05 KB instead of 4,144 B) — header carries /// the unit so per-row entries stay numbers-only. CSV / raw-data outputs keep the precise byte /// integers untouched. /// [MethodImpl(MethodImplOptions.AggressiveInlining)] private static double ToKilobytes(long bytes) => bytes / 1024.0; 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(); 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/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!"); } /// /// Profiler mode: warmup only, then EXIT immediately. /// Usage: dotnet run -- profiler /// 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(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(bytes); } System.Console.WriteLine("Running hot path deserialization (1000 iterations for profiling)..."); for (var i = 0; i < 1000; i++) { _ = AcBinaryDeserializer.Deserialize(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 RunBenchmarksForTestData(TestDataSet testData, string mode, string serializerMode) { var results = new List(); 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, SetupSerializeAllocBytes = serializer.SetupSerializeAllocBytes, SetupDeserializeAllocBytes = serializer.SetupDeserializeAllocBytes, IsRoundTripOnly = serializer.IsRoundTripOnly }; // Group label for in-place \r progress. Identifies (cell × serializer) so a stuck benchmark // is visibly stuck on a specific row at a specific %% rather than silently hanging. var groupLabel = $"{result.SerializerName}"; 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, $"{groupLabel} [RT timing]"); result.RoundTripAllocBytesPerOp = MeasureAllocationTotal(() => serializer.Serialize(), TestIterations, $"{groupLabel} [RT alloc]"); } // 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, $"{groupLabel} [Ser timing]"); // Dedicated alloc-only sample (separate from timing samples; keeps timing pure) result.SerializeAllocBytesPerOp = MeasureAllocation(() => serializer.Serialize(), TestIterations, $"{groupLabel} [Ser alloc]"); } if (mode is "all" or "deserialize" or "des") { result.DeserializeTimeMs = RunTimed(() => serializer.Deserialize(), TestIterations, $"{groupLabel} [Des timing]"); result.DeserializeAllocBytesPerOp = MeasureAllocation(() => serializer.Deserialize(), TestIterations, $"{groupLabel} [Des alloc]"); } // 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 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 { // Chunked-framed AsyncPipe: SerializeChunkedFramed + AsyncPipeReaderInput.DrainFromAsync. // Measures the FULL streaming-I/O stack — wire framing + drain task + sliding-window buffer + // MRES wait-on-byte-shortage — over a kernel NamedPipe. new AcBinaryNamedPipeBenchmark(testData.Order, binaryFastModePipeChunkOnly, "FastMode (PipeChunk)"), // Raw byte[] over NamedPipe (sync receive, no chunk-framing). Same kernel-pipe transport, // same inBufferSize, but: serialize → byte[] → Stream.Write → Stream.Read → Deserialize(byte[]). // No drain task, no AsyncPipeReaderInput, no [201][UINT16][data]…[202] framing. Side-by-side with // the chunked-row above this isolates AsyncPipe-framework-overhead (Δ vs raw) from // kernel-transport-overhead (raw vs in-process Byte[]). new AcBinaryNamedPipeRawByteArrayBenchmark(testData.Order, binaryFastModePipeChunkOnly, "FastMode (PipeRaw)"), }; } // 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 { // ============================================================ // 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)"), // 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") }; } /// /// Runs the action times for 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 <= 1, falls back to single-sample timing (Debug / quick mode). /// When is non-null, emits in-place \r progress updates so a /// stuck benchmark (e.g. deadlocked NamedPipe row) is visibly stuck at a specific %% rather than /// silently hanging. /// private static double RunTimed(Action action, int iterations, string? progressLabel = null) { var samples = BenchmarkSamples; if (samples <= 1) { // Single-sample fast path (Debug or trivial run) — no allocation, no sort. var sw = Stopwatch.StartNew(); RunWithProgress(action, iterations, progressLabel, samples: 1, sampleIndex: 0); sw.Stop(); EndProgress(progressLabel, sw.Elapsed.TotalMilliseconds); return sw.Elapsed.TotalMilliseconds; } var times = new double[samples]; for (var s = 0; s < samples; s++) { var sw = Stopwatch.StartNew(); RunWithProgress(action, iterations, progressLabel, samples, s); sw.Stop(); times[s] = sw.Elapsed.TotalMilliseconds; } Array.Sort(times); // Median: middle value for odd sample counts, average of two middles for even counts. var medianMs = samples % 2 == 1 ? times[samples / 2] : (times[samples / 2 - 1] + times[samples / 2]) / 2.0; EndProgress(progressLabel, medianMs); return medianMs; } /// /// Measures per-call allocation in bytes after a clean GC. Single dedicated sample (no median) — keeps timing samples pure. /// private static long MeasureAllocation(Action action, int iterations, string? progressLabel = null) { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); var sw = Stopwatch.StartNew(); var before = GC.GetAllocatedBytesForCurrentThread(); RunWithProgress(action, iterations, progressLabel, samples: 1, sampleIndex: 0); var after = GC.GetAllocatedBytesForCurrentThread(); sw.Stop(); EndProgress(progressLabel, sw.Elapsed.TotalMilliseconds); return (after - before) / iterations; } /// /// Process-wide allocation measurement — needed for round-trip-only benchmarks (NamedPipe etc.) where /// the work happens across multiple threads. would /// only count the caller-thread allocations, missing the server-side new byte[len] buffers and /// any drain-pump-thread allocations. covers the entire process. /// Slightly noisier than the per-thread variant (background threads / GC bookkeeping leak in), but /// over 1000 iterations the signal dominates. /// private static long MeasureAllocationTotal(Action action, int iterations, string? progressLabel = null) { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); var sw = Stopwatch.StartNew(); var before = GC.GetTotalAllocatedBytes(precise: true); RunWithProgress(action, iterations, progressLabel, samples: 1, sampleIndex: 0); var after = GC.GetTotalAllocatedBytes(precise: true); sw.Stop(); EndProgress(progressLabel, sw.Elapsed.TotalMilliseconds); return (after - before) / iterations; } // ============================================================================================ // Progress reporting — \r-driven in-place updates so a stuck benchmark surfaces the exact phase // and % where it stopped, instead of appearing as a silent hang. Used by RunTimed and the // MeasureAllocation* helpers when the caller passes a non-null progressLabel. // ============================================================================================ // Tracks the longest line written by the current progress session, so EndProgress can clear // any leftover characters from a prior longer line (avoids "ghost" trailing chars after \r). private static int _progressLastLineLen; /// /// Runs times, emitting \r-overwriting /// progress every ~10% (approx. 10 progress prints per sample). When /// is null, runs without any progress output (zero overhead beyond a null check per iter). /// private static void RunWithProgress(Action action, int iterations, string? label, int samples, int sampleIndex) { if (label is null) { for (var i = 0; i < iterations; i++) action(); return; } // ~10 progress emits per sample run. Avoid emitting on every iter (Console.Write is // expensive enough to skew sub-µs benchmarks if overdone). var step = Math.Max(1, iterations / 10); for (var i = 0; i < iterations; i++) { action(); if ((i + 1) % step == 0 || i == iterations - 1) { var pct = (int)((i + 1) * 100L / iterations); var line = samples > 1 ? $" > {label} sample {sampleIndex + 1}/{samples} {pct,3}% ({i + 1}/{iterations})" : $" > {label} {pct,3}% ({i + 1}/{iterations})"; System.Console.Write('\r'); System.Console.Write(line); if (line.Length < _progressLastLineLen) System.Console.Write(new string(' ', _progressLastLineLen - line.Length)); _progressLastLineLen = line.Length; } } } /// /// Closes a progress line cleanly: clears any leftover chars and writes a final "done" line on /// the same row, terminated by \n so subsequent WriteLine calls render below. /// private static void EndProgress(string? label, double elapsedMs) { if (label is null) return; var done = $" > {label} done in {elapsedMs,7:F1} ms"; System.Console.Write('\r'); System.Console.Write(done); if (done.Length < _progressLastLineLen) System.Console.Write(new string(' ', _progressLastLineLen - done.Length)); System.Console.WriteLine(); _progressLastLineLen = 0; } private static readonly JsonSerializerOptions VerifyJsonOpts = new() { WriteIndented = false, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles }; /// /// 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. /// 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; } /// /// 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. /// 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); } } } /// /// Interactive menu shown when no CLI args. Returns the layer keyword (core/comprehensive/edge/all) or null on Quit. /// 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") }; } /// /// 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. /// private static List FilterByLayer(List 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 { /// Serializer engine — e.g. "AcBinary", "MemoryPack", "MessagePack". string Engine { get; } /// I/O mode — e.g. "Byte[]", "BufWr reuse", "BufWr new", "NamedPipe", "FileStream". string IoMode { get; } /// Dispatch mode — "SGen", "Runtime", or "Hybrid". For AcBinary derived from UseGeneratedCode + child-type SGen coverage; non-AcBinary engines report their own native dispatch model. string DispatchMode { get; } /// Options preset name — e.g. "FastMode", "Default", "NoIntern", "WithCompression". string OptionsPreset { get; } /// Synthesized display name from Engine + IoMode + OptionsPreset. string Name => $"{Engine} ({IoMode}, {OptionsPreset})"; int SerializedSize { get; } string? OptionsDescription => null; /// One-time SERIALIZER-side setup allocation cost (e.g., pre-allocated ArrayBufferWriter with internal buffer). Captured in constructor; 0 for byte[] API and Fresh-BufWriter variants. long SetupSerializeAllocBytes { get; } /// One-time DESERIALIZER-side setup allocation cost (e.g., long-lived AsyncPipeReaderInput's ArrayPool rent + ManualResetEventSlim, drain-task scaffolding). Captured in constructor; 0 for byte[] API and any setup-free deserialize path. long SetupDeserializeAllocBytes { get; } /// 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. bool IsRoundTripOnly => false; void Warmup(int iterations); void Serialize(); void Deserialize(); /// Round-trip correctness check — called once per cell before warmup. Returns true if Serialize+Deserialize preserves data. 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 SetupSerializeAllocBytes => 0; public long SetupDeserializeAllocBytes => 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(_serialized, _options); public bool VerifyRoundTrip() { var bytes = AcBinarySerializer.Serialize(_order, _options); var roundTripped = AcBinaryDeserializer.Deserialize(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 SetupSerializeAllocBytes => 0; public long SetupDeserializeAllocBytes => 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(_serialized); public bool VerifyRoundTrip() { var bytes = MemoryPackSerializer.Serialize(_order); var roundTripped = MemoryPackSerializer.Deserialize(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 SetupSerializeAllocBytes => 0; public long SetupDeserializeAllocBytes => 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(_serialized, _options); public bool VerifyRoundTrip() { var bytes = MessagePackSerializer.Serialize(_order, _options); var roundTripped = MessagePackSerializer.Deserialize(bytes, _options); return DeepEqualsViaJson(_order, roundTripped); } } /// /// 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). /// /// /// 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). /// 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 SetupSerializeAllocBytes => 0; public long SetupDeserializeAllocBytes => 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(); // FRESH every call — alloc + grow as needed AcBinarySerializer.Serialize(_order, abw, _options); } [MethodImpl(MethodImplOptions.NoInlining)] public void Deserialize() => AcBinaryDeserializer.Deserialize(_serialized, _options); public bool VerifyRoundTrip() { var abw = new ArrayBufferWriter(); AcBinarySerializer.Serialize(_order, abw, _options); var roundTripped = AcBinaryDeserializer.Deserialize(abw.WrittenSpan.ToArray(), _options); return DeepEqualsViaJson(_order, roundTripped); } } /// /// Benchmarks AcBinary over a long-lived NamedPipe IPC connection using the AcBinary native streaming API /// ( /// + + ). /// Mirrors what a real consumer (e.g. DeserializeFromPipeReaderAsync) does per message: /// long-lived with multi-message wire framing on top of a long-lived NamedPipe. /// /// Architecture: /// /// Constructor (NOT timed): sets up + , /// waits for connection, creates one long-lived / /// pair, ONE long-lived /// in multiMessage = true mode, ONE drain Task that pumps /// forever, and ONE deserialize Task that loops AcBinaryDeserializer.Deserialize<T>(input, opts) /// producing into a . /// Per-iteration (timed): sender writes via /// /// — multi-message wire ([201][UINT16][data]...[202]); the [202] end marker arms the input's /// _readPos = -1 sentinel, so the next message's first AppendToBuffer recycles the buffer to 0. /// Then receiver awaits the channel for the deserialized result. /// is a no-op (full round-trip captured in ); /// =true → Ser ms / SerAlloc oszlopok N/A, RT ms = full round-trip. /// /// /// Per-iter overhead: 0 new Task.Run, 0 new AsyncPipeReaderInput, 0 new CancellationTokenSource. /// Pure cost = SerializeChunkedFramed (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: _readPos = -1 /// sentinel + AppendToBuffer sliding-window cycling). /// /// Approximation note: 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. /// 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 SetupSerializeAllocBytes { get; } public long SetupDeserializeAllocBytes { get; } 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}"; // === SERIALIZE-side setup measurement === // pipe-pair (server + client) + connect handshake + writer-side PipeWriter wrapper. GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); var beforeSer = GC.GetAllocatedBytesForCurrentThread(); _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); var afterSer = GC.GetAllocatedBytesForCurrentThread(); SetupSerializeAllocBytes = afterSer - beforeSer; // === DESERIALIZE-side setup measurement === // PipeReader wrapper + AsyncPipeReaderInput (ArrayPool rent + ManualResetEventSlim) + drain // task scaffolding. The long-lived deserialize-side allocation that per-iter measurements // hide today, surfaced here for comparison-vs-FreshInstance fairness. GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); var beforeDes = GC.GetAllocatedBytesForCurrentThread(); _pipeReader = PipeReader.Create(_pipeServer); // 1× multi-message receive infrastructure: long-lived input + 1 background drain task. // Per-iter Serialize() does its own Deserialize(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)); var afterDes = GC.GetAllocatedBytesForCurrentThread(); SetupDeserializeAllocBytes = afterDes - beforeDes; } 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 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 returns. _ = AcBinaryDeserializer.Deserialize(_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(_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 */ } } } /// /// Raw byte[] over a long-lived NamedPipe — NO chunk-framing, NO AsyncPipeReaderInput, /// NO sliding-window buffer. Calling thread serialises + writes; a long-lived background read-thread /// drains the pipe sync; calling thread deserialises. The background-read mirrors /// 's drain task — needed to avoid a kernel-buffer-full deadlock /// when bytes.Length > inBufferSize (Large/Repeated/Deep payloads on a 16 KB pipe-buffer). /// /// Side-by-side with (chunked-framed AsyncPipe stack) this /// isolates two cost components on the SAME kernel-pipe transport with the SAME inBufferSize: /// /// This row vs (Byte[]) — pure kernel-NamedPipe /// overhead (WriteFile / ReadFile syscalls + IRP queueing + buffer-copy + thread-handoff). /// This row vs (chunked-framed) — pure /// AsyncPipe-framework overhead (chunk header writes + sliding-window Feed + MRES wait inside /// AsyncPipeReaderInput). Both rows have a long-lived read-thread; only the framing differs. /// /// Per-iter byte[] allocation from AcBinarySerializer.Serialize is part of the cost (matches /// 's API contract); the receive-side scratch buffer is also allocated per-iter /// on the read-thread (counted via GC.GetTotalAllocatedBytes in MeasureAllocationTotal). /// private sealed class AcBinaryNamedPipeRawByteArrayBenchmark : ISerializerBenchmark, IDisposable { private readonly TestOrder _order; private readonly AcBinarySerializerOptions _options; private readonly byte[] _serialized; // for SerializedSize reporting + receive-side size known upfront // Long-lived pipe lifecycle (set up once in ctor — NOT timed). private readonly NamedPipeServerStream _pipeServer; private readonly NamedPipeClientStream _pipeClient; // Long-lived read-thread infrastructure — mirrors AcBinaryNamedPipeBenchmark's _drainTask. Needed // to prevent kernel-buffer-full deadlock: when bytes.Length > inBufferSize, _pipeClient.Write // blocks until the kernel buffer drains, but the drain only happens when SOMETHING reads from the // server end. Single-thread Write→Read sequencing → deadlock. Background-read overlaps the read. private readonly CancellationTokenSource _cts; private readonly Task _readTask; private readonly ManualResetEventSlim _readRequest = new(false); private readonly ManualResetEventSlim _readDone = new(false); private int _pendingReadSize; private byte[]? _receivedSlot; private bool _disposed; public string Engine => EngineAcBinary; public string IoMode => IoNamedPipeRaw; public string DispatchMode => _options.UseGeneratedCode ? ModeSGen : ModeRuntime; public string OptionsPreset { get; } public int SerializedSize => _serialized.Length; public long SetupSerializeAllocBytes { get; } public long SetupDeserializeAllocBytes { get; } public bool IsRoundTripOnly => true; public string OptionsDescription => BuildAcBinaryOptionsDescription(_options, $", BufferSize={_options.BufferWriterChunkSize}B, Transport=NamedPipe(raw,bg-read)"); public AcBinaryNamedPipeRawByteArrayBenchmark(TestOrder order, AcBinarySerializerOptions options, string optionsPreset) { _order = order; // BufferWriterChunkSize comes from the caller — same source-of-truth contract as // AcBinaryNamedPipeBenchmark. The kernel pipe-buffer (inBufferSize) is wired to it so the // raw-vs-chunked comparison runs on identical transport conditions. _options = options; OptionsPreset = optionsPreset; _serialized = AcBinarySerializer.Serialize(order, _options); var pipeName = $"AcBinaryBenchRaw-{Guid.NewGuid():N}"; // === SERIALIZE-side setup measurement === // pipe-pair (server + client) + connect handshake. NO PipeWriter wrapper — we use the raw // Stream.Write API directly, matching the no-framing semantics of this benchmark. GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); var beforeSer = GC.GetAllocatedBytesForCurrentThread(); _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(); var afterSer = GC.GetAllocatedBytesForCurrentThread(); SetupSerializeAllocBytes = afterSer - beforeSer; // === DESERIALIZE-side setup measurement === // 1× background read-thread + 2× MRES (request / done) + cancellation source. Matches the // chunked benchmark's deserialize-side setup cost shape (it has 1× drain Task + AsyncPipeReaderInput // with 1× MRES + ArrayPool rent). GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); var beforeDes = GC.GetAllocatedBytesForCurrentThread(); _cts = new CancellationTokenSource(); _readTask = Task.Run(ReadLoop); var afterDes = GC.GetAllocatedBytesForCurrentThread(); SetupDeserializeAllocBytes = afterDes - beforeDes; } // Long-lived read-loop on a background thread. Pattern: wait for request → drain N bytes → publish // result via _receivedSlot → signal done. The calling thread provides the size via _pendingReadSize // BEFORE setting _readRequest, so the read-thread always knows how much to read. private void ReadLoop() { var ct = _cts.Token; try { while (!ct.IsCancellationRequested) { _readRequest.Wait(ct); if (ct.IsCancellationRequested) break; _readRequest.Reset(); var size = _pendingReadSize; var bytes = new byte[size]; // per-iter alloc — counted by MeasureAllocationTotal var totalRead = 0; while (totalRead < size) { var n = _pipeServer.Read(bytes, totalRead, size - totalRead); if (n == 0) break; // pipe closed / EOF — partial read returned to caller totalRead += n; } _receivedSlot = bytes; _readDone.Set(); } } catch (OperationCanceledException) { // Cooperative cancel — Dispose path. Swallow. } catch { // Any other error during teardown → swallow; the calling thread's _readDone.Wait() // would then time out, surfaced by the dispose timeout below. } } public void Warmup(int iterations) { for (var i = 0; i < iterations; i++) { Serialize(); } } [MethodImpl(MethodImplOptions.NoInlining)] public void Serialize() { // Sender: serialize → fresh byte[] (per-iter alloc, matches AcBinaryBenchmark API contract). var bytes = AcBinarySerializer.Serialize(_order, _options); // Hand off the expected size to the read-thread BEFORE signalling — read-thread reads // _pendingReadSize after _readRequest.Wait returns, so write-then-set ordering is sufficient // (MRES.Set has release semantics; MRES.Wait has acquire). _pendingReadSize = bytes.Length; _readDone.Reset(); _readRequest.Set(); // Sync write on calling thread, OVERLAPPING with the read-thread's Read loop. The kernel // buffer may fill (bytes.Length > inBufferSize) — Write blocks; the read-thread drains; // Write resumes. Total wall time ≈ Write-bound or Read-bound, whichever is slower. _pipeClient.Write(bytes, 0, bytes.Length); _pipeClient.Flush(); // Wait for the read-thread to finish accumulating the message. _readDone.Wait(); var receivedBytes = _receivedSlot!; _ = AcBinaryDeserializer.Deserialize(receivedBytes, _options); } [MethodImpl(MethodImplOptions.NoInlining)] public void Deserialize() { // No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract. } public bool VerifyRoundTrip() { // Inlined version of Serialize() that captures the deserialised graph (Serialize()'s // discard-pattern is correct for the timed loop but useless for verification). var bytes = AcBinarySerializer.Serialize(_order, _options); _pendingReadSize = bytes.Length; _readDone.Reset(); _readRequest.Set(); _pipeClient.Write(bytes, 0, bytes.Length); _pipeClient.Flush(); _readDone.Wait(); var received = _receivedSlot!; var result = AcBinaryDeserializer.Deserialize(received, _options); return result != null && DeepEqualsViaJson(_order, result); } public void Dispose() { if (_disposed) return; _disposed = true; // Cancel the read-loop → ReadLoop exits its Wait via OperationCanceledException. try { _cts.Cancel(); } catch { /* swallow on teardown */ } try { _readRequest.Set(); } catch { /* nudge in case Wait is parked */ } try { _readTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ } // Symmetric teardown — close client first (writer side), then server. try { _pipeClient.Dispose(); } catch { /* swallow on teardown */ } try { _pipeServer.Dispose(); } catch { /* swallow on teardown */ } try { _readRequest.Dispose(); } catch { /* swallow on teardown */ } try { _readDone.Dispose(); } catch { /* swallow on teardown */ } try { _cts.Dispose(); } catch { /* swallow on teardown */ } } } /// /// Benchmarks MemoryPack via the IBufferWriter overload, allocating a FRESH ArrayBufferWriter on EVERY call. /// Apples-to-apples counterpart to AcBinaryFreshBufferWriterBenchmark. /// 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 SetupSerializeAllocBytes => 0; public long SetupDeserializeAllocBytes => 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(); MemoryPackSerializer.Serialize(abw, _order); } [MethodImpl(MethodImplOptions.NoInlining)] public void Deserialize() => MemoryPackSerializer.Deserialize(_serialized); public bool VerifyRoundTrip() { var abw = new ArrayBufferWriter(); MemoryPackSerializer.Serialize(abw, _order); var roundTripped = MemoryPackSerializer.Deserialize(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 _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 SetupSerializeAllocBytes { get; } public long SetupDeserializeAllocBytes => 0; 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 on the serialize side (excluding the // helper Serialize above). Deserialize side reads directly from `_serialized` byte[] — no // dedicated setup allocation, hence SetupDeserializeAllocBytes = 0. GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); var beforeSetup = GC.GetAllocatedBytesForCurrentThread(); _bufferWriter = new ArrayBufferWriter(_serialized.Length * 2); var afterSetup = GC.GetAllocatedBytesForCurrentThread(); SetupSerializeAllocBytes = 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(_serialized, _options); public bool VerifyRoundTrip() { _bufferWriter.ResetWrittenCount(); AcBinarySerializer.Serialize(_order, _bufferWriter, _options); var roundTripped = AcBinaryDeserializer.Deserialize(_bufferWriter.WrittenSpan.ToArray(), _options); return DeepEqualsViaJson(_order, roundTripped); } } /// /// 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. /// private sealed class MemoryPackBufferWriterBenchmark : ISerializerBenchmark { private readonly TestOrder _order; private readonly byte[] _serialized; private readonly ArrayBufferWriter _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 SetupSerializeAllocBytes { get; } public long SetupDeserializeAllocBytes => 0; public MemoryPackBufferWriterBenchmark(TestOrder order, string optionsPreset) { _order = order; OptionsPreset = optionsPreset; _serialized = MemoryPackSerializer.Serialize(order); // Serialize-side setup only — see AcBinaryBufferWriterBenchmark for the full rationale. GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); var beforeSetup = GC.GetAllocatedBytesForCurrentThread(); _bufferWriter = new ArrayBufferWriter(_serialized.Length * 2); var afterSetup = GC.GetAllocatedBytesForCurrentThread(); SetupSerializeAllocBytes = 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(_serialized); public bool VerifyRoundTrip() { _bufferWriter.ResetWrittenCount(); MemoryPackSerializer.Serialize(_bufferWriter, _order); var roundTripped = MemoryPackSerializer.Deserialize(_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 SetupSerializeAllocBytes => 0; public long SetupDeserializeAllocBytes => 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(_serialized, _options); public bool VerifyRoundTrip() { var json = JsonSerializer.Serialize(_order, _options); var roundTripped = JsonSerializer.Deserialize(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; } = ""; /// 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 µs/op / SerAlloc / Des µs/op / DesAlloc /// all show "N/A" since they were never measured separately; RT µs/op / RT Alloc carry the full round-trip values. public bool IsRoundTripOnly { get; set; } /// 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). 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 SetupSerializeAllocBytes { get; set; } public long SetupDeserializeAllocBytes { get; set; } /// Total round-trip time. For in-memory benchmarks: Serialize + Deserialize (set explicitly in /// RunBenchmarksForTestData). For round-trip-only benchmarks (NamedPipe etc.): the directly-measured /// pipe round-trip time, since Ser and Des are not separately measurable there. public double RoundTripTimeMs { get; set; } /// Total round-trip allocation per op. For in-memory benchmarks: SerializeAlloc + DeserializeAlloc. /// For round-trip-only benchmarks: process-wide allocation measured via /// (covers ALL threads — client, server-drain, channel internals — not just the caller). public long RoundTripAllocBytesPerOp { get; set; } } private static void PrintResult(BenchmarkResult result) { // Numbers-only per-row entries; the column-headers carry units (µs/op, KB/op). var ser = result.SerializeTimeMs > 0 ? $"{ToPerOpMicros(result.SerializeTimeMs),7:F2}" : " N/A"; var des = result.DeserializeTimeMs > 0 ? $"{ToPerOpMicros(result.DeserializeTimeMs),7:F2}" : " N/A"; var serAlloc = result.SerializeTimeMs > 0 ? $"{ToKilobytes(result.SerializeAllocBytesPerOp),7:F2}" : " N/A"; var desAlloc = result.DeserializeTimeMs > 0 ? $"{ToKilobytes(result.DeserializeAllocBytesPerOp),7:F2}" : " N/A"; System.Console.WriteLine($" {result.SerializerName,-40} | Size: {result.SerializedSize,8:N0} B | Ser: {ser} µs/op ({serAlloc} KB/op) | Des: {des} µs/op ({desAlloc} KB/op)"); } private static void PrintGroupedResults(List results, List 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, '─') + "┐"); // Header-only units; per-row entries are numbers (µs/op for time, KB/op for alloc, KB pair "ser / des" for Setup, B for Size). System.Console.WriteLine($"│ {"#",-4} │ {"Engine",-11} │ {"Options",-22} │ {"IO",-12} │ {"Mode",-8} │ {"Setup S/D KB",-14} │ {"Size B",-8} │ {"Ser µs/op",-10} │ {"SerAlc KB",-10} │ {"Des µs/op",-10} │ {"DesAlc KB",-10} │ {"RT µs/op",-10} │ {"RTAlc KB",-10} │"); System.Console.WriteLine($"├{"─".PadRight(6, '─')}┼{"─".PadRight(13, '─')}┼{"─".PadRight(24, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(10, '─')}┼{"─".PadRight(16, '─')}┼{"─".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 = $"{ToKilobytes(result.SetupSerializeAllocBytes):F2} / {ToKilobytes(result.SetupDeserializeAllocBytes):F2}"; var ser = result.SerializeTimeMs > 0 ? $"{ToPerOpMicros(result.SerializeTimeMs):F2}" : "N/A"; var des = result.DeserializeTimeMs > 0 ? $"{ToPerOpMicros(result.DeserializeTimeMs):F2}" : "N/A"; var rt = result.RoundTripTimeMs > 0 ? $"{ToPerOpMicros(result.RoundTripTimeMs):F2}" : "N/A"; var serAlloc = result.SerializeTimeMs > 0 ? $"{ToKilobytes(result.SerializeAllocBytesPerOp):F2}" : "N/A"; var desAlloc = result.DeserializeTimeMs > 0 ? $"{ToKilobytes(result.DeserializeAllocBytesPerOp):F2}" : "N/A"; var rtAlloc = result.RoundTripAllocBytesPerOp > 0 ? $"{ToKilobytes(result.RoundTripAllocBytesPerOp):F2}" : "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,14} │ {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 S/D KB, Size, Ser µs/op, SerAlc KB, Des µs/op, DesAlc KB, RT µs/op, RTAlc KB). System.Console.WriteLine($"├{"─".PadRight(6, '─')}┴{"─".PadRight(13, '─')}┴{"─".PadRight(24, '─')}┴{"─".PadRight(14, '─')}┴{"─".PadRight(10, '─')}┼{"─".PadRight(16, '─')}┼{"─".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 S/D KB (n/a for Byte[] vs Byte[] — neither pre-allocates) System.Console.Write($"{"—",14}"); 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(16, '─')}┴{"─".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} │ {ToPerOpMicros(fastestSer.AvgTime),12:F2} µs/op"); // 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} │ {ToPerOpMicros(fastestDes.AvgTime),12:F2} µs/op"); // 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} │ {ToPerOpMicros(fastestRt.AvgTime),12:F2} µs/op"); // 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}% ({ToPerOpMicros(acBinaryAvgSer):F2} µs/op vs {ToPerOpMicros(memPackAvgSer):F2} µs/op)"); 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}% ({ToPerOpMicros(acBinaryAvgDes):F2} µs/op vs {ToPerOpMicros(memPackAvgDes):F2} µs/op)"); System.Console.ResetColor(); System.Console.ForegroundColor = rtPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red; System.Console.WriteLine($" Round-trip: {rtPctAll:+0;-0}% ({ToPerOpMicros(acBinaryAvgRt):F2} µs/op vs {ToPerOpMicros(memPackAvgRt):F2} µs/op)"); 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 results, List 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 — keeps raw byte integers (no KB rounding) so external tools can compute precisely. sb.AppendLine("=== RAW DATA (CSV) ==="); sb.AppendLine("TestData,Engine,IO,Mode,Options,Size,SerializeMicrosPerOp,DeserializeMicrosPerOp,RoundTripMicrosPerOp,SerializeAllocBytesPerOp,DeserializeAllocBytesPerOp,RoundTripAllocBytesPerOp,SetupSerializeAllocBytes,SetupDeserializeAllocBytes"); 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},{ToPerOpMicros(result.SerializeTimeMs):F2},{ToPerOpMicros(result.DeserializeTimeMs):F2},{ToPerOpMicros(result.RoundTripTimeMs):F2},{result.SerializeAllocBytesPerOp},{result.DeserializeAllocBytesPerOp},{result.RoundTripAllocBytesPerOp},{result.SetupSerializeAllocBytes},{result.SetupDeserializeAllocBytes}"); } } 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 B",-12} {"Setup S/D KB",-14} {"Ser µs/op",-12} {"Des µs/op",-12} {"RT µs/op",-12} {"SerAlc KB",-11} {"DesAlc KB",-11}"); sb.AppendLine(new string('-', 140)); 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 setup = $"{ToKilobytes(result.SetupSerializeAllocBytes):F2} / {ToKilobytes(result.SetupDeserializeAllocBytes):F2}"; var ser = result.SerializeTimeMs > 0 ? $"{ToPerOpMicros(result.SerializeTimeMs):F2}" : "N/A"; var des = result.DeserializeTimeMs > 0 ? $"{ToPerOpMicros(result.DeserializeTimeMs):F2}" : "N/A"; var rt = result.RoundTripTimeMs > 0 ? $"{ToPerOpMicros(result.RoundTripTimeMs):F2}" : "N/A"; var serAlloc = result.SerializeTimeMs > 0 ? $"{ToKilobytes(result.SerializeAllocBytesPerOp):F2}" : "N/A"; var desAlloc = result.DeserializeTimeMs > 0 ? $"{ToKilobytes(result.DeserializeAllocBytesPerOp):F2}" : "N/A"; sb.AppendLine($"{rank++,2} {prefix}{result.SerializerName,-40} {size,-12} {setup,-14} {ser,-12} {des,-12} {rt,-12} {serAlloc,-11} {desAlloc,-11}"); } // 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(); // Skip comparison block if either side has no Byte[] data — happens in AsyncPipe-only mode // where only NamedPipe rows exist (no MemoryPack baseline, no AcBinary Byte[] reference). // Mirrors the same early-return guard in PrintGroupedResults. if (memPackRtResults2.Count == 0 || acBinaryRtResults2.Count == 0) { sb.AppendLine(" (Comparison requires both serialize and deserialize data)"); File.WriteAllText(logFilePath, sb.ToString(), Utf8NoBom); System.Console.WriteLine($"✓ Results saved to: {logFilePath}"); var llmFilePathEarly = Path.Combine(ResultsDirectory, $"{baseFileName}.LLM"); SaveLlmResults(llmFilePathEarly, results, testDataSets); return; } 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}% ({ToPerOpMicros(acBinaryAvgSer2):F2} µs/op vs {ToPerOpMicros(memPackAvgSer2):F2} µs/op)"); 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}% ({ToPerOpMicros(acBinaryAvgDes2):F2} µs/op vs {ToPerOpMicros(memPackAvgDes2):F2} µs/op)"); 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}% ({ToPerOpMicros(acBinaryAvgRt2):F2} µs/op vs {ToPerOpMicros(memPackAvgRt2):F2} µs/op)"); } 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 results, List 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(µs/op) | Deser(µs/op) | RT(µs/op) | SerAlloc(KB/op) | DesAlloc(KB/op) | RTAlloc(KB/op) | Setup S/D(KB)"); 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 ? ToPerOpMicros(r.SerializeTimeMs).ToString("F2", inv) : "-"; var des = r.DeserializeTimeMs > 0 ? ToPerOpMicros(r.DeserializeTimeMs).ToString("F2", inv) : "-"; var rt = r.RoundTripTimeMs > 0 ? ToPerOpMicros(r.RoundTripTimeMs).ToString("F2", inv) : "-"; var serAlloc = r.SerializeTimeMs > 0 ? ToKilobytes(r.SerializeAllocBytesPerOp).ToString("F2", inv) : "-"; var desAlloc = r.DeserializeTimeMs > 0 ? ToKilobytes(r.DeserializeAllocBytesPerOp).ToString("F2", inv) : "-"; var rtAlloc = r.RoundTripAllocBytesPerOp > 0 ? ToKilobytes(r.RoundTripAllocBytesPerOp).ToString("F2", inv) : "-"; var setupAlloc = $"{ToKilobytes(r.SetupSerializeAllocBytes).ToString("F2", inv)} / {ToKilobytes(r.SetupDeserializeAllocBytes).ToString("F2", 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}"); } /// /// Formats byte array as hex dump with offset, hex values, and ASCII representation. /// 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 }