Refactor: move benchmark engines to shared scenarios

Major refactor: all benchmark engine implementations, enums, and helpers moved from Console project to AyCode.Core.Benchmarks.Workloads.Scenarios for unified cross-runner use. BenchmarkResult and reporting logic moved to AyCode.Core.Benchmarks.Reporting. Attribute-flag aggregation centralized in BenchmarkOptions. Updated all usages, project references, and SGen codegen for ref-handling. Prepares codebase for shared reporting and future extensibility.
This commit is contained in:
Loretta 2026-05-15 19:55:52 +02:00
parent d9ab3940eb
commit 9dcb62ce23
30 changed files with 387 additions and 258 deletions

View File

@ -103,7 +103,8 @@
"Bash(awk 'NR < 69 || NR > 1257 { print }' AcBinarySourceGenerator.cs)",
"Bash(awk 'NR == 68 { print; print \"\"; print \" // Writer-side emit pass \\(GenWriter + GenScanProperties + EmitProp + EmitScan* + EmitDirect*Write +\"; print \" // EmitSkip + EmitVal + EmitMarkerless + helpers\\) moved to AcBinarySourceGenerator.GenWriter.cs.\"; next } { print }' tmp.cs)",
"Bash(awk 'NR < 73 || NR > 930 { print }' AcBinarySourceGenerator.cs)",
"Bash(awk 'NR == 72 { print; print \" // Reader-side emit pass \\(GenReader + EmitReadProp + EmitRead* helpers\\) moved to\"; print \" // AcBinarySourceGenerator.GenReader.cs.\"; next } { print }' tmp.cs)"
"Bash(awk 'NR == 72 { print; print \" // Reader-side emit pass \\(GenReader + EmitReadProp + EmitRead* helpers\\) moved to\"; print \" // AcBinarySourceGenerator.GenReader.cs.\"; next } { print }' tmp.cs)",
"Bash(rm -f \"AyCode.Core.Serializers.Console/Benchmarks/\"*.cs && rmdir \"AyCode.Core.Serializers.Console/Benchmarks\" && rm -f \"AyCode.Core.Serializers.Console/BenchmarkResult.cs\" && echo \"Deleted Console-side moved files.\")"
]
}
}

View File

@ -17,6 +17,7 @@
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
<PackageReference Include="MemoryPack" Version="1.21.4" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers" Version="18.6.37110.2" />
<PackageReference Include="MongoDB.Bson" Version="3.5.2" />

View File

@ -1,13 +1,14 @@
using AyCode.Core.Serializers.Console.Benchmarks;
using AyCode.Core.Benchmarks.Workloads.Scenarios;
namespace AyCode.Core.Serializers.Console;
namespace AyCode.Core.Benchmarks.Reporting;
/// <summary>
/// Per-cell benchmark result row. Populated by the benchmark execution loop in
/// <c>BenchmarkLoop.RunBenchmarksForTestData</c>; consumed by the output formatters in
/// <c>Output</c> (console table + .log + .LLM file writers). Pure DTO — no behaviour.
/// Per-cell benchmark result row. Populated by the benchmark execution loop (Console-side
/// <c>BenchmarkLoop.RunBenchmarksForTestData</c> / BDN-side <c>BdnSummaryAdapter</c>); consumed by the
/// output formatters in <c>BenchmarkReportWriter</c> (console table + .log + .LLM file writers).
/// Pure DTO — no behaviour.
/// </summary>
internal sealed class BenchmarkResult
public sealed class BenchmarkResult
{
public string TestDataName { get; set; } = "";
public BenchmarkEngine Engine { get; set; }
@ -18,7 +19,7 @@ internal sealed class BenchmarkResult
/// <summary>
/// CLR type name of the order graph serialised in this row (e.g. <c>"TestOrder_All_False"</c>,
/// <c>"TestOrder_All_True"</c>). Captured from <see cref="ISerializerBenchmark.OrderTypeName"/> in
/// <c>RunBenchmarksForTestData</c>; surfaced in the SERIALIZER OPTIONS section of every output
/// the runner loop; surfaced in the SERIALIZER OPTIONS section of every output
/// (.log, .LLM, console) so the reader can correlate each preset with its TestOrder variant
/// without inflating the per-row tables with an extra column.
/// </summary>

View File

@ -0,0 +1,47 @@
using System.Text;
namespace AyCode.Core.Benchmarks.Reporting;
/// <summary>
/// Context bundle for the unified benchmark report writer. Same record on both sides (Console / BDN),
/// only the <see cref="SourceTag"/> differs ("Console" / "Bdn") — that drives the filename prefix
/// (e.g. <c>Console.FullBenchmark_Release_{timestamp}.LLM</c> vs <c>Bdn.FullBenchmark_Release_{timestamp}.LLM</c>).
/// The <see cref="ResultsDirectory"/> resolution walks up from <see cref="AppContext.BaseDirectory"/> to the
/// nearest <c>AyCode.Core.sln</c> and combines with <c>Test_Benchmark_Results\Benchmark</c> — works across
/// build modes (Debug / Release / AOT publish) and worktrees (each worktree has its own .sln, so its bench
/// results land alongside its code).
/// </summary>
public sealed record ReportingContext(
string SourceTag,
string ResultsDirectory,
string BuildConfiguration,
UTF8Encoding Utf8NoBom)
{
/// <summary>
/// Resolves the canonical <see cref="ResultsDirectory"/> by walking up from
/// <see cref="AppContext.BaseDirectory"/> to the nearest <c>AyCode.Core.sln</c>, then combining
/// with <c>Test_Benchmark_Results\Benchmark</c>. The build configuration is supplied by the caller
/// (Console resolves via <c>#if AYCODE_NATIVEAOT|DEBUG|else Release</c>; BDN-side currently mirrors
/// the JIT vs AOT discriminator from <see cref="System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeCompiled"/>).
/// </summary>
public static ReportingContext Create(string sourceTag, string buildConfiguration) =>
new(sourceTag, ResolveResultsDirectory(), buildConfiguration, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
/// <summary>
/// Walk-up from the assembly's BaseDirectory to find the repo root (marker: <c>AyCode.Core.sln</c>).
/// Returns <c>{repoRoot}\Test_Benchmark_Results\Benchmark</c>. Worktree-aware: if running from a
/// worktree, the walk finds the worktree's own .sln (each worktree has its own checkout), so
/// results land in the worktree's results folder — the natural place when the worktree's code
/// changes are what produced the numbers.
/// </summary>
private static string ResolveResultsDirectory()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AyCode.Core.sln")))
dir = dir.Parent;
if (dir == null)
throw new InvalidOperationException(
"Cannot locate repo root (AyCode.Core.sln) from AppContext.BaseDirectory: " + AppContext.BaseDirectory);
return Path.Combine(dir.FullName, "Test_Benchmark_Results", "Benchmark");
}
}

View File

@ -1,14 +1,14 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Serializers.Console.Benchmarks;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// AcBinary benchmark, Byte[] I/O mode. The headline AcBinary row in every cell — compared
/// against <see cref="MemoryPackBenchmark"/> as the SOTA baseline.
/// against <see cref="MemoryPackBenchmark{T}"/> as the SOTA baseline.
/// </summary>
internal sealed class AcBinaryBenchmark<T> : ISerializerBenchmark where T : class
public sealed class AcBinaryBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly AcBinarySerializerOptions _options;
@ -42,6 +42,6 @@ internal sealed class AcBinaryBenchmark<T> : ISerializerBenchmark where T : clas
{
var bytes = AcBinarySerializer.Serialize(_order, _options);
var roundTripped = AcBinaryDeserializer.Deserialize<T>(bytes, _options);
return BenchmarkLoop.DeepEqualsViaJson(_order, roundTripped);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}

View File

@ -1,15 +1,15 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using System.Buffers;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Serializers.Console.Benchmarks;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <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>
internal sealed class AcBinaryBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
public sealed class AcBinaryBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly AcBinarySerializerOptions _options;
@ -64,6 +64,6 @@ internal sealed class AcBinaryBufferWriterBenchmark<T> : ISerializerBenchmark wh
AcBinarySerializer.Serialize(_order, _bufferWriter, _options);
var roundTripped = AcBinaryDeserializer.Deserialize<T>(new ReadOnlySequence<byte>(_bufferWriter.WrittenMemory), _options);
return BenchmarkLoop.DeepEqualsViaJson(_order, roundTripped);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}

View File

@ -1,9 +1,9 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using System.Buffers;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Serializers.Console.Benchmarks;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Benchmarks AcBinary via the IBufferWriter overload, allocating a FRESH ArrayBufferWriter on EVERY call.
@ -12,7 +12,7 @@ namespace AyCode.Core.Serializers.Console.Benchmarks;
/// 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>
internal sealed class AcBinaryFreshBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
public sealed class AcBinaryFreshBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly AcBinarySerializerOptions _options;
@ -59,6 +59,6 @@ internal sealed class AcBinaryFreshBufferWriterBenchmark<T> : ISerializerBenchma
var abw = new ArrayBufferWriter<byte>();
AcBinarySerializer.Serialize(_order, abw, _options);
var roundTripped = AcBinaryDeserializer.Deserialize<T>(new ReadOnlySequence<byte>(abw.WrittenMemory), _options);
return BenchmarkLoop.DeepEqualsViaJson(_order, roundTripped);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}

View File

@ -1,13 +1,13 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.Serialization; // DrainFromAsync extension (test-only, used by benchmark)
using AyCode.Core.Tests.TestModels;
using System.IO.Pipelines;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Serializers.Console.Benchmarks;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Same chunked-framed AsyncPipe code path as <see cref="AcBinaryNamedPipeBenchmark"/>, but the transport
/// Same chunked-framed AsyncPipe code path as <see cref="AcBinaryNamedPipeBenchmark{T}"/>, 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.
@ -15,16 +15,16 @@ namespace AyCode.Core.Serializers.Console.Benchmarks;
/// <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
/// CPU cost. The expected delta vs <see cref="AcBinaryNamedPipeBenchmark{T}"/>: 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>
/// should converge dramatically toward <see cref="AcBinaryNamedPipeRawByteArrayBenchmark{T}"/>.</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>
internal sealed class AcBinaryInMemoryPipeBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
public sealed class AcBinaryInMemoryPipeBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
{
private readonly T _order;
private readonly AcBinarySerializerOptions _options;
@ -160,7 +160,7 @@ internal sealed class AcBinaryInMemoryPipeBenchmark<T> : ISerializerBenchmark, I
{
Serialize();
var result = _lastResult as T;
return result != null && BenchmarkLoop.DeepEqualsViaJson(_order, result);
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
}
finally
{

View File

@ -1,8 +1,8 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Serializers.Console.Benchmarks;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Raw <c>byte[]</c> over an in-memory cross-thread handoff — NO transport (no NamedPipe, no Pipe, no
@ -11,17 +11,17 @@ namespace AyCode.Core.Serializers.Console.Benchmarks;
///
/// <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>NamedPipe + Chunked</b> = <see cref="AcBinaryNamedPipeBenchmark{T}"/></description></item>
/// <item><description><b>NamedPipe + Raw</b> = <see cref="AcBinaryNamedPipeRawByteArrayBenchmark{T}"/></description></item>
/// <item><description><b>In-memory Pipe + Chunked</b> = <see cref="AcBinaryInMemoryPipeBenchmark{T}"/></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
/// <para>Side-by-side with <see cref="AcBinaryInMemoryPipeBenchmark{T}"/> 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
/// Side-by-side with <see cref="AcBinaryNamedPipeRawByteArrayBenchmark{T}"/> this isolates the kernel-NamedPipe
/// overhead on the raw-byte[] side.</para>
/// </summary>
internal sealed class AcBinaryInMemoryRawByteArrayBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
public sealed class AcBinaryInMemoryRawByteArrayBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
{
private readonly T _order;
private readonly AcBinarySerializerOptions _options;
@ -144,7 +144,7 @@ internal sealed class AcBinaryInMemoryRawByteArrayBenchmark<T> : ISerializerBenc
{
Serialize();
var result = _lastResult as T;
return result != null && BenchmarkLoop.DeepEqualsViaJson(_order, result);
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
}
finally
{

View File

@ -1,11 +1,11 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.Serialization; // DrainFromAsync extension (test-only, used by benchmark)
using AyCode.Core.Tests.TestModels;
using System.IO.Pipelines;
using System.IO.Pipes;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Serializers.Console.Benchmarks;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Benchmarks AcBinary over a long-lived NamedPipe IPC connection using the AcBinary native streaming API
@ -39,7 +39,7 @@ namespace AyCode.Core.Serializers.Console.Benchmarks;
/// <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>
internal sealed class AcBinaryNamedPipeBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
public sealed class AcBinaryNamedPipeBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
{
private readonly T _order;
private readonly AcBinarySerializerOptions _options;
@ -205,7 +205,7 @@ internal sealed class AcBinaryNamedPipeBenchmark<T> : ISerializerBenchmark, IDis
{
Serialize();
var result = _lastResult as T;
return result != null && BenchmarkLoop.DeepEqualsViaJson(_order, result);
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
}
finally
{

View File

@ -1,9 +1,9 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using System.IO.Pipes;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Serializers.Console.Benchmarks;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Raw <c>byte[]</c> over a long-lived NamedPipe — NO chunk-framing, NO <c>AsyncPipeReaderInput</c>,
@ -11,21 +11,21 @@ namespace AyCode.Core.Serializers.Console.Benchmarks;
/// reads and deserialises. Two-task pattern enables Ser↔Read overlap (kernel-pipe-pipelined) AND
/// avoids the kernel-buffer-full deadlock when <c>bytes.Length &gt; inBufferSize</c>.
///
/// Side-by-side with <see cref="AcBinaryNamedPipeBenchmark"/> (chunked-framed AsyncPipe stack) this
/// Side-by-side with <see cref="AcBinaryNamedPipeBenchmark{T}"/> (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
/// <item><description><b>This row vs <see cref="AcBinaryBenchmark{T}"/> (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
/// <item><description><b>This row vs <see cref="AcBinaryNamedPipeBenchmark{T}"/> (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
/// <see cref="AcBinaryBenchmark{T}"/>'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>BenchmarkLoop.MeasureAllocationTotal</c>).
/// </summary>
internal sealed class AcBinaryNamedPipeRawByteArrayBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
public sealed class AcBinaryNamedPipeRawByteArrayBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
{
private readonly T _order;
private readonly AcBinarySerializerOptions _options;
@ -185,7 +185,7 @@ internal sealed class AcBinaryNamedPipeRawByteArrayBenchmark<T> : ISerializerBen
{
Serialize();
var result = _lastResult as T;
return result != null && BenchmarkLoop.DeepEqualsViaJson(_order, result);
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
}
finally
{

View File

@ -1,11 +1,11 @@
namespace AyCode.Core.Serializers.Console.Benchmarks;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Serializer engine identifier — replaces the prior <c>Configuration.EngineXxx</c> string constants
/// with a type-safe enum. The benchmark-result <c>Engine</c> column uses <see cref="ToDisplay"/> for
/// the human-readable form.
/// </summary>
internal enum BenchmarkEngine
public enum BenchmarkEngine
{
AcBinary,
MemoryPack,
@ -21,7 +21,7 @@ internal enum BenchmarkEngine
/// (they distinguish chunked-framed vs raw-byte[] semantics, but render identically in the IO column);
/// the same applies to <see cref="InMemoryPipe"/> + <see cref="InMemoryRaw"/> (<c>"Pipe(in-mem)"</c>).
/// </summary>
internal enum BenchmarkIoMode
public enum BenchmarkIoMode
{
ByteArray,
BufWrReuse,
@ -42,7 +42,7 @@ internal enum BenchmarkIoMode
/// <item><see cref="Hybrid"/> — SGen root with non-SGen child types reached via bridge methods (see docs/BINARY/BINARY_SGEN.md).</item>
/// </list>
/// </summary>
internal enum BenchmarkDispatchMode
public enum BenchmarkDispatchMode
{
SGen,
Runtime,
@ -50,7 +50,7 @@ internal enum BenchmarkDispatchMode
}
/// <summary>
/// Test-data layer filter — selects which <see cref="TestDataSet"/> cells participate in the run.
/// Test-data layer filter — selects which test data cells participate in the run.
/// Replaces the prior string-typed <c>layer</c> parameter; CLI/menu callers parse user input via
/// <see cref="Enum.TryParse{T}(string, bool, out T)"/> with <c>ignoreCase: true</c>.
/// <list type="bullet">
@ -59,7 +59,7 @@ internal enum BenchmarkDispatchMode
/// <item><see cref="Small"/>/<see cref="Medium"/>/<see cref="Large"/>/<see cref="Repeated"/>/<see cref="Deep"/> — single-cell mini-suites for tight A/B iteration loops.</item>
/// </list>
/// </summary>
internal enum BenchmarkLayer
public enum BenchmarkLayer
{
All,
Core,
@ -78,7 +78,7 @@ internal enum BenchmarkLayer
/// a no-op and only run on <see cref="Serialize"/> or <see cref="All"/>. Replaces the prior string-typed
/// <c>mode</c>/<c>opMode</c> parameter.
/// </summary>
internal enum BenchmarkOpMode
public enum BenchmarkOpMode
{
All,
Serialize,
@ -86,7 +86,7 @@ internal enum BenchmarkOpMode
}
/// <summary>
/// Serializer-set selection — drives <c>BenchmarkLoop.CreateSerializers</c> to return one of three
/// Serializer-set selection — drives the runner's serializer-factory to return one of three
/// preset bundles instead of a magic string. Replaces the prior string-typed <c>serializerMode</c>
/// parameter.
/// <list type="bullet">
@ -95,7 +95,7 @@ internal enum BenchmarkOpMode
/// <item><see cref="AsyncPipe"/> — streaming I/O isolation (NamedPipe + in-memory Pipe variants only).</item>
/// </list>
/// </summary>
internal enum SerializerSelectionMode
public enum SerializerSelectionMode
{
Standard,
FastestByte,
@ -107,9 +107,9 @@ internal enum SerializerSelectionMode
/// human-readable form used by the per-row console table, the <c>.log</c> file CSV/formatted output,
/// and the <c>.LLM</c> markdown table. Centralised here so every output formatter renders identically.
/// </summary>
internal static class BenchmarkEnumExtensions
public static class BenchmarkEnumExtensions
{
internal static string ToDisplay(this BenchmarkEngine engine) => engine switch
public static string ToDisplay(this BenchmarkEngine engine) => engine switch
{
BenchmarkEngine.AcBinary => "AcBinary",
BenchmarkEngine.MemoryPack => "MemoryPack",
@ -120,7 +120,7 @@ internal static class BenchmarkEnumExtensions
_ => throw new ArgumentOutOfRangeException(nameof(engine), engine, null),
};
internal static string ToDisplay(this BenchmarkIoMode mode) => mode switch
public static string ToDisplay(this BenchmarkIoMode mode) => mode switch
{
BenchmarkIoMode.ByteArray => "Byte[]",
BenchmarkIoMode.BufWrReuse => "BufWr reuse",
@ -133,7 +133,7 @@ internal static class BenchmarkEnumExtensions
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
};
internal static string ToDisplay(this BenchmarkDispatchMode mode) => mode switch
public static string ToDisplay(this BenchmarkDispatchMode mode) => mode switch
{
BenchmarkDispatchMode.SGen => "SGen",
BenchmarkDispatchMode.Runtime => "Runtime",

View File

@ -0,0 +1,89 @@
using AyCode.Core.Serializers;
using AyCode.Core.Serializers.Attributes;
using AyCode.Core.Serializers.Binaries;
using MemoryPack;
using System.Reflection;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Per-engine options-formatting + selection helpers shared by all benchmark rows. Centralizes
/// the Options-column display string (so the .log / .LLM / console headers stay consistent), the
/// MemoryPack <c>WireMode</c>-aligned options selection (so AcBinary FastWire ↔ MemoryPack UTF-16
/// comparisons stay apples-to-apples), and the cached <see cref="AttrFlags"/> attribute-flag aggregation.
/// </summary>
public static class BenchmarkOptions
{
/// <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="BuildAcBinary"/> 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>
public 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));
}
/// <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>
public static string BuildAcBinary(AcBinarySerializerOptions options, string extra = "")
{
// PropertyFilter: opt-side is "Set"/"None" depending on whether a callback is registered (the callback
// itself isn't a meaningful display value); attr-side is the cross-type-aggregated bool (true = every
// tagged type has the feature enabled, false = at least one type opted out via
// [AcBinarySerializable(enablePropertyFilterFeature: false)] → SGen-emit + Runtime hot-loop both gate).
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), " +
$"SGen={options.UseGeneratedCode}, " +
$"Compression={options.UseCompression}{extra}";
}
/// <summary>
/// Returns MemoryPack serializer options aligned with the given <paramref name="wireMode"/> for a fair
/// apples-to-apples wire-format comparison:
/// <list type="bullet">
/// <item><see cref="WireMode.Compact"/> → <see cref="MemoryPackSerializerOptions.Default"/> (UTF-8) — both
/// engines encode UTF-8, comparison is purely about header / tier / dispatch overhead.</item>
/// <item><see cref="WireMode.Fast"/> → <see cref="MemoryPackSerializerOptions.Utf16"/> (UTF-16 raw memcpy) —
/// both engines write UTF-16 raw bytes, so wire-size and CPU comparison reflect the same string-encoding family.</item>
/// </list>
/// Without this alignment the FastWire vs MemPack-default comparison conflates two unrelated dimensions
/// (UTF-16 raw vs UTF-8 encoded) and produces a misleading +40% wire-size delta that is structurally
/// the encoding-family difference, NOT an AcBinary-specific overhead.
/// </summary>
public static MemoryPackSerializerOptions GetMemPack(WireMode wireMode) =>
wireMode == WireMode.Fast
? MemoryPackSerializerOptions.Utf16
: MemoryPackSerializerOptions.Default;
}

View File

@ -1,4 +1,4 @@
namespace AyCode.Core.Serializers.Console.Benchmarks;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Common contract for every per-engine, per-I/O-mode benchmark row. Each implementation captures
@ -11,7 +11,7 @@ namespace AyCode.Core.Serializers.Console.Benchmarks;
/// warmup state (rare). Round-trip-only benchmarks (NamedPipe etc.) set <see cref="IsRoundTripOnly"/>
/// to true so the bench loop skips the Des-phase and routes timing into the RT columns.</para>
/// </summary>
internal interface ISerializerBenchmark
public interface ISerializerBenchmark
{
/// <summary>Serializer engine — typed enum, see <see cref="BenchmarkEnumExtensions.ToDisplay(BenchmarkEngine)"/> for the human-readable form.</summary>
BenchmarkEngine Engine { get; }
@ -24,7 +24,7 @@ internal interface ISerializerBenchmark
/// <summary>
/// CLR type of the order graph this benchmark serializes (e.g. <c>typeof(TestOrder_All_False)</c>,
/// <c>typeof(TestOrder_All_True)</c>). Per-instance: AcBinary picks variant by options preset
/// (<see cref="BenchmarkLoop.UsesAllFalseVariant"/>), MemPack / MsgPack always use <c>_All_False</c>.
/// (caller-side dispatch rule), MemPack / MsgPack always use <c>_All_False</c>.
/// Concrete benchmarks return <c>typeof(T)</c> for their generic parameter.
/// </summary>
Type OrderType { get; }

View File

@ -1,15 +1,16 @@
using AyCode.Core.Tests.TestModels;
using AyCode.Core.Serializers;
using AyCode.Core.Tests.TestModels;
using MemoryPack;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Serializers.Console.Benchmarks;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// MemoryPack benchmark, Byte[] I/O mode. The SOTA baseline AcBinary is compared against in every
/// cell. WireMode-aligned options via <see cref="BenchmarkOptions.GetMemPack"/> so Compact ↔ UTF-8
/// and FastWire ↔ UTF-16 are apples-to-apples on the string-encoding axis.
/// </summary>
internal sealed class MemoryPackBenchmark<T> : ISerializerBenchmark where T : class
public sealed class MemoryPackBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly MemoryPackSerializerOptions _options;
@ -25,11 +26,11 @@ internal sealed class MemoryPackBenchmark<T> : ISerializerBenchmark where T : cl
public long SetupDeserializeAllocBytes => 0;
public string? OptionsDescription => $"StringEncoding={_options.StringEncoding}";
public MemoryPackBenchmark(T order, string optionsPreset)
public MemoryPackBenchmark(T order, WireMode wireMode, string optionsPreset)
{
_order = order;
OptionsPreset = optionsPreset;
_options = BenchmarkOptions.GetMemPack();
_options = BenchmarkOptions.GetMemPack(wireMode);
_serialized = MemoryPackSerializer.Serialize(order, _options);
}
@ -43,6 +44,6 @@ internal sealed class MemoryPackBenchmark<T> : ISerializerBenchmark where T : cl
{
var bytes = MemoryPackSerializer.Serialize(_order, _options);
var roundTripped = MemoryPackSerializer.Deserialize<T>(bytes, _options);
return BenchmarkLoop.DeepEqualsViaJson(_order, roundTripped);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}

View File

@ -1,16 +1,17 @@
using AyCode.Core.Tests.TestModels;
using AyCode.Core.Serializers;
using AyCode.Core.Tests.TestModels;
using MemoryPack;
using System.Buffers;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Serializers.Console.Benchmarks;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Benchmarks MemoryPack via the IBufferWriter overload with a pre-allocated, reused ArrayBufferWriter.
/// Apples-to-apples counterpart to <see cref="AcBinaryBufferWriterBenchmark"/> — MemoryPack's IBufferWriter
/// Apples-to-apples counterpart to <see cref="AcBinaryBufferWriterBenchmark{T}"/> — MemoryPack's IBufferWriter
/// is the path it's designed for.
/// </summary>
internal sealed class MemoryPackBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
public sealed class MemoryPackBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly MemoryPackSerializerOptions _options;
@ -27,11 +28,11 @@ internal sealed class MemoryPackBufferWriterBenchmark<T> : ISerializerBenchmark
public long SetupDeserializeAllocBytes => 0;
public string? OptionsDescription => $"StringEncoding={_options.StringEncoding}";
public MemoryPackBufferWriterBenchmark(T order, string optionsPreset)
public MemoryPackBufferWriterBenchmark(T order, WireMode wireMode, string optionsPreset)
{
_order = order;
OptionsPreset = optionsPreset;
_options = BenchmarkOptions.GetMemPack();
_options = BenchmarkOptions.GetMemPack(wireMode);
_serialized = MemoryPackSerializer.Serialize(order, _options);
// Serialize-side setup only — see AcBinaryBufferWriterBenchmark for the full rationale.
@ -59,6 +60,6 @@ internal sealed class MemoryPackBufferWriterBenchmark<T> : ISerializerBenchmark
_bufferWriter.ResetWrittenCount();
MemoryPackSerializer.Serialize(_bufferWriter, _order, _options);
var roundTripped = MemoryPackSerializer.Deserialize<T>(new ReadOnlySequence<byte>(_bufferWriter.WrittenMemory), _options);
return BenchmarkLoop.DeepEqualsViaJson(_order, roundTripped);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}

View File

@ -1,15 +1,16 @@
using AyCode.Core.Tests.TestModels;
using AyCode.Core.Serializers;
using AyCode.Core.Tests.TestModels;
using MemoryPack;
using System.Buffers;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Serializers.Console.Benchmarks;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Benchmarks MemoryPack via the IBufferWriter overload, allocating a FRESH ArrayBufferWriter on EVERY call.
/// Apples-to-apples counterpart to <see cref="AcBinaryFreshBufferWriterBenchmark"/>.
/// Apples-to-apples counterpart to <see cref="AcBinaryFreshBufferWriterBenchmark{T}"/>.
/// </summary>
internal sealed class MemoryPackFreshBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
public sealed class MemoryPackFreshBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly MemoryPackSerializerOptions _options;
@ -25,11 +26,11 @@ internal sealed class MemoryPackFreshBufferWriterBenchmark<T> : ISerializerBench
public long SetupDeserializeAllocBytes => 0;
public string? OptionsDescription => $"StringEncoding={_options.StringEncoding}";
public MemoryPackFreshBufferWriterBenchmark(T order, string optionsPreset)
public MemoryPackFreshBufferWriterBenchmark(T order, WireMode wireMode, string optionsPreset)
{
_order = order;
OptionsPreset = optionsPreset;
_options = BenchmarkOptions.GetMemPack();
_options = BenchmarkOptions.GetMemPack(wireMode);
_serialized = MemoryPackSerializer.Serialize(order, _options);
}
@ -50,6 +51,6 @@ internal sealed class MemoryPackFreshBufferWriterBenchmark<T> : ISerializerBench
var abw = new ArrayBufferWriter<byte>();
MemoryPackSerializer.Serialize(abw, _order, _options);
var roundTripped = MemoryPackSerializer.Deserialize<T>(new ReadOnlySequence<byte>(abw.WrittenMemory), _options);
return BenchmarkLoop.DeepEqualsViaJson(_order, roundTripped);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}

View File

@ -1,10 +1,10 @@
#if !AYCODE_NATIVEAOT
#if !AYCODE_NATIVEAOT
using AyCode.Core.Tests.TestModels;
using MessagePack;
using MessagePack.Resolvers;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Serializers.Console.Benchmarks;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// MessagePack benchmark, Byte[] I/O mode. Excluded from NativeAOT build because v3's StandardResolver
@ -12,7 +12,7 @@ namespace AyCode.Core.Serializers.Console.Benchmarks;
/// which uses Activator.CreateInstance on formatter types the AOT trimmer drops →
/// MissingMethodException at runtime. Available for regular JIT runs (<c>dotnet run</c>) only.
/// </summary>
internal sealed class MessagePackBenchmark<T> : ISerializerBenchmark where T : class
public sealed class MessagePackBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly MessagePackSerializerOptions _options;
@ -53,7 +53,7 @@ internal sealed class MessagePackBenchmark<T> : ISerializerBenchmark where T : c
{
var bytes = MessagePackSerializer.Serialize(_order, _options);
var roundTripped = MessagePackSerializer.Deserialize<T>(bytes, _options);
return BenchmarkLoop.DeepEqualsViaJson(_order, roundTripped);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}
#endif

View File

@ -0,0 +1,49 @@
using System.Text.Json;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Round-trip correctness validator — serializes both sides to canonical System.Text.Json form
/// and compares the resulting strings. Works for any object graph without a custom comparer (slower
/// than property-by-property but universal). Used by every benchmark's <c>VerifyRoundTrip()</c>
/// implementation as the deep-equality oracle before warmup begins.
/// </summary>
public static class RoundTripValidator
{
#if !AYCODE_NATIVEAOT
private static readonly JsonSerializerOptions VerifyJsonOpts = new()
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
};
#endif
/// <summary>
/// Round-trip equality check via canonical System.Text.Json. Returns true if both sides serialize
/// to identical JSON strings.
/// </summary>
/// <remarks>
/// AOT publish skip: <c>System.Text.Json</c>'s reflection path uses runtime closed-generic instantiation
/// (<c>JsonPropertyInfo&lt;TestStatus&gt;</c> et al.) that the trimmer drops, causing
/// <c>NotSupportedException: missing native code or metadata</c>. The validation is JIT-only — the actual
/// benchmark Serialize/Deserialize loops don't touch this path. Under AOT we return <c>true</c> so all
/// <c>VerifyRoundTrip()</c> calls pass without running the cross-format validation.
/// </remarks>
public static bool DeepEqualsViaJson(object? a, object? b)
{
#if AYCODE_NATIVEAOT
// Skip cross-format validation under AOT — STJ reflection path is incompatible. The roundtrip
// itself still runs (caller-side Serialize+Deserialize), just the JSON-canonical compare is bypassed.
return true;
#else
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;
#endif
}
}

View File

@ -1,16 +1,17 @@
using AyCode.Core.Tests.TestModels;
using AyCode.Core.Tests.TestModels;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
namespace AyCode.Core.Serializers.Console.Benchmarks;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// System.Text.Json benchmark, String I/O mode. Reference comparison — uses reflection-based metadata
/// (no source-generator opt-in here). Typically NOT in the active suite (commented out in
/// <c>BenchmarkLoop.CreateSerializers</c>); ranks far behind binary serializers on µs/op but provides
/// (no source-generator opt-in here). Typically NOT in the active suite (commented out in the
/// caller-side <c>CreateSerializers</c>); ranks far behind binary serializers on µs/op but provides
/// a familiar JSON baseline when needed.
/// </summary>
internal sealed class SystemTextJsonBenchmark<T> : ISerializerBenchmark where T : class
public sealed class SystemTextJsonBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly JsonSerializerOptions _options;
@ -37,7 +38,10 @@ internal sealed class SystemTextJsonBenchmark<T> : ISerializerBenchmark where T
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
};
_serialized = JsonSerializer.Serialize(order, _options);
_serializedUtf8 = Configuration.Utf8NoBom.GetBytes(_serialized);
// Encoding.UTF8.GetBytes(string) does NOT prepend the BOM (the preamble is only emitted by
// GetPreamble() / stream-level writes), so this produces identical bytes to the prior
// `new UTF8Encoding(false).GetBytes(_serialized)` call. Size-reporting only.
_serializedUtf8 = Encoding.UTF8.GetBytes(_serialized);
}
[MethodImpl(MethodImplOptions.NoInlining)]
@ -50,6 +54,6 @@ internal sealed class SystemTextJsonBenchmark<T> : ISerializerBenchmark where T
{
var json = JsonSerializer.Serialize(_order, _options);
var roundTripped = JsonSerializer.Deserialize<T>(json, _options);
return BenchmarkLoop.DeepEqualsViaJson(_order, roundTripped);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}

View File

@ -3,6 +3,7 @@
<ItemGroup>
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
<ProjectReference Include="..\AyCode.Core.Tests\AyCode.Core.Tests.csproj" />
<ProjectReference Include="..\AyCode.Benchmark\AyCode.Benchmark.csproj" />
</ItemGroup>
<ItemGroup>
@ -16,6 +17,7 @@
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<StartupObject>AyCode.Core.Serializers.Console.Program</StartupObject>
</PropertyGroup>
<!-- AOT-mode is publish-time only.

View File

@ -1,10 +1,10 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Console.Benchmarks;
using AyCode.Core.Benchmarks.Reporting;
using AyCode.Core.Benchmarks.Workloads.Scenarios;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using MemoryPack;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Text.Json;
namespace AyCode.Core.Serializers.Console;
@ -403,7 +403,7 @@ internal static class BenchmarkLoop
//MakeAcBinary(testData, fastWireOptions, "FastMode (FastWire)"),
// MemPack canonically on _All_True (no AcBinary opt-in/opt-out axis applies; the MemoryPackable
// contract serialises identical bytes either way, but _All_True is the established baseline).
new MemoryPackBenchmark<TestOrder_All_False>(orderFalse, "Default"),
new MemoryPackBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
};
}
@ -541,9 +541,9 @@ internal static class BenchmarkLoop
// MemoryPack — three I/O modes for apples-to-apples comparison
// ============================================================
// MemPack canonically on _All_True (see FastestByte-mode comment above for rationale).
new MemoryPackBenchmark<TestOrder_All_False>(orderFalse, "Default"),
new MemoryPackBufferWriterBenchmark<TestOrder_All_False>(orderFalse, "Default"),
new MemoryPackFreshBufferWriterBenchmark<TestOrder_All_False>(orderFalse, "Default"),
new MemoryPackBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
new MemoryPackBufferWriterBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
new MemoryPackFreshBufferWriterBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
// ============================================================
// MessagePack — for legacy comparison
@ -795,44 +795,6 @@ internal static class BenchmarkLoop
_progressLastLineLen = 0;
}
#if !AYCODE_NATIVEAOT
private static readonly JsonSerializerOptions VerifyJsonOpts = new()
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
};
#endif
/// <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>
/// <remarks>
/// AOT publish skip: <c>System.Text.Json</c>'s reflection path uses runtime closed-generic instantiation
/// (<c>JsonPropertyInfo&lt;TestStatus&gt;</c> et al.) that the trimmer drops, causing
/// <c>NotSupportedException: missing native code or metadata</c>. The validation is JIT-only — the actual
/// benchmark Serialize/Deserialize loops don't touch this path. Under AOT we return <c>true</c> so all
/// <c>VerifyRoundTrip()</c> calls pass without running the cross-format validation.
/// </remarks>
internal static bool DeepEqualsViaJson(object? a, object? b)
{
#if AYCODE_NATIVEAOT
// Skip cross-format validation under AOT — STJ reflection path is incompatible. The roundtrip
// itself still runs (caller-side Serialize+Deserialize), just the JSON-canonical compare is bypassed.
return true;
#else
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;
#endif
}
/// <summary>
/// Validates MemoryPack setup at startup. Aborts the benchmark if TestOrder_All_True is not [MemoryPackable].
/// Without this attribute, MemoryPack falls back to runtime resolver (slower) — comparison would be INVALID.

View File

@ -1,55 +0,0 @@
using AyCode.Core.Serializers.Binaries;
using MemoryPack;
namespace AyCode.Core.Serializers.Console.Benchmarks;
/// <summary>
/// Per-engine options-formatting + selection helpers shared by all benchmark rows. Centralizes
/// the Options-column display string (so the .log / .LLM / console headers stay consistent) and
/// the MemoryPack <c>WireMode</c>-aligned options selection (so AcBinary FastWire ↔ MemoryPack
/// UTF-16 comparisons stay apples-to-apples).
/// </summary>
internal static class BenchmarkOptions
{
/// <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>
internal static string BuildAcBinary(AcBinarySerializerOptions options, string extra = "")
{
// PropertyFilter: opt-side is "Set"/"None" depending on whether a callback is registered (the callback
// itself isn't a meaningful display value); attr-side is the cross-type-aggregated bool (true = every
// tagged type has the feature enabled, false = at least one type opted out via
// [AcBinarySerializable(enablePropertyFilterFeature: false)] → SGen-emit + Runtime hot-loop both gate).
var propFilterOpt = options.PropertyFilter == null ? "None" : "Set";
return $"WireMode={options.WireMode}, " +
$"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="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
/// engines encode UTF-8, comparison is purely about header / tier / dispatch overhead.</item>
/// <item><see cref="WireMode.Fast"/> → <see cref="MemoryPackSerializerOptions.Utf16"/> (UTF-16 raw memcpy) —
/// both engines write UTF-16 raw bytes, so wire-size and CPU comparison reflect the same string-encoding family.</item>
/// </list>
/// Without this alignment the FastWire vs MemPack-default comparison conflates two unrelated dimensions
/// (UTF-16 raw vs UTF-8 encoded) and produces a misleading +40% wire-size delta that is structurally
/// the encoding-family difference, NOT an AcBinary-specific overhead.
/// </summary>
internal static MemoryPackSerializerOptions GetMemPack() =>
Configuration.SelectedWireMode == WireMode.Fast
? MemoryPackSerializerOptions.Utf16
: MemoryPackSerializerOptions.Default;
}

View File

@ -1,7 +1,5 @@
using AyCode.Core.Serializers.Attributes;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
@ -76,37 +74,6 @@ internal static class Configuration
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));
}
/// <summary>
/// Returns a human-readable name for the currently-active <c>BenchmarkTestDataProvider.LongStringSuffix</c>
/// charset. Returns "Custom" when the suffix doesn't match any of the predefined

View File

@ -1,5 +1,5 @@
using AyCode.Core.Benchmarks.Workloads.Scenarios;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Console.Benchmarks;
using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Serializers.Console;

View File

@ -1,5 +1,6 @@
using AyCode.Core.Benchmarks.Reporting;
using AyCode.Core.Benchmarks.Workloads.Scenarios;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Console.Benchmarks;
using AyCode.Core.Tests.TestModels;
using System.Globalization;
using System.Runtime.CompilerServices;

View File

@ -18,7 +18,7 @@ using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using AyCode.Core.Serializers.Console.Benchmarks;
using AyCode.Core.Benchmarks.Workloads.Scenarios;
namespace AyCode.Core.Serializers.Console;

View File

@ -59,7 +59,7 @@ public partial class AcBinarySourceGenerator
foreach (var p in ci.Properties)
{
sb.AppendLine();
EmitReadProp(sb, p, " ", ci.EnableMetadata, ci.EnableInternString);
EmitReadProp(sb, p, " ", ci.EnableMetadata, ci.EnableInternString, ci.EnableRefHandling);
}
sb.AppendLine(" }");
@ -85,7 +85,7 @@ public partial class AcBinarySourceGenerator
/// Markered types: read type code byte, then dispatch.
/// Mirrors the serializer's EmitProp symmetry.
/// </summary>
private static void EmitReadProp(StringBuilder sb, PropInfo p, string i, bool enableMetadata, bool enableInternString)
private static void EmitReadProp(StringBuilder sb, PropInfo p, string i, bool enableMetadata, bool enableInternString, bool enableRefHandling)
{
var a = $"obj.{p.Name}";
@ -145,15 +145,15 @@ public partial class AcBinarySourceGenerator
break;
case PropertyTypeKind.Complex:
EmitReadComplex(sb, p, a, tc, i + " ");
EmitReadComplex(sb, p, a, tc, i + " ", enableRefHandling);
break;
case PropertyTypeKind.Collection:
EmitReadCollection(sb, p, a, tc, i + " ", enableInternString);
EmitReadCollection(sb, p, a, tc, i + " ", enableInternString, enableRefHandling);
break;
case PropertyTypeKind.Dictionary:
EmitReadDictionary(sb, p, a, tc, i + " ", enableInternString);
EmitReadDictionary(sb, p, a, tc, i + " ", enableInternString, enableRefHandling);
break;
default:
@ -267,7 +267,7 @@ public partial class AcBinarySourceGenerator
/// Non-nullable + no ref → ZERO branches (tc consumed but ignored).
/// No SGen → runtime fallback via ReadValueGenerated.
/// </summary>
private static void EmitReadComplex(StringBuilder sb, PropInfo p, string a, string tc, string i)
private static void EmitReadComplex(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableRefHandling)
{
if (!p.HasGeneratedWriter)
{
@ -292,7 +292,11 @@ public partial class AcBinarySourceGenerator
var reader = p.WriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader");
var cast = $"({p.TypeNameForTypeof})";
if (!p.ChildNeedsRefScan)
// Ref-aware switch ONLY when both (a) the parent type opts into ref handling via EnableRefHandlingFeature
// (otherwise no Complex property of this type's reader will ever see an ObjectRef* marker — writer never
// emits them on this type) AND (b) the child type subtree may emit ref markers (ChildNeedsRefScan).
// Either flag false → ZERO-branch path (Object / FixObj only).
if (!enableRefHandling || !p.ChildNeedsRefScan)
{
// Compile-time proven: child never tracked → only Object (+ Null for nullable) in stream
// Inline: parent creates instance, calls ReadProperties directly (mirrors EmitDirectObjectWrite)
@ -384,12 +388,12 @@ public partial class AcBinarySourceGenerator
/// Known collection kind + inlineable element → inline Array loop with direct element reads.
/// Else → runtime fallback via ReadValueGenerated.
/// </summary>
private static void EmitReadCollection(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString)
private static void EmitReadCollection(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString, bool enableRefHandling)
{
// Check if we can inline: known collection shape + inlineable element type
if (p.CollectionKind != null && CanInlineCollectionRead(p))
{
EmitReadCollectionInline(sb, p, a, tc, i, enableInternString);
EmitReadCollectionInline(sb, p, a, tc, i, enableInternString, enableRefHandling);
return;
}
@ -415,7 +419,7 @@ public partial class AcBinarySourceGenerator
/// Wire format: [Dictionary][VarUInt count][key₁ value₁ key₂ value₂ ...].
/// Keys and values are read inline when their types are known (primitive/string/Complex+SGen).
/// </summary>
private static void EmitReadDictionary(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString)
private static void EmitReadDictionary(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString, bool enableRefHandling)
{
var s = p.Name;
var keyType = p.DictKeyTypeName ?? "object";
@ -462,6 +466,12 @@ public partial class AcBinarySourceGenerator
sb.AppendLine($"{i} {valReader}.Instance.ReadProperties(rv_{s}, context);");
sb.AppendLine($"{i} dv_{s} = rv_{s};");
sb.AppendLine($"{i} }}");
// ObjectRefFirst / ObjectRef cases — only emit when both (a) the parent type opts into
// ref handling and (b) the dict-value subtree may emit ref markers. Either flag false →
// skip these branches (writer never emits them; reader handles unknown markers via the
// fallback ReadValueGenerated path below). ACCORE-BIN-T-K9M3 Phase C step 2.
if (enableRefHandling && p.DictValueNeedsRefScan)
{
sb.AppendLine($"{i} else if ({vtc} == BinaryTypeCode.ObjectRefFirst)");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} var rci_{s} = (int)context.ReadVarUInt();");
@ -472,6 +482,7 @@ public partial class AcBinarySourceGenerator
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i} else if ({vtc} == BinaryTypeCode.ObjectRef)");
sb.AppendLine($"{i} dv_{s} = ({valType})context.GetInternedObject((int)context.ReadVarUInt())!;");
}
sb.AppendLine($"{i} else if ({vtc} != BinaryTypeCode.Null)");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} context._position--;");
@ -595,7 +606,7 @@ public partial class AcBinarySourceGenerator
/// Reads count + loops with direct element reads (Complex with SGen, or primitive/string/enum).
/// Eliminates per-element: ReadValue dispatch, ReadObjectCore dict lookup, Activator.CreateInstance.
/// </summary>
private static void EmitReadCollectionInline(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString)
private static void EmitReadCollectionInline(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString, bool enableRefHandling)
{
var isComplexElement = p.ElementKind == PropertyTypeKind.Complex && p.ElementHasGeneratedWriter;
var elemType = p.ElementFullTypeName!;
@ -622,7 +633,7 @@ public partial class AcBinarySourceGenerator
sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)");
sb.AppendLine($"{i} {{");
if (isComplexElement)
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: true, p.ElementNeedsRefScan, enableInternString);
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: true, p.ElementNeedsRefScan, enableInternString, enableRefHandling);
else
EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: true, null, enableInternString);
sb.AppendLine($"{i} }}");
@ -637,7 +648,7 @@ public partial class AcBinarySourceGenerator
sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)");
sb.AppendLine($"{i} {{");
if (isComplexElement)
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: false, p.ElementNeedsRefScan, enableInternString, p.CollectionAddMethod);
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: false, p.ElementNeedsRefScan, enableInternString, enableRefHandling, p.CollectionAddMethod);
else
EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: false, p.CollectionAddMethod, enableInternString);
sb.AppendLine($"{i} }}");
@ -648,7 +659,7 @@ public partial class AcBinarySourceGenerator
sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)");
sb.AppendLine($"{i} {{");
if (isComplexElement)
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: false, p.ElementNeedsRefScan, enableInternString);
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: false, p.ElementNeedsRefScan, enableInternString, enableRefHandling);
else
EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: false, null, enableInternString);
sb.AppendLine($"{i} }}");
@ -663,7 +674,7 @@ public partial class AcBinarySourceGenerator
/// SGen reader = non-metadata mode → no ObjectWithMetadata fallback.
/// !needsRefScan → only Object/Null possible → 1 branch per element.
/// </summary>
private static void EmitReadCollectionElement(StringBuilder sb, string reader, string elemTypeName, string elemCast, string indexVar, string propSuffix, string i, bool isArray, bool needsRefScan, bool enableInternString, string? addMethod = null)
private static void EmitReadCollectionElement(StringBuilder sb, string reader, string elemTypeName, string elemCast, string indexVar, string propSuffix, string i, bool isArray, bool needsRefScan, bool enableInternString, bool enableRefHandling, string? addMethod = null)
{
var etc = $"etc_{propSuffix}";
sb.AppendLine($"{i}var {etc} = context.ReadByte();");
@ -672,7 +683,9 @@ public partial class AcBinarySourceGenerator
var assignNull = isArray ? $"col_{propSuffix}[{indexVar}] = null!;" : $"col_{propSuffix}.{addCall}(null!);";
var assignExpr = isArray ? $"col_{propSuffix}[{indexVar}] = re_{propSuffix};" : $"col_{propSuffix}.{addCall}(re_{propSuffix});";
if (!needsRefScan)
// Ref-aware switch ONLY when both the parent type opts in (EnableRefHandlingFeature) and the
// element subtree may emit ref markers (needsRefScan). Either flag false → ZERO-branch path.
if (!enableRefHandling || !needsRefScan)
{
// No ref tracking → only Object, FixObj or Null in stream — inline ReadProperties
// FixObj slot: populate slot cache to keep _nextRuntimeSlot in sync.

View File

@ -413,6 +413,7 @@ Global
{A20861A9-411E-6150-BF5C-69E8196E5D22}.Product|x86.ActiveCfg = Product|Any CPU
{A20861A9-411E-6150-BF5C-69E8196E5D22}.Product|x86.Build.0 = Product|Any CPU
{A20861A9-411E-6150-BF5C-69E8196E5D22}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A20861A9-411E-6150-BF5C-69E8196E5D22}.Release|Any CPU.Build.0 = Release|Any CPU
{A20861A9-411E-6150-BF5C-69E8196E5D22}.Release|x64.ActiveCfg = Release|Any CPU
{A20861A9-411E-6150-BF5C-69E8196E5D22}.Release|x64.Build.0 = Release|Any CPU
{A20861A9-411E-6150-BF5C-69E8196E5D22}.Release|x86.ActiveCfg = Release|Any CPU

View File

@ -34,8 +34,51 @@ namespace AyCode.Core.Serializers.Attributes;
public sealed class AcBinarySerializableAttribute : Attribute
{
public bool EnableMetadataFeature { get; }
/// <summary>
/// When <c>true</c> (default): the SGen-emitted code recognizes the type's tracking-Id property
/// and emits Id-keyed reference deduplication support, active when <c>ReferenceHandling</c> is
/// <c>OnlyId</c> or <c>All</c>.
/// <para>When <c>false</c>: Id-tracking emit is skipped entirely. <b>Significantly reduces
/// scan-pass cost</b> — the per-instance <c>wrapper.TryTrack*</c> call + <c>IdentityMap</c>
/// lookup is one of the dominant scan-phase costs. Combined with <c>EnableRefHandlingFeature
/// = false</c>, the scan body for this type collapses to near-no-op. Reader-side
/// <c>ObjectRefFirst</c> / <c>ObjectRef</c> case-emit also strips when the child subtree's
/// tracking is provably absent. The runtime <c>ReferenceHandling</c> option is silently
/// ignored for instances of this type. Use only when this type is never shared by Id (e.g.
/// immutable value-DTO, single-use message payload, append-only log entry).</para>
/// </summary>
public bool EnableIdTrackingFeature { get; }
/// <summary>
/// When <c>true</c> (default): the SGen-emitted code emits non-IId reference tracking
/// (<c>wrapper.TryTrackInt32(GetHashCode(...))</c> in the scan pass when <c>ReferenceHandling
/// = All</c>) AND the reader-side <c>ObjectRef</c> / <c>ObjectRefFirst</c> / <c>ObjectWithMetadataRefFirst</c>
/// case-emit on every Complex / Collection-element / Dictionary-value property of this type.
/// <para>When <c>false</c>: both emit blocks are omitted. <b>Significantly reduces scan-pass
/// cost</b> — the per-instance hash-track lookup is eliminated; combined with
/// <c>EnableIdTrackingFeature = false</c> the scan pass for this type degenerates to a primitive-property
/// iteration only. Reader-side switch-dispatch shrinks by 2-3 cases per Complex/Collection/Dict
/// property (smaller jump table, better branch predictor, smaller IL). The runtime
/// <c>ReferenceHandling</c> option is silently ignored for instances of this type. Use only when
/// the type is never reference-shared across the serialized graph.</para>
/// </summary>
public bool EnableRefHandlingFeature { get; }
/// <summary>
/// When <c>true</c> (default): the SGen-emitted code emits string-interning support for
/// intern-eligible properties — <c>context.ScanInternString(str)</c> in the scan pass and
/// <c>StringInterned</c> / <c>StringInternFirstSmall</c> / <c>StringInternFirstMedium</c>
/// case-emit in the reader.
/// <para>When <c>false</c>: both emit blocks are omitted. <b>Significantly reduces scan-pass
/// cost</b> — string-property iteration with <c>IdentityMap</c> lookup is eliminated entirely
/// (often the heaviest scan-phase work on string-rich DTOs). The writer's per-property
/// <c>StringInternEligible</c> flag is always <c>false</c> for this type, so plan-entry
/// consumption never fires. Reader switch dispatch shrinks by 3 cases per string property
/// (smaller jump table, better branch predictor). The runtime <c>UseStringInterning</c> option
/// is silently ignored for instances of this type. Use only when this type's strings have low
/// intern-yield (unique per instance — Ids, GUIDs, free-text, never-repeated content).</para>
/// </summary>
public bool EnableInternStringFeature { get; }
/// <summary>