[LOADED_DOCS: 2 files, no new loads]

Refactor: centralize config/state in Configuration.cs

Moved all benchmark configuration, mutable state, and attribute-flag aggregation from Program.cs to a new Configuration.cs static class. Updated all references in Program.cs and related benchmark classes to use Configuration.<value>. Removed the "profiler" CLI mode and its code. Updated README.md to reflect these changes. This improves maintainability and keeps Program.cs focused on orchestration and UX, with no changes to benchmark logic.
This commit is contained in:
Loretta 2026-05-11 21:22:48 +02:00
parent 46b26b7238
commit eb3185c78d
4 changed files with 281 additions and 339 deletions

View File

@ -0,0 +1,133 @@
using AyCode.Core.Serializers.Attributes;
using AyCode.Core.Serializers.Binaries;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
namespace AyCode.Core.Serializers.Console;
/// <summary>
/// Configuration state for the benchmark application. Holds compile-time and runtime constants,
/// per-run mutable settings (WireMode, charset, iteration counts), and the attribute-flag
/// aggregation that drives the per-row Options column. Split out from <c>Program.cs</c> so the
/// entry-point file can focus on UX-flow and benchmark orchestration; everything in this class
/// is config / state — no benchmark logic. Single instance (static class) — the application is
/// a one-shot process, no multi-tenant state isolation needed.
/// </summary>
internal static class Configuration
{
internal const string ResultsDirectory = @"H:\Applications\Aycode\Source\AyCode.Core\Test_Benchmark_Results\Benchmark";
#if AYCODE_NATIVEAOT
internal const string BuildConfiguration = "NativeAOT";
#elif DEBUG
internal const string BuildConfiguration = "Debug";
#else
internal const string BuildConfiguration = "Release";
#endif
#if DEBUG
internal static int WarmupIterations = 0;
internal static int TestIterations = 1;
internal static int BenchmarkSamples = 1; // Debug: single sample, fast iteration
#else
internal static int WarmupIterations = 5000; //10000 — per-phase (Ser AND Des get their own warmup separately)
internal static int TestIterations = 1000; //1000
internal static int BenchmarkSamples = 10;
#endif
// Interactive settings: selected AcBinary wire mode for benchmark runs.
// 1 = Compact, 2 = Fast
internal static WireMode SelectedWireMode = WireMode.Compact;
// Serializer name constants
// Engine identifiers (used in Engine column + comparison logic)
internal const string EngineAcBinary = "AcBinary";
internal const string EngineMemoryPack = "MemoryPack";
#if !AYCODE_NATIVEAOT
internal const string EngineMessagePack = "MessagePack";
#endif
internal const string EngineSystemTextJson = "System.Text.Json";
// IO mode identifiers (used in IO column + comparison logic)
internal const string IoByteArray = "Byte[]";
internal const string IoBufWrReuse = "BufWr reuse";
internal const string IoBufWrNew = "BufWr new";
internal const string IoString = "String";
internal const string IoNamedPipe = "NamedPipe";
internal const string IoNamedPipeRaw = "NamedPipe";
internal const string IoInMemoryPipe = "Pipe(in-mem)";
internal 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.
internal 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.
internal const string ModeSGen = "SGen";
internal const string ModeRuntime = "Runtime";
internal const string ModeHybrid = "Hybrid";
// Per-cell adaptive iteration target wall-clock duration. Each Ser/Des function calibrates its
// own iteration count post-warmup so the sample batch lands in this range — equalizes the
// per-sample window across cells of vastly different per-op cost (Small ~6 ns/op vs Large
// ~140 µs/op). Below ~100 ms Stopwatch precision and OS preempt spikes start to dominate.
internal const int TargetSampleMs = 250;
// CV (coefficient of variation = stddev / mean) threshold above which a row's range is flagged
// as "unstable" in the markdown output (⚠️ marker). 3% is a reasonable noise-floor expectation
// for stabilized in-memory benchmarks; rows above it should be discounted when reading
// sub-3% inter-engine deltas.
internal const double UnstableCVThreshold = 0.03;
// JIT-tier-promotion drain delay between warmup and measurement.
// - JIT mode (RuntimeFeature.IsDynamicCodeCompiled == true): tiered JIT promotes hot methods
// in a background thread; we wait briefly for the queue to drain so the first measurement
// sample doesn't catch a Tier-0 → Tier-1 transition mid-flight.
// - AOT mode (NativeAOT publish): no dynamic compilation happens; the sleep is pure noise.
// 250ms (vs the historical 3000ms) is sufficient for a few-method working set under .NET 9's
// tiered JIT — empirically the queue drains in <100ms for the bench's hot path.
internal static int JitSleep => RuntimeFeature.IsDynamicCodeCompiled ? 250 : 0;
// OptionsPreset values are passed per-instance (constructor argument), not constants —
// each CreateSerializers call line specifies its own preset name (e.g. "FastMode", "NoIntern").
internal 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 the benchmark's per-row Options-column formatter so the 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>
internal static readonly (bool refHandling, bool internString, bool metadata, bool idTracking, bool propertyFilter) AttrFlags
= ScanAttributeFlags();
private static (bool refHandling, bool internString, bool metadata, bool idTracking, bool propertyFilter) ScanAttributeFlags()
{
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, 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),
propertyFilter: attrs.All(a => a!.EnablePropertyFilterFeature));
}
}

View File

@ -32,120 +32,7 @@ namespace AyCode.Core.Serializers.Console;
/// </summary>
public static class Program
{
private const string ResultsDirectory = @"H:\Applications\Aycode\Source\AyCode.Core\Test_Benchmark_Results\Benchmark";
#if AYCODE_NATIVEAOT
private const string BuildConfiguration = "NativeAOT";
#elif DEBUG
private const string BuildConfiguration = "Debug";
#else
private const string BuildConfiguration = "Release";
#endif
#if DEBUG
private static int WarmupIterations = 0;
private static int TestIterations = 1;
private static int BenchmarkSamples = 1; // Debug: single sample, fast iteration
#else
private static int WarmupIterations = 5000; //10000 — per-phase (Ser AND Des get their own warmup separately)
private static int TestIterations = 1000; //1000
private static int BenchmarkSamples = 10;
#endif
// Interactive settings: selected AcBinary wire mode for benchmark runs.
// 1 = Compact, 2 = Fast
private static WireMode SelectedWireMode = WireMode.Compact;
// Serializer name constants
// Engine identifiers (used in Engine column + comparison logic)
private const string EngineAcBinary = "AcBinary";
private const string EngineMemoryPack = "MemoryPack";
#if !AYCODE_NATIVEAOT
private const string EngineMessagePack = "MessagePack";
#endif
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";
// Per-cell adaptive iteration target wall-clock duration. Each Ser/Des function calibrates its
// own iteration count post-warmup so the sample batch lands in this range — equalizes the
// per-sample window across cells of vastly different per-op cost (Small ~6 ns/op vs Large
// ~140 µs/op). Below ~100 ms Stopwatch precision and OS preempt spikes start to dominate.
private const int TargetSampleMs = 250;
// CV (coefficient of variation = stddev / mean) threshold above which a row's range is flagged
// as "unstable" in the markdown output (⚠️ marker). 3% is a reasonable noise-floor expectation
// for stabilized in-memory benchmarks; rows above it should be discounted when reading
// sub-3% inter-engine deltas.
private const double UnstableCVThreshold = 0.03;
// JIT-tier-promotion drain delay between warmup and measurement.
// - JIT mode (RuntimeFeature.IsDynamicCodeCompiled == true): tiered JIT promotes hot methods
// in a background thread; we wait briefly for the queue to drain so the first measurement
// sample doesn't catch a Tier-0 → Tier-1 transition mid-flight.
// - AOT mode (NativeAOT publish): no dynamic compilation happens; the sleep is pure noise.
// 250ms (vs the historical 3000ms) is sufficient for a few-method working set under .NET 9's
// tiered JIT — empirically the queue drains in <100ms for the bench's hot path.
private static int JitSleep => RuntimeFeature.IsDynamicCodeCompiled ? 250 : 0;
// 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, bool propertyFilter) _attrFlags
= ScanAcBinaryAttributeFlags();
private static (bool refHandling, bool internString, bool metadata, bool idTracking, bool propertyFilter) 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, 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),
propertyFilter: attrs.All(a => a!.EnablePropertyFilterFeature));
}
// Configuration (constants, mutable state, attribute-flag aggregation) → Configuration.cs
/// <summary>
/// Common Options-column formatter for every AcBinary serializer benchmark row. Renders the
@ -163,16 +50,16 @@ public static class Program
var propFilterOpt = options.PropertyFilter == null ? "None" : "Set";
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), " +
$"PropertyFilter={propFilterOpt}(opt) | {_attrFlags.propertyFilter} (attr), " +
$"RefHandling={options.ReferenceHandling}(opt) | {Configuration.AttrFlags.refHandling} (attr), " +
$"Interning={options.UseStringInterning}(opt) | {Configuration.AttrFlags.internString} (attr), " +
$"Metadata={options.UseMetadata}(opt) | {Configuration.AttrFlags.metadata} (attr), " +
$"PropertyFilter={propFilterOpt}(opt) | {Configuration.AttrFlags.propertyFilter} (attr), " +
$"SGen={options.UseGeneratedCode}, " +
$"Compression={options.UseCompression}{extra}";
}
/// <summary>
/// Returns MemoryPack serializer options aligned with <see cref="SelectedWireMode"/> for a fair
/// Returns MemoryPack serializer options aligned with <see cref="Configuration.SelectedWireMode"/> for a fair
/// apples-to-apples wire-format comparison:
/// <list type="bullet">
/// <item><see cref="WireMode.Compact"/> → <see cref="MemoryPackSerializerOptions.Default"/> (UTF-8) — both
@ -185,15 +72,15 @@ public static class Program
/// the encoding-family difference, NOT an AcBinary-specific overhead.
/// </summary>
private static MemoryPackSerializerOptions GetMemPackOptions() =>
SelectedWireMode == WireMode.Fast
Configuration.SelectedWireMode == WireMode.Fast
? MemoryPackSerializerOptions.Utf16
: MemoryPackSerializerOptions.Default;
/// <summary>
/// Converts a total-time (in ms across <see cref="TestIterations"/>) into per-operation microseconds.
/// Converts a total-time (in ms across <see cref="Configuration.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
/// independent (e.g. switching <c>Configuration.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)]
@ -202,7 +89,7 @@ public static class Program
/// Converts a total-time (in ms across <paramref name="iterations"/>) into per-operation microseconds.
/// Per-op µs is the iter-independent unit: 1000 iter and 50000 iter of the same operation should
/// produce the same per-op µs (within noise). Necessary because per-cell adaptive iteration makes
/// <c>iterations</c> a per-row property — there is no longer a single global TestIterations to divide by.
/// <c>iterations</c> a per-row property — there is no longer a single global Configuration.TestIterations to divide by.
/// </summary>
private static double ToPerOpMicros(double totalMs, int iterations) => iterations > 0 ? totalMs / iterations * 1000.0 : 0;
@ -271,7 +158,7 @@ public static class Program
/// <summary>
/// Formats a per-op micros value with its inter-sample range and CV-threshold marker as
/// <c>"26.86 (24.5..29.1)"</c> or <c>"26.86 (24.5..29.1) ⚠5.2%"</c>. Median first, range in parentheses,
/// CV warning suffix only when CV > <see cref="UnstableCVThreshold"/>. When min == max == median
/// CV warning suffix only when CV > <see cref="Configuration.UnstableCVThreshold"/>. When min == max == median
/// (single-sample / Debug / quick mode), collapses to bare median to avoid visual clutter.
/// All time inputs are total-batch milliseconds; <paramref name="iterations"/> is the per-row iter
/// count (post-adaptive-calibration).
@ -292,7 +179,7 @@ public static class Program
if (medianMs > 0 && stdDevMs > 0)
{
var cv = stdDevMs / medianMs;
if (cv > UnstableCVThreshold)
if (cv > Configuration.UnstableCVThreshold)
{
var cvPct = (cv * 100).ToString("F1", inv);
return $"{range} ⚠️{cvPct}%";
@ -324,7 +211,7 @@ public static class Program
if (args.Length > 0)
{
if (!TryParseCliArgs(args, out var layer, out var opMode, out var serializerMode))
return; // profiler mode (already ran) or invalid args
return; // invalid args
RunBenchmark(layer, opMode, serializerMode);
return;
@ -350,8 +237,7 @@ public static class Program
/// <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.
/// 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)
{
@ -361,19 +247,12 @@ public static class Program
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;
Configuration.WarmupIterations = 5;
Configuration.TestIterations = 100;
Configuration.BenchmarkSamples = 3;
layer = "all";
}
else if (arg is "core" or "comprehensive" or "edge" or "all"
@ -424,7 +303,7 @@ public static class Program
// randomly inflate samples by 5-15%.
// Try/finally guarantees the original state is restored even if a benchmark throws — leaving
// a developer machine pinned to one core after a crashed run is a real foot-gun.
// Skipped on Debug single-sample mode (BenchmarkSamples <= 1) where stabilization is moot.
// Skipped on Debug single-sample mode (Configuration.BenchmarkSamples <= 1) where stabilization is moot.
var process = Process.GetCurrentProcess();
var origAffinity = (IntPtr)0;
var origPriority = ProcessPriorityClass.Normal;
@ -433,7 +312,7 @@ public static class Program
// ProcessorAffinity is only supported on Windows + Linux (CA1416). macOS would throw at
// runtime; skip the affinity step there but still raise priority class (which IS supported
// on macOS, just less effective for stabilization than affinity pinning).
if (BenchmarkSamples > 1 && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()))
if (Configuration.BenchmarkSamples > 1 && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()))
{
try
{
@ -465,8 +344,8 @@ public static class Program
var allTestDataSets = BenchmarkTestDataProvider.CreateTestDataSets();
var testDataSets = FilterByLayer(allTestDataSets, layer);
System.Console.WriteLine($"Layer: {layer} | OpMode: {opMode} | SerializerMode: {serializerMode} | Charset: {GetCurrentCharsetName()} | Iterations: per-cell adaptive (~{TargetSampleMs} ms target) | Warmup: {WarmupIterations} per phase (Ser/Des isolated) | Samples: {BenchmarkSamples} (median) + pilot discard");
System.Console.WriteLine($"Build: {BuildConfiguration} | .NET: {Environment.Version} | Test Type: {testDataSets.FirstOrDefault()?.TypeName ?? "unknown"} | Test Cells: {testDataSets.Count}/{allTestDataSets.Count}");
System.Console.WriteLine($"Layer: {layer} | OpMode: {opMode} | SerializerMode: {serializerMode} | Charset: {GetCurrentCharsetName()} | Iterations: per-cell adaptive (~{Configuration.TargetSampleMs} ms target) | Warmup: {Configuration.WarmupIterations} per phase (Ser/Des isolated) | Samples: {Configuration.BenchmarkSamples} (median) + pilot discard");
System.Console.WriteLine($"Build: {Configuration.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.
@ -476,7 +355,7 @@ public static class Program
// 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)
if (Configuration.BenchmarkSamples > 1) // skip in DEBUG (single-sample fast iteration)
{
System.Console.WriteLine($"Global JIT pre-warmup ({testDataSets.Count} cells × all serializers, light pass)...");
@ -500,7 +379,7 @@ public static class Program
}
// Let background tiered-JIT compilation drain before we begin measuring.
if (JitSleep > 0) Thread.Sleep(JitSleep);
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
System.Console.WriteLine("✓ Global pre-warmup complete.\n");
}
@ -536,62 +415,6 @@ public static class Program
}
}
/// <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
/// <summary>
@ -644,9 +467,9 @@ public static class Program
// pool churn from Ser, deserialized object graph from Des) so the next phase starts with a quiescent
// heap — GC tier-promotion timing during measurement is then driven only by THAT phase's allocations.
//
// JitSleep per-phase: tiered JIT background promotion drain after each warmup (mode-aware: 0 ms in AOT).
// Configuration.JitSleep per-phase: tiered JIT background promotion drain after each warmup (mode-aware: 0 ms in AOT).
// Each phase's freshly-promoted methods settle before its timing starts.
System.Console.WriteLine($"Running benchmarks (target ~{TargetSampleMs} ms/sample × {BenchmarkSamples} samples median, phase-isolated warmup/measure per Ser/Des)...\n");
System.Console.WriteLine($"Running benchmarks (target ~{Configuration.TargetSampleMs} ms/sample × {Configuration.BenchmarkSamples} samples median, phase-isolated warmup/measure per Ser/Des)...\n");
foreach (var serializer in serializers)
{
@ -676,10 +499,10 @@ public static class Program
if (mode is "all" or "serialize" or "ser")
{
ForceGcCollect();
serializer.WarmupSerialize(WarmupIterations);
if (JitSleep > 0) Thread.Sleep(JitSleep);
serializer.WarmupSerialize(Configuration.WarmupIterations);
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
var rtIter = CalibrateIterations(() => serializer.Serialize(), TargetSampleMs);
var rtIter = CalibrateIterations(() => serializer.Serialize(), Configuration.TargetSampleMs);
var (rtMed, rtMin, rtMax, rtStd) = RunTimed(() => serializer.Serialize(), rtIter, $"{groupLabel} [RT timing]");
result.RoundTripTimeMs = rtMed;
result.RoundTripTimeMinMs = rtMin;
@ -694,14 +517,14 @@ public static class Program
}
else
{
// ── Ser phase ── isolated warmup → JitSleep → calibrate → time → alloc; preceded by GC.Collect.
// ── Ser phase ── isolated warmup → Configuration.JitSleep → calibrate → time → alloc; preceded by GC.Collect.
if (mode is "all" or "serialize" or "ser")
{
ForceGcCollect();
serializer.WarmupSerialize(WarmupIterations);
if (JitSleep > 0) Thread.Sleep(JitSleep);
serializer.WarmupSerialize(Configuration.WarmupIterations);
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
var serIter = CalibrateIterations(() => serializer.Serialize(), TargetSampleMs);
var serIter = CalibrateIterations(() => serializer.Serialize(), Configuration.TargetSampleMs);
var (serMed, serMin, serMax, serStd) = RunTimed(() => serializer.Serialize(), serIter, $"{groupLabel} [Ser timing]");
result.SerializeTimeMs = serMed;
result.SerializeTimeMinMs = serMin;
@ -712,16 +535,16 @@ public static class Program
result.SerializeAllocBytesPerOp = MeasureAllocation(() => serializer.Serialize(), serIter, $"{groupLabel} [Ser alloc]");
}
// ── Des phase ── isolated warmup → JitSleep → calibrate → time → alloc; preceded by GC.Collect.
// ── Des phase ── isolated warmup → Configuration.JitSleep → calibrate → time → alloc; preceded by GC.Collect.
// The GC.Collect here is critical: it discards the Ser-phase's write-buffer pool churn so the
// Des-phase's allocation measurement reflects ONLY Des-side allocations (deserialized object graph).
if (mode is "all" or "deserialize" or "des")
{
ForceGcCollect();
serializer.WarmupDeserialize(WarmupIterations);
if (JitSleep > 0) Thread.Sleep(JitSleep);
serializer.WarmupDeserialize(Configuration.WarmupIterations);
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
var desIter = CalibrateIterations(() => serializer.Deserialize(), TargetSampleMs);
var desIter = CalibrateIterations(() => serializer.Deserialize(), Configuration.TargetSampleMs);
var (desMed, desMin, desMax, desStd) = RunTimed(() => serializer.Deserialize(), desIter, $"{groupLabel} [Des timing]");
result.DeserializeTimeMs = desMed;
result.DeserializeTimeMinMs = desMin;
@ -767,7 +590,7 @@ public static class Program
if (serializerMode == "fastestbyte")
{
var fastestByteOptions = AcBinarySerializerOptions.FastMode;
fastestByteOptions.WireMode = SelectedWireMode;
fastestByteOptions.WireMode = Configuration.SelectedWireMode;
return new List<ISerializerBenchmark>
{
@ -790,8 +613,8 @@ public static class Program
// 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;
binaryFastModePipeChunkOnly.WireMode = SelectedWireMode;
binaryFastModePipeChunkOnly.BufferWriterChunkSize = Configuration.PipeChunkSize;
binaryFastModePipeChunkOnly.WireMode = Configuration.SelectedWireMode;
return new List<ISerializerBenchmark>
{
@ -826,18 +649,18 @@ public static class Program
var binaryNoInternOption = AcBinarySerializerOptions.Default;
binaryNoInternOption.UseStringInterning = StringInterningMode.None;
binaryNoInternOption.WireMode = SelectedWireMode;
binaryNoInternOption.WireMode = Configuration.SelectedWireMode;
var binaryDefaultNoSgenOption = AcBinarySerializerOptions.Default;
binaryDefaultNoSgenOption.UseGeneratedCode = false;
binaryDefaultNoSgenOption.WireMode = SelectedWireMode;
binaryDefaultNoSgenOption.WireMode = Configuration.SelectedWireMode;
var binaryFastModeNoSgenOption = AcBinarySerializerOptions.FastMode;
binaryFastModeNoSgenOption.UseGeneratedCode = false;
binaryFastModeNoSgenOption.WireMode = SelectedWireMode;
binaryFastModeNoSgenOption.WireMode = Configuration.SelectedWireMode;
var binaryFastModeOption = AcBinarySerializerOptions.FastMode;
binaryFastModeOption.WireMode = SelectedWireMode;
binaryFastModeOption.WireMode = Configuration.SelectedWireMode;
// 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.
@ -845,20 +668,20 @@ public static class Program
// allocates a fresh ABW. Independent of the AsyncPipe profile (different mechanism: alloc overhead
// vs syscall count).
var binaryFastModeBufWrChunk = AcBinarySerializerOptions.FastMode;
binaryFastModeBufWrChunk.BufferWriterChunkSize = PipeChunkSize;
binaryFastModeBufWrChunk.WireMode = SelectedWireMode;
binaryFastModeBufWrChunk.BufferWriterChunkSize = Configuration.PipeChunkSize;
binaryFastModeBufWrChunk.WireMode = Configuration.SelectedWireMode;
// 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;
binaryFastModePipeChunkInMem.WireMode = SelectedWireMode;
binaryFastModePipeChunkInMem.BufferWriterChunkSize = Configuration.PipeChunkSize;
binaryFastModePipeChunkInMem.WireMode = Configuration.SelectedWireMode;
var defaultOptions = AcBinarySerializerOptions.Default;
defaultOptions.UseStringInterning = StringInterningMode.None;
defaultOptions.ReferenceHandling = ReferenceHandlingMode.OnlyId;
defaultOptions.WireMode = SelectedWireMode;
defaultOptions.WireMode = Configuration.SelectedWireMode;
return new List<ISerializerBenchmark>
{
@ -927,10 +750,10 @@ public static class Program
}
/// <summary>
/// Runs the action <paramref name="iterations"/> times for <see cref="BenchmarkSamples"/> independent samples,
/// Runs the action <paramref name="iterations"/> times for <see cref="Configuration.BenchmarkSamples"/> independent samples,
/// returning the median, min, and max elapsed time. Multi-sample design reduces single-run variance
/// from ~±15% to ~±5% by smoothing transient effects (background activity, thermal/turbo state).
/// When <see cref="BenchmarkSamples"/> &lt;= 1, falls back to single-sample timing (Debug / quick mode).
/// When <see cref="Configuration.BenchmarkSamples"/> &lt;= 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.
@ -948,7 +771,7 @@ public static class Program
/// </summary>
private static (double medianMs, double minMs, double maxMs, double stdDevMs) RunTimed(Action action, int iterations, string? progressLabel = null)
{
var samples = BenchmarkSamples;
var samples = Configuration.BenchmarkSamples;
if (samples <= 1)
{
// Single-sample fast path (Debug or trivial run) — no allocation, no sort, no stddev.
@ -1018,16 +841,16 @@ public static class Program
/// <summary>
/// Per-cell adaptive iteration calibration. Runs a 100-iter measurement after warmup and computes
/// how many iterations are needed to reach <see cref="TargetSampleMs"/> wall-clock per sample.
/// how many iterations are needed to reach <see cref="Configuration.TargetSampleMs"/> wall-clock per sample.
/// Returns iter rounded UP to the nearest 1000, floored at 1000 (the prior fixed minimum) and
/// ceiling-capped at 200_000 (sanity bound for pathologically fast ops). In Debug single-sample mode
/// (<c>BenchmarkSamples &lt;= 1</c>) returns the global <see cref="TestIterations"/> unchanged —
/// (<c>Configuration.BenchmarkSamples &lt;= 1</c>) returns the global <see cref="Configuration.TestIterations"/> unchanged —
/// calibration overhead is unjustified there. Calibration runs OUTSIDE the timed sample loop and
/// does NOT count toward warmup; its sole purpose is to measure per-op cost.
/// </summary>
private static int CalibrateIterations(Action action, int targetMs)
{
if (BenchmarkSamples <= 1) return TestIterations; // Debug fast path
if (Configuration.BenchmarkSamples <= 1) return Configuration.TestIterations; // Debug fast path
GC.Collect();
GC.WaitForPendingFinalizers();
@ -1244,7 +1067,7 @@ public static class Program
System.Console.WriteLine(" [A] All layers");
System.Console.WriteLine(" [F] FastestByte — AcBinary FastMode Byte[] vs MemoryPack Byte[] only (tight optimization loop)");
System.Console.WriteLine(" [P] AsyncPipe — streaming I/O isolation (only AsyncPipe, all test data)");
System.Console.WriteLine($" [S] Settings — Iteration / WireMode (current: {SelectedWireMode})");
System.Console.WriteLine($" [S] Settings — Iteration / WireMode (current: {Configuration.SelectedWireMode})");
System.Console.WriteLine(" [Q] Quit");
System.Console.Write("\nSelection: ");
@ -1270,7 +1093,7 @@ public static class Program
/// <summary>
/// Settings sub-menu — prompts for Warmup / Iterations / Samples values. Empty input keeps the
/// current value. Validation: WarmupIterations ≥ 0; TestIterations ≥ 1; BenchmarkSamples ≥ 1.
/// current value. Validation: Configuration.WarmupIterations ≥ 0; Configuration.TestIterations ≥ 1; Configuration.BenchmarkSamples ≥ 1.
/// Returns to the caller (which re-displays the main menu).
/// </summary>
private static void ShowSettingsMenu()
@ -1282,7 +1105,7 @@ public static class Program
System.Console.WriteLine("Settings");
System.Console.WriteLine("─────────────────────────────────────────────");
System.Console.WriteLine(" [1] Iteration — Warmup / Iterations / Samples");
System.Console.WriteLine($" [2] WireMode — current: {SelectedWireMode}");
System.Console.WriteLine($" [2] WireMode — current: {Configuration.SelectedWireMode}");
System.Console.WriteLine($" [3] Charset — current: {GetCurrentCharsetName()}");
System.Console.WriteLine(" [B] Back");
System.Console.Write("\nSelection: ");
@ -1395,12 +1218,12 @@ public static class Program
System.Console.WriteLine("─────────────────────────────────────────────");
System.Console.WriteLine();
WarmupIterations = PromptInt("WarmupIterations", WarmupIterations, min: 0);
TestIterations = PromptInt("TestIterations ", TestIterations, min: 1);
BenchmarkSamples = PromptInt("BenchmarkSamples", BenchmarkSamples, min: 1);
Configuration.WarmupIterations = PromptInt("Configuration.WarmupIterations", Configuration.WarmupIterations, min: 0);
Configuration.TestIterations = PromptInt("Configuration.TestIterations ", Configuration.TestIterations, min: 1);
Configuration.BenchmarkSamples = PromptInt("Configuration.BenchmarkSamples", Configuration.BenchmarkSamples, min: 1);
System.Console.WriteLine();
System.Console.WriteLine($"✓ Iteration settings updated: Warmup={WarmupIterations} | Iterations={TestIterations} | Samples={BenchmarkSamples}");
System.Console.WriteLine($"✓ Iteration settings updated: Warmup={Configuration.WarmupIterations} | Iterations={Configuration.TestIterations} | Samples={Configuration.BenchmarkSamples}");
}
private static void ShowWireModeSettingsMenu()
@ -1411,7 +1234,7 @@ public static class Program
System.Console.WriteLine("─────────────────────────────────────────────");
System.Console.WriteLine("WireMode settings");
System.Console.WriteLine("─────────────────────────────────────────────");
System.Console.WriteLine($"Current: {SelectedWireMode}");
System.Console.WriteLine($"Current: {Configuration.SelectedWireMode}");
System.Console.WriteLine(" [1] Compact");
System.Console.WriteLine(" [2] Fast");
System.Console.WriteLine(" [B] Back");
@ -1423,11 +1246,11 @@ public static class Program
switch (char.ToLower(key))
{
case '1':
SelectedWireMode = WireMode.Compact;
Configuration.SelectedWireMode = WireMode.Compact;
System.Console.WriteLine("✓ WireMode set to Compact");
return;
case '2':
SelectedWireMode = WireMode.Fast;
Configuration.SelectedWireMode = WireMode.Fast;
System.Console.WriteLine("✓ WireMode set to Fast");
return;
case 'b':
@ -1515,8 +1338,8 @@ public static class Program
/// 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;
/// <summary>Combined warmup (Ser + Deser interleaved). Kept for backward-compat with <c>ProfilerMode</c>
/// and other callers that don't need phase-separated warmup. The benchmark loop prefers the split
/// <summary>Combined warmup (Ser + Deser interleaved). Currently unused — kept as a legacy entry point
/// for any external caller that still wants single-call warmup. The benchmark loop uses the split
/// <see cref="WarmupSerialize"/> + <see cref="WarmupDeserialize"/> pair for cache-isolated measurements.</summary>
void Warmup(int iterations);
@ -1549,9 +1372,9 @@ public static class Program
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 Engine => Configuration.EngineAcBinary;
public string IoMode => Configuration.IoByteArray;
public string DispatchMode => _options.UseGeneratedCode ? Configuration.ModeSGen : Configuration.ModeRuntime;
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes => 0;
@ -1606,9 +1429,9 @@ public static class Program
private readonly MemoryPackSerializerOptions _options;
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 Engine => Configuration.EngineMemoryPack;
public string IoMode => Configuration.IoByteArray;
public string DispatchMode => Configuration.ModeSGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes => 0;
@ -1657,9 +1480,9 @@ public static class Program
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 Engine => Configuration.EngineMessagePack;
public string IoMode => Configuration.IoByteArray;
public string DispatchMode => Configuration.ModeSGen; // MessagePack uses [MessagePackObject] source-generated formatters (StandardResolver)
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes => 0;
@ -1722,9 +1545,9 @@ public static class Program
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 Engine => Configuration.EngineAcBinary;
public string IoMode => Configuration.IoBufWrNew;
public string DispatchMode => _options.UseGeneratedCode ? Configuration.ModeSGen : Configuration.ModeRuntime;
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes => 0;
@ -1830,9 +1653,9 @@ public static class Program
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 Engine => Configuration.EngineAcBinary;
public string IoMode => Configuration.IoNamedPipe;
public string DispatchMode => _options.UseGeneratedCode ? Configuration.ModeSGen : Configuration.ModeRuntime;
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes { get; }
@ -2053,9 +1876,9 @@ public static class Program
private bool _captureResult;
private bool _disposed;
public string Engine => EngineAcBinary;
public string IoMode => IoInMemoryPipe;
public string DispatchMode => _options.UseGeneratedCode ? ModeSGen : ModeRuntime;
public string Engine => Configuration.EngineAcBinary;
public string IoMode => Configuration.IoInMemoryPipe;
public string DispatchMode => _options.UseGeneratedCode ? Configuration.ModeSGen : Configuration.ModeRuntime;
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes { get; }
@ -2244,9 +2067,9 @@ public static class Program
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 Engine => Configuration.EngineAcBinary;
public string IoMode => Configuration.IoNamedPipeRaw;
public string DispatchMode => _options.UseGeneratedCode ? Configuration.ModeSGen : Configuration.ModeRuntime;
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes { get; }
@ -2449,9 +2272,9 @@ public static class Program
private bool _captureResult;
private bool _disposed;
public string Engine => EngineAcBinary;
public string IoMode => IoInMemoryRaw;
public string DispatchMode => _options.UseGeneratedCode ? ModeSGen : ModeRuntime;
public string Engine => Configuration.EngineAcBinary;
public string IoMode => Configuration.IoInMemoryRaw;
public string DispatchMode => _options.UseGeneratedCode ? Configuration.ModeSGen : Configuration.ModeRuntime;
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes { get; }
@ -2593,9 +2416,9 @@ public static class Program
private readonly MemoryPackSerializerOptions _options;
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 Engine => Configuration.EngineMemoryPack;
public string IoMode => Configuration.IoBufWrNew;
public string DispatchMode => Configuration.ModeSGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes => 0;
@ -2647,9 +2470,9 @@ public static class Program
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 Engine => Configuration.EngineAcBinary;
public string IoMode => Configuration.IoBufWrReuse;
public string DispatchMode => _options.UseGeneratedCode ? Configuration.ModeSGen : Configuration.ModeRuntime;
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes { get; }
@ -2718,9 +2541,9 @@ public static class Program
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 Engine => Configuration.EngineMemoryPack;
public string IoMode => Configuration.IoBufWrReuse;
public string DispatchMode => Configuration.ModeSGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes { get; }
@ -2779,9 +2602,9 @@ public static class Program
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 Engine => Configuration.EngineSystemTextJson;
public string IoMode => Configuration.IoString;
public string DispatchMode => Configuration.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;
@ -2798,7 +2621,7 @@ public static class Program
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
};
_serialized = JsonSerializer.Serialize(order, _options);
_serializedUtf8 = Utf8NoBom.GetBytes(_serialized);
_serializedUtf8 = Configuration.Utf8NoBom.GetBytes(_serialized);
}
public void Warmup(int iterations)
@ -2854,11 +2677,11 @@ public static class Program
public double DeserializeTimeMinMs { get; set; }
public double DeserializeTimeMaxMs { get; set; }
// Sample-population stddev (ms). Used by FormatMicrosWithRange to compute CV (stddev/mean)
// and emit the ⚠️ marker on rows above UnstableCVThreshold. 0 in single-sample mode.
// and emit the ⚠️ marker on rows above Configuration.UnstableCVThreshold. 0 in single-sample mode.
public double SerializeTimeStdDevMs { get; set; }
public double DeserializeTimeStdDevMs { get; set; }
// Per-row adaptive iteration count (post-CalibrateIterations). Each Ser and Des function calibrates
// independently to land its sample window at ~TargetSampleMs; per-op µs is then iter-independent
// independently to land its sample window at ~Configuration.TargetSampleMs; per-op µs is then iter-independent
// (`SerializeTimeMs / SerializeIterations * 1000`). For round-trip-only rows (NamedPipe etc.),
// RoundTripIterations carries the calibrated iter count; SerializeIterations and DeserializeIterations
// stay 0 (Ser and Des are not separately measurable on those rows).
@ -2923,10 +2746,10 @@ public static class Program
// Order by per-op µs (iter-independent) — rows may have different iter counts post-calibration.
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => RtPerOp(r)).ToList();
// Baseline switched MessagePack → MemoryPack: MemoryPack is the SOTA performance leader.
var memPackResult = testResults.FirstOrDefault(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray));
var memPackResult = testResults.FirstOrDefault(r => (r.Engine == Configuration.EngineMemoryPack && r.IoMode == Configuration.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));
var acBinaryResult = testResults.FirstOrDefault(r => (r.Engine == Configuration.EngineAcBinary && r.IoMode == Configuration.IoByteArray && r.DispatchMode == Configuration.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).
@ -2947,8 +2770,8 @@ public static class Program
// 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 isHighlighted = (result.Engine == Configuration.EngineMemoryPack && result.IoMode == Configuration.IoByteArray)
|| (result.Engine == Configuration.EngineAcBinary && result.IoMode == Configuration.IoByteArray && result.DispatchMode == Configuration.ModeSGen);
var prefix = isHighlighted ? "│►" : "│ ";
var suffix = isHighlighted ? "◄│" : " │";
@ -2956,7 +2779,7 @@ public static class Program
// Color logic: Green = winner (faster), Red = loser (slower)
if (isHighlighted && memPackResult != null && acBinaryResult != null)
{
var isMemPack = (result.Engine == EngineMemoryPack && result.IoMode == IoByteArray);
var isMemPack = (result.Engine == Configuration.EngineMemoryPack && result.IoMode == Configuration.IoByteArray);
var memPackFaster = RtPerOp(memPackResult) < RtPerOp(acBinaryResult);
if (isMemPack)
@ -3103,13 +2926,13 @@ public static class Program
// 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 memPackSerResults = results.Where(r => (r.Engine == Configuration.EngineMemoryPack && r.IoMode == Configuration.IoByteArray) && r.SerializeTimeMs > 0).ToList();
var memPackDesResults = results.Where(r => (r.Engine == Configuration.EngineMemoryPack && r.IoMode == Configuration.IoByteArray) && r.DeserializeTimeMs > 0).ToList();
var memPackRtResults = results.Where(r => (r.Engine == Configuration.EngineMemoryPack && r.IoMode == Configuration.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();
var acBinarySerResults = results.Where(r => (r.Engine == Configuration.EngineAcBinary && r.IoMode == Configuration.IoByteArray && r.DispatchMode == Configuration.ModeSGen) && r.SerializeTimeMs > 0).ToList();
var acBinaryDesResults = results.Where(r => (r.Engine == Configuration.EngineAcBinary && r.IoMode == Configuration.IoByteArray && r.DispatchMode == Configuration.ModeSGen) && r.DeserializeTimeMs > 0).ToList();
var acBinaryRtResults = results.Where(r => (r.Engine == Configuration.EngineAcBinary && r.IoMode == Configuration.IoByteArray && r.DispatchMode == Configuration.ModeSGen) && r.RoundTripTimeMs > 0).ToList();
// Skip comparison if no data available
if (memPackRtResults.Count == 0 || acBinaryRtResults.Count == 0)
@ -3128,8 +2951,8 @@ public static class Program
// - Median of per-cell ratios — outlier-resistant.
// The geo/median variants surface when a single cell dominates the arithmetic average
// (typical when one cell's µs-per-op is an order of magnitude larger than the others).
var sizeAcResults = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen)).ToList();
var sizeMpResults = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray)).ToList();
var sizeAcResults = results.Where(r => (r.Engine == Configuration.EngineAcBinary && r.IoMode == Configuration.IoByteArray && r.DispatchMode == Configuration.ModeSGen)).ToList();
var sizeMpResults = results.Where(r => (r.Engine == Configuration.EngineMemoryPack && r.IoMode == Configuration.IoByteArray)).ToList();
var serStats = ComputeOverallStats(acBinarySerResults, memPackSerResults, SerPerOp);
var desStats = ComputeOverallStats(acBinaryDesResults, memPackDesResults, DesPerOp);
@ -3183,12 +3006,12 @@ public static class Program
private static void SaveResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
{
Directory.CreateDirectory(ResultsDirectory);
Directory.CreateDirectory(Configuration.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");
var baseFileName = $"Console.FullBenchmark_{Configuration.BuildConfiguration}_{timestamp}";
var logFilePath = Path.Combine(Configuration.ResultsDirectory, $"{baseFileName}.log");
var outputFilePath = Path.Combine(Configuration.ResultsDirectory, $"{baseFileName}.output");
// Save binary output to separate .output file
var largeTestData = testDataSets.FirstOrDefault(t => t.Name.StartsWith("Large"));
@ -3208,7 +3031,7 @@ public static class Program
outputSb.AppendLine("Hex dump:");
outputSb.AppendLine(FormatHexDump(serializedBytes));
File.WriteAllText(outputFilePath, outputSb.ToString(), Utf8NoBom);
File.WriteAllText(outputFilePath, outputSb.ToString(), Configuration.Utf8NoBom);
System.Console.WriteLine($"✓ Binary output saved to: {outputFilePath}");
}
@ -3217,10 +3040,10 @@ public static class Program
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($"║ Build: {Configuration.BuildConfiguration}".PadRight(100) + "║");
sb.AppendLine($"║ Charset: {GetCurrentCharsetName()}".PadRight(100) + "║");
sb.AppendLine($"║ Iterations: per-cell adaptive (~{TargetSampleMs} ms target)".PadRight(100) + "║");
sb.AppendLine($"║ Samples: {BenchmarkSamples} (median) + 1 pilot discarded".PadRight(100) + "║");
sb.AppendLine($"║ Iterations: per-cell adaptive (~{Configuration.TargetSampleMs} ms target)".PadRight(100) + "║");
sb.AppendLine($"║ Samples: {Configuration.BenchmarkSamples} (median) + 1 pilot discarded".PadRight(100) + "║");
sb.AppendLine($"║ Test Type: {testDataSets.FirstOrDefault()?.TypeName ?? "unknown"}".PadRight(100) + "║");
sb.AppendLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
sb.AppendLine();
@ -3262,10 +3085,10 @@ public static class Program
{
// Order by per-op µs (iter-independent) — rows may have different iter counts post-calibration.
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => RtPerOp(r)).ToList();
var memPackResult = testResults.FirstOrDefault(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray));
var memPackResult = testResults.FirstOrDefault(r => (r.Engine == Configuration.EngineMemoryPack && r.IoMode == Configuration.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));
var acBinaryResult = testResults.FirstOrDefault(r => (r.Engine == Configuration.EngineAcBinary && r.IoMode == Configuration.IoByteArray && r.DispatchMode == Configuration.ModeSGen));
sb.AppendLine();
sb.AppendLine($"--- {testData.DisplayName} ---");
@ -3275,7 +3098,7 @@ public static class Program
var rank = 1;
foreach (var result in testResults)
{
var isHighlighted = ((result.Engine == EngineMemoryPack || result.Engine == EngineAcBinary) && result.IoMode == IoByteArray);
var isHighlighted = ((result.Engine == Configuration.EngineMemoryPack || result.Engine == Configuration.EngineAcBinary) && result.IoMode == Configuration.IoByteArray);
var prefix = isHighlighted ? "► " : " ";
var size = $"{result.SerializedSize:N0}";
@ -3312,13 +3135,13 @@ public static class Program
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 memPackSerResults2 = results.Where(r => (r.Engine == Configuration.EngineMemoryPack && r.IoMode == Configuration.IoByteArray) && r.SerializeTimeMs > 0).ToList();
var memPackDesResults2 = results.Where(r => (r.Engine == Configuration.EngineMemoryPack && r.IoMode == Configuration.IoByteArray) && r.DeserializeTimeMs > 0).ToList();
var memPackRtResults2 = results.Where(r => (r.Engine == Configuration.EngineMemoryPack && r.IoMode == Configuration.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();
var acBinarySerResults2 = results.Where(r => (r.Engine == Configuration.EngineAcBinary && r.IoMode == Configuration.IoByteArray && r.DispatchMode == Configuration.ModeSGen) && r.SerializeTimeMs > 0).ToList();
var acBinaryDesResults2 = results.Where(r => (r.Engine == Configuration.EngineAcBinary && r.IoMode == Configuration.IoByteArray && r.DispatchMode == Configuration.ModeSGen) && r.DeserializeTimeMs > 0).ToList();
var acBinaryRtResults2 = results.Where(r => (r.Engine == Configuration.EngineAcBinary && r.IoMode == Configuration.IoByteArray && r.DispatchMode == Configuration.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).
@ -3326,18 +3149,18 @@ public static class Program
if (memPackRtResults2.Count == 0 || acBinaryRtResults2.Count == 0)
{
sb.AppendLine(" (Comparison requires both serialize and deserialize data)");
File.WriteAllText(logFilePath, sb.ToString(), Utf8NoBom);
File.WriteAllText(logFilePath, sb.ToString(), Configuration.Utf8NoBom);
System.Console.WriteLine($"✓ Results saved to: {logFilePath}");
var llmFilePathEarly = Path.Combine(ResultsDirectory, $"{baseFileName}.LLM");
var llmFilePathEarly = Path.Combine(Configuration.ResultsDirectory, $"{baseFileName}.LLM");
SaveLlmResults(llmFilePathEarly, results, testDataSets);
return;
}
// Per-cell-paired aggregation: arithmetic / geometric / median. See PrintSummary's parallel
// block + the OverallStats record for the rationale (per-cell ratio vs magnitude-weighted mean).
var sizeAcResults2 = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen)).ToList();
var sizeMpResults2 = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray)).ToList();
var sizeAcResults2 = results.Where(r => (r.Engine == Configuration.EngineAcBinary && r.IoMode == Configuration.IoByteArray && r.DispatchMode == Configuration.ModeSGen)).ToList();
var sizeMpResults2 = results.Where(r => (r.Engine == Configuration.EngineMemoryPack && r.IoMode == Configuration.IoByteArray)).ToList();
AppendOverallLine(sb, "Serialize", "µs/op", ComputeOverallStats(acBinarySerResults2, memPackSerResults2, SerPerOp));
AppendOverallLine(sb, "Ser Alloc", "B/op", ComputeOverallStats(acBinarySerResults2, memPackSerResults2, r => r.SerializeAllocBytesPerOp), "F0");
@ -3346,11 +3169,11 @@ public static class Program
AppendOverallLine(sb, "Round-trip", "µs/op", ComputeOverallStats(acBinaryRtResults2, memPackRtResults2, RtPerOp));
AppendOverallLine(sb, "Size", "B", ComputeOverallStats(sizeAcResults2, sizeMpResults2, r => r.SerializedSize), "F0");
File.WriteAllText(logFilePath, sb.ToString(), Utf8NoBom);
File.WriteAllText(logFilePath, sb.ToString(), Configuration.Utf8NoBom);
System.Console.WriteLine($"✓ Results saved to: {logFilePath}");
// Save LLM-optimized results
var llmFilePath = Path.Combine(ResultsDirectory, $"{baseFileName}.LLM");
var llmFilePath = Path.Combine(Configuration.ResultsDirectory, $"{baseFileName}.LLM");
SaveLlmResults(llmFilePath, results, testDataSets);
}
@ -3358,8 +3181,8 @@ public static class Program
{
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($"Charset: {GetCurrentCharsetName()} | Iterations: per-cell adaptive (target ~{TargetSampleMs} ms/sample) | Warmup: {WarmupIterations} per phase (Ser/Des isolated) | Samples: {BenchmarkSamples} (median) + 1 pilot discarded | .NET: {Environment.Version} | TestType: {testTypeName} | UnstableCV threshold: {UnstableCVThreshold * 100:F0}%");
sb.AppendLine($"# AcBinary Benchmark {Configuration.BuildConfiguration} {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
sb.AppendLine($"Charset: {GetCurrentCharsetName()} | Iterations: per-cell adaptive (target ~{Configuration.TargetSampleMs} ms/sample) | Warmup: {Configuration.WarmupIterations} per phase (Ser/Des isolated) | Samples: {Configuration.BenchmarkSamples} (median) + 1 pilot discarded | .NET: {Environment.Version} | TestType: {testTypeName} | UnstableCV threshold: {Configuration.UnstableCVThreshold * 100:F0}%");
sb.AppendLine("Baseline: MemoryPack (Byte[]) (SOTA reference) | Verified: round-trip correctness checked once per cell before warmup");
// Options summary
@ -3379,7 +3202,7 @@ public static class Program
// Flat results table sorted by test data then round-trip (now includes Alloc + Iter columns).
// Iter column shows per-row Ser/Des iteration counts (post-adaptive-calibration), so the reader
// can verify that each cell's batch sample landed near the TargetSampleMs window.
// can verify that each cell's batch sample landed near the Configuration.TargetSampleMs window.
sb.AppendLine();
sb.AppendLine("## Results");
sb.AppendLine();
@ -3429,8 +3252,8 @@ public static class Program
// arith mean is magnitude-weighted (Large cell dominates); geo/median are per-cell-equal
// signals. Adding this lets an LLM diagnose whether a headline delta is a real overall
// win/loss or a single-cell artifact.
var memPackByteArrayResults = results.Where(r => r.Engine == EngineMemoryPack && r.IoMode == IoByteArray).ToList();
var acBinarySGenByteArrayResults = results.Where(r => r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen).ToList();
var memPackByteArrayResults = results.Where(r => r.Engine == Configuration.EngineMemoryPack && r.IoMode == Configuration.IoByteArray).ToList();
var acBinarySGenByteArrayResults = results.Where(r => r.Engine == Configuration.EngineAcBinary && r.IoMode == Configuration.IoByteArray && r.DispatchMode == Configuration.ModeSGen).ToList();
var memPackSerResultsLlm = memPackByteArrayResults.Where(r => r.SerializeTimeMs > 0).ToList();
var memPackDesResultsLlm = memPackByteArrayResults.Where(r => r.DeserializeTimeMs > 0).ToList();
var memPackRtResultsLlm = memPackByteArrayResults.Where(r => r.RoundTripTimeMs > 0).ToList();
@ -3455,7 +3278,7 @@ public static class Program
sb.AppendLine("```");
}
File.WriteAllText(filePath, sb.ToString(), Utf8NoBom);
File.WriteAllText(filePath, sb.ToString(), Configuration.Utf8NoBom);
System.Console.WriteLine($"✓ LLM results saved to: {filePath}");
}

View File

@ -12,7 +12,7 @@ Standalone benchmark console application for comparing serializer performance. T
## Key Files
- **`Program.cs`** — Benchmark runner. Modes: `all` (default), `quick` (fewer iterations), `serialize`, `deserialize`, `profiler` (memory profiler warmup). Outputs results to `Test_Benchmark_Results/Benchmark/`. Iterations: 5000 warmup + 1000 test (Release), 0+1 (Debug).
- **`Program.cs`** — Benchmark runner. Modes: `all` (default), `quick` (fewer iterations), `serialize`, `deserialize`. Outputs results to `Test_Benchmark_Results/Benchmark/`. Iterations: 5000 warmup + 1000 test (Release), 0+1 (Debug).
- **`BenchmarkTestDataProvider.cs`** — Test data factory producing 5 data shapes:
- Small (2x2x2x2), Medium (3x3x3x4), Large (5x5x5x10)
- Repeated Strings (10 items, string deduplication testing)

View File

@ -78,20 +78,6 @@ public static class BenchmarkTestDataProvider
};
}
public static TestOrder CreateProfilerOrder()
{
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedUser = TestDataFactory.CreateUser("shareduser");
return TestDataFactory.CreateOrder(
itemCount: 3,
palletsPerItem: 3,
measurementsPerPallet: 3,
pointsPerMeasurement: 4,
sharedTag: sharedTag,
sharedUser: sharedUser);
}
private static TestDataSet CreateSmallTestData(bool resetId = true)
{
if (resetId) TestDataFactory.ResetIdCounter();