2779 lines
151 KiB
C#
2779 lines
151 KiB
C#
using AyCode.Core.Compression;
|
||
using AyCode.Core.Serializers.Attributes;
|
||
using AyCode.Core.Serializers.Binaries;
|
||
using AyCode.Core.Tests.Serialization; // DrainFromAsync extension (test-only, used by benchmark)
|
||
using AyCode.Core.Tests.TestModels;
|
||
using MemoryPack;
|
||
using MessagePack;
|
||
using MessagePack.Resolvers;
|
||
using Microsoft.Extensions.Options;
|
||
using System.Buffers;
|
||
using System.Diagnostics;
|
||
using System.IO.Pipelines;
|
||
using System.IO.Pipes;
|
||
using System.Reflection;
|
||
using System.Runtime.CompilerServices;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
|
||
namespace AyCode.Core.Serializers.Console;
|
||
|
||
/// <summary>
|
||
/// Comprehensive benchmark application for all serializers.
|
||
/// Compares: AcBinary (all options), MemoryPack, MessagePack, Newtonsoft.Json, System.Text.Json
|
||
///
|
||
/// Usage:
|
||
/// dotnet run # Run all benchmarks
|
||
/// dotnet run -- quick # Quick mode (fewer iterations)
|
||
/// dotnet run -- serialize # Serialize only
|
||
/// dotnet run -- deserialize # Deserialize only
|
||
/// </summary>
|
||
public static class Program
|
||
{
|
||
private const string ResultsDirectory = @"H:\Applications\Aycode\Source\AyCode.Core\Test_Benchmark_Results\Benchmark";
|
||
|
||
#if DEBUG
|
||
private const string BuildConfiguration = "Debug";
|
||
#else
|
||
private const string BuildConfiguration = "Release";
|
||
#endif
|
||
|
||
#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 = 100; //5000
|
||
private static int TestIterations = 10; //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";
|
||
private const string IoInMemoryPipe = "Pipe(in-mem)";
|
||
private const string IoInMemoryRaw = "Pipe(in-mem)";
|
||
|
||
// Single source of truth for the chunk size used by ALL pipe-related benchmarks (NamedPipe PipeChunk,
|
||
// NamedPipe PipeRaw, in-memory Pipe, in-memory RawMem) AND the NamedPipe server's inBufferSize/outBufferSize.
|
||
// Same value across both layers ensures apples-to-apples comparison: chunked-streaming chunk-on-wire size
|
||
// matches the kernel pipe-buffer slot exactly. Tweak HERE when experimenting; do NOT scatter chunkSize
|
||
// overrides across individual benchmark rows.
|
||
private const int PipeChunkSize = 4096;
|
||
|
||
// Dispatch mode identifiers — describes how property access / type dispatch happens for a given run.
|
||
// SGen = compile-time source generator path (Unsafe.As<T> direct fields, slot-array wrapper lookup).
|
||
// Runtime= reflection / compiled-delegate path.
|
||
// Hybrid = SGen root with non-SGen child types reached via bridge methods. See docs/BINARY/BINARY_SGEN.md.
|
||
private const string ModeSGen = "SGen";
|
||
private const string ModeRuntime = "Runtime";
|
||
private const string ModeHybrid = "Hybrid";
|
||
|
||
private const int JitSleep = 3000;
|
||
|
||
// OptionsPreset values are passed per-instance (constructor argument), not constants —
|
||
// each CreateSerializers call line specifies its own preset name (e.g. "FastMode", "NoIntern").
|
||
|
||
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
|
||
|
||
/// <summary>
|
||
/// Aggregated <see cref="AcBinarySerializableAttribute"/> feature flags across every type tagged with
|
||
/// the attribute in the loaded assemblies. Cached on first access (single reflection scan at startup).
|
||
/// Used by <see cref="BuildAcBinaryOptionsDescription"/> so the per-row Options column shows BOTH the
|
||
/// configured options-level value AND the effective attribute-level enable flag — a feature flagged
|
||
/// off at the type level overrides the options regardless of preset, and that asymmetry must surface
|
||
/// in the log to avoid misreading a "RefHandling=OnlyId" / "Interning=All" line as actually active.
|
||
/// Aggregation rule: if ALL tagged types have the feature enabled → <c>true</c>; if any tagged type
|
||
/// disables it → <c>false</c> (a single disabling type suppresses the feature on the type-graph).
|
||
/// </summary>
|
||
private static readonly (bool refHandling, bool internString, bool metadata, bool idTracking) _attrFlags
|
||
= ScanAcBinaryAttributeFlags();
|
||
|
||
private static (bool refHandling, bool internString, bool metadata, bool idTracking) ScanAcBinaryAttributeFlags()
|
||
{
|
||
var attrs = AppDomain.CurrentDomain.GetAssemblies()
|
||
.SelectMany(a => { try { return a.GetTypes(); } catch { return Array.Empty<Type>(); } })
|
||
.Select(t => t.GetCustomAttribute<AcBinarySerializableAttribute>())
|
||
.Where(a => a != null)
|
||
.ToList();
|
||
|
||
if (attrs.Count == 0) return (false, false, false, false);
|
||
|
||
return (
|
||
refHandling: attrs.All(a => a!.EnableRefHandlingFeature),
|
||
internString: attrs.All(a => a!.EnableInternStringFeature),
|
||
metadata: attrs.All(a => a!.EnableMetadataFeature),
|
||
idTracking: attrs.All(a => a!.EnableIdTrackingFeature));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Common Options-column formatter for every AcBinary serializer benchmark row. Renders the
|
||
/// configured options-level value AND the effective attribute-level enable flag side-by-side
|
||
/// (e.g. <c>Interning=All(opt) | False (attr)</c>) so attribute-suppressed features cannot
|
||
/// silently mislead. Pass any benchmark-specific extras (e.g. <c>", BufferSize=4096B"</c>)
|
||
/// in <paramref name="extra"/> — they are appended after the common fields.
|
||
/// </summary>
|
||
private static string BuildAcBinaryOptionsDescription(AcBinarySerializerOptions options, string extra = "")
|
||
{
|
||
return $"WireMode={options.WireMode}, " +
|
||
$"RefHandling={options.ReferenceHandling}(opt) | {_attrFlags.refHandling} (attr), " +
|
||
$"Interning={options.UseStringInterning}(opt) | {_attrFlags.internString} (attr), " +
|
||
$"Metadata={options.UseMetadata}(opt) | {_attrFlags.metadata} (attr), " +
|
||
$"SGen={options.UseGeneratedCode}, " +
|
||
$"Compression={options.UseCompression}{extra}";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Converts a total-time (in ms across <see cref="TestIterations"/>) into per-operation microseconds.
|
||
/// Formula: <c>totalMs / iterations × 1000</c>. The benchmark stores <c>*TimeMs</c> as the cumulative
|
||
/// median over the timing run; the display layer renders per-op µs to make numbers iteration-count
|
||
/// independent (e.g. switching <c>TestIterations</c> 1000 → 100 leaves the displayed µs/op unchanged
|
||
/// — only its sample noise grows). Symmetric with the already-per-op <c>*AllocBytesPerOp</c> fields.
|
||
/// </summary>
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
private static double ToPerOpMicros(double totalMs) => totalMs / TestIterations * 1000.0;
|
||
|
||
/// <summary>
|
||
/// Converts a byte count to KB (1 KB = 1024 B). Display-only helper so allocation columns can
|
||
/// render compact F2 KB values (e.g. <c>4.05 KB</c> instead of <c>4,144 B</c>) — header carries
|
||
/// the unit so per-row entries stay numbers-only. CSV / raw-data outputs keep the precise byte
|
||
/// integers untouched.
|
||
/// </summary>
|
||
[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();
|
||
|
||
// CLI mode (args provided): run once, parse args, exit. Backward-compatible behaviour.
|
||
if (args.Length > 0)
|
||
{
|
||
if (!TryParseCliArgs(args, out var layer, out var opMode, out var serializerMode))
|
||
return; // profiler mode (already ran) or invalid args
|
||
RunBenchmark(layer, opMode, serializerMode);
|
||
return;
|
||
}
|
||
|
||
// Interactive mode (no args): loop the menu so the user doesn't have to restart between runs.
|
||
// Q exits the menu (and the application).
|
||
while (true)
|
||
{
|
||
var selection = ShowInteractiveMenu();
|
||
if (selection == null) return; // user pressed Q
|
||
|
||
RunBenchmark(selection.Value.layer, "all", selection.Value.serializerMode);
|
||
|
||
System.Console.WriteLine();
|
||
System.Console.WriteLine("─────────────────────────────────────────────────────────────────────");
|
||
System.Console.WriteLine("Returning to menu — press any key to continue, or Q to quit...");
|
||
var key = System.Console.ReadKey(intercept: true);
|
||
if (key.Key == ConsoleKey.Q) return;
|
||
System.Console.WriteLine();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Parses CLI arguments into (layer, opMode, serializerMode). Returns <c>false</c> if the args
|
||
/// indicate a special mode that has already been handled (e.g. <c>profiler</c>) or are invalid;
|
||
/// the caller should then exit without running the standard benchmark.
|
||
/// </summary>
|
||
private static bool TryParseCliArgs(string[] args, out string layer, out string opMode, out string serializerMode)
|
||
{
|
||
layer = "all";
|
||
opMode = "all";
|
||
serializerMode = "standard";
|
||
|
||
var arg = args[0].ToLower();
|
||
|
||
// Profiler mode: warmup only, then exit (for memory profiler analysis)
|
||
if (arg == "profiler")
|
||
{
|
||
RunProfilerMode();
|
||
return false;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runs the benchmark suite end-to-end for the given configuration: pre-warmup → per-cell warmup
|
||
/// + measurement → grouped results print → save to disk. Used by both the CLI and interactive
|
||
/// menu paths; the interactive loop calls this repeatedly without restarting the process.
|
||
/// </summary>
|
||
private static void RunBenchmark(string layer, string opMode, string serializerMode)
|
||
{
|
||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
|
||
System.Console.WriteLine("║ COMPREHENSIVE SERIALIZER BENCHMARK SUITE ║");
|
||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
|
||
|
||
var allResults = new List<BenchmarkResult>();
|
||
var allTestDataSets = BenchmarkTestDataProvider.CreateTestDataSets();
|
||
var testDataSets = FilterByLayer(allTestDataSets, layer);
|
||
|
||
System.Console.WriteLine($"Layer: {layer} | OpMode: {opMode} | SerializerMode: {serializerMode} | Iterations: {TestIterations} | Warmup: {WarmupIterations} | Samples: {BenchmarkSamples} (median)");
|
||
System.Console.WriteLine($"Build: {BuildConfiguration} | .NET: {Environment.Version} | Test Type: {testDataSets.FirstOrDefault()?.TypeName ?? "unknown"} | Test Cells: {testDataSets.Count}/{allTestDataSets.Count}");
|
||
System.Console.WriteLine();
|
||
|
||
// Global JIT pre-warmup — touches every (testdata × serializer) code path BEFORE any timing happens.
|
||
// Without this, the FIRST test data measured carries JIT-tier-promotion latency: the per-cell warmup
|
||
// alone doesn't ensure that every Serialize<T>/IBufferWriter overload is fully Tier 1 by the time we
|
||
// start measuring. Symptom: first cell's BufferWriter variants run ~2x slower than the SAME variants
|
||
// on later cells (e.g. Small BufWr reuse 9ms vs Medium BufWr reuse 4ms — even though Medium is bigger).
|
||
// Pre-warmup runs every overload at least once with each data shape so .NET 9's tiered JIT promotes
|
||
// them all in the background; the per-cell warmup that follows then locks in cache + branch state.
|
||
if (BenchmarkSamples > 1) // skip in DEBUG (single-sample fast iteration)
|
||
{
|
||
System.Console.WriteLine($"Global JIT pre-warmup ({testDataSets.Count} cells × all serializers, light pass)...");
|
||
foreach (var testData in testDataSets)
|
||
{
|
||
var preSerializers = CreateSerializers(testData, serializerMode);
|
||
try
|
||
{
|
||
foreach (var s in preSerializers)
|
||
{
|
||
// Light warmup just to trigger Tier 0 → Tier 1 promotion. The per-cell 5000-iter warmup
|
||
// inside RunBenchmarksForTestData still runs afterwards for cache/BTB warming.
|
||
s.Warmup(2000);
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
// Dispose any IDisposable serializers (NamedPipe / FileStream variants own OS resources).
|
||
foreach (var s in preSerializers) (s as IDisposable)?.Dispose();
|
||
}
|
||
}
|
||
// Let background tiered-JIT compilation drain before we begin measuring.
|
||
Thread.Sleep(JitSleep);
|
||
System.Console.WriteLine("✓ Global pre-warmup complete.\n");
|
||
}
|
||
|
||
foreach (var testData in testDataSets)
|
||
{
|
||
System.Console.WriteLine($"\n{'═'.ToString().PadRight(70, '═')}");
|
||
System.Console.WriteLine($"TEST DATA: {testData.DisplayName}");
|
||
System.Console.WriteLine($"{'═'.ToString().PadRight(70, '═')}");
|
||
|
||
var results = RunBenchmarksForTestData(testData, opMode, serializerMode);
|
||
allResults.AddRange(results);
|
||
}
|
||
|
||
// Print grouped results
|
||
PrintGroupedResults(allResults, testDataSets);
|
||
|
||
// Save results to file
|
||
SaveResults(allResults, testDataSets);
|
||
|
||
System.Console.WriteLine("\n✓ Benchmark complete!");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Profiler mode: warmup only, then EXIT immediately.
|
||
/// Usage: dotnet run -- profiler
|
||
/// </summary>
|
||
private static void RunProfilerMode()
|
||
{
|
||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
|
||
System.Console.WriteLine("║ PROFILER MODE (AcBinary only) ║");
|
||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
|
||
System.Console.WriteLine($"Build: {BuildConfiguration} | .NET: {Environment.Version}");
|
||
System.Console.WriteLine();
|
||
|
||
var order = BenchmarkTestDataProvider.CreateProfilerOrder();
|
||
|
||
var options = AcBinarySerializerOptions.WithoutReferenceHandling;
|
||
options.UseStringInterning = StringInterningMode.None;
|
||
|
||
var bytes = AcBinarySerializer.Serialize(order, options);
|
||
|
||
// Warmup (fills caches)
|
||
System.Console.WriteLine("Warming up (1000 iterations)...");
|
||
for (var i = 0; i < 1000; i++)
|
||
{
|
||
_ = AcBinarySerializer.Serialize(order, options);
|
||
_ = AcBinaryDeserializer.Deserialize<TestOrder>(bytes);
|
||
}
|
||
|
||
Thread.Sleep(2000);
|
||
System.Console.WriteLine("Warmup complete. Caches are now populated.");
|
||
System.Console.WriteLine();
|
||
|
||
// HOT PATH - this is what the profiler should capture!
|
||
System.Console.WriteLine("Running hot path serialization (1000 iterations for profiling)...");
|
||
for (var i = 0; i < 1000; i++)
|
||
{
|
||
_ = AcBinarySerializer.Serialize(order, options);
|
||
//_ = AcBinaryDeserializer.Deserialize<TestOrder>(bytes);
|
||
}
|
||
|
||
System.Console.WriteLine("Running hot path deserialization (1000 iterations for profiling)...");
|
||
for (var i = 0; i < 1000; i++)
|
||
{
|
||
_ = AcBinaryDeserializer.Deserialize<TestOrder>(bytes);
|
||
}
|
||
|
||
System.Console.WriteLine("Hot path complete.");
|
||
System.Console.WriteLine();
|
||
|
||
System.Console.WriteLine(">>> ATTACH MEMORY PROFILER NOW <<<");
|
||
System.Console.WriteLine("Press any key to exit...");
|
||
System.Console.ReadKey(intercept: true);
|
||
|
||
System.Console.WriteLine();
|
||
System.Console.WriteLine("✓ Profiler mode complete. Exiting now.");
|
||
}
|
||
|
||
#region Benchmark Execution
|
||
|
||
private static List<BenchmarkResult> RunBenchmarksForTestData(TestDataSet testData, string mode, string serializerMode)
|
||
{
|
||
var results = new List<BenchmarkResult>();
|
||
var serializers = CreateSerializers(testData, serializerMode);
|
||
|
||
// Round-trip correctness check — once per (cell × serializer), BEFORE warmup. Aborts the entire benchmark on failure.
|
||
System.Console.WriteLine("Verifying round-trip correctness...");
|
||
|
||
foreach (var serializer in serializers)
|
||
{
|
||
if (!serializer.VerifyRoundTrip())
|
||
{
|
||
System.Console.Error.WriteLine($"❌ FATAL: Round-trip verification FAILED for {serializer.Name} on {testData.DisplayName}");
|
||
System.Console.Error.WriteLine("Benchmark numbers from a serializer with broken round-trip would be meaningless. Aborting.");
|
||
|
||
Environment.Exit(1);
|
||
}
|
||
}
|
||
|
||
System.Console.WriteLine("✓ All serializers passed round-trip verification.");
|
||
|
||
// Warmup all serializers
|
||
System.Console.WriteLine($"Warming up ({WarmupIterations} iterations)...");
|
||
foreach (var serializer in serializers)
|
||
{
|
||
serializer.Warmup(WarmupIterations);
|
||
}
|
||
|
||
// Wait for tiered JIT background compilation to complete
|
||
Thread.Sleep(JitSleep);
|
||
|
||
// Run benchmarks
|
||
System.Console.WriteLine($"Running benchmarks ({TestIterations} iterations × {BenchmarkSamples} samples median)...\n");
|
||
|
||
foreach (var serializer in serializers)
|
||
{
|
||
var result = new BenchmarkResult
|
||
{
|
||
TestDataName = testData.DisplayName, // Use DisplayName for IId% info
|
||
Engine = serializer.Engine,
|
||
IoMode = serializer.IoMode,
|
||
DispatchMode = serializer.DispatchMode,
|
||
OptionsPreset = serializer.OptionsPreset,
|
||
OptionsDescription = serializer.OptionsDescription,
|
||
SerializedSize = serializer.SerializedSize,
|
||
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<ISerializerBenchmark> CreateSerializers(TestDataSet testData, string serializerMode)
|
||
{
|
||
// AsyncPipe-only mode — return ONLY the AsyncPipe streaming benchmark (no other serializer).
|
||
// Streaming I/O has long-lived pipe setup + kernel-buffer overhead that, when interleaved with
|
||
// the standard byte-array / IBufferWriter measurements, masks the steady-state numbers. Run it
|
||
// in isolation so the timing numbers reflect ONLY the streaming path.
|
||
if (serializerMode == "asyncpipe")
|
||
{
|
||
// NamedPipe — pipe-aligned chunk size for the long-lived IPC scenario. The chunkSize here
|
||
// drives the AsyncPipeWriterOutput's chunk-on-wire size (header + data, page-aligned thanks to
|
||
// the AcquireChunk fix) AND the kernel pipe buffer size (inBufferSize/outBufferSize on the
|
||
// NamedPipeServerStream ctor). Same value across both layers = one WriteFile(chunkSize) syscall
|
||
// fits blocking-free in one kernel pipe-buffer slot. Single source of truth for both app-level
|
||
// wire chunk AND kernel transfer unit; change ONLY this line when tuning.
|
||
var binaryFastModePipeChunkOnly = AcBinarySerializerOptions.FastMode;
|
||
binaryFastModePipeChunkOnly.BufferWriterChunkSize = PipeChunkSize;
|
||
|
||
return new List<ISerializerBenchmark>
|
||
{
|
||
// 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<T>(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)"),
|
||
// Chunked-framed AsyncPipe over an IN-MEMORY System.IO.Pipelines.Pipe (NO NamedPipe, NO kernel).
|
||
// Same chunked-streaming code path (SerializeChunkedFramed → AsyncPipeReaderInput) but with the
|
||
// kernel-pipe replaced by a managed-only Pipe. Eliminates per-chunk syscall overhead (~30 µs/chunk
|
||
// on NamedPipe → ~1-2 µs/chunk on in-memory Pipe). Side-by-side with the NamedPipe row above this
|
||
// isolates pure CPU cost of the chunked-streaming framework (vs kernel-pipe transport cost) — the
|
||
// in-memory Pipe row should be much closer to the raw-byte[] row, validating that NamedPipe loopback
|
||
// is the worst-case benchmark scenario for chunked-streaming and not representative of real network
|
||
// / file / cross-thread Pipe scenarios.
|
||
new AcBinaryInMemoryPipeBenchmark(testData.Order, binaryFastModePipeChunkOnly, "FastMode (PipeChunk)"),
|
||
// Raw byte[] over IN-MEMORY direct cross-thread handoff (no transport at all). Apples-to-apples
|
||
// baseline for the in-memory chunked row above: same in-memory transport (zero kernel), but raw
|
||
// byte[] vs chunked-streaming wire format. Completes the 2x2 matrix [chunked,raw] × [kernel,memory].
|
||
new AcBinaryInMemoryRawByteArrayBenchmark(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 = PipeChunkSize;
|
||
|
||
// In-memory Pipe variant — same 4 KB chunkSize as the AsyncPipe mode, no kernel-pipe alignment
|
||
// concern (managed slabs are not page-aligned anyway). Drives SerializeChunkedFramed via the in-memory
|
||
// System.IO.Pipelines.Pipe (zero-copy slab handoff between producer and drain task).
|
||
var binaryFastModePipeChunkInMem = AcBinarySerializerOptions.FastMode;
|
||
binaryFastModePipeChunkInMem.BufferWriterChunkSize = PipeChunkSize;
|
||
|
||
var defaultOptions = AcBinarySerializerOptions.Default;
|
||
defaultOptions.UseStringInterning = StringInterningMode.None;
|
||
defaultOptions.ReferenceHandling = ReferenceHandlingMode.OnlyId;
|
||
|
||
return new List<ISerializerBenchmark>
|
||
{
|
||
// ============================================================
|
||
// AcBinary — Byte[] API (uncomment to compare option presets side-by-side)
|
||
// ============================================================
|
||
// Fastest Byte[] — SGen path (UseGeneratedCode=true, default).
|
||
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, "FastMode"),
|
||
// Fastest Byte[] — Runtime path (UseGeneratedCode=false). Same wire/options, no source-generated dispatch.
|
||
// Always paired with the SGen variant so every layer can compare the SGen speed-up apples-to-apples.
|
||
// COMMENTED: Reflection.Emit-based dispatch crashes under NativeAOT (PlatformNotSupportedException).
|
||
// Re-enable for JIT-mode benchmarks where SGen-vs-Runtime delta matters.
|
||
//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)"),
|
||
|
||
// AcBinary chunked-streaming over an IN-MEMORY Pipe (no kernel transport). Side-by-side with the
|
||
// Byte[] / IBufferWriter rows above this shows the chunked-streaming framework's pure CPU cost
|
||
// (no NamedPipe loopback noise) vs the simpler in-process serialize-then-deserialize patterns.
|
||
// The IO column shows "Pipe(in-mem)" — distinct from the NamedPipe AsyncPipe rows in [P] mode.
|
||
new AcBinaryInMemoryPipeBenchmark(testData.Order, binaryFastModePipeChunkInMem, "FastMode (PipeChunk)"),
|
||
|
||
// Raw byte[] over IN-MEMORY direct cross-thread handoff (no transport, no kernel, no Pipe). Apples-to-
|
||
// apples baseline for the in-memory chunked row above: same in-memory pattern, but raw byte[] vs
|
||
// chunked-streaming wire format. The IO column shows "Bytes(in-mem)".
|
||
new AcBinaryInMemoryRawByteArrayBenchmark(testData.Order, binaryFastModePipeChunkInMem, "FastMode (PipeRaw)"),
|
||
|
||
// AsyncPipe streaming over kernel NamedPipe (AcBinaryNamedPipeBenchmark) is intentionally OMITTED
|
||
// here — run it via the dedicated AsyncPipe menu [P] / CLI mode for isolated kernel-transport
|
||
// measurements.
|
||
|
||
// ============================================================
|
||
// MemoryPack — three I/O modes for apples-to-apples comparison
|
||
// ============================================================
|
||
new MemoryPackBenchmark(testData.Order, "Default"),
|
||
new MemoryPackBufferWriterBenchmark(testData.Order, "Default"),
|
||
new MemoryPackFreshBufferWriterBenchmark(testData.Order, "Default"),
|
||
|
||
// ============================================================
|
||
// MessagePack — for legacy comparison
|
||
// ============================================================
|
||
new MessagePackBenchmark(testData.Order, "ContractBased"),
|
||
|
||
// System.Text.Json (commented — JSON serializer for reference; not in active suite)
|
||
//new SystemTextJsonBenchmark(testData.Order, "Default")
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runs the action <paramref name="iterations"/> times for <see cref="BenchmarkSamples"/> independent samples,
|
||
/// returning the median elapsed time. Multi-sample design reduces single-run variance from ~±15% to ~±5%
|
||
/// by smoothing transient effects (background activity, thermal/turbo state, JIT tier-promotion timing).
|
||
/// When <see cref="BenchmarkSamples"/> <= 1, falls back to single-sample timing (Debug / quick mode).
|
||
/// When <paramref name="progressLabel"/> is non-null, emits in-place <c>\r</c> progress updates so a
|
||
/// stuck benchmark (e.g. deadlocked NamedPipe row) is visibly stuck at a specific %% rather than
|
||
/// silently hanging.
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <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, 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;
|
||
}
|
||
|
||
/// <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, 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;
|
||
|
||
/// <summary>
|
||
/// Runs <paramref name="action"/> <paramref name="iterations"/> times, emitting \r-overwriting
|
||
/// progress every ~10% (approx. 10 progress prints per sample). When <paramref name="label"/>
|
||
/// is null, runs without any progress output (zero overhead beyond a null check per iter).
|
||
/// </summary>
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Closes a progress line cleanly: clears any leftover chars and writes a final "done" line on
|
||
/// the same row, terminated by \n so subsequent <c>WriteLine</c> calls render below.
|
||
/// </summary>
|
||
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
|
||
};
|
||
|
||
/// <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.
|
||
/// Loops on settings-changes ([S]) — user is returned to this menu after modifying iteration counts.
|
||
/// </summary>
|
||
private static (string layer, string serializerMode)? ShowInteractiveMenu()
|
||
{
|
||
while (true)
|
||
{
|
||
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($" [S] Settings — modify Warmup ({WarmupIterations}) / Iterations ({TestIterations}) / Samples ({BenchmarkSamples})");
|
||
System.Console.WriteLine(" [Q] Quit");
|
||
System.Console.Write("\nSelection: ");
|
||
|
||
var key = System.Console.ReadKey(intercept: false).KeyChar;
|
||
System.Console.WriteLine();
|
||
|
||
switch (char.ToLower(key))
|
||
{
|
||
case '1': return ("core", "standard");
|
||
case '2': return ("comprehensive", "standard");
|
||
case '3': return ("edge", "standard");
|
||
case 'a': return ("all", "standard");
|
||
case 'p': return ("all", "asyncpipe");
|
||
case 's':
|
||
ShowSettingsMenu();
|
||
continue; // re-display the main menu after settings update
|
||
case 'q': return null;
|
||
default: return ("all", "standard");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Settings sub-menu — prompts for Warmup / Iterations / Samples values. Empty input keeps the
|
||
/// current value. Validation: WarmupIterations ≥ 0; TestIterations ≥ 1; BenchmarkSamples ≥ 1.
|
||
/// Returns to the caller (which re-displays the main menu).
|
||
/// </summary>
|
||
private static void ShowSettingsMenu()
|
||
{
|
||
System.Console.WriteLine();
|
||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||
System.Console.WriteLine("Settings — press Enter to keep current value");
|
||
System.Console.WriteLine("─────────────────────────────────────────────");
|
||
System.Console.WriteLine();
|
||
|
||
WarmupIterations = PromptInt("WarmupIterations", WarmupIterations, min: 0);
|
||
TestIterations = PromptInt("TestIterations ", TestIterations, min: 1);
|
||
BenchmarkSamples = PromptInt("BenchmarkSamples", BenchmarkSamples, min: 1);
|
||
|
||
System.Console.WriteLine();
|
||
System.Console.WriteLine($"✓ Settings updated: Warmup={WarmupIterations} | Iterations={TestIterations} | Samples={BenchmarkSamples}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Prompts the user for an integer with a default (current value). Returns the current value if
|
||
/// the user presses Enter on empty input or if parsing fails / value is below the minimum.
|
||
/// </summary>
|
||
private static int PromptInt(string name, int currentValue, int min)
|
||
{
|
||
System.Console.Write($" {name} [{currentValue}]: ");
|
||
var input = System.Console.ReadLine()?.Trim() ?? "";
|
||
if (input.Length == 0) return currentValue;
|
||
if (int.TryParse(input, out var newValue) && newValue >= min) return newValue;
|
||
System.Console.WriteLine($" ! Invalid value (need int ≥ {min}) — keeping {currentValue}");
|
||
return currentValue;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Filters test data sets by layer keyword. Layered approach lets you run only what's needed for the iteration cadence.
|
||
/// P1: only "Core" data exists (Small/Medium/Large/Repeated/Deep). Comprehensive and Edge layers will be expanded in P2.
|
||
/// </summary>
|
||
private static List<TestDataSet> FilterByLayer(List<TestDataSet> all, string layer)
|
||
{
|
||
if (layer == "all") return all.ToList();
|
||
|
||
var coreNames = new[] { "Small", "Medium", "Large", "Repeated", "Deep" };
|
||
// P2 will add: "Flat", "Polymorphic", "Collection", "Numeric", "NonAscii", etc.
|
||
var comprehensiveExtras = new string[] { /* P2 */ };
|
||
// P3 will add: "ColdStart", "VeryLarge", "PathologicalString", etc.
|
||
var edgeExtras = new string[] { /* P3 */ };
|
||
|
||
return layer switch
|
||
{
|
||
"core" => all.Where(t => StartsWithAny(t.Name, coreNames)).ToList(),
|
||
"comprehensive" => all.Where(t => StartsWithAny(t.Name, coreNames) || StartsWithAny(t.Name, comprehensiveExtras)).ToList(),
|
||
"edge" => all.Where(t => StartsWithAny(t.Name, coreNames) || StartsWithAny(t.Name, comprehensiveExtras) || StartsWithAny(t.Name, edgeExtras)).ToList(),
|
||
_ => all.ToList()
|
||
};
|
||
|
||
static bool StartsWithAny(string name, string[] prefixes) => prefixes.Any(name.StartsWith);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Serializer Implementations
|
||
|
||
private interface ISerializerBenchmark
|
||
{
|
||
/// <summary>Serializer engine — e.g. "AcBinary", "MemoryPack", "MessagePack".</summary>
|
||
string Engine { get; }
|
||
/// <summary>I/O mode — e.g. "Byte[]", "BufWr reuse", "BufWr new", "NamedPipe", "FileStream".</summary>
|
||
string IoMode { get; }
|
||
/// <summary>Dispatch mode — "SGen", "Runtime", or "Hybrid". For AcBinary derived from <c>UseGeneratedCode</c> + child-type SGen coverage; non-AcBinary engines report their own native dispatch model.</summary>
|
||
string DispatchMode { get; }
|
||
/// <summary>Options preset name — e.g. "FastMode", "Default", "NoIntern", "WithCompression".</summary>
|
||
string OptionsPreset { get; }
|
||
/// <summary>Synthesized display name from Engine + IoMode + OptionsPreset.</summary>
|
||
string Name => $"{Engine} ({IoMode}, {OptionsPreset})";
|
||
int SerializedSize { get; }
|
||
string? OptionsDescription => null;
|
||
/// <summary>One-time SERIALIZER-side setup allocation cost (e.g., pre-allocated ArrayBufferWriter with internal buffer). Captured in constructor; 0 for byte[] API and Fresh-BufWriter variants.</summary>
|
||
long SetupSerializeAllocBytes { get; }
|
||
/// <summary>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.</summary>
|
||
long SetupDeserializeAllocBytes { 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 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<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 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<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 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<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 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<byte>(); // FRESH every call — alloc + grow as needed
|
||
AcBinarySerializer.Serialize(_order, abw, _options);
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Deserialize() => AcBinaryDeserializer.Deserialize<TestOrder>(_serialized, _options);
|
||
|
||
public bool VerifyRoundTrip()
|
||
{
|
||
var abw = new ArrayBufferWriter<byte>();
|
||
AcBinarySerializer.Serialize(_order, abw, _options);
|
||
var roundTripped = AcBinaryDeserializer.Deserialize<TestOrder>(abw.WrittenSpan.ToArray(), _options);
|
||
return DeepEqualsViaJson(_order, roundTripped);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Benchmarks AcBinary over a long-lived NamedPipe IPC connection using the AcBinary native streaming API
|
||
/// (<see cref="AcBinarySerializer.SerializeChunked{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>
|
||
/// + <see cref="AsyncPipeReaderInput"/> + <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/>).
|
||
/// Mirrors what a real consumer (e.g. <c>DeserializeFromPipeReaderAsync</c>) does per message:
|
||
/// long-lived <see cref="AsyncPipeReaderInput"/> with multi-message wire framing on top of a long-lived NamedPipe.
|
||
///
|
||
/// <para><b>Architecture</b>:</para>
|
||
/// <list type="bullet">
|
||
/// <item>Constructor (NOT timed): sets up <see cref="NamedPipeServerStream"/> + <see cref="NamedPipeClientStream"/>,
|
||
/// waits for connection, creates one long-lived <see cref="System.IO.Pipelines.PipeWriter"/> /
|
||
/// <see cref="System.IO.Pipelines.PipeReader"/> pair, ONE long-lived <see cref="AsyncPipeReaderInput"/>
|
||
/// in <c>multiMessage = true</c> mode, ONE drain Task that pumps <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/>
|
||
/// forever, and ONE deserialize Task that loops <c>AcBinaryDeserializer.Deserialize<T>(input, opts)</c>
|
||
/// producing into a <see cref="System.Threading.Channels.Channel{T}"/>.</item>
|
||
/// <item>Per-iteration <see cref="Serialize"/> (timed): sender writes via
|
||
/// <see cref="AcBinarySerializer.SerializeChunkedFramed{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>
|
||
/// — multi-message wire (<c>[201][UINT16][data]...[202]</c>); the <c>[202]</c> end marker arms the input's
|
||
/// <c>_readPos = -1</c> sentinel, so the next message's first <c>AppendToBuffer</c> recycles the buffer to 0.
|
||
/// Then receiver awaits the channel for the deserialized result.</item>
|
||
/// <item><see cref="Deserialize"/> is a no-op (full round-trip captured in <see cref="Serialize"/>);
|
||
/// <see cref="IsRoundTripOnly"/>=true → Ser ms / SerAlloc oszlopok N/A, RT ms = full round-trip.</item>
|
||
/// </list>
|
||
///
|
||
/// <para><b>Per-iter overhead</b>: 0 new <c>Task.Run</c>, 0 new <c>AsyncPipeReaderInput</c>, 0 new <c>CancellationTokenSource</c>.
|
||
/// Pure cost = <c>SerializeChunkedFramed</c> (CPU + chunk-onkénti flush) + kernel write/read syscalls + 1 sync barrier
|
||
/// (channel) + deserialized graph alloc. The "multi-message reuse" pattern enabled by Q4T8 fix (R5K2 minimum: <c>_readPos = -1</c>
|
||
/// sentinel + <c>AppendToBuffer</c> sliding-window cycling).</para>
|
||
///
|
||
/// <para><b>Approximation note</b>: single-process loopback NamedPipe. Real cross-process / cross-machine SignalR
|
||
/// adds further transport latency (TCP, WebSocket framing) on top. The benchmark gives a lower bound.</para>
|
||
/// </summary>
|
||
private sealed class AcBinaryNamedPipeBenchmark : ISerializerBenchmark, IDisposable
|
||
{
|
||
private readonly TestOrder _order;
|
||
private readonly AcBinarySerializerOptions _options;
|
||
private readonly byte[] _serialized; // for SerializedSize reporting only
|
||
|
||
// Long-lived pipe lifecycle (set up once in ctor — NOT timed).
|
||
private readonly NamedPipeServerStream _pipeServer;
|
||
private readonly NamedPipeClientStream _pipeClient;
|
||
private readonly PipeWriter _pipeWriter;
|
||
private readonly PipeReader _pipeReader;
|
||
|
||
// Long-lived multi-message receive infrastructure (set up once in ctor).
|
||
private readonly AsyncPipeReaderInput _input;
|
||
private readonly CancellationTokenSource _cts;
|
||
private readonly Task _drainTask; // BG: PipeReader → input.Feed (continuous pump)
|
||
private readonly Task _consumerTask; // BG: per-iter Deserialize<T>(input) loop, signaled by calling thread
|
||
private readonly ManualResetEventSlim _consumeRequest = new(false);
|
||
private readonly ManualResetEventSlim _consumeDone = new(false);
|
||
private object? _lastResult; // captured during VerifyRoundTrip; null in benchmark iters
|
||
private bool _captureResult; // toggle: when true, ConsumeLoop stores result; otherwise discards
|
||
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,2-task)");
|
||
|
||
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 + consumer task scaffolding. Two long-lived BG tasks total: drain pumps bytes from the
|
||
// kernel pipe into input; consumer drives Deserialize<T>(input) per iter on signal.
|
||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
|
||
_pipeReader = PipeReader.Create(_pipeServer);
|
||
_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 — its overhead is amortised across all messages.
|
||
_drainTask = Task.Run(() => _input.DrainFromAsync(_pipeReader, _cts.Token));
|
||
// Consumer task: per-iter Deserialize<T>(input) loop. Started here once; signaled per-iter via
|
||
// _consumeRequest. Enables Ser↔Des streaming overlap — calling thread runs SerializeChunkedFramed
|
||
// while THIS task simultaneously runs Deserialize<T>, both consuming/producing through the
|
||
// sliding-window buffer pipelined by the drain task.
|
||
_consumerTask = Task.Run(ConsumeLoop);
|
||
var afterDes = GC.GetAllocatedBytesForCurrentThread();
|
||
SetupDeserializeAllocBytes = afterDes - beforeDes;
|
||
}
|
||
|
||
// BG consumer: parks on _consumeRequest, runs Deserialize<T>(_input) when signaled, signals _consumeDone.
|
||
// The Deserialize call internally blocks on the input's MRES whenever the drain hasn't yet fed enough
|
||
// bytes for the next read — that's where the streaming-pipeline overlap with the calling thread (Ser)
|
||
// happens.
|
||
private void ConsumeLoop()
|
||
{
|
||
var ct = _cts.Token;
|
||
try
|
||
{
|
||
while (true)
|
||
{
|
||
_consumeRequest.Wait(ct);
|
||
if (ct.IsCancellationRequested) return;
|
||
_consumeRequest.Reset();
|
||
|
||
try
|
||
{
|
||
var result = AcBinaryDeserializer.Deserialize<TestOrder>(_input, _options);
|
||
if (_captureResult) _lastResult = result;
|
||
}
|
||
catch
|
||
{
|
||
// Swallow — calling thread sees the failure via missing/incorrect _lastResult during VerifyRoundTrip,
|
||
// or the benchmark loop just continues (timing impacted). Production teardown handled in Dispose.
|
||
}
|
||
finally
|
||
{
|
||
_consumeDone.Set();
|
||
}
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Cooperative cancel — Dispose path. Swallow.
|
||
}
|
||
}
|
||
|
||
public void Warmup(int iterations)
|
||
{
|
||
for (var i = 0; i < iterations; i++)
|
||
{
|
||
Serialize();
|
||
}
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Serialize()
|
||
{
|
||
// 2-task streaming pipeline:
|
||
// 1. Calling thread signals consumer task to begin Deserialize<T>(input). Consumer immediately
|
||
// starts; first read blocks on input's MRES because no bytes flowed yet.
|
||
// 2. Calling thread starts SerializeChunkedFramed → chunks flow through PipeWriter → kernel pipe →
|
||
// drain task (BG) feeds input.Feed → MRES pulses → consumer's Deserialize<T> consumes bytes
|
||
// chunk by chunk. Ser↔Des truly overlap here.
|
||
// 3. Calling thread waits for _consumeDone (signaling Deserialize<T> returned).
|
||
_consumeDone.Reset();
|
||
_consumeRequest.Set();
|
||
|
||
AcBinarySerializer.SerializeChunkedFramed(_order, _pipeWriter, _options);
|
||
|
||
_consumeDone.Wait();
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Deserialize()
|
||
{
|
||
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
|
||
}
|
||
|
||
public bool VerifyRoundTrip()
|
||
{
|
||
// Use the same 2-task streaming path as the benchmark, but capture the result for graph-equality.
|
||
_captureResult = true;
|
||
try
|
||
{
|
||
Serialize();
|
||
var result = _lastResult as TestOrder;
|
||
return result != null && DeepEqualsViaJson(_order, result);
|
||
}
|
||
finally
|
||
{
|
||
_captureResult = false;
|
||
_lastResult = null;
|
||
}
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed) return;
|
||
_disposed = true;
|
||
|
||
// Cancel drain + consumer tasks → both exit. Pulse _consumeRequest in case consumer is parked.
|
||
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
|
||
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
|
||
try { _drainTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||
try { _consumerTask.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 { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
|
||
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
|
||
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Same chunked-framed AsyncPipe code path as <see cref="AcBinaryNamedPipeBenchmark"/>, but the transport
|
||
/// is an in-memory <see cref="System.IO.Pipelines.Pipe"/> instead of a kernel <c>NamedPipe</c>. The Pipe's
|
||
/// <c>Writer</c>/<c>Reader</c> pair is a managed-only zero-copy slab handoff — no syscalls, no kernel
|
||
/// buffer copy, no IRP queueing.
|
||
///
|
||
/// <para><b>Why this benchmark matters</b>: by holding ALL other variables constant (same SerializeChunkedFramed,
|
||
/// same AsyncPipeReaderInput, same drain task, same consumer task, same multi-message wire format), this
|
||
/// row isolates the <b>kernel-NamedPipe transport overhead</b> from the chunked-streaming framework's pure
|
||
/// CPU cost. The expected delta vs <see cref="AcBinaryNamedPipeBenchmark"/>: per-chunk overhead drops from
|
||
/// ~25-30 µs (kernel-syscall pair + IRP) to ~1-2 µs (managed slab handoff). Multi-chunk Large-message rows
|
||
/// should converge dramatically toward <see cref="AcBinaryNamedPipeRawByteArrayBenchmark"/>.</para>
|
||
///
|
||
/// <para><b>Real-world relevance</b>: in-memory Pipe is the typical primitive used for cross-thread serializer
|
||
/// pipelines inside a single process (e.g. SignalR's Kestrel transport adapter, gRPC framework internals,
|
||
/// custom message brokers). The numbers from this row reflect that scenario, NOT the kernel-pipe loopback
|
||
/// of the NamedPipe benchmark.</para>
|
||
/// </summary>
|
||
private sealed class AcBinaryInMemoryPipeBenchmark : ISerializerBenchmark, IDisposable
|
||
{
|
||
private readonly TestOrder _order;
|
||
private readonly AcBinarySerializerOptions _options;
|
||
private readonly byte[] _serialized; // for SerializedSize reporting only
|
||
|
||
// Long-lived in-memory pipe lifecycle (set up once in ctor — NOT timed).
|
||
private readonly Pipe _pipe;
|
||
private readonly PipeWriter _pipeWriter;
|
||
private readonly PipeReader _pipeReader;
|
||
|
||
// Long-lived multi-message receive infrastructure (set up once in ctor) — same pattern as the NamedPipe
|
||
// variant: drain pumps reader into AsyncPipeReaderInput, consumer task drives Deserialize<T>(input).
|
||
private readonly AsyncPipeReaderInput _input;
|
||
private readonly CancellationTokenSource _cts;
|
||
private readonly Task _drainTask;
|
||
private readonly Task _consumerTask;
|
||
private readonly ManualResetEventSlim _consumeRequest = new(false);
|
||
private readonly ManualResetEventSlim _consumeDone = new(false);
|
||
private object? _lastResult;
|
||
private bool _captureResult;
|
||
private bool _disposed;
|
||
|
||
public string Engine => EngineAcBinary;
|
||
public string IoMode => IoInMemoryPipe;
|
||
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=Pipe(in-memory,multiMessage,2-task)");
|
||
|
||
public AcBinaryInMemoryPipeBenchmark(TestOrder order, AcBinarySerializerOptions options, string optionsPreset)
|
||
{
|
||
_order = order;
|
||
_options = options;
|
||
OptionsPreset = optionsPreset;
|
||
|
||
_serialized = AcBinarySerializer.Serialize(order, _options);
|
||
|
||
// === SERIALIZE-side setup measurement ===
|
||
// In-memory Pipe construction. NO kernel-pipe pair, NO Connect handshake — just a managed Pipe object
|
||
// and a reference to its Writer side. PipeWriterImpl (parallel-flush capable, NOT StreamPipeWriter).
|
||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||
var beforeSer = GC.GetAllocatedBytesForCurrentThread();
|
||
_pipe = new Pipe();
|
||
_pipeWriter = _pipe.Writer;
|
||
var afterSer = GC.GetAllocatedBytesForCurrentThread();
|
||
SetupSerializeAllocBytes = afterSer - beforeSer;
|
||
|
||
// === DESERIALIZE-side setup measurement ===
|
||
// PipeReader reference + AsyncPipeReaderInput (ArrayPool rent + ManualResetEventSlim) + drain task +
|
||
// consumer task scaffolding. Identical to the NamedPipe variant on the receive side.
|
||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
|
||
_pipeReader = _pipe.Reader;
|
||
_input = new AsyncPipeReaderInput(_options.BufferWriterChunkSize * 2, multiMessage: true);
|
||
_cts = new CancellationTokenSource();
|
||
_drainTask = Task.Run(() => _input.DrainFromAsync(_pipeReader, _cts.Token));
|
||
_consumerTask = Task.Run(ConsumeLoop);
|
||
var afterDes = GC.GetAllocatedBytesForCurrentThread();
|
||
SetupDeserializeAllocBytes = afterDes - beforeDes;
|
||
}
|
||
|
||
// BG consumer: parks on _consumeRequest, runs Deserialize<T>(_input) when signaled, signals _consumeDone.
|
||
// Mirror of AcBinaryNamedPipeBenchmark.ConsumeLoop — same pattern, same MRES protocol.
|
||
private void ConsumeLoop()
|
||
{
|
||
var ct = _cts.Token;
|
||
try
|
||
{
|
||
while (true)
|
||
{
|
||
_consumeRequest.Wait(ct);
|
||
if (ct.IsCancellationRequested) return;
|
||
_consumeRequest.Reset();
|
||
|
||
try
|
||
{
|
||
var result = AcBinaryDeserializer.Deserialize<TestOrder>(_input, _options);
|
||
if (_captureResult) _lastResult = result;
|
||
}
|
||
catch
|
||
{
|
||
// Swallow — see ConsumeLoop in NamedPipe variant for rationale.
|
||
}
|
||
finally
|
||
{
|
||
_consumeDone.Set();
|
||
}
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Cooperative cancel — Dispose path. Swallow.
|
||
}
|
||
}
|
||
|
||
public void Warmup(int iterations)
|
||
{
|
||
for (var i = 0; i < iterations; i++) Serialize();
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Serialize()
|
||
{
|
||
// Same 2-task streaming pipeline as NamedPipe variant — only the transport differs (in-memory Pipe
|
||
// instead of kernel NamedPipe). Per-chunk SerializeChunkedFramed → PipeWriter slab → drain task
|
||
// reads from PipeReader → input.Feed → consumer Deserialize<T> consumes byte-by-byte.
|
||
//
|
||
// Uses the Pipe-overload (instead of the PipeWriter-overload) so the FlushPolicy parameter is
|
||
// exposed for tuning. Toggle between FlushPolicy.PerChunk (bounded peak memory, per-chunk await
|
||
// FlushAsync) and FlushPolicy.Coalesced (fire-and-forget per chunk, pipe-coalesced flushes up to
|
||
// PauseWriterThreshold ~64 KB) to A/B-test the streaming-pipeline overhead. FlushPolicy.PerChunk
|
||
// is functionally equivalent to the PipeWriter-overload (both internally route to
|
||
// SerializeToPipeWriterCore with FlushPolicy.PerChunk).
|
||
_consumeDone.Reset();
|
||
_consumeRequest.Set();
|
||
|
||
AcBinarySerializer.SerializeChunkedFramed(_order, _pipe, _options, FlushPolicy.Coalesced);
|
||
|
||
_consumeDone.Wait();
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Deserialize()
|
||
{
|
||
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
|
||
}
|
||
|
||
public bool VerifyRoundTrip()
|
||
{
|
||
_captureResult = true;
|
||
try
|
||
{
|
||
Serialize();
|
||
var result = _lastResult as TestOrder;
|
||
return result != null && DeepEqualsViaJson(_order, result);
|
||
}
|
||
finally
|
||
{
|
||
_captureResult = false;
|
||
_lastResult = null;
|
||
}
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed) return;
|
||
_disposed = true;
|
||
|
||
// Cancel drain + consumer tasks → both exit. Pulse _consumeRequest in case consumer is parked.
|
||
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
|
||
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
|
||
try { _drainTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||
try { _consumerTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||
|
||
// Complete writer + reader (in-memory Pipe — no underlying stream to dispose).
|
||
try { _pipeWriter.CompleteAsync().AsTask().Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||
try { _pipeReader.Complete(); } catch { /* swallow on teardown */ }
|
||
try { _input.Dispose(); } catch { /* swallow on teardown */ }
|
||
try { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
|
||
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
|
||
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Raw <c>byte[]</c> over a long-lived NamedPipe — NO chunk-framing, NO <c>AsyncPipeReaderInput</c>,
|
||
/// NO sliding-window buffer. Calling thread serialises + writes; a long-lived background consumer task
|
||
/// reads and deserialises. Two-task pattern enables Ser↔Read overlap (kernel-pipe-pipelined) AND
|
||
/// avoids the kernel-buffer-full deadlock when <c>bytes.Length > inBufferSize</c>.
|
||
///
|
||
/// Side-by-side with <see cref="AcBinaryNamedPipeBenchmark"/> (chunked-framed AsyncPipe stack) this
|
||
/// isolates two cost components on the SAME kernel-pipe transport with the SAME <c>inBufferSize</c>:
|
||
/// <list type="bullet">
|
||
/// <item><description><b>This row vs <see cref="AcBinaryBenchmark"/> (Byte[])</b> — pure kernel-NamedPipe
|
||
/// overhead (WriteFile / ReadFile syscalls + IRP queueing + buffer-copy + thread-handoff).</description></item>
|
||
/// <item><description><b>This row vs <see cref="AcBinaryNamedPipeBenchmark"/> (chunked-framed)</b> — pure
|
||
/// AsyncPipe-framework overhead (chunk header writes + sliding-window <c>Feed</c> + MRES wait inside
|
||
/// <c>AsyncPipeReaderInput</c>) AND the streaming-pipeline benefit of intra-message Ser↔Des overlap (which
|
||
/// raw lacks — raw can only Ser↔Read overlap, with Des sequential after Read completes).</description></item>
|
||
/// </list>
|
||
/// Per-iter <c>byte[]</c> allocation from <c>AcBinarySerializer.Serialize</c> is part of the cost (matches
|
||
/// <see cref="AcBinaryBenchmark"/>'s API contract); the receive-side scratch buffer is also allocated per-iter
|
||
/// on the consumer-task (counted via <c>GC.GetTotalAllocatedBytes</c> in <c>MeasureAllocationTotal</c>).
|
||
/// </summary>
|
||
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 consumer-task infrastructure (Read + Deserialize on BG thread, signaled per iter).
|
||
// Mirrors AcBinaryNamedPipeBenchmark's drain+consumer pair, but raw byte[] doesn't have an
|
||
// intermediate sliding-window buffer, so Read+Des happen sequentially in one BG task: Read N bytes
|
||
// → Deserialize<T>(bytes) → signal done. Calling thread's Ser↔Write overlaps with this BG Read+Des
|
||
// through kernel-pipe pipelining.
|
||
private readonly CancellationTokenSource _cts;
|
||
private readonly Task _consumerTask;
|
||
private readonly ManualResetEventSlim _consumeRequest = new(false);
|
||
private readonly ManualResetEventSlim _consumeDone = new(false);
|
||
private int _pendingReadSize;
|
||
private object? _lastResult; // captured during VerifyRoundTrip; null in benchmark iters
|
||
private bool _captureResult; // toggle: when true, ConsumerLoop stores result; otherwise discards
|
||
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,2-task)");
|
||
|
||
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 consumer-task + 2× MRES (request / done) + cancellation source. Matches the
|
||
// chunked benchmark's deserialize-side setup cost shape.
|
||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
|
||
_cts = new CancellationTokenSource();
|
||
_consumerTask = Task.Run(ConsumerLoop);
|
||
var afterDes = GC.GetAllocatedBytesForCurrentThread();
|
||
SetupDeserializeAllocBytes = afterDes - beforeDes;
|
||
}
|
||
|
||
// BG consumer: parks on _consumeRequest, reads N bytes from pipe, runs Deserialize<T>(bytes), signals
|
||
// _consumeDone. The Read overlaps with the calling thread's Write through the kernel-pipe; Des happens
|
||
// sequentially after Read completes (raw byte[] needs the full message to deserialize).
|
||
private void ConsumerLoop()
|
||
{
|
||
var ct = _cts.Token;
|
||
try
|
||
{
|
||
while (true)
|
||
{
|
||
_consumeRequest.Wait(ct);
|
||
if (ct.IsCancellationRequested) return;
|
||
_consumeRequest.Reset();
|
||
|
||
try
|
||
{
|
||
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 swallowed
|
||
totalRead += n;
|
||
}
|
||
var result = AcBinaryDeserializer.Deserialize<TestOrder>(bytes, _options);
|
||
if (_captureResult) _lastResult = result;
|
||
}
|
||
catch
|
||
{
|
||
// Swallow — calling thread sees the failure via missing/incorrect _lastResult during VerifyRoundTrip,
|
||
// or the benchmark loop just continues (timing impacted). Production teardown handled in Dispose.
|
||
}
|
||
finally
|
||
{
|
||
_consumeDone.Set();
|
||
}
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Cooperative cancel — Dispose path. Swallow.
|
||
}
|
||
}
|
||
|
||
public void Warmup(int iterations)
|
||
{
|
||
for (var i = 0; i < iterations; i++)
|
||
{
|
||
Serialize();
|
||
}
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Serialize()
|
||
{
|
||
// 2-task streaming pipeline:
|
||
// 1. Calling thread serialises → fresh byte[] (per-iter alloc, matches AcBinaryBenchmark contract).
|
||
// 2. Calling thread hands off expected size + signals consumer task. Consumer task starts Read loop
|
||
// on the pipe (BG thread). Calling thread proceeds to Write the bytes — Read and Write overlap
|
||
// through the kernel-pipe (kernel buffer fills, drains as consumer reads, sender resumes).
|
||
// 3. Calling thread waits for _consumeDone (consumer task finished Read+Des).
|
||
//
|
||
// Note: unlike chunked, raw byte[] cannot do Ser↔Des overlap (Des needs the full bytes before
|
||
// starting). Only Write↔Read overlaps here. The Des sequence on BG thread is: Read full bytes →
|
||
// Des the full graph → signal done. This is the architectural difference between raw and chunked.
|
||
var bytes = AcBinarySerializer.Serialize(_order, _options);
|
||
|
||
_pendingReadSize = bytes.Length;
|
||
_consumeDone.Reset();
|
||
_consumeRequest.Set();
|
||
|
||
_pipeClient.Write(bytes, 0, bytes.Length);
|
||
_pipeClient.Flush();
|
||
|
||
_consumeDone.Wait();
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Deserialize()
|
||
{
|
||
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
|
||
}
|
||
|
||
public bool VerifyRoundTrip()
|
||
{
|
||
// Use the same 2-task streaming path as the benchmark, but capture the result for graph-equality.
|
||
_captureResult = true;
|
||
try
|
||
{
|
||
Serialize();
|
||
var result = _lastResult as TestOrder;
|
||
return result != null && DeepEqualsViaJson(_order, result);
|
||
}
|
||
finally
|
||
{
|
||
_captureResult = false;
|
||
_lastResult = null;
|
||
}
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed) return;
|
||
_disposed = true;
|
||
|
||
// Cancel the consumer task → ConsumerLoop exits its Wait via OperationCanceledException.
|
||
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
|
||
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
|
||
try { _consumerTask.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 { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
|
||
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
|
||
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Raw <c>byte[]</c> over an in-memory cross-thread handoff — NO transport (no NamedPipe, no Pipe, no
|
||
/// Channel<see langword="<T>"/>). Calling thread serialises into a fresh <c>byte[]</c>, hands it to a
|
||
/// background consumer task via a single byte[] slot + MRES pair; the consumer deserialises and signals done.
|
||
///
|
||
/// <para><b>Why this benchmark matters</b>: completes the 2x2 transport × wire-format matrix:</para>
|
||
/// <list type="bullet">
|
||
/// <item><description><b>NamedPipe + Chunked</b> = <see cref="AcBinaryNamedPipeBenchmark"/></description></item>
|
||
/// <item><description><b>NamedPipe + Raw</b> = <see cref="AcBinaryNamedPipeRawByteArrayBenchmark"/></description></item>
|
||
/// <item><description><b>In-memory Pipe + Chunked</b> = <see cref="AcBinaryInMemoryPipeBenchmark"/></description></item>
|
||
/// <item><description><b>In-memory + Raw</b> = THIS row — apples-to-apples baseline for the in-memory chunked row</description></item>
|
||
/// </list>
|
||
/// <para>Side-by-side with <see cref="AcBinaryInMemoryPipeBenchmark"/> this isolates the chunked-streaming
|
||
/// framework's pure CPU cost, with the same in-memory transport (zero kernel involvement) on both sides.
|
||
/// Side-by-side with <see cref="AcBinaryNamedPipeRawByteArrayBenchmark"/> this isolates the kernel-NamedPipe
|
||
/// overhead on the raw-byte[] side.</para>
|
||
/// </summary>
|
||
private sealed class AcBinaryInMemoryRawByteArrayBenchmark : ISerializerBenchmark, IDisposable
|
||
{
|
||
private readonly TestOrder _order;
|
||
private readonly AcBinarySerializerOptions _options;
|
||
private readonly byte[] _serialized; // for SerializedSize reporting only
|
||
|
||
// Long-lived consumer-task infrastructure (Deserialize on BG thread, signaled per iter).
|
||
// No transport — just a byte[] slot for handoff between calling thread and consumer task.
|
||
private readonly CancellationTokenSource _cts;
|
||
private readonly Task _consumerTask;
|
||
private readonly ManualResetEventSlim _consumeRequest = new(false);
|
||
private readonly ManualResetEventSlim _consumeDone = new(false);
|
||
private byte[]? _pendingBytes; // calling thread → consumer task handoff slot
|
||
private object? _lastResult; // captured during VerifyRoundTrip; null in benchmark iters
|
||
private bool _captureResult;
|
||
private bool _disposed;
|
||
|
||
public string Engine => EngineAcBinary;
|
||
public string IoMode => IoInMemoryRaw;
|
||
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=in-memory(raw,2-task)");
|
||
|
||
public AcBinaryInMemoryRawByteArrayBenchmark(TestOrder order, AcBinarySerializerOptions options, string optionsPreset)
|
||
{
|
||
_order = order;
|
||
_options = options;
|
||
OptionsPreset = optionsPreset;
|
||
|
||
_serialized = AcBinarySerializer.Serialize(order, _options);
|
||
|
||
// === SERIALIZE-side setup measurement ===
|
||
// Nothing to set up — calling thread allocates byte[] per iter via AcBinarySerializer.Serialize.
|
||
SetupSerializeAllocBytes = 0;
|
||
|
||
// === DESERIALIZE-side setup measurement ===
|
||
// 1× background consumer-task + 2× MRES (request / done) + cancellation source.
|
||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
|
||
_cts = new CancellationTokenSource();
|
||
_consumerTask = Task.Run(ConsumerLoop);
|
||
var afterDes = GC.GetAllocatedBytesForCurrentThread();
|
||
SetupDeserializeAllocBytes = afterDes - beforeDes;
|
||
}
|
||
|
||
// BG consumer: parks on _consumeRequest, picks up the byte[] from _pendingBytes, runs Deserialize<T>(bytes),
|
||
// signals _consumeDone. Direct in-process handoff — no transport syscall, no buffer copy beyond the byte[]
|
||
// reference itself (zero-copy by reference).
|
||
private void ConsumerLoop()
|
||
{
|
||
var ct = _cts.Token;
|
||
try
|
||
{
|
||
while (true)
|
||
{
|
||
_consumeRequest.Wait(ct);
|
||
if (ct.IsCancellationRequested) return;
|
||
_consumeRequest.Reset();
|
||
|
||
try
|
||
{
|
||
var bytes = _pendingBytes;
|
||
if (bytes != null)
|
||
{
|
||
var result = AcBinaryDeserializer.Deserialize<TestOrder>(bytes, _options);
|
||
if (_captureResult) _lastResult = result;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
// Swallow — see ConsumerLoop in NamedPipe variant for rationale.
|
||
}
|
||
finally
|
||
{
|
||
_consumeDone.Set();
|
||
}
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Cooperative cancel — Dispose path. Swallow.
|
||
}
|
||
}
|
||
|
||
public void Warmup(int iterations)
|
||
{
|
||
for (var i = 0; i < iterations; i++) Serialize();
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Serialize()
|
||
{
|
||
// 2-task in-memory pipeline:
|
||
// 1. Calling thread serialises → fresh byte[] (per-iter alloc, matches AcBinaryBenchmark contract).
|
||
// 2. Calling thread parks the byte[] into _pendingBytes and signals consumer task. Consumer task
|
||
// picks up the reference (zero-copy) and runs Deserialize<T>(bytes).
|
||
// 3. Calling thread waits for _consumeDone (consumer task finished Des).
|
||
//
|
||
// Same architectural limitation as the NamedPipe-raw variant: Des cannot start until full bytes
|
||
// are available. Only the per-iter Ser↔Des thread-handoff overlaps slightly (calling thread starts
|
||
// signalling and waiting while consumer thread takes the byte[]).
|
||
var bytes = AcBinarySerializer.Serialize(_order, _options);
|
||
|
||
_pendingBytes = bytes;
|
||
_consumeDone.Reset();
|
||
_consumeRequest.Set();
|
||
|
||
_consumeDone.Wait();
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||
public void Deserialize()
|
||
{
|
||
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
|
||
}
|
||
|
||
public bool VerifyRoundTrip()
|
||
{
|
||
_captureResult = true;
|
||
try
|
||
{
|
||
Serialize();
|
||
var result = _lastResult as TestOrder;
|
||
return result != null && DeepEqualsViaJson(_order, result);
|
||
}
|
||
finally
|
||
{
|
||
_captureResult = false;
|
||
_lastResult = null;
|
||
}
|
||
}
|
||
|
||
public void Dispose()
|
||
{
|
||
if (_disposed) return;
|
||
_disposed = true;
|
||
|
||
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
|
||
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
|
||
try { _consumerTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
|
||
|
||
try { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
|
||
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
|
||
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Benchmarks MemoryPack via the IBufferWriter overload, allocating a FRESH ArrayBufferWriter on EVERY call.
|
||
/// Apples-to-apples counterpart to AcBinaryFreshBufferWriterBenchmark.
|
||
/// </summary>
|
||
private sealed class MemoryPackFreshBufferWriterBenchmark : ISerializerBenchmark
|
||
{
|
||
private readonly TestOrder _order;
|
||
private readonly byte[] _serialized;
|
||
|
||
public string Engine => EngineMemoryPack;
|
||
public string IoMode => IoBufWrNew;
|
||
public string DispatchMode => ModeSGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
|
||
public string OptionsPreset { get; }
|
||
public int SerializedSize => _serialized.Length;
|
||
public long 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<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 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<byte>(_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<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 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<byte>(_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<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 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<TestOrder>(_serialized, _options);
|
||
|
||
public bool VerifyRoundTrip()
|
||
{
|
||
var json = JsonSerializer.Serialize(_order, _options);
|
||
var roundTripped = JsonSerializer.Deserialize<TestOrder>(json, _options);
|
||
return DeepEqualsViaJson(_order, roundTripped);
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Results
|
||
|
||
private sealed class BenchmarkResult
|
||
{
|
||
public string TestDataName { get; set; } = "";
|
||
public string Engine { get; set; } = "";
|
||
public string IoMode { get; set; } = "";
|
||
public string DispatchMode { get; set; } = "";
|
||
public string OptionsPreset { get; set; } = "";
|
||
/// <summary>True if Serialize() captures a full round-trip and Deserialize() is a no-op
|
||
/// (single-use streaming transports like NamedPipe). Excluded from "Fastest Serialize" / "Fastest Deserialize"
|
||
/// winners rankings; still ranked in "Fastest Round-trip". Display-side: Ser µ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.</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 SetupSerializeAllocBytes { get; set; }
|
||
public long SetupDeserializeAllocBytes { 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)
|
||
{
|
||
// 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<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, '─') + "┐");
|
||
// 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<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 — 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<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(µ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}");
|
||
}
|
||
|
||
/// <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
|
||
}
|