1793 lines
97 KiB
C#
1793 lines
97 KiB
C#
using AyCode.Core.Compression;
|
||
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.Runtime.CompilerServices;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
|
||
namespace AyCode.Core.Serializers.Console;
|
||
|
||
/// <summary>
|
||
/// Comprehensive benchmark application for all serializers.
|
||
/// Compares: AcBinary (all options), MemoryPack, MessagePack, Newtonsoft.Json, System.Text.Json
|
||
///
|
||
/// Usage:
|
||
/// dotnet run # Run all benchmarks
|
||
/// dotnet run -- quick # Quick mode (fewer iterations)
|
||
/// dotnet run -- serialize # Serialize only
|
||
/// dotnet run -- deserialize # Deserialize only
|
||
/// </summary>
|
||
public static class Program
|
||
{
|
||
private const string ResultsDirectory = @"H:\Applications\Aycode\Source\AyCode.Core\Test_Benchmark_Results\Benchmark";
|
||
|
||
#if DEBUG
|
||
private const string BuildConfiguration = "Debug";
|
||
#else
|
||
private const string BuildConfiguration = "Release";
|
||
#endif
|
||
|
||
// Serializer name constants
|
||
// Engine identifiers (used in Engine column + comparison logic)
|
||
private const string EngineAcBinary = "AcBinary";
|
||
private const string EngineMemoryPack = "MemoryPack";
|
||
private const string EngineMessagePack = "MessagePack";
|
||
private const string EngineSystemTextJson = "System.Text.Json";
|
||
|
||
// IO mode identifiers (used in IO column + comparison logic)
|
||
private const string IoByteArray = "Byte[]";
|
||
private const string IoBufWrReuse = "BufWr reuse";
|
||
private const string IoBufWrNew = "BufWr new";
|
||
private const string IoString = "String";
|
||
private const string IoNamedPipe = "NamedPipe";
|
||
|
||
// Dispatch mode identifiers — describes how property access / type dispatch happens for a given run.
|
||
// SGen = compile-time source generator path (Unsafe.As<T> direct fields, slot-array wrapper lookup).
|
||
// Runtime= reflection / compiled-delegate path.
|
||
// Hybrid = SGen root with non-SGen child types reached via bridge methods. See docs/BINARY/BINARY_SGEN.md.
|
||
private const string ModeSGen = "SGen";
|
||
private const string ModeRuntime = "Runtime";
|
||
private const string ModeHybrid = "Hybrid";
|
||
|
||
// 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);
|
||
|
||
#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;
|
||
private static int TestIterations = 1000;
|
||
private static int BenchmarkSamples = 5; // Release: 5-sample median for stability (~±5% variance vs. ~±15% single-sample)
|
||
|
||
//private static int WarmupIterations = 5000;
|
||
//private static int TestIterations = 2000;
|
||
#endif
|
||
|
||
public static void Main(string[] args)
|
||
{
|
||
// Set console encoding to UTF-8 for proper Unicode character display
|
||
System.Console.OutputEncoding = Encoding.UTF8;
|
||
|
||
// Setup validation — abort BEFORE any benchmark logic if MemoryPack baseline is invalid.
|
||
// Done early so user is told immediately, not after warmup.
|
||
ValidateMemoryPackSetup();
|
||
|
||
// Determine layer (which test data to run) and opMode (ser/des/all).
|
||
// CLI args take precedence; if no args, show interactive menu.
|
||
string layer;
|
||
string opMode = "all";
|
||
|
||
if (args.Length == 0)
|
||
{
|
||
var selection = ShowInteractiveMenu();
|
||
if (selection == null) return; // user pressed Q
|
||
layer = selection;
|
||
}
|
||
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 "ser" or "serialize")
|
||
{
|
||
opMode = "serialize";
|
||
layer = "all";
|
||
}
|
||
else if (arg is "des" or "deserialize")
|
||
{
|
||
opMode = "deserialize";
|
||
layer = "all";
|
||
}
|
||
else
|
||
{
|
||
// Backwards compat: unknown arg → treat as layer keyword
|
||
layer = arg;
|
||
}
|
||
}
|
||
|
||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
|
||
System.Console.WriteLine("║ COMPREHENSIVE SERIALIZER BENCHMARK SUITE ║");
|
||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
|
||
var allResults = new List<BenchmarkResult>();
|
||
var allTestDataSets = BenchmarkTestDataProvider.CreateTestDataSets();
|
||
var testDataSets = FilterByLayer(allTestDataSets, layer);
|
||
|
||
System.Console.WriteLine($"Layer: {layer} | OpMode: {opMode} | Iterations: {TestIterations} | Warmup: {WarmupIterations} | Samples: {BenchmarkSamples} (median)");
|
||
System.Console.WriteLine($"Build: {BuildConfiguration} | .NET: {Environment.Version} | Test Type: {testDataSets.FirstOrDefault()?.TypeName ?? "unknown"} | Test Cells: {testDataSets.Count}/{allTestDataSets.Count}");
|
||
System.Console.WriteLine();
|
||
|
||
// Global JIT pre-warmup — touches every (testdata × serializer) code path BEFORE any timing happens.
|
||
// Without this, the FIRST test data measured carries JIT-tier-promotion latency: the per-cell warmup
|
||
// alone doesn't ensure that every Serialize<T>/IBufferWriter overload is fully Tier 1 by the time we
|
||
// start measuring. Symptom: first cell's BufferWriter variants run ~2x slower than the SAME variants
|
||
// on later cells (e.g. Small BufWr reuse 9ms vs Medium BufWr reuse 4ms — even though Medium is bigger).
|
||
// Pre-warmup runs every overload at least once with each data shape so .NET 9's tiered JIT promotes
|
||
// them all in the background; the per-cell warmup that follows then locks in cache + branch state.
|
||
if (BenchmarkSamples > 1) // skip in DEBUG (single-sample fast iteration)
|
||
{
|
||
System.Console.WriteLine($"Global JIT pre-warmup ({testDataSets.Count} cells × all serializers, light pass)...");
|
||
foreach (var testData in testDataSets)
|
||
{
|
||
var preSerializers = CreateSerializers(testData);
|
||
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(3000);
|
||
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);
|
||
allResults.AddRange(results);
|
||
}
|
||
|
||
// Print grouped results
|
||
PrintGroupedResults(allResults, testDataSets);
|
||
|
||
// Save results to file
|
||
SaveResults(allResults, testDataSets);
|
||
|
||
System.Console.WriteLine("\n✓ Benchmark complete!");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Profiler mode: warmup only, then EXIT immediately.
|
||
/// Usage: dotnet run -- profiler
|
||
/// </summary>
|
||
private static void RunProfilerMode()
|
||
{
|
||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
|
||
System.Console.WriteLine("║ PROFILER MODE (AcBinary only) ║");
|
||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
|
||
System.Console.WriteLine($"Build: {BuildConfiguration} | .NET: {Environment.Version}");
|
||
System.Console.WriteLine();
|
||
|
||
var order = BenchmarkTestDataProvider.CreateProfilerOrder();
|
||
|
||
var options = AcBinarySerializerOptions.WithoutReferenceHandling;
|
||
options.UseStringInterning = StringInterningMode.None;
|
||
|
||
byte[] bytes = AcBinarySerializer.Serialize(order, options);
|
||
// Warmup (fills caches)
|
||
System.Console.WriteLine("Warming up (1000 iterations)...");
|
||
for (var i = 0; i < 1000; i++)
|
||
{
|
||
_ = AcBinarySerializer.Serialize(order, options);
|
||
_ = AcBinaryDeserializer.Deserialize<TestOrder>(bytes);
|
||
}
|
||
|
||
Thread.Sleep(2000);
|
||
System.Console.WriteLine("Warmup complete. Caches are now populated.");
|
||
System.Console.WriteLine();
|
||
|
||
// HOT PATH - this is what the profiler should capture!
|
||
System.Console.WriteLine("Running hot path serialization (1000 iterations for profiling)...");
|
||
for (var i = 0; i < 1000; i++)
|
||
{
|
||
_ = AcBinarySerializer.Serialize(order, options);
|
||
//_ = AcBinaryDeserializer.Deserialize<TestOrder>(bytes);
|
||
}
|
||
|
||
System.Console.WriteLine("Running hot path deserialization (1000 iterations for profiling)...");
|
||
for (var i = 0; i < 1000; i++)
|
||
{
|
||
_ = AcBinaryDeserializer.Deserialize<TestOrder>(bytes);
|
||
}
|
||
|
||
System.Console.WriteLine("Hot path complete.");
|
||
System.Console.WriteLine();
|
||
|
||
System.Console.WriteLine(">>> ATTACH MEMORY PROFILER NOW <<<");
|
||
System.Console.WriteLine("Press any key to exit...");
|
||
System.Console.ReadKey(intercept: true);
|
||
System.Console.WriteLine();
|
||
System.Console.WriteLine("✓ Profiler mode complete. Exiting now.");
|
||
}
|
||
|
||
#region Benchmark Execution
|
||
|
||
private static List<BenchmarkResult> RunBenchmarksForTestData(TestDataSet testData, string mode)
|
||
{
|
||
var results = new List<BenchmarkResult>();
|
||
var serializers = CreateSerializers(testData);
|
||
|
||
// 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(3000);
|
||
|
||
// Run benchmarks
|
||
System.Console.WriteLine($"Running benchmarks ({TestIterations} iterations × {BenchmarkSamples} samples median)...\n");
|
||
|
||
foreach (var serializer in serializers)
|
||
{
|
||
var result = new BenchmarkResult
|
||
{
|
||
TestDataName = testData.DisplayName, // Use DisplayName for IId% info
|
||
Engine = serializer.Engine,
|
||
IoMode = serializer.IoMode,
|
||
DispatchMode = serializer.DispatchMode,
|
||
OptionsPreset = serializer.OptionsPreset,
|
||
OptionsDescription = serializer.OptionsDescription,
|
||
SerializedSize = serializer.SerializedSize,
|
||
SetupAllocBytes = serializer.SetupAllocBytes,
|
||
IsRoundTripOnly = serializer.IsRoundTripOnly
|
||
};
|
||
|
||
if (serializer.IsRoundTripOnly)
|
||
{
|
||
// Round-trip-only benchmarks (NamedPipe etc.): measure the full pipe round-trip directly into the RT
|
||
// columns. Ser ms / SerAlloc / Des ms / DesAlloc stay 0 → display as "N/A". Allocation uses the
|
||
// process-wide measurement so the server-drain-thread allocations (e.g. server-side new byte[len])
|
||
// also show up — otherwise current-thread alloc would only count the client side and look ~halved.
|
||
if (mode is "all" or "serialize" or "ser")
|
||
{
|
||
result.RoundTripTimeMs = RunTimed(() => serializer.Serialize(), TestIterations);
|
||
result.RoundTripAllocBytesPerOp = MeasureAllocationTotal(() => serializer.Serialize(), TestIterations);
|
||
}
|
||
// mode == "deserialize" alone is meaningless for a round-trip-only benchmark; skip silently.
|
||
}
|
||
else
|
||
{
|
||
if (mode is "all" or "serialize" or "ser")
|
||
{
|
||
result.SerializeTimeMs = RunTimed(() => serializer.Serialize(), TestIterations);
|
||
// Dedicated alloc-only sample (separate from timing samples; keeps timing pure)
|
||
result.SerializeAllocBytesPerOp = MeasureAllocation(() => serializer.Serialize(), TestIterations);
|
||
}
|
||
if (mode is "all" or "deserialize" or "des")
|
||
{
|
||
result.DeserializeTimeMs = RunTimed(() => serializer.Deserialize(), TestIterations);
|
||
result.DeserializeAllocBytesPerOp = MeasureAllocation(() => serializer.Deserialize(), TestIterations);
|
||
}
|
||
// Compose RT from Ser+Des (the previously computed property's behavior, now explicit since RT is settable).
|
||
result.RoundTripTimeMs = result.SerializeTimeMs + result.DeserializeTimeMs;
|
||
result.RoundTripAllocBytesPerOp = result.SerializeAllocBytesPerOp + result.DeserializeAllocBytesPerOp;
|
||
}
|
||
|
||
results.Add(result);
|
||
PrintResult(result);
|
||
}
|
||
|
||
// Dispose any IDisposable serializers (NamedPipe / FileStream variants own OS resources that must be released
|
||
// before the next test data builds new ones — otherwise pipes / handles leak across test cells).
|
||
foreach (var s in serializers) (s as IDisposable)?.Dispose();
|
||
|
||
return results;
|
||
}
|
||
|
||
private static List<ISerializerBenchmark> CreateSerializers(TestDataSet testData)
|
||
{
|
||
var binaryNoInternOption = AcBinarySerializerOptions.Default;
|
||
binaryNoInternOption.UseStringInterning = StringInterningMode.None;
|
||
|
||
var binaryDefaultNoSgenOption = AcBinarySerializerOptions.Default;
|
||
binaryDefaultNoSgenOption.UseGeneratedCode = false;
|
||
|
||
var binaryFastModeNoSgenOption = AcBinarySerializerOptions.FastMode;
|
||
binaryFastModeNoSgenOption.UseGeneratedCode = false;
|
||
|
||
return new List<ISerializerBenchmark>
|
||
{
|
||
// ============================================================
|
||
// AcBinary — Byte[] API (uncomment to compare option presets side-by-side)
|
||
// ============================================================
|
||
// Fastest Byte[] — SGen path (UseGeneratedCode=true, default).
|
||
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, "FastMode"),
|
||
// Fastest Byte[] — Runtime path (UseGeneratedCode=false). Same wire/options, no source-generated dispatch.
|
||
// Always paired with the SGen variant so every layer can compare the SGen speed-up apples-to-apples.
|
||
new AcBinaryBenchmark(testData.Order, binaryFastModeNoSgenOption, "FastMode"),
|
||
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.Default, "Default"),
|
||
//new AcBinaryBenchmark(testData.Order, binaryDefaultNoSgenOption, "Default"),
|
||
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.WithoutReferenceHandling, "NoRef"),
|
||
//new AcBinaryBenchmark(testData.Order, binaryNoInternOption, "NoIntern"),
|
||
|
||
// AcBinary via IBufferWriter (reused ArrayBufferWriter — long-running service / batch scenario)
|
||
new AcBinaryBufferWriterBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, "FastMode"),
|
||
|
||
// AcBinary via IBufferWriter (FRESH ArrayBufferWriter per call — one-shot scenario).
|
||
// BufferWriterChunkSize=4096 → AcBinary advances every 4 KB (smaller internal buffer = sooner Advance/GetMemory cycle,
|
||
// matches Kestrel slab + TCP MTU). Despite the property name "ChunkSize", in the IBufferWriter path this is just the
|
||
// internal buffer size; wire-format "chunks" only exist in AsyncPipeWriterOutput's chunked-framing mode.
|
||
new AcBinaryFreshBufferWriterBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, "FastMode (4KB buffer)"),
|
||
|
||
// AcBinary over a long-lived NamedPipe IPC connection — pipe set up ONCE, reused for every iteration.
|
||
// Per-iter cost = Byte[] serialize + 4-byte length-prefix framing + pipe write/read syscall + Byte[] deserialize.
|
||
// SignalR-style approximation: persistent connection + per-message round-trip + 4 KB initial buffer
|
||
// (Kestrel slab + TCP MTU aligned). Single-process loopback, so the number is a lower bound (real
|
||
// cross-process / cross-machine adds transport latency on top). Result row: full round-trip shown in
|
||
// Ser ms, Des ms = N/A (IsRoundTripOnly).
|
||
new AcBinaryNamedPipeBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, "FastMode (4KB buffer)"),
|
||
|
||
// ============================================================
|
||
// MemoryPack — three I/O modes for apples-to-apples comparison
|
||
// ============================================================
|
||
new MemoryPackBenchmark(testData.Order, "Default"),
|
||
new MemoryPackBufferWriterBenchmark(testData.Order, "Default"),
|
||
new MemoryPackFreshBufferWriterBenchmark(testData.Order, "Default"),
|
||
|
||
// ============================================================
|
||
// MessagePack — for legacy comparison
|
||
// ============================================================
|
||
new MessagePackBenchmark(testData.Order, "ContractBased"),
|
||
|
||
// System.Text.Json (commented — JSON serializer for reference; not in active suite)
|
||
//new SystemTextJsonBenchmark(testData.Order, "Default")
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runs the action <paramref name="iterations"/> times for <see cref="BenchmarkSamples"/> independent samples,
|
||
/// returning the median elapsed time. Multi-sample design reduces single-run variance from ~±15% to ~±5%
|
||
/// by smoothing transient effects (background activity, thermal/turbo state, JIT tier-promotion timing).
|
||
/// When <see cref="BenchmarkSamples"/> <= 1, falls back to single-sample timing (Debug / quick mode).
|
||
/// </summary>
|
||
private static double RunTimed(Action action, int iterations)
|
||
{
|
||
var samples = BenchmarkSamples;
|
||
if (samples <= 1)
|
||
{
|
||
// Single-sample fast path (Debug or trivial run) — no allocation, no sort.
|
||
var sw = Stopwatch.StartNew();
|
||
for (var i = 0; i < iterations; i++) action();
|
||
sw.Stop();
|
||
return sw.Elapsed.TotalMilliseconds;
|
||
}
|
||
|
||
var times = new double[samples];
|
||
for (int s = 0; s < samples; s++)
|
||
{
|
||
var sw = Stopwatch.StartNew();
|
||
for (var i = 0; i < iterations; i++) action();
|
||
sw.Stop();
|
||
times[s] = sw.Elapsed.TotalMilliseconds;
|
||
}
|
||
Array.Sort(times);
|
||
// Median: middle value for odd sample counts, average of two middles for even counts.
|
||
return samples % 2 == 1
|
||
? times[samples / 2]
|
||
: (times[samples / 2 - 1] + times[samples / 2]) / 2.0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Measures per-call allocation in bytes after a clean GC. Single dedicated sample (no median) — keeps timing samples pure.
|
||
/// </summary>
|
||
private static long MeasureAllocation(Action action, int iterations)
|
||
{
|
||
GC.Collect();
|
||
GC.WaitForPendingFinalizers();
|
||
GC.Collect();
|
||
var before = GC.GetAllocatedBytesForCurrentThread();
|
||
for (var i = 0; i < iterations; i++) action();
|
||
var after = GC.GetAllocatedBytesForCurrentThread();
|
||
return (after - before) / iterations;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Process-wide allocation measurement — needed for round-trip-only benchmarks (NamedPipe etc.) where
|
||
/// the work happens across multiple threads. <see cref="GC.GetAllocatedBytesForCurrentThread"/> would
|
||
/// only count the caller-thread allocations, missing the server-side <c>new byte[len]</c> buffers and
|
||
/// any drain-pump-thread allocations. <see cref="GC.GetTotalAllocatedBytes"/> covers the entire process.
|
||
/// Slightly noisier than the per-thread variant (background threads / GC bookkeeping leak in), but
|
||
/// over 1000 iterations the signal dominates.
|
||
/// </summary>
|
||
private static long MeasureAllocationTotal(Action action, int iterations)
|
||
{
|
||
GC.Collect();
|
||
GC.WaitForPendingFinalizers();
|
||
GC.Collect();
|
||
var before = GC.GetTotalAllocatedBytes(precise: true);
|
||
for (var i = 0; i < iterations; i++) action();
|
||
var after = GC.GetTotalAllocatedBytes(precise: true);
|
||
return (after - before) / iterations;
|
||
}
|
||
|
||
private static readonly JsonSerializerOptions VerifyJsonOpts = new()
|
||
{
|
||
WriteIndented = false,
|
||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
|
||
};
|
||
|
||
/// <summary>
|
||
/// Round-trip equality check: serialize both via System.Text.Json (canonical form) and compare strings.
|
||
/// Slower than property-by-property compare, but universal — works for any object graph without custom comparer.
|
||
/// </summary>
|
||
private static bool DeepEqualsViaJson(object? a, object? b)
|
||
{
|
||
if (a == null && b == null) return true;
|
||
if (a == null || b == null) return false;
|
||
var jsonA = JsonSerializer.Serialize(a, VerifyJsonOpts);
|
||
var jsonB = JsonSerializer.Serialize(b, VerifyJsonOpts);
|
||
return jsonA == jsonB;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Validates MemoryPack setup at startup. Aborts the benchmark if TestOrder is not [MemoryPackable].
|
||
/// Without this attribute, MemoryPack falls back to runtime resolver (slower) — comparison would be INVALID.
|
||
/// </summary>
|
||
private static void ValidateMemoryPackSetup()
|
||
{
|
||
var typesToCheck = new[] { typeof(TestOrder) };
|
||
foreach (var type in typesToCheck)
|
||
{
|
||
var hasAttr = type.GetCustomAttributes(typeof(MemoryPackableAttribute), inherit: true).Any();
|
||
if (!hasAttr)
|
||
{
|
||
System.Console.Error.WriteLine($"❌ FATAL: {type.FullName} is not [MemoryPackable] — MemoryPack would fall back to runtime resolver, comparison is INVALID for SGen-vs-SGen claim.");
|
||
System.Console.Error.WriteLine("Add [MemoryPackable] to the type and any nested types referenced from it.");
|
||
Environment.Exit(1);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Interactive menu shown when no CLI args. Returns the layer keyword (core/comprehensive/edge/all) or null on Quit.
|
||
/// </summary>
|
||
private static string? 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(" [Q] Quit");
|
||
System.Console.Write("\nSelection: ");
|
||
var key = System.Console.ReadKey(intercept: false).KeyChar;
|
||
System.Console.WriteLine();
|
||
return char.ToLower(key) switch
|
||
{
|
||
'1' => "core",
|
||
'2' => "comprehensive",
|
||
'3' => "edge",
|
||
'a' => "all",
|
||
'q' => null,
|
||
_ => "core"
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Filters test data sets by layer keyword. Layered approach lets you run only what's needed for the iteration cadence.
|
||
/// P1: only "Core" data exists (Small/Medium/Large/Repeated/Deep). Comprehensive and Edge layers will be expanded in P2.
|
||
/// </summary>
|
||
private static List<TestDataSet> FilterByLayer(List<TestDataSet> all, string layer)
|
||
{
|
||
if (layer == "all") return all.ToList();
|
||
|
||
var coreNames = new[] { "Small", "Medium", "Large", "Repeated", "Deep" };
|
||
// P2 will add: "Flat", "Polymorphic", "Collection", "Numeric", "NonAscii", etc.
|
||
var comprehensiveExtras = new string[] { /* P2 */ };
|
||
// P3 will add: "ColdStart", "VeryLarge", "PathologicalString", etc.
|
||
var edgeExtras = new string[] { /* P3 */ };
|
||
|
||
bool StartsWithAny(string name, string[] prefixes) => prefixes.Any(p => name.StartsWith(p));
|
||
|
||
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()
|
||
};
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Serializer Implementations
|
||
|
||
private interface ISerializerBenchmark
|
||
{
|
||
/// <summary>Serializer engine — e.g. "AcBinary", "MemoryPack", "MessagePack".</summary>
|
||
string Engine { get; }
|
||
/// <summary>I/O mode — e.g. "Byte[]", "BufWr reuse", "BufWr new", "NamedPipe", "FileStream".</summary>
|
||
string IoMode { get; }
|
||
/// <summary>Dispatch mode — "SGen", "Runtime", or "Hybrid". For AcBinary derived from <c>UseGeneratedCode</c> + child-type SGen coverage; non-AcBinary engines report their own native dispatch model.</summary>
|
||
string DispatchMode { get; }
|
||
/// <summary>Options preset name — e.g. "FastMode", "Default", "NoIntern", "WithCompression".</summary>
|
||
string OptionsPreset { get; }
|
||
/// <summary>Synthesized display name from Engine + IoMode + OptionsPreset.</summary>
|
||
string Name => $"{Engine} ({IoMode}, {OptionsPreset})";
|
||
int SerializedSize { get; }
|
||
string? OptionsDescription => null;
|
||
/// <summary>One-time setup allocation cost (e.g., pre-allocated ArrayBufferWriter with internal buffer). Captured in constructor; 0 for byte[] API and Fresh-BufWriter variants.</summary>
|
||
long SetupAllocBytes { get; }
|
||
/// <summary>True when Serialize() does a full round-trip (e.g. NamedPipe) and Deserialize() is a no-op.
|
||
/// Used by the SUMMARY: WINNERS section to skip such cells from "Fastest Serialize" and "Fastest Deserialize"
|
||
/// rankings (because both metrics are misleading there) — they still participate in "Fastest Round-trip".
|
||
/// Default false for in-memory IO modes which measure Ser and Des separately.</summary>
|
||
bool IsRoundTripOnly => false;
|
||
void Warmup(int iterations);
|
||
void Serialize();
|
||
void Deserialize();
|
||
/// <summary>Round-trip correctness check — called once per cell before warmup. Returns true if Serialize+Deserialize preserves data.</summary>
|
||
bool VerifyRoundTrip();
|
||
}
|
||
|
||
private sealed class AcBinaryBenchmark : ISerializerBenchmark
|
||
{
|
||
private readonly TestOrder _order;
|
||
private readonly AcBinarySerializerOptions _options;
|
||
private readonly byte[] _serialized;
|
||
|
||
public string Engine => EngineAcBinary;
|
||
public string IoMode => IoByteArray;
|
||
public string DispatchMode => _options.UseGeneratedCode ? ModeSGen : ModeRuntime;
|
||
public string OptionsPreset { get; }
|
||
public int SerializedSize => _serialized.Length;
|
||
public long SetupAllocBytes => 0;
|
||
public string OptionsDescription => $"WireMode={_options.WireMode}, RefHandling={_options.ReferenceHandling}, Interning={_options.UseStringInterning}, Metadata={_options.UseMetadata}, SGen={_options.UseGeneratedCode}, Compression={_options.UseCompression}";
|
||
|
||
public AcBinaryBenchmark(TestOrder order, AcBinarySerializerOptions options, string optionsPreset)
|
||
{
|
||
_order = order;
|
||
_options = options;
|
||
OptionsPreset = optionsPreset;
|
||
_serialized = AcBinarySerializer.Serialize(order, options);
|
||
|
||
//_options.UseCompression = Lz4CompressionMode.Block;
|
||
}
|
||
|
||
public void Warmup(int iterations)
|
||
{
|
||
for (var i = 0; i < iterations; i++)
|
||
{
|
||
Serialize();
|
||
Deserialize();
|
||
}
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Serialize()
|
||
{
|
||
AcBinarySerializer.Serialize(_order, _options);
|
||
|
||
//if (_options.ReferenceHandling != ReferenceHandlingMode.None || _options.UseStringInterning != StringInterningMode.None)
|
||
//{
|
||
// AcBinarySerializer.ScanOnly(_order, _options);
|
||
//}
|
||
//else AcBinarySerializer.Serialize(_order, _options);
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Deserialize() => AcBinaryDeserializer.Deserialize<TestOrder>(_serialized, _options);
|
||
|
||
public bool VerifyRoundTrip()
|
||
{
|
||
var bytes = AcBinarySerializer.Serialize(_order, _options);
|
||
var roundTripped = AcBinaryDeserializer.Deserialize<TestOrder>(bytes, _options);
|
||
return DeepEqualsViaJson(_order, roundTripped);
|
||
}
|
||
}
|
||
|
||
private sealed class MemoryPackBenchmark : ISerializerBenchmark
|
||
{
|
||
private readonly TestOrder _order;
|
||
private readonly byte[] _serialized;
|
||
|
||
public string Engine => EngineMemoryPack;
|
||
public string IoMode => IoByteArray;
|
||
public string DispatchMode => ModeSGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
|
||
public string OptionsPreset { get; }
|
||
public int SerializedSize => _serialized.Length;
|
||
public long SetupAllocBytes => 0;
|
||
|
||
public MemoryPackBenchmark(TestOrder order, string optionsPreset)
|
||
{
|
||
_order = order;
|
||
OptionsPreset = optionsPreset;
|
||
_serialized = MemoryPackSerializer.Serialize(order);
|
||
}
|
||
|
||
public void Warmup(int iterations)
|
||
{
|
||
for (var i = 0; i < iterations; i++)
|
||
{
|
||
Serialize();
|
||
Deserialize();
|
||
}
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Serialize() => MemoryPackSerializer.Serialize(_order);
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Deserialize() => MemoryPackSerializer.Deserialize<TestOrder>(_serialized);
|
||
|
||
public bool VerifyRoundTrip()
|
||
{
|
||
var bytes = MemoryPackSerializer.Serialize(_order);
|
||
var roundTripped = MemoryPackSerializer.Deserialize<TestOrder>(bytes);
|
||
return DeepEqualsViaJson(_order, roundTripped);
|
||
}
|
||
}
|
||
|
||
private sealed class MessagePackBenchmark : ISerializerBenchmark
|
||
{
|
||
private readonly TestOrder _order;
|
||
private readonly MessagePackSerializerOptions _options;
|
||
private readonly byte[] _serialized;
|
||
|
||
public string Engine => EngineMessagePack;
|
||
public string IoMode => IoByteArray;
|
||
public string DispatchMode => ModeSGen; // MessagePack uses [MessagePackObject] source-generated formatters (StandardResolver)
|
||
public string OptionsPreset { get; }
|
||
public int SerializedSize => _serialized.Length;
|
||
public long SetupAllocBytes => 0;
|
||
public string OptionsDescription { get; }
|
||
|
||
public MessagePackBenchmark(TestOrder order, string optionsPreset)
|
||
{
|
||
_order = order;
|
||
OptionsPreset = optionsPreset;
|
||
|
||
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
|
||
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.Lz4Block);
|
||
_options = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.None);
|
||
|
||
var isContractless = _options.Resolver is ContractlessStandardResolver;
|
||
OptionsDescription = $"Mode={( isContractless ? "Contractless" : "ContractBased")}, Compression={_options.Compression}";
|
||
|
||
_serialized = MessagePackSerializer.Serialize(order, _options);
|
||
}
|
||
|
||
public void Warmup(int iterations)
|
||
{
|
||
for (var i = 0; i < iterations; i++)
|
||
{
|
||
Serialize();
|
||
Deserialize();
|
||
}
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Serialize() => MessagePackSerializer.Serialize(_order, _options);
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Deserialize() => MessagePackSerializer.Deserialize<TestOrder>(_serialized, _options);
|
||
|
||
public bool VerifyRoundTrip()
|
||
{
|
||
var bytes = MessagePackSerializer.Serialize(_order, _options);
|
||
var roundTripped = MessagePackSerializer.Deserialize<TestOrder>(bytes, _options);
|
||
return DeepEqualsViaJson(_order, roundTripped);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Benchmarks AcBinary via the IBufferWriter overload with a pre-allocated, reused ArrayBufferWriter.
|
||
/// Realistic IBufferWriter usage pattern: caller owns + reuses the writer (zero alloc per call after warmup).
|
||
/// </summary>
|
||
/// <summary>
|
||
/// Benchmarks AcBinary via the IBufferWriter overload, allocating a FRESH ArrayBufferWriter on EVERY call.
|
||
/// One-shot scenario — represents code that doesn't reuse a writer across calls.
|
||
/// Uses BufferWriterChunkSize=4096 (production-realistic, SignalR-aligned) instead of the 65535 default —
|
||
/// otherwise AcBinary would request 64KB upfront via GetSpan(), forcing the fresh ABW to allocate 64KB
|
||
/// regardless of payload size (heavy over-allocation for small payloads).
|
||
/// </summary>
|
||
private sealed class AcBinaryFreshBufferWriterBenchmark : ISerializerBenchmark
|
||
{
|
||
private readonly TestOrder _order;
|
||
private readonly AcBinarySerializerOptions _options;
|
||
private readonly byte[] _serialized;
|
||
|
||
public string Engine => EngineAcBinary;
|
||
public string IoMode => IoBufWrNew;
|
||
public string DispatchMode => _options.UseGeneratedCode ? ModeSGen : ModeRuntime;
|
||
public string OptionsPreset { get; }
|
||
public int SerializedSize => _serialized.Length;
|
||
public long SetupAllocBytes => 0;
|
||
public string OptionsDescription => $"WireMode={_options.WireMode}, RefHandling={_options.ReferenceHandling}, Interning={_options.UseStringInterning}, Metadata={_options.UseMetadata}, SGen={_options.UseGeneratedCode}, Compression={_options.UseCompression}, BufferSize={_options.BufferWriterChunkSize}B";
|
||
|
||
public AcBinaryFreshBufferWriterBenchmark(TestOrder order, AcBinarySerializerOptions options, string optionsPreset)
|
||
{
|
||
_order = order;
|
||
_options = options;
|
||
// Override: 4 KB internal buffer instead of 65535 default — controls how often AcBinary advances
|
||
// (Advance + GetMemory) on the underlying IBufferWriter. Smaller buffer = sooner advance = matches
|
||
// Kestrel slab + TCP MTU for streaming. NOT a wire-format chunk size (that exists only in
|
||
// AsyncPipeWriterOutput's chunked-framing mode); on ArrayBufferWriter this is purely the grow step.
|
||
_options.BufferWriterChunkSize = 4096;
|
||
OptionsPreset = optionsPreset;
|
||
_serialized = AcBinarySerializer.Serialize(order, _options);
|
||
}
|
||
|
||
public void Warmup(int iterations)
|
||
{
|
||
for (var i = 0; i < iterations; i++)
|
||
{
|
||
Serialize();
|
||
Deserialize();
|
||
}
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Serialize()
|
||
{
|
||
var abw = new ArrayBufferWriter<byte>(); // FRESH every call — alloc + grow as needed
|
||
AcBinarySerializer.Serialize(_order, abw, _options);
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Deserialize() => AcBinaryDeserializer.Deserialize<TestOrder>(_serialized, _options);
|
||
|
||
public bool VerifyRoundTrip()
|
||
{
|
||
var abw = new ArrayBufferWriter<byte>();
|
||
AcBinarySerializer.Serialize(_order, abw, _options);
|
||
var roundTripped = AcBinaryDeserializer.Deserialize<TestOrder>(abw.WrittenSpan.ToArray(), _options);
|
||
return DeepEqualsViaJson(_order, roundTripped);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Benchmarks AcBinary over a long-lived NamedPipe IPC connection — pipe is set up ONCE in the constructor;
|
||
/// each iteration only sends a length-prefixed payload through the existing pipe. Closer to a real SignalR-style
|
||
/// scenario where the connection is established at process start and reused for many messages, rather than the
|
||
/// pathological one-pipe-per-message setup overhead.
|
||
///
|
||
/// <para><b>Architecture</b>:</para>
|
||
/// <list type="bullet">
|
||
/// <item>Constructor: sets up <see cref="NamedPipeServerStream"/> + <see cref="NamedPipeClientStream"/>,
|
||
/// waits for connection, starts a long-lived background drain task on the server side that reads length-prefixed
|
||
/// messages and pushes deserialized results into a <see cref="System.Threading.Channels.Channel{T}"/>.</item>
|
||
/// <item>Per-iteration <see cref="Serialize"/>: encodes the payload via the Byte[] API, writes a 4-byte length
|
||
/// prefix + payload bytes to the pipe, then awaits the channel for the server-deserialized result.</item>
|
||
/// <item><see cref="Deserialize"/> is a no-op (the round-trip happens inside Serialize); same IsRoundTripOnly contract
|
||
/// as the previous one-shot variant.</item>
|
||
/// </list>
|
||
///
|
||
/// <para><b>What this measures</b>: per-message Byte[] serialize + length-prefix framing + pipe write/read syscall +
|
||
/// kernel context switch + Byte[] deserialize. NOT measured: pipe lifecycle (one-time setup amortized over all iterations
|
||
/// and across all test data cells, since this benchmark runs against many cells).</para>
|
||
///
|
||
/// <para><b>Approximation note</b>: this is a single-process loopback pipe. Real cross-process or cross-machine SignalR
|
||
/// will add transport latency (TCP, WebSocket framing) on top of these numbers. The benchmark gives a lower bound for
|
||
/// streaming/IPC scenarios.</para>
|
||
/// </summary>
|
||
private sealed class AcBinaryNamedPipeBenchmark : ISerializerBenchmark, IDisposable
|
||
{
|
||
private readonly TestOrder _order;
|
||
private readonly AcBinarySerializerOptions _options;
|
||
private readonly byte[] _serialized; // for SerializedSize reporting
|
||
|
||
// Long-lived pipe + drain pump (set up once in ctor)
|
||
private readonly NamedPipeServerStream _pipeServer;
|
||
private readonly NamedPipeClientStream _pipeClient;
|
||
private readonly Task _drainTask;
|
||
private readonly System.Threading.Channels.Channel<TestOrder?> _resultChannel;
|
||
private bool _disposed;
|
||
|
||
public string Engine => EngineAcBinary;
|
||
public string IoMode => IoNamedPipe;
|
||
public string DispatchMode => _options.UseGeneratedCode ? ModeSGen : ModeRuntime;
|
||
public string OptionsPreset { get; }
|
||
public int SerializedSize => _serialized.Length;
|
||
public long SetupAllocBytes => 0;
|
||
public bool IsRoundTripOnly => true; // Serialize() does the full per-message round-trip; Deserialize() is a no-op
|
||
public string OptionsDescription => $"WireMode={_options.WireMode}, RefHandling={_options.ReferenceHandling}, Interning={_options.UseStringInterning}, Metadata={_options.UseMetadata}, SGen={_options.UseGeneratedCode}, Compression={_options.UseCompression}, BufferSize={_options.BufferWriterChunkSize}B, Transport=NamedPipe(long-lived,length-prefixed)";
|
||
|
||
public AcBinaryNamedPipeBenchmark(TestOrder order, AcBinarySerializerOptions options, string optionsPreset)
|
||
{
|
||
_order = order;
|
||
_options = options;
|
||
// SignalR-aligned 4 KB initial buffer for the Byte[] API — matches Kestrel slab + TCP MTU,
|
||
// simulates the realistic per-message buffer profile the SignalR transport ends up with.
|
||
// (The 65535 default is fine for big batch encoding but over-allocates on small messages.)
|
||
_options.BufferWriterChunkSize = 4096;
|
||
OptionsPreset = optionsPreset;
|
||
_serialized = AcBinarySerializer.Serialize(order, _options);
|
||
|
||
// 1× setup — pipe persists for the lifetime of the benchmark instance.
|
||
// Byte mode (not Message mode) — we frame messages ourselves with a 4-byte length prefix.
|
||
// PipeOptions.Asynchronous → enables overlapped I/O on Windows; harmless on Linux/macOS.
|
||
var pipeName = $"AcBinaryBench-{Guid.NewGuid():N}";
|
||
_pipeServer = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, System.IO.Pipes.PipeOptions.Asynchronous);
|
||
_pipeClient = new NamedPipeClientStream(".", pipeName, PipeDirection.Out, System.IO.Pipes.PipeOptions.Asynchronous);
|
||
|
||
// Establish the connection. Server async-wait + client connect happen in parallel.
|
||
var serverWait = _pipeServer.WaitForConnectionAsync();
|
||
_pipeClient.Connect();
|
||
serverWait.GetAwaiter().GetResult();
|
||
|
||
_resultChannel = System.Threading.Channels.Channel.CreateUnbounded<TestOrder?>();
|
||
|
||
// Long-lived drain loop on the server side. Reads length-prefixed messages until the pipe is closed.
|
||
_drainTask = Task.Run(async () =>
|
||
{
|
||
var lenBuf = new byte[4];
|
||
try
|
||
{
|
||
while (true)
|
||
{
|
||
// Read 4-byte length prefix (handle short reads in a loop)
|
||
if (!await ReadExactAsync(_pipeServer, lenBuf, 0, 4).ConfigureAwait(false))
|
||
break;
|
||
var len = BitConverter.ToInt32(lenBuf, 0);
|
||
if (len <= 0) break; // sentinel / corruption guard
|
||
var data = new byte[len];
|
||
if (!await ReadExactAsync(_pipeServer, data, 0, len).ConfigureAwait(false))
|
||
break;
|
||
|
||
var result = AcBinaryDeserializer.Deserialize<TestOrder>(data, _options);
|
||
await _resultChannel.Writer.WriteAsync(result).ConfigureAwait(false);
|
||
}
|
||
}
|
||
catch (Exception ex) when (ex is System.IO.IOException or ObjectDisposedException)
|
||
{
|
||
// pipe closed — normal teardown path
|
||
}
|
||
finally
|
||
{
|
||
_resultChannel.Writer.TryComplete();
|
||
}
|
||
});
|
||
}
|
||
|
||
/// <summary>Reads exactly <paramref name="count"/> bytes; returns false if pipe closed before completion.</summary>
|
||
private static async Task<bool> ReadExactAsync(System.IO.Stream stream, byte[] buffer, int offset, int count)
|
||
{
|
||
var read = 0;
|
||
while (read < count)
|
||
{
|
||
var n = await stream.ReadAsync(buffer.AsMemory(offset + read, count - read)).ConfigureAwait(false);
|
||
if (n == 0) return false; // EOF
|
||
read += n;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
public void Warmup(int iterations)
|
||
{
|
||
for (var i = 0; i < iterations; i++)
|
||
{
|
||
Serialize();
|
||
}
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Serialize()
|
||
{
|
||
// 1) Byte[] encode (same path as the IoByteArray benchmark)
|
||
var payload = AcBinarySerializer.Serialize(_order, _options);
|
||
|
||
// 2) Length-prefix framing (4 bytes little-endian) — pure benchmark-side framing, not an AcBinary feature.
|
||
// Stack-allocated to avoid per-iter heap traffic for the prefix.
|
||
Span<byte> lenBuf = stackalloc byte[4];
|
||
BitConverter.TryWriteBytes(lenBuf, payload.Length);
|
||
|
||
// 3) Sync write to the pipe — Stream.Write blocks until the OS accepts the bytes into the pipe buffer.
|
||
_pipeClient.Write(lenBuf);
|
||
_pipeClient.Write(payload, 0, payload.Length);
|
||
_pipeClient.Flush();
|
||
|
||
// 4) Wait for the server drain loop to deserialize and post the result. Sync wait via channel reader.
|
||
// A console app has no SynchronizationContext, so .GetAwaiter().GetResult() is deadlock-safe.
|
||
_resultChannel.Reader.ReadAsync().AsTask().GetAwaiter().GetResult();
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Deserialize()
|
||
{
|
||
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
|
||
}
|
||
|
||
public bool VerifyRoundTrip()
|
||
{
|
||
// Round-trip a single message and compare structurally.
|
||
var payload = AcBinarySerializer.Serialize(_order, _options);
|
||
Span<byte> lenBuf = stackalloc byte[4];
|
||
BitConverter.TryWriteBytes(lenBuf, payload.Length);
|
||
_pipeClient.Write(lenBuf);
|
||
_pipeClient.Write(payload, 0, payload.Length);
|
||
_pipeClient.Flush();
|
||
var result = _resultChannel.Reader.ReadAsync().AsTask().GetAwaiter().GetResult();
|
||
return result != null && DeepEqualsViaJson(_order, result);
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed) return;
|
||
_disposed = true;
|
||
// Closing the client triggers EOF on the server's ReadAsync → drain loop exits gracefully.
|
||
try { _pipeClient.Dispose(); } catch { /* swallow on teardown */ }
|
||
try { _pipeServer.Dispose(); } catch { /* swallow on teardown */ }
|
||
try { _drainTask.Wait(TimeSpan.FromSeconds(5)); } catch { /* swallow on teardown */ }
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Benchmarks MemoryPack via the IBufferWriter overload, allocating a FRESH ArrayBufferWriter on EVERY call.
|
||
/// Apples-to-apples counterpart to AcBinaryFreshBufferWriterBenchmark.
|
||
/// </summary>
|
||
private sealed class MemoryPackFreshBufferWriterBenchmark : ISerializerBenchmark
|
||
{
|
||
private readonly TestOrder _order;
|
||
private readonly byte[] _serialized;
|
||
|
||
public string Engine => EngineMemoryPack;
|
||
public string IoMode => IoBufWrNew;
|
||
public string DispatchMode => ModeSGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
|
||
public string OptionsPreset { get; }
|
||
public int SerializedSize => _serialized.Length;
|
||
public long SetupAllocBytes => 0;
|
||
|
||
public MemoryPackFreshBufferWriterBenchmark(TestOrder order, string optionsPreset)
|
||
{
|
||
_order = order;
|
||
OptionsPreset = optionsPreset;
|
||
_serialized = MemoryPackSerializer.Serialize(order);
|
||
}
|
||
|
||
public void Warmup(int iterations)
|
||
{
|
||
for (var i = 0; i < iterations; i++)
|
||
{
|
||
Serialize();
|
||
Deserialize();
|
||
}
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Serialize()
|
||
{
|
||
var abw = new ArrayBufferWriter<byte>();
|
||
MemoryPackSerializer.Serialize(abw, _order);
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Deserialize() => MemoryPackSerializer.Deserialize<TestOrder>(_serialized);
|
||
|
||
public bool VerifyRoundTrip()
|
||
{
|
||
var abw = new ArrayBufferWriter<byte>();
|
||
MemoryPackSerializer.Serialize(abw, _order);
|
||
var roundTripped = MemoryPackSerializer.Deserialize<TestOrder>(abw.WrittenSpan.ToArray());
|
||
return DeepEqualsViaJson(_order, roundTripped);
|
||
}
|
||
}
|
||
|
||
private sealed class AcBinaryBufferWriterBenchmark : ISerializerBenchmark
|
||
{
|
||
private readonly TestOrder _order;
|
||
private readonly AcBinarySerializerOptions _options;
|
||
private readonly byte[] _serialized;
|
||
private readonly ArrayBufferWriter<byte> _bufferWriter;
|
||
|
||
public string Engine => EngineAcBinary;
|
||
public string IoMode => IoBufWrReuse;
|
||
public string DispatchMode => _options.UseGeneratedCode ? ModeSGen : ModeRuntime;
|
||
public string OptionsPreset { get; }
|
||
public int SerializedSize => _serialized.Length;
|
||
public long SetupAllocBytes { get; }
|
||
public string OptionsDescription => $"WireMode={_options.WireMode}, RefHandling={_options.ReferenceHandling}, Interning={_options.UseStringInterning}, Metadata={_options.UseMetadata}, SGen={_options.UseGeneratedCode}, Compression={_options.UseCompression}";
|
||
|
||
public AcBinaryBufferWriterBenchmark(TestOrder order, AcBinarySerializerOptions options, string optionsPreset)
|
||
{
|
||
_order = order;
|
||
_options = options;
|
||
OptionsPreset = optionsPreset;
|
||
_serialized = AcBinarySerializer.Serialize(order, options);
|
||
|
||
// Measure ONLY the BufferWriter infrastructure setup (excluding the helper Serialize above)
|
||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||
var beforeSetup = GC.GetAllocatedBytesForCurrentThread();
|
||
_bufferWriter = new ArrayBufferWriter<byte>(_serialized.Length * 2);
|
||
var afterSetup = GC.GetAllocatedBytesForCurrentThread();
|
||
SetupAllocBytes = afterSetup - beforeSetup;
|
||
}
|
||
|
||
public void Warmup(int iterations)
|
||
{
|
||
for (var i = 0; i < iterations; i++)
|
||
{
|
||
Serialize();
|
||
Deserialize();
|
||
}
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Serialize()
|
||
{
|
||
_bufferWriter.ResetWrittenCount(); // reuse — no alloc, no zeroing
|
||
AcBinarySerializer.Serialize(_order, _bufferWriter, _options);
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Deserialize() => AcBinaryDeserializer.Deserialize<TestOrder>(_serialized, _options);
|
||
|
||
public bool VerifyRoundTrip()
|
||
{
|
||
_bufferWriter.ResetWrittenCount();
|
||
AcBinarySerializer.Serialize(_order, _bufferWriter, _options);
|
||
var roundTripped = AcBinaryDeserializer.Deserialize<TestOrder>(_bufferWriter.WrittenSpan.ToArray(), _options);
|
||
return DeepEqualsViaJson(_order, roundTripped);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Benchmarks MemoryPack via the IBufferWriter overload with a pre-allocated, reused ArrayBufferWriter.
|
||
/// Apples-to-apples counterpart to AcBinaryBufferWriterBenchmark — MemoryPack's IBufferWriter is the path it's designed for.
|
||
/// </summary>
|
||
private sealed class MemoryPackBufferWriterBenchmark : ISerializerBenchmark
|
||
{
|
||
private readonly TestOrder _order;
|
||
private readonly byte[] _serialized;
|
||
private readonly ArrayBufferWriter<byte> _bufferWriter;
|
||
|
||
public string Engine => EngineMemoryPack;
|
||
public string IoMode => IoBufWrReuse;
|
||
public string DispatchMode => ModeSGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
|
||
public string OptionsPreset { get; }
|
||
public int SerializedSize => _serialized.Length;
|
||
public long SetupAllocBytes { get; }
|
||
|
||
public MemoryPackBufferWriterBenchmark(TestOrder order, string optionsPreset)
|
||
{
|
||
_order = order;
|
||
OptionsPreset = optionsPreset;
|
||
_serialized = MemoryPackSerializer.Serialize(order);
|
||
|
||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||
var beforeSetup = GC.GetAllocatedBytesForCurrentThread();
|
||
_bufferWriter = new ArrayBufferWriter<byte>(_serialized.Length * 2);
|
||
var afterSetup = GC.GetAllocatedBytesForCurrentThread();
|
||
SetupAllocBytes = afterSetup - beforeSetup;
|
||
}
|
||
|
||
public void Warmup(int iterations)
|
||
{
|
||
for (var i = 0; i < iterations; i++)
|
||
{
|
||
Serialize();
|
||
Deserialize();
|
||
}
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Serialize()
|
||
{
|
||
_bufferWriter.ResetWrittenCount();
|
||
MemoryPackSerializer.Serialize(_bufferWriter, _order);
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Deserialize() => MemoryPackSerializer.Deserialize<TestOrder>(_serialized);
|
||
|
||
public bool VerifyRoundTrip()
|
||
{
|
||
_bufferWriter.ResetWrittenCount();
|
||
MemoryPackSerializer.Serialize(_bufferWriter, _order);
|
||
var roundTripped = MemoryPackSerializer.Deserialize<TestOrder>(_bufferWriter.WrittenSpan.ToArray());
|
||
return DeepEqualsViaJson(_order, roundTripped);
|
||
}
|
||
}
|
||
|
||
private sealed class SystemTextJsonBenchmark : ISerializerBenchmark
|
||
{
|
||
private readonly TestOrder _order;
|
||
private readonly JsonSerializerOptions _options;
|
||
private readonly string _serialized;
|
||
private readonly byte[] _serializedUtf8;
|
||
|
||
public string Engine => EngineSystemTextJson;
|
||
public string IoMode => IoString;
|
||
public string DispatchMode => ModeRuntime; // System.Text.Json default uses reflection-based metadata (no source generator opt-in here)
|
||
public string OptionsPreset { get; }
|
||
public int SerializedSize => _serializedUtf8.Length;
|
||
public long SetupAllocBytes => 0;
|
||
|
||
public SystemTextJsonBenchmark(TestOrder order, string optionsPreset)
|
||
{
|
||
_order = order;
|
||
OptionsPreset = optionsPreset;
|
||
_options = new JsonSerializerOptions
|
||
{
|
||
WriteIndented = false,
|
||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
|
||
};
|
||
_serialized = System.Text.Json.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() => System.Text.Json.JsonSerializer.Serialize(_order, _options);
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Deserialize() => System.Text.Json.JsonSerializer.Deserialize<TestOrder>(_serialized, _options);
|
||
|
||
public bool VerifyRoundTrip()
|
||
{
|
||
var json = System.Text.Json.JsonSerializer.Serialize(_order, _options);
|
||
var roundTripped = System.Text.Json.JsonSerializer.Deserialize<TestOrder>(json, _options);
|
||
return DeepEqualsViaJson(_order, roundTripped);
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Results
|
||
|
||
private sealed class BenchmarkResult
|
||
{
|
||
public string TestDataName { get; set; } = "";
|
||
public string Engine { get; set; } = "";
|
||
public string IoMode { get; set; } = "";
|
||
public string DispatchMode { get; set; } = "";
|
||
public string OptionsPreset { get; set; } = "";
|
||
/// <summary>True if Serialize() captures a full round-trip and Deserialize() is a no-op
|
||
/// (single-use streaming transports like NamedPipe). Excluded from "Fastest Serialize" / "Fastest Deserialize"
|
||
/// winners rankings; still ranked in "Fastest Round-trip". Display-side: Ser ms / SerAlloc / Des ms / DesAlloc
|
||
/// all show "N/A" since they were never measured separately; RT ms / RT Alloc carry the full round-trip values.</summary>
|
||
public bool IsRoundTripOnly { get; set; }
|
||
/// <summary>Synthesized display name for backwards compatibility / single-string-row scenarios. Includes DispatchMode so SGen and Runtime variants of the same preset don't collide in grouping (e.g. SUMMARY: WINNERS).</summary>
|
||
public string SerializerName => $"{Engine} ({IoMode}, {OptionsPreset}, {DispatchMode})";
|
||
public string? OptionsDescription { get; set; }
|
||
public int SerializedSize { get; set; }
|
||
public double SerializeTimeMs { get; set; }
|
||
public double DeserializeTimeMs { get; set; }
|
||
public long SerializeAllocBytesPerOp { get; set; }
|
||
public long DeserializeAllocBytesPerOp { get; set; }
|
||
public long SetupAllocBytes { get; set; }
|
||
/// <summary>Total round-trip time. For in-memory benchmarks: <c>Serialize + Deserialize</c> (set explicitly in
|
||
/// <c>RunBenchmarksForTestData</c>). For round-trip-only benchmarks (NamedPipe etc.): the directly-measured
|
||
/// pipe round-trip time, since Ser and Des are not separately measurable there.</summary>
|
||
public double RoundTripTimeMs { get; set; }
|
||
/// <summary>Total round-trip allocation per op. For in-memory benchmarks: <c>SerializeAlloc + DeserializeAlloc</c>.
|
||
/// For round-trip-only benchmarks: process-wide allocation measured via <see cref="GC.GetTotalAllocatedBytes"/>
|
||
/// (covers ALL threads — client, server-drain, channel internals — not just the caller).</summary>
|
||
public long RoundTripAllocBytesPerOp { get; set; }
|
||
}
|
||
|
||
private static void PrintResult(BenchmarkResult result)
|
||
{
|
||
var ser = result.SerializeTimeMs > 0 ? $"{result.SerializeTimeMs,8:F2} ms" : " N/A";
|
||
var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs,8:F2} ms" : " N/A";
|
||
var serAlloc = result.SerializeTimeMs > 0 ? $"{result.SerializeAllocBytesPerOp,8:N0} B/op" : " N/A";
|
||
var desAlloc = result.DeserializeTimeMs > 0 ? $"{result.DeserializeAllocBytesPerOp,8:N0} B/op" : " N/A";
|
||
System.Console.WriteLine($" {result.SerializerName,-40} | Size: {result.SerializedSize,8:N0} | Ser: {ser} ({serAlloc}) | Des: {des} ({desAlloc})");
|
||
}
|
||
|
||
private static void PrintGroupedResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
|
||
{
|
||
System.Console.WriteLine("\n");
|
||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||
System.Console.WriteLine("║ GROUPED RESULTS BY TEST DATA ║");
|
||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||
|
||
// Print serializer options
|
||
var optionsMap = results
|
||
.Where(r => r.OptionsDescription != null)
|
||
.Select(r => (r.SerializerName, r.OptionsDescription!))
|
||
.Distinct()
|
||
.ToList();
|
||
if (optionsMap.Count > 0)
|
||
{
|
||
System.Console.WriteLine();
|
||
System.Console.WriteLine(" Serializer Options:");
|
||
foreach (var (name, opts) in optionsMap)
|
||
System.Console.WriteLine($" {name}: {opts}");
|
||
}
|
||
|
||
foreach (var testData in testDataSets)
|
||
{
|
||
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.RoundTripTimeMs).ToList();
|
||
// Baseline switched MessagePack → MemoryPack: MemoryPack is the SOTA performance leader.
|
||
var memPackResult = testResults.FirstOrDefault(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray));
|
||
// Pin the comparison to AcBinary's SGen variant — apples-to-apples vs MemoryPack (also source-generated).
|
||
// The Runtime variant is shown alongside in the table for context, not used as the headline number.
|
||
var acBinaryResult = testResults.FirstOrDefault(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen));
|
||
|
||
System.Console.WriteLine($"\n┌─ {testData.DisplayName} ─".PadRight(172, '─') + "┐");
|
||
System.Console.WriteLine($"│ {"#",-4} │ {"Engine",-11} │ {"Options",-22} │ {"IO",-12} │ {"Mode",-8} │ {"Setup",-8} │ {"Size",-8} │ {"Ser ms",-10} │ {"SerAlloc",-10} │ {"Des ms",-10} │ {"DesAlloc",-10} │ {"RT ms",-10} │ {"RT Alloc",-10} │");
|
||
System.Console.WriteLine($"├{"─".PadRight(6, '─')}┼{"─".PadRight(13, '─')}┼{"─".PadRight(24, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(10, '─')}┼{"─".PadRight(10, '─')}┼{"─".PadRight(10, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┤");
|
||
|
||
var rank = 1;
|
||
foreach (var result in testResults)
|
||
{
|
||
var size = $"{result.SerializedSize:N0}";
|
||
var setup = result.SetupAllocBytes > 0 ? $"{result.SetupAllocBytes:N0}" : "0";
|
||
var ser = result.SerializeTimeMs > 0 ? $"{result.SerializeTimeMs:F2} ms" : "N/A";
|
||
var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs:F2} ms" : "N/A";
|
||
var rt = result.RoundTripTimeMs > 0 ? $"{result.RoundTripTimeMs:F2} ms" : "N/A";
|
||
var serAlloc = result.SerializeTimeMs > 0 ? $"{result.SerializeAllocBytesPerOp:N0} B" : "N/A";
|
||
var desAlloc = result.DeserializeTimeMs > 0 ? $"{result.DeserializeAllocBytesPerOp:N0} B" : "N/A";
|
||
var rtAlloc = result.RoundTripAllocBytesPerOp > 0 ? $"{result.RoundTripAllocBytesPerOp:N0} B" : "N/A";
|
||
|
||
// Highlight MemoryPack baseline (any Byte[]) and AcBinary headline contender (Byte[] + SGen) with win/lose colors.
|
||
// The AcBinary Byte[]+Runtime variant is shown unhighlighted — it's contextual (SGen speed-up reference), not the headline.
|
||
var isHighlighted = (result.Engine == EngineMemoryPack && result.IoMode == IoByteArray)
|
||
|| (result.Engine == EngineAcBinary && result.IoMode == IoByteArray && result.DispatchMode == ModeSGen);
|
||
var prefix = isHighlighted ? "│►" : "│ ";
|
||
var suffix = isHighlighted ? "◄│" : " │";
|
||
|
||
// Color logic: Green = winner (faster), Red = loser (slower)
|
||
if (isHighlighted && memPackResult != null && acBinaryResult != null)
|
||
{
|
||
var isMemPack = (result.Engine == EngineMemoryPack && result.IoMode == IoByteArray);
|
||
var memPackFaster = memPackResult.RoundTripTimeMs < acBinaryResult.RoundTripTimeMs;
|
||
|
||
if (isMemPack)
|
||
{
|
||
System.Console.ForegroundColor = memPackFaster ? ConsoleColor.Green : ConsoleColor.Red;
|
||
}
|
||
else
|
||
{
|
||
System.Console.ForegroundColor = memPackFaster ? ConsoleColor.Red : ConsoleColor.Green;
|
||
}
|
||
}
|
||
|
||
System.Console.WriteLine($"{prefix}{rank++,4} │ {result.Engine,-11} │ {result.OptionsPreset,-22} │ {result.IoMode,-12} │ {result.DispatchMode,-8} │ {setup,8} │ {size,8} │ {ser,10} │ {serAlloc,10} │ {des,10} │ {desAlloc,10} │ {rt,10} │ {rtAlloc,10}{suffix}");
|
||
|
||
if (isHighlighted)
|
||
{
|
||
System.Console.ResetColor();
|
||
}
|
||
}
|
||
|
||
// Footer row: AcBinary (Byte[]) vs MemoryPack (Byte[]) comparison per column
|
||
if (memPackResult != null && acBinaryResult != null)
|
||
{
|
||
var sizePct = (acBinaryResult.SerializedSize / (double)memPackResult.SerializedSize - 1) * 100;
|
||
var serPct = memPackResult.SerializeTimeMs > 0 ? (acBinaryResult.SerializeTimeMs / memPackResult.SerializeTimeMs - 1) * 100 : 0;
|
||
var desPct = memPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / memPackResult.DeserializeTimeMs - 1) * 100 : 0;
|
||
var rtPct = memPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / memPackResult.RoundTripTimeMs - 1) * 100 : 0;
|
||
var serAllocPct = memPackResult.SerializeAllocBytesPerOp > 0 ? (acBinaryResult.SerializeAllocBytesPerOp / (double)memPackResult.SerializeAllocBytesPerOp - 1) * 100 : 0;
|
||
var desAllocPct = memPackResult.DeserializeAllocBytesPerOp > 0 ? (acBinaryResult.DeserializeAllocBytesPerOp / (double)memPackResult.DeserializeAllocBytesPerOp - 1) * 100 : 0;
|
||
var rtAllocPct = memPackResult.RoundTripAllocBytesPerOp > 0 ? (acBinaryResult.RoundTripAllocBytesPerOp / (double)memPackResult.RoundTripAllocBytesPerOp - 1) * 100 : 0;
|
||
|
||
// Footer separator: merge first 5 cols (#, Engine, Options, IO, Mode) → comparison label;
|
||
// remaining 8 cols stay aligned (Setup, Size, Ser ms, SerAlloc, Des ms, DesAlloc, RT ms, RT Alloc).
|
||
System.Console.WriteLine($"├{"─".PadRight(6, '─')}┴{"─".PadRight(13, '─')}┴{"─".PadRight(24, '─')}┴{"─".PadRight(14, '─')}┴{"─".PadRight(10, '─')}┼{"─".PadRight(10, '─')}┼{"─".PadRight(10, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(12, '─')}┤");
|
||
// Merged label cell width = 4 + 11 + 22 + 12 + 8 + 4*3 (dropped separators) = 69
|
||
System.Console.Write($"│ {"► AcBinary (Byte[]) vs MemoryPack (Byte[])",-69} │ ");
|
||
|
||
// Setup (n/a for Byte[] vs Byte[] — neither pre-allocates)
|
||
System.Console.Write($"{"—",8}");
|
||
System.Console.Write(" │ ");
|
||
|
||
// Size
|
||
System.Console.ForegroundColor = sizePct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||
System.Console.Write($"{sizePct,+7:+0;-0}%");
|
||
System.Console.ResetColor();
|
||
System.Console.Write(" │ ");
|
||
|
||
// Serialize
|
||
System.Console.ForegroundColor = serPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||
System.Console.Write($"{serPct,+9:+0;-0}%");
|
||
System.Console.ResetColor();
|
||
System.Console.Write(" │ ");
|
||
|
||
// Serialize Alloc
|
||
System.Console.ForegroundColor = serAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||
System.Console.Write($"{serAllocPct,+9:+0;-0}%");
|
||
System.Console.ResetColor();
|
||
System.Console.Write(" │ ");
|
||
|
||
// Deserialize
|
||
System.Console.ForegroundColor = desPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||
System.Console.Write($"{desPct,+9:+0;-0}%");
|
||
System.Console.ResetColor();
|
||
System.Console.Write(" │ ");
|
||
|
||
// Deserialize Alloc
|
||
System.Console.ForegroundColor = desAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||
System.Console.Write($"{desAllocPct,+9:+0;-0}%");
|
||
System.Console.ResetColor();
|
||
System.Console.Write(" │ ");
|
||
|
||
// Round-trip
|
||
System.Console.ForegroundColor = rtPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||
System.Console.Write($"{rtPct,+9:+0;-0}%");
|
||
System.Console.ResetColor();
|
||
System.Console.Write(" │ ");
|
||
|
||
// Round-trip Alloc
|
||
System.Console.ForegroundColor = rtAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||
System.Console.Write($"{rtAllocPct,+9:+0;-0}%");
|
||
System.Console.ResetColor();
|
||
System.Console.WriteLine(" │");
|
||
}
|
||
|
||
// Closing line: merged on left (─ between cols 1-5), ┴ on the right (cols 6-13 boundary, 8 unmerged cells).
|
||
System.Console.WriteLine($"└{"─".PadRight(6, '─')}─{"─".PadRight(13, '─')}─{"─".PadRight(24, '─')}─{"─".PadRight(14, '─')}─{"─".PadRight(10, '─')}┴{"─".PadRight(10, '─')}┴{"─".PadRight(10, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(12, '─')}┴{"─".PadRight(12, '─')}┘");
|
||
//System.Console.WriteLine($"GrowBufferCount: {AcBinarySerializer.GrowBufferCount}");
|
||
//System.Console.WriteLine($"GrowBufferTotalBytes: {AcBinarySerializer.GrowBufferTotalBytes:N0} bytes");
|
||
}
|
||
|
||
// Summary: Best serializer for each category
|
||
System.Console.WriteLine("\n");
|
||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||
System.Console.WriteLine("║ SUMMARY: WINNERS ║");
|
||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||
|
||
System.Console.WriteLine($"\n{"Category",-20} │ {"Winner",-40} │ {"Avg Value",-18}");
|
||
System.Console.WriteLine($"{"─".PadRight(20, '─')}─┼─{"─".PadRight(40, '─')}─┼─{"─".PadRight(18, '─')}");
|
||
|
||
// Fastest Serialize — round-trip-only serializers (NamedPipe etc.) excluded:
|
||
// their Serialize() captures the full round-trip and isn't comparable to a pure Ser metric.
|
||
var fastestSer = results.Where(r => r.SerializeTimeMs > 0 && !r.IsRoundTripOnly)
|
||
.GroupBy(r => r.SerializerName)
|
||
.Select(g => new { Name = g.Key, AvgTime = g.Average(r => r.SerializeTimeMs) })
|
||
.OrderBy(x => x.AvgTime)
|
||
.FirstOrDefault();
|
||
if (fastestSer != null)
|
||
System.Console.WriteLine($"{"Fastest Serialize",-20} │ {fastestSer.Name,-40} │ {fastestSer.AvgTime,15:F2} ms");
|
||
|
||
// Fastest Deserialize — round-trip-only serializers excluded (their Deserialize() is a no-op).
|
||
var fastestDes = results.Where(r => r.DeserializeTimeMs > 0 && !r.IsRoundTripOnly)
|
||
.GroupBy(r => r.SerializerName)
|
||
.Select(g => new { Name = g.Key, AvgTime = g.Average(r => r.DeserializeTimeMs) })
|
||
.OrderBy(x => x.AvgTime)
|
||
.FirstOrDefault();
|
||
if (fastestDes != null)
|
||
System.Console.WriteLine($"{"Fastest Deserialize",-20} │ {fastestDes.Name,-40} │ {fastestDes.AvgTime,15:F2} ms");
|
||
|
||
// Smallest Size
|
||
var smallestSize = results
|
||
.GroupBy(r => r.SerializerName)
|
||
.Select(g => new { Name = g.Key, AvgSize = g.Average(r => r.SerializedSize) })
|
||
.OrderBy(x => x.AvgSize)
|
||
.FirstOrDefault();
|
||
if (smallestSize != null)
|
||
System.Console.WriteLine($"{"Smallest Size",-20} │ {smallestSize.Name,-40} │ {smallestSize.AvgSize,15:F0} B");
|
||
|
||
// Fastest Round-trip
|
||
var fastestRt = results.Where(r => r.RoundTripTimeMs > 0)
|
||
.GroupBy(r => r.SerializerName)
|
||
.Select(g => new { Name = g.Key, AvgTime = g.Average(r => r.RoundTripTimeMs) })
|
||
.OrderBy(x => x.AvgTime)
|
||
.FirstOrDefault();
|
||
if (fastestRt != null)
|
||
System.Console.WriteLine($"{"Fastest Round-trip",-20} │ {fastestRt.Name,-40} │ {fastestRt.AvgTime,15:F2} ms");
|
||
|
||
// Overall AcBinary (SGen) vs MemoryPack comparison (baseline switched MessagePack → MemoryPack as SOTA reference).
|
||
// AcBinary side is restricted to DispatchMode == SGen — apples-to-apples vs MemoryPack which is also source-generated.
|
||
// The Runtime variant is shown side-by-side in each per-test fancy table for SGen-speedup context, but excluded from this headline.
|
||
var memPackSerResults = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray) && r.SerializeTimeMs > 0).ToList();
|
||
var memPackDesResults = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray) && r.DeserializeTimeMs > 0).ToList();
|
||
var memPackRtResults = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray) && r.RoundTripTimeMs > 0).ToList();
|
||
|
||
var acBinarySerResults = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen) && r.SerializeTimeMs > 0).ToList();
|
||
var acBinaryDesResults = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen) && r.DeserializeTimeMs > 0).ToList();
|
||
var acBinaryRtResults = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen) && r.RoundTripTimeMs > 0).ToList();
|
||
|
||
// Skip comparison if no data available
|
||
if (memPackRtResults.Count == 0 || acBinaryRtResults.Count == 0)
|
||
{
|
||
System.Console.WriteLine();
|
||
System.Console.WriteLine($"── {"AcBinary (Byte[], SGen)"} vs {"MemoryPack (Byte[])"} (Overall) ──");
|
||
System.Console.WriteLine(" (Comparison requires both serialize and deserialize data)");
|
||
return;
|
||
}
|
||
|
||
var memPackAvgSer = memPackSerResults.Count > 0 ? memPackSerResults.Average(r => r.SerializeTimeMs) : 0;
|
||
var memPackAvgDes = memPackDesResults.Average(r => r.DeserializeTimeMs);
|
||
var memPackAvgRt = memPackRtResults.Average(r => r.RoundTripTimeMs);
|
||
var memPackAvgSize = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray)).Average(r => r.SerializedSize);
|
||
var memPackAvgSerAlloc = memPackSerResults.Count > 0 ? memPackSerResults.Average(r => r.SerializeAllocBytesPerOp) : 0;
|
||
var memPackAvgDesAlloc = memPackDesResults.Count > 0 ? memPackDesResults.Average(r => r.DeserializeAllocBytesPerOp) : 0;
|
||
|
||
var acBinaryAvgSer = acBinarySerResults.Count > 0 ? acBinarySerResults.Average(r => r.SerializeTimeMs) : 0;
|
||
var acBinaryAvgDes = acBinaryDesResults.Average(r => r.DeserializeTimeMs);
|
||
var acBinaryAvgRt = acBinaryRtResults.Average(r => r.RoundTripTimeMs);
|
||
var acBinaryAvgSize = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen)).Average(r => r.SerializedSize);
|
||
var acBinaryAvgSerAlloc = acBinarySerResults.Count > 0 ? acBinarySerResults.Average(r => r.SerializeAllocBytesPerOp) : 0;
|
||
var acBinaryAvgDesAlloc = acBinaryDesResults.Count > 0 ? acBinaryDesResults.Average(r => r.DeserializeAllocBytesPerOp) : 0;
|
||
|
||
System.Console.WriteLine();
|
||
System.Console.WriteLine($"── {"AcBinary (Byte[], SGen)"} vs {"MemoryPack (Byte[])"} (Overall) ──");
|
||
|
||
// Only show serialize comparison if data available
|
||
if (memPackAvgSer > 0 && acBinaryAvgSer > 0)
|
||
{
|
||
var serPctAll = (acBinaryAvgSer / memPackAvgSer - 1) * 100;
|
||
System.Console.ForegroundColor = serPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||
System.Console.WriteLine($" Serialize: {serPctAll:+0;-0}% ({acBinaryAvgSer:F2} ms vs {memPackAvgSer:F2} ms)");
|
||
System.Console.ResetColor();
|
||
}
|
||
|
||
var desPctAll = (acBinaryAvgDes / memPackAvgDes - 1) * 100;
|
||
var rtPctAll = (acBinaryAvgRt / memPackAvgRt - 1) * 100;
|
||
var sizePctAll = (acBinaryAvgSize / memPackAvgSize - 1) * 100;
|
||
|
||
System.Console.ForegroundColor = desPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||
System.Console.WriteLine($" Deserialize: {desPctAll:+0;-0}% ({acBinaryAvgDes:F2} ms vs {memPackAvgDes:F2} ms)");
|
||
System.Console.ResetColor();
|
||
|
||
System.Console.ForegroundColor = rtPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||
System.Console.WriteLine($" Round-trip: {rtPctAll:+0;-0}% ({acBinaryAvgRt:F2} ms vs {memPackAvgRt:F2} ms)");
|
||
System.Console.ResetColor();
|
||
|
||
System.Console.ForegroundColor = sizePctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||
System.Console.WriteLine($" Size: {sizePctAll:+0;-0}% ({acBinaryAvgSize:F0} B vs {memPackAvgSize:F0} B)");
|
||
System.Console.ResetColor();
|
||
|
||
// Allocation comparison: byte[] API allocates the output array on both sides — delta shows serializer-overhead diff.
|
||
if (memPackAvgSerAlloc > 0 && acBinaryAvgSerAlloc > 0)
|
||
{
|
||
var serAllocPct = (acBinaryAvgSerAlloc / memPackAvgSerAlloc - 1) * 100;
|
||
System.Console.ForegroundColor = serAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||
System.Console.WriteLine($" Ser Alloc: {serAllocPct:+0;-0}% ({acBinaryAvgSerAlloc:F0} B/op vs {memPackAvgSerAlloc:F0} B/op)");
|
||
System.Console.ResetColor();
|
||
}
|
||
if (memPackAvgDesAlloc > 0 && acBinaryAvgDesAlloc > 0)
|
||
{
|
||
var desAllocPct = (acBinaryAvgDesAlloc / memPackAvgDesAlloc - 1) * 100;
|
||
System.Console.ForegroundColor = desAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||
System.Console.WriteLine($" Des Alloc: {desAllocPct:+0;-0}% ({acBinaryAvgDesAlloc:F0} B/op vs {memPackAvgDesAlloc:F0} B/op)");
|
||
System.Console.ResetColor();
|
||
}
|
||
}
|
||
|
||
private static void SaveResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
|
||
{
|
||
Directory.CreateDirectory(ResultsDirectory);
|
||
|
||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
|
||
var baseFileName = $"Console.FullBenchmark_{BuildConfiguration}_{timestamp}";
|
||
var logFilePath = Path.Combine(ResultsDirectory, $"{baseFileName}.log");
|
||
var outputFilePath = Path.Combine(ResultsDirectory, $"{baseFileName}.output");
|
||
|
||
// Save binary output to separate .output file
|
||
var largeTestData = testDataSets.FirstOrDefault(t => t.Name.StartsWith("Large"));
|
||
if (largeTestData != null)
|
||
{
|
||
var outputSb = new StringBuilder();
|
||
outputSb.AppendLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||
outputSb.AppendLine("║ SERIALIZED BINARY OUTPUT ║");
|
||
outputSb.AppendLine($"║ Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}".PadRight(100) + "║");
|
||
outputSb.AppendLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||
outputSb.AppendLine();
|
||
|
||
outputSb.AppendLine("=== SERIALIZED BYTES: Large (5x5x5x10) - AcBinary (Default) ===");
|
||
var serializedBytes = AcBinarySerializer.Serialize(largeTestData.Order, AcBinarySerializerOptions.Default);
|
||
outputSb.AppendLine($"Size: {serializedBytes.Length:N0} bytes");
|
||
outputSb.AppendLine();
|
||
outputSb.AppendLine("Hex dump:");
|
||
outputSb.AppendLine(FormatHexDump(serializedBytes));
|
||
|
||
File.WriteAllText(outputFilePath, outputSb.ToString(), Utf8NoBom);
|
||
System.Console.WriteLine($"✓ Binary output saved to: {outputFilePath}");
|
||
}
|
||
|
||
// Save benchmark results to .log file
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
|
||
sb.AppendLine("║ SERIALIZER BENCHMARK RESULTS ║");
|
||
sb.AppendLine($"║ Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}".PadRight(100) + "║");
|
||
sb.AppendLine($"║ Build: {BuildConfiguration}".PadRight(100) + "║");
|
||
sb.AppendLine($"║ Iterations: {TestIterations}".PadRight(100) + "║");
|
||
sb.AppendLine($"║ Samples: {BenchmarkSamples} (median)".PadRight(100) + "║");
|
||
sb.AppendLine($"║ Test Type: {testDataSets.FirstOrDefault()?.TypeName ?? "unknown"}".PadRight(100) + "║");
|
||
sb.AppendLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
|
||
sb.AppendLine();
|
||
|
||
// Serializer options summary
|
||
var optionsMap = results
|
||
.Where(r => r.OptionsDescription != null)
|
||
.Select(r => (r.SerializerName, r.OptionsDescription!))
|
||
.Distinct()
|
||
.ToList();
|
||
if (optionsMap.Count > 0)
|
||
{
|
||
sb.AppendLine("=== SERIALIZER OPTIONS ===");
|
||
foreach (var (name, opts) in optionsMap)
|
||
sb.AppendLine($" {name}: {opts}");
|
||
sb.AppendLine();
|
||
}
|
||
|
||
// CSV-like data for easy import (now includes per-op allocation columns)
|
||
sb.AppendLine("=== RAW DATA (CSV) ===");
|
||
sb.AppendLine("TestData,Engine,IO,Mode,Options,Size,SerializeMs,DeserializeMs,RoundTripMs,SerializeAllocBytesPerOp,DeserializeAllocBytesPerOp,RoundTripAllocBytesPerOp,SetupAllocBytes");
|
||
foreach (var testData in testDataSets)
|
||
{
|
||
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).ToList();
|
||
foreach (var result in testResults)
|
||
{
|
||
sb.AppendLine($"{result.TestDataName},{result.Engine},{result.IoMode},{result.DispatchMode},{result.OptionsPreset},{result.SerializedSize},{result.SerializeTimeMs:F2},{result.DeserializeTimeMs:F2},{result.RoundTripTimeMs:F2},{result.SerializeAllocBytesPerOp},{result.DeserializeAllocBytesPerOp},{result.RoundTripAllocBytesPerOp},{result.SetupAllocBytes}");
|
||
}
|
||
}
|
||
sb.AppendLine();
|
||
|
||
// Formatted results
|
||
sb.AppendLine("=== FORMATTED RESULTS BY TEST DATA ===");
|
||
sb.AppendLine($"(►) = Highlighted: {"MemoryPack (Byte[])"} (baseline) and {"AcBinary (Byte[])"}");
|
||
sb.AppendLine();
|
||
|
||
foreach (var testData in testDataSets)
|
||
{
|
||
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.RoundTripTimeMs).ToList();
|
||
var memPackResult = testResults.FirstOrDefault(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray));
|
||
// Pin the comparison to AcBinary's SGen variant — apples-to-apples vs MemoryPack (also source-generated).
|
||
// The Runtime variant is shown alongside in the table for context, not used as the headline number.
|
||
var acBinaryResult = testResults.FirstOrDefault(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen));
|
||
|
||
sb.AppendLine();
|
||
sb.AppendLine($"--- {testData.DisplayName} ---");
|
||
sb.AppendLine($"{"#",-4} {"Serializer",-42} {"Size",-12} {"Serialize",-14} {"Deserialize",-14} {"Round-trip",-14} {"SerAlloc",-12} {"DesAlloc",-12}");
|
||
sb.AppendLine(new string('-', 130));
|
||
|
||
var rank = 1;
|
||
foreach (var result in testResults)
|
||
{
|
||
var isHighlighted = ((result.Engine == EngineMemoryPack || result.Engine == EngineAcBinary) && result.IoMode == IoByteArray);
|
||
var prefix = isHighlighted ? "► " : " ";
|
||
|
||
var size = $"{result.SerializedSize:N0}";
|
||
var ser = result.SerializeTimeMs > 0 ? $"{result.SerializeTimeMs:F2} ms" : "N/A";
|
||
var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs:F2} ms" : "N/A";
|
||
var rt = result.RoundTripTimeMs > 0 ? $"{result.RoundTripTimeMs:F2} ms" : "N/A";
|
||
var serAlloc = result.SerializeTimeMs > 0 ? $"{result.SerializeAllocBytesPerOp:N0} B" : "N/A";
|
||
var desAlloc = result.DeserializeTimeMs > 0 ? $"{result.DeserializeAllocBytesPerOp:N0} B" : "N/A";
|
||
|
||
sb.AppendLine($"{rank++,2} {prefix}{result.SerializerName,-40} {size,-12} {ser,-14} {des,-14} {rt,-14} {serAlloc,-12} {desAlloc,-12}");
|
||
}
|
||
|
||
// Summary row for this test data (vs MemoryPack — baseline switched MessagePack → MemoryPack)
|
||
if (memPackResult != null && acBinaryResult != null)
|
||
{
|
||
var sizePct = (acBinaryResult.SerializedSize / (double)memPackResult.SerializedSize - 1) * 100;
|
||
var serPct = memPackResult.SerializeTimeMs > 0 ? (acBinaryResult.SerializeTimeMs / memPackResult.SerializeTimeMs - 1) * 100 : 0;
|
||
var desPct = memPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / memPackResult.DeserializeTimeMs - 1) * 100 : 0;
|
||
var rtPct = memPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / memPackResult.RoundTripTimeMs - 1) * 100 : 0;
|
||
|
||
sb.AppendLine($" {"AcBinary (Byte[])"} vs {"MemoryPack (Byte[])"}: Size {sizePct:+0;-0}% │ Ser {serPct:+0;-0}% │ Des {desPct:+0;-0}% │ RT {rtPct:+0;-0}%");
|
||
}
|
||
|
||
//sb.AppendLine($"GrowBufferCount: {AcBinarySerializer.GrowBufferCount}");
|
||
//sb.AppendLine($"GrowBufferTotalBytes: {AcBinarySerializer.GrowBufferTotalBytes:N0} bytes");
|
||
}
|
||
|
||
|
||
// Summary comparison (vs MemoryPack)
|
||
// Restrict AcBinary side to SGen — the SGen vs Runtime variants are shown side-by-side
|
||
// in the per-test fancy table; the headline should compare apples-to-apples (both source-generated).
|
||
sb.AppendLine();
|
||
sb.AppendLine($"=== {"AcBinary (Byte[], SGen)"} vs {"MemoryPack (Byte[])"} (Overall) ===");
|
||
|
||
var memPackSerResults2 = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray) && r.SerializeTimeMs > 0).ToList();
|
||
var memPackDesResults2 = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray) && r.DeserializeTimeMs > 0).ToList();
|
||
var memPackRtResults2 = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray) && r.RoundTripTimeMs > 0).ToList();
|
||
|
||
var acBinarySerResults2 = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen) && r.SerializeTimeMs > 0).ToList();
|
||
var acBinaryDesResults2 = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen) && r.DeserializeTimeMs > 0).ToList();
|
||
var acBinaryRtResults2 = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen) && r.RoundTripTimeMs > 0).ToList();
|
||
|
||
if (memPackSerResults2.Count > 0 && acBinarySerResults2.Count > 0)
|
||
{
|
||
var memPackAvgSer2 = memPackSerResults2.Average(r => r.SerializeTimeMs);
|
||
var acBinaryAvgSer2 = acBinarySerResults2.Average(r => r.SerializeTimeMs);
|
||
var memPackAvgSerAlloc2 = memPackSerResults2.Average(r => r.SerializeAllocBytesPerOp);
|
||
var acBinaryAvgSerAlloc2 = acBinarySerResults2.Average(r => r.SerializeAllocBytesPerOp);
|
||
sb.AppendLine($" Serialize: {((acBinaryAvgSer2 / memPackAvgSer2 - 1) * 100):+0;-0}% ({acBinaryAvgSer2:F2} ms vs {memPackAvgSer2:F2} ms)");
|
||
if (memPackAvgSerAlloc2 > 0)
|
||
sb.AppendLine($" Ser Alloc: {((acBinaryAvgSerAlloc2 / memPackAvgSerAlloc2 - 1) * 100):+0;-0}% ({acBinaryAvgSerAlloc2:F0} B/op vs {memPackAvgSerAlloc2:F0} B/op)");
|
||
}
|
||
|
||
if (memPackDesResults2.Count > 0 && acBinaryDesResults2.Count > 0)
|
||
{
|
||
var memPackAvgDes2 = memPackDesResults2.Average(r => r.DeserializeTimeMs);
|
||
var acBinaryAvgDes2 = acBinaryDesResults2.Average(r => r.DeserializeTimeMs);
|
||
var memPackAvgDesAlloc2 = memPackDesResults2.Average(r => r.DeserializeAllocBytesPerOp);
|
||
var acBinaryAvgDesAlloc2 = acBinaryDesResults2.Average(r => r.DeserializeAllocBytesPerOp);
|
||
sb.AppendLine($" Deserialize: {((acBinaryAvgDes2 / memPackAvgDes2 - 1) * 100):+0;-0}% ({acBinaryAvgDes2:F2} ms vs {memPackAvgDes2:F2} ms)");
|
||
if (memPackAvgDesAlloc2 > 0)
|
||
sb.AppendLine($" Des Alloc: {((acBinaryAvgDesAlloc2 / memPackAvgDesAlloc2 - 1) * 100):+0;-0}% ({acBinaryAvgDesAlloc2:F0} B/op vs {memPackAvgDesAlloc2:F0} B/op)");
|
||
}
|
||
|
||
if (memPackRtResults2.Count > 0 && acBinaryRtResults2.Count > 0)
|
||
{
|
||
var memPackAvgRt2 = memPackRtResults2.Average(r => r.RoundTripTimeMs);
|
||
var acBinaryAvgRt2 = acBinaryRtResults2.Average(r => r.RoundTripTimeMs);
|
||
sb.AppendLine($" Round-trip: {((acBinaryAvgRt2 / memPackAvgRt2 - 1) * 100):+0;-0}% ({acBinaryAvgRt2:F2} ms vs {memPackAvgRt2:F2} ms)");
|
||
}
|
||
|
||
var memPackAvgSize2 = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray)).Average(r => r.SerializedSize);
|
||
var acBinaryAvgSize2 = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen)).Average(r => r.SerializedSize);
|
||
sb.AppendLine($" Size: {((acBinaryAvgSize2 / memPackAvgSize2 - 1) * 100):+0;-0}% ({acBinaryAvgSize2:F0} B vs {memPackAvgSize2:F0} B)");
|
||
|
||
File.WriteAllText(logFilePath, sb.ToString(), Utf8NoBom);
|
||
System.Console.WriteLine($"✓ Results saved to: {logFilePath}");
|
||
|
||
// Save LLM-optimized results
|
||
var llmFilePath = Path.Combine(ResultsDirectory, $"{baseFileName}.LLM");
|
||
SaveLlmResults(llmFilePath, results, testDataSets);
|
||
}
|
||
|
||
private static void SaveLlmResults(string filePath, List<BenchmarkResult> results, List<TestDataSet> testDataSets)
|
||
{
|
||
var sb = new StringBuilder();
|
||
var testTypeName = testDataSets.FirstOrDefault()?.TypeName ?? "unknown";
|
||
sb.AppendLine($"# AcBinary Benchmark {BuildConfiguration} {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
|
||
sb.AppendLine($"Iterations: {TestIterations} | Warmup: {WarmupIterations} | Samples: {BenchmarkSamples} (median) | .NET: {Environment.Version} | TestType: {testTypeName}");
|
||
sb.AppendLine($"Baseline: {"MemoryPack (Byte[])"} (SOTA reference) | Verified: round-trip correctness checked once per cell before warmup");
|
||
|
||
// Options summary
|
||
var optionsMap = results
|
||
.Where(r => r.OptionsDescription != null)
|
||
.Select(r => (r.SerializerName, r.OptionsDescription!))
|
||
.Distinct()
|
||
.ToList();
|
||
if (optionsMap.Count > 0)
|
||
{
|
||
sb.AppendLine();
|
||
sb.AppendLine("## Options");
|
||
sb.AppendLine();
|
||
foreach (var (name, opts) in optionsMap)
|
||
sb.AppendLine($"- **{name}**: {opts}");
|
||
}
|
||
|
||
// Flat results table sorted by test data then round-trip (now includes Alloc columns)
|
||
sb.AppendLine();
|
||
sb.AppendLine("## Results");
|
||
sb.AppendLine();
|
||
sb.AppendLine("TestData | Engine | IO | Mode | Options | Size(B) | Ser(ms) | Deser(ms) | RT(ms) | SerAlloc(B/op) | DesAlloc(B/op) | RTAlloc(B/op) | SetupAlloc(B)");
|
||
sb.AppendLine("---|---|---|---|---|---|---|---|---|---|---|---|---");
|
||
|
||
foreach (var testData in testDataSets)
|
||
{
|
||
var testResults = results
|
||
.Where(r => r.TestDataName == testData.DisplayName)
|
||
.OrderBy(r => r.RoundTripTimeMs)
|
||
.ToList();
|
||
|
||
foreach (var r in testResults)
|
||
{
|
||
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
||
var ser = r.SerializeTimeMs > 0 ? r.SerializeTimeMs.ToString("F2", inv) : "-";
|
||
var des = r.DeserializeTimeMs > 0 ? r.DeserializeTimeMs.ToString("F2", inv) : "-";
|
||
var rt = r.RoundTripTimeMs > 0 ? r.RoundTripTimeMs.ToString("F2", inv) : "-";
|
||
var serAlloc = r.SerializeTimeMs > 0 ? r.SerializeAllocBytesPerOp.ToString(inv) : "-";
|
||
var desAlloc = r.DeserializeTimeMs > 0 ? r.DeserializeAllocBytesPerOp.ToString(inv) : "-";
|
||
var rtAlloc = r.RoundTripAllocBytesPerOp > 0 ? r.RoundTripAllocBytesPerOp.ToString(inv) : "-";
|
||
var setupAlloc = r.SetupAllocBytes.ToString(inv);
|
||
sb.AppendLine($"{r.TestDataName} | {r.Engine} | {r.IoMode} | {r.DispatchMode} | {r.OptionsPreset} | {r.SerializedSize} | {ser} | {des} | {rt} | {serAlloc} | {desAlloc} | {rtAlloc} | {setupAlloc}");
|
||
}
|
||
}
|
||
|
||
|
||
File.WriteAllText(filePath, sb.ToString(), Utf8NoBom);
|
||
System.Console.WriteLine($"✓ LLM results saved to: {filePath}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Formats byte array as hex dump with offset, hex values, and ASCII representation.
|
||
/// </summary>
|
||
private static string FormatHexDump(byte[] bytes, int bytesPerLine = 16)
|
||
{
|
||
var sb = new StringBuilder();
|
||
for (var i = 0; i < bytes.Length; i += bytesPerLine)
|
||
{
|
||
// Offset
|
||
sb.Append($"{i:X8} ");
|
||
|
||
// Hex bytes
|
||
for (var j = 0; j < bytesPerLine; j++)
|
||
{
|
||
if (i + j < bytes.Length)
|
||
sb.Append($"{bytes[i + j]:X2} ");
|
||
else
|
||
sb.Append(" ");
|
||
|
||
if (j == 7) sb.Append(' '); // Extra space in middle
|
||
}
|
||
|
||
sb.Append(" |");
|
||
|
||
// ASCII representation
|
||
for (var j = 0; j < bytesPerLine && i + j < bytes.Length; j++)
|
||
{
|
||
var b = bytes[i + j];
|
||
sb.Append(b is >= 32 and < 127 ? (char)b : '.');
|
||
}
|
||
|
||
sb.AppendLine("|");
|
||
}
|
||
return sb.ToString();
|
||
}
|
||
|
||
#endregion
|
||
}
|