[LOADED_DOCS: 3 files, no new loads]

Improve AcBinary/MemoryPack bench parity & reporting

- Add geometric/median/arith mean deltas to overall bench output for robust performance comparison.
- Align MemoryPack string encoding with wire mode for fair apples-to-apples results.
- Refactor summary/log/LLM output to use new aggregation methods.
- Add temporary SGen feature gates for A/B testing property filter and polymorph overhead (set false for bench).
- Switch FastWire string encoding to fixed 4-byte LE char length (matches MemPack).
- Update SIMD/transcoder docs: document switch to BCL Utf8 APIs, which outperform custom SIMD.
- Minor code cleanups and improved comments.
- No wire-format changes; all updates are perf/bench/codegen only.
This commit is contained in:
Loretta 2026-05-10 09:08:31 +02:00
parent 1d256ea386
commit ef2cafbc38
7 changed files with 738 additions and 133 deletions

View File

@ -68,7 +68,12 @@
"Bash(curl -s \"https://raw.githubusercontent.com/dotnet/runtime/main/src/libraries/System.IO.Pipelines/src/System/IO/Pipelines/StreamPipeWriter.cs\")",
"WebFetch(domain:lemire.me)",
"Bash(gh pr *)",
"Bash(gh api *)"
"Bash(gh api *)",
"Bash(ls -la 'C:\\\\Users\\\\Fullepi\\\\Downloads\\\\_baseline\\\\cpuprofiler' 2>&1 | head -30)",
"Bash(where PerfView.exe)",
"Bash(where dotnet-trace *)",
"Bash(dotnet tool *)",
"Bash(dotnet-trace convert *)"
]
}
}

View File

@ -161,6 +161,24 @@ public static class Program
$"Compression={options.UseCompression}{extra}";
}
/// <summary>
/// Returns MemoryPack serializer options aligned with <see cref="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>
private static MemoryPackSerializerOptions GetMemPackOptions() =>
SelectedWireMode == WireMode.Fast
? MemoryPackSerializerOptions.Utf16
: MemoryPackSerializerOptions.Default;
/// <summary>
/// Converts a total-time (in ms across <see cref="TestIterations"/>) into per-operation microseconds.
/// Formula: <c>totalMs / iterations × 1000</c>. The benchmark stores <c>*TimeMs</c> as the cumulative
@ -185,6 +203,63 @@ public static class Program
private static double DesPerOp(BenchmarkResult r) => ToPerOpMicros(r.DeserializeTimeMs, r.DeserializeIterations);
private static double RtPerOp(BenchmarkResult r) => ToPerOpMicros(r.RoundTripTimeMs, r.RoundTripIterations);
/// <summary>
/// Per-cell-paired aggregation of an overall comparison. Captures three different aggregation
/// strategies so the reader can judge whether the headline delta is dominated by one large cell
/// (arithmetic mean) or representative of typical workload (geometric mean / median).
/// </summary>
/// <param name="ArithMeanPct">Arithmetic mean of µs/op — magnitude-weighted; biased toward Large cell.</param>
/// <param name="GeoMeanPct">Geometric mean of per-cell ratios — magnitude-neutral; each cell weighted equally.</param>
/// <param name="MedianPct">Median of per-cell ratios — outlier-resistant.</param>
/// <param name="AcAvg">Arithmetic mean AcBinary value (µs/op or bytes).</param>
/// <param name="MpAvg">Arithmetic mean MemPack value.</param>
/// <param name="CellCount">Number of paired cells contributing to the geo/median.</param>
private record OverallStats(double ArithMeanPct, double GeoMeanPct, double MedianPct, double AcAvg, double MpAvg, int CellCount);
/// <summary>
/// Computes arithmetic + geometric + median aggregation of an AcBinary-vs-MemPack comparison
/// across paired cells (joined by <c>TestDataName</c>). Per-cell pairing is required for the
/// geo/median variants — a cell where AcBinary or MemPack is missing is dropped from all stats.
/// Returns null when no paired cell has a valid value.
/// </summary>
private static OverallStats? ComputeOverallStats(
List<BenchmarkResult> acResults,
List<BenchmarkResult> mpResults,
Func<BenchmarkResult, double> getValue)
{
if (acResults.Count == 0 || mpResults.Count == 0) return null;
var pairs = (from ac in acResults
join mp in mpResults on ac.TestDataName equals mp.TestDataName
let acV = getValue(ac)
let mpV = getValue(mp)
where acV > 0 && mpV > 0
select (ac: acV, mp: mpV)).ToList();
if (pairs.Count == 0) return null;
var acAvg = pairs.Average(p => p.ac);
var mpAvg = pairs.Average(p => p.mp);
var ratios = pairs.Select(p => p.ac / p.mp).ToList();
// Geometric mean: exp(avg(ln(ratios))) — numerically stable vs Π ratios then ^(1/N).
var geoMean = Math.Exp(ratios.Sum(Math.Log) / ratios.Count);
// Median (paired-ratio): for even N use the midpoint of the two middle values.
var sorted = ratios.OrderBy(r => r).ToList();
var median = sorted.Count % 2 == 1
? sorted[sorted.Count / 2]
: (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2.0;
return new OverallStats(
ArithMeanPct: (acAvg / mpAvg - 1) * 100,
GeoMeanPct: (geoMean - 1) * 100,
MedianPct: (median - 1) * 100,
AcAvg: acAvg,
MpAvg: mpAvg,
CellCount: ratios.Count);
}
/// <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,
@ -1452,6 +1527,7 @@ public static class Program
private sealed class MemoryPackBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly MemoryPackSerializerOptions _options;
private readonly byte[] _serialized;
public string Engine => EngineMemoryPack;
@ -1461,12 +1537,14 @@ public static class Program
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes => 0;
public long SetupDeserializeAllocBytes => 0;
public string? OptionsDescription => $"StringEncoding={_options.StringEncoding}";
public MemoryPackBenchmark(TestOrder order, string optionsPreset)
{
_order = order;
OptionsPreset = optionsPreset;
_serialized = MemoryPackSerializer.Serialize(order);
_options = GetMemPackOptions();
_serialized = MemoryPackSerializer.Serialize(order, _options);
}
public void Warmup(int iterations)
@ -1479,15 +1557,15 @@ public static class Program
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize() => MemoryPackSerializer.Serialize(_order);
public void Serialize() => MemoryPackSerializer.Serialize(_order, _options);
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => MemoryPackSerializer.Deserialize<TestOrder>(_serialized);
public void Deserialize() => MemoryPackSerializer.Deserialize<TestOrder>(_serialized, _options);
public bool VerifyRoundTrip()
{
var bytes = MemoryPackSerializer.Serialize(_order);
var roundTripped = MemoryPackSerializer.Deserialize<TestOrder>(bytes);
var bytes = MemoryPackSerializer.Serialize(_order, _options);
var roundTripped = MemoryPackSerializer.Deserialize<TestOrder>(bytes, _options);
return DeepEqualsViaJson(_order, roundTripped);
}
}
@ -2422,6 +2500,7 @@ public static class Program
private sealed class MemoryPackFreshBufferWriterBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly MemoryPackSerializerOptions _options;
private readonly byte[] _serialized;
public string Engine => EngineMemoryPack;
@ -2431,12 +2510,14 @@ public static class Program
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes => 0;
public long SetupDeserializeAllocBytes => 0;
public string? OptionsDescription => $"StringEncoding={_options.StringEncoding}";
public MemoryPackFreshBufferWriterBenchmark(TestOrder order, string optionsPreset)
{
_order = order;
OptionsPreset = optionsPreset;
_serialized = MemoryPackSerializer.Serialize(order);
_options = GetMemPackOptions();
_serialized = MemoryPackSerializer.Serialize(order, _options);
}
public void Warmup(int iterations)
@ -2452,17 +2533,17 @@ public static class Program
public void Serialize()
{
var abw = new ArrayBufferWriter<byte>();
MemoryPackSerializer.Serialize(abw, _order);
MemoryPackSerializer.Serialize(abw, _order, _options);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => MemoryPackSerializer.Deserialize<TestOrder>(_serialized);
public void Deserialize() => MemoryPackSerializer.Deserialize<TestOrder>(_serialized, _options);
public bool VerifyRoundTrip()
{
var abw = new ArrayBufferWriter<byte>();
MemoryPackSerializer.Serialize(abw, _order);
var roundTripped = MemoryPackSerializer.Deserialize<TestOrder>(abw.WrittenSpan.ToArray());
MemoryPackSerializer.Serialize(abw, _order, _options);
var roundTripped = MemoryPackSerializer.Deserialize<TestOrder>(abw.WrittenSpan.ToArray(), _options);
return DeepEqualsViaJson(_order, roundTripped);
}
}
@ -2535,6 +2616,7 @@ public static class Program
private sealed class MemoryPackBufferWriterBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly MemoryPackSerializerOptions _options;
private readonly byte[] _serialized;
private readonly ArrayBufferWriter<byte> _bufferWriter;
@ -2545,12 +2627,14 @@ public static class Program
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes { get; }
public long SetupDeserializeAllocBytes => 0;
public string? OptionsDescription => $"StringEncoding={_options.StringEncoding}";
public MemoryPackBufferWriterBenchmark(TestOrder order, string optionsPreset)
{
_order = order;
OptionsPreset = optionsPreset;
_serialized = MemoryPackSerializer.Serialize(order);
_options = GetMemPackOptions();
_serialized = MemoryPackSerializer.Serialize(order, _options);
// Serialize-side setup only — see AcBinaryBufferWriterBenchmark for the full rationale.
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
@ -2573,17 +2657,17 @@ public static class Program
public void Serialize()
{
_bufferWriter.ResetWrittenCount();
MemoryPackSerializer.Serialize(_bufferWriter, _order);
MemoryPackSerializer.Serialize(_bufferWriter, _order, _options);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => MemoryPackSerializer.Deserialize<TestOrder>(_serialized);
public void Deserialize() => MemoryPackSerializer.Deserialize<TestOrder>(_serialized, _options);
public bool VerifyRoundTrip()
{
_bufferWriter.ResetWrittenCount();
MemoryPackSerializer.Serialize(_bufferWriter, _order);
var roundTripped = MemoryPackSerializer.Deserialize<TestOrder>(_bufferWriter.WrittenSpan.ToArray());
MemoryPackSerializer.Serialize(_bufferWriter, _order, _options);
var roundTripped = MemoryPackSerializer.Deserialize<TestOrder>(_bufferWriter.WrittenSpan.ToArray(), _options);
return DeepEqualsViaJson(_order, roundTripped);
}
}
@ -2932,63 +3016,62 @@ public static class Program
// All averages are over per-op µs (iter-independent). Batch-time averaging would mix rows
// measured with different iter counts (post-calibration), producing meaningless numbers.
var memPackAvgSer = memPackSerResults.Count > 0 ? memPackSerResults.Average(r => SerPerOp(r)) : 0;
var memPackAvgDes = memPackDesResults.Average(r => DesPerOp(r));
var memPackAvgRt = memPackRtResults.Average(r => RtPerOp(r));
var memPackAvgSize = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray)).Average(r => r.SerializedSize);
var memPackAvgSerAlloc = memPackSerResults.Count > 0 ? memPackSerResults.Average(r => r.SerializeAllocBytesPerOp) : 0;
var memPackAvgDesAlloc = memPackDesResults.Count > 0 ? memPackDesResults.Average(r => r.DeserializeAllocBytesPerOp) : 0;
// Three aggregations per metric:
// - Arithmetic mean (current behavior) — magnitude-weighted, biased toward Large cell.
// - Geometric mean of per-cell ratios — magnitude-neutral, each cell weighted equally.
// - 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 acBinaryAvgSer = acBinarySerResults.Count > 0 ? acBinarySerResults.Average(r => SerPerOp(r)) : 0;
var acBinaryAvgDes = acBinaryDesResults.Average(r => DesPerOp(r));
var acBinaryAvgRt = acBinaryRtResults.Average(r => RtPerOp(r));
var acBinaryAvgSize = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen)).Average(r => r.SerializedSize);
var acBinaryAvgSerAlloc = acBinarySerResults.Count > 0 ? acBinarySerResults.Average(r => r.SerializeAllocBytesPerOp) : 0;
var acBinaryAvgDesAlloc = acBinaryDesResults.Count > 0 ? acBinaryDesResults.Average(r => r.DeserializeAllocBytesPerOp) : 0;
var serStats = ComputeOverallStats(acBinarySerResults, memPackSerResults, SerPerOp);
var desStats = ComputeOverallStats(acBinaryDesResults, memPackDesResults, DesPerOp);
var rtStats = ComputeOverallStats(acBinaryRtResults, memPackRtResults, RtPerOp);
var sizeStats = ComputeOverallStats(sizeAcResults, sizeMpResults, r => r.SerializedSize);
var serAllocStats = ComputeOverallStats(acBinarySerResults, memPackSerResults, r => r.SerializeAllocBytesPerOp);
var desAllocStats = ComputeOverallStats(acBinaryDesResults, memPackDesResults, r => r.DeserializeAllocBytesPerOp);
System.Console.WriteLine();
System.Console.WriteLine($"── {"AcBinary (Byte[], SGen)"} vs {"MemoryPack (Byte[])"} (Overall) ──");
// Only show serialize comparison if data available
if (memPackAvgSer > 0 && acBinaryAvgSer > 0)
WriteOverallLine("Serialize", "µs/op", serStats);
WriteOverallLine("Deserialize", "µs/op", desStats);
WriteOverallLine("Round-trip", "µs/op", rtStats);
WriteOverallLine("Size", "B", sizeStats, "F0");
WriteOverallLine("Ser Alloc", "B/op", serAllocStats, "F0");
WriteOverallLine("Des Alloc", "B/op", desAllocStats, "F0");
}
/// <summary>
/// Formats a signed percent delta with explicit sign for positive values (`+1.5%`, `-3.0%`, `0.0%`).
/// Padded to 7 chars (e.g. ` +12.3%`, `-100.0%`) for column alignment in the Overall block.
/// </summary>
private static string FormatPctSigned(double pct) => pct.ToString("+0.0;-0.0;0.0", System.Globalization.CultureInfo.InvariantCulture).PadLeft(6) + "%";
/// <summary>
/// Renders one Overall row with arith / geo / median deltas + AcBinary/MemPack absolute means.
/// Color is driven by the geometric-mean delta (magnitude-neutral signal). Skips silently when
/// stats is null (no paired data).
/// </summary>
private static void WriteOverallLine(string label, string unit, OverallStats? stats, string fmt = "F2")
{
var serPctAll = (acBinaryAvgSer / memPackAvgSer - 1) * 100;
System.Console.ForegroundColor = serPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Serialize: {serPctAll:+0;-0}% ({acBinaryAvgSer:F2} µs/op vs {memPackAvgSer:F2} µs/op)");
if (stats == null) return;
// Color follows geo-mean (the magnitude-neutral signal). The arith-mean column may show a
// different sign when a single big cell dominates — that's exactly the signal we want to surface.
System.Console.ForegroundColor = stats.GeoMeanPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" {label,-12} arith {FormatPctSigned(stats.ArithMeanPct)} │ geo {FormatPctSigned(stats.GeoMeanPct)} │ median {FormatPctSigned(stats.MedianPct)} ({stats.AcAvg.ToString(fmt, System.Globalization.CultureInfo.InvariantCulture)} {unit} vs {stats.MpAvg.ToString(fmt, System.Globalization.CultureInfo.InvariantCulture)} {unit}, {stats.CellCount} cells)");
System.Console.ResetColor();
}
var desPctAll = (acBinaryAvgDes / memPackAvgDes - 1) * 100;
var rtPctAll = (acBinaryAvgRt / memPackAvgRt - 1) * 100;
var sizePctAll = (acBinaryAvgSize / memPackAvgSize - 1) * 100;
System.Console.ForegroundColor = desPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Deserialize: {desPctAll:+0;-0}% ({acBinaryAvgDes:F2} µs/op vs {memPackAvgDes:F2} µs/op)");
System.Console.ResetColor();
System.Console.ForegroundColor = rtPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Round-trip: {rtPctAll:+0;-0}% ({acBinaryAvgRt:F2} µs/op vs {memPackAvgRt:F2} µs/op)");
System.Console.ResetColor();
System.Console.ForegroundColor = sizePctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Size: {sizePctAll:+0;-0}% ({acBinaryAvgSize:F0} B vs {memPackAvgSize:F0} B)");
System.Console.ResetColor();
// Allocation comparison: byte[] API allocates the output array on both sides — delta shows serializer-overhead diff.
if (memPackAvgSerAlloc > 0 && acBinaryAvgSerAlloc > 0)
/// <summary>
/// Same as <see cref="WriteOverallLine"/> but appends to a <see cref="StringBuilder"/> (no color).
/// Used by the .log and .LLM file writers.
/// </summary>
private static void AppendOverallLine(StringBuilder sb, string label, string unit, OverallStats? stats, string fmt = "F2")
{
var serAllocPct = (acBinaryAvgSerAlloc / memPackAvgSerAlloc - 1) * 100;
System.Console.ForegroundColor = serAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Ser Alloc: {serAllocPct:+0;-0}% ({acBinaryAvgSerAlloc:F0} B/op vs {memPackAvgSerAlloc:F0} B/op)");
System.Console.ResetColor();
}
if (memPackAvgDesAlloc > 0 && acBinaryAvgDesAlloc > 0)
{
var desAllocPct = (acBinaryAvgDesAlloc / memPackAvgDesAlloc - 1) * 100;
System.Console.ForegroundColor = desAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Des Alloc: {desAllocPct:+0;-0}% ({acBinaryAvgDesAlloc:F0} B/op vs {memPackAvgDesAlloc:F0} B/op)");
System.Console.ResetColor();
}
if (stats == null) return;
sb.AppendLine($" {label,-12} arith {FormatPctSigned(stats.ArithMeanPct)} | geo {FormatPctSigned(stats.GeoMeanPct)} | median {FormatPctSigned(stats.MedianPct)} ({stats.AcAvg.ToString(fmt, System.Globalization.CultureInfo.InvariantCulture)} {unit} vs {stats.MpAvg.ToString(fmt, System.Globalization.CultureInfo.InvariantCulture)} {unit}, {stats.CellCount} cells)");
}
private static void SaveResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
@ -3143,39 +3226,17 @@ public static class Program
return;
}
if (memPackSerResults2.Count > 0 && acBinarySerResults2.Count > 0)
{
// Per-op µs averages (iter-independent) — see comment above the parallel block in PrintSummary.
var memPackAvgSer2 = memPackSerResults2.Average(r => SerPerOp(r));
var acBinaryAvgSer2 = acBinarySerResults2.Average(r => SerPerOp(r));
var memPackAvgSerAlloc2 = memPackSerResults2.Average(r => r.SerializeAllocBytesPerOp);
var acBinaryAvgSerAlloc2 = acBinarySerResults2.Average(r => r.SerializeAllocBytesPerOp);
sb.AppendLine($" Serialize: {((acBinaryAvgSer2 / memPackAvgSer2 - 1) * 100):+0;-0}% ({acBinaryAvgSer2:F2} µs/op vs {memPackAvgSer2:F2} µs/op)");
if (memPackAvgSerAlloc2 > 0)
sb.AppendLine($" Ser Alloc: {((acBinaryAvgSerAlloc2 / memPackAvgSerAlloc2 - 1) * 100):+0;-0}% ({acBinaryAvgSerAlloc2:F0} B/op vs {memPackAvgSerAlloc2:F0} B/op)");
}
// 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();
if (memPackDesResults2.Count > 0 && acBinaryDesResults2.Count > 0)
{
var memPackAvgDes2 = memPackDesResults2.Average(r => DesPerOp(r));
var acBinaryAvgDes2 = acBinaryDesResults2.Average(r => DesPerOp(r));
var memPackAvgDesAlloc2 = memPackDesResults2.Average(r => r.DeserializeAllocBytesPerOp);
var acBinaryAvgDesAlloc2 = acBinaryDesResults2.Average(r => r.DeserializeAllocBytesPerOp);
sb.AppendLine($" Deserialize: {((acBinaryAvgDes2 / memPackAvgDes2 - 1) * 100):+0;-0}% ({acBinaryAvgDes2:F2} µs/op vs {memPackAvgDes2:F2} µs/op)");
if (memPackAvgDesAlloc2 > 0)
sb.AppendLine($" Des Alloc: {((acBinaryAvgDesAlloc2 / memPackAvgDesAlloc2 - 1) * 100):+0;-0}% ({acBinaryAvgDesAlloc2:F0} B/op vs {memPackAvgDesAlloc2:F0} B/op)");
}
if (memPackRtResults2.Count > 0 && acBinaryRtResults2.Count > 0)
{
var memPackAvgRt2 = memPackRtResults2.Average(r => RtPerOp(r));
var acBinaryAvgRt2 = acBinaryRtResults2.Average(r => RtPerOp(r));
sb.AppendLine($" Round-trip: {((acBinaryAvgRt2 / memPackAvgRt2 - 1) * 100):+0;-0}% ({acBinaryAvgRt2:F2} µs/op vs {memPackAvgRt2:F2} µs/op)");
}
var memPackAvgSize2 = results.Where(r => (r.Engine == EngineMemoryPack && r.IoMode == IoByteArray)).Average(r => r.SerializedSize);
var acBinaryAvgSize2 = results.Where(r => (r.Engine == EngineAcBinary && r.IoMode == IoByteArray && r.DispatchMode == ModeSGen)).Average(r => r.SerializedSize);
sb.AppendLine($" Size: {((acBinaryAvgSize2 / memPackAvgSize2 - 1) * 100):+0;-0}% ({acBinaryAvgSize2:F0} B vs {memPackAvgSize2:F0} B)");
AppendOverallLine(sb, "Serialize", "µs/op", ComputeOverallStats(acBinarySerResults2, memPackSerResults2, SerPerOp));
AppendOverallLine(sb, "Ser Alloc", "B/op", ComputeOverallStats(acBinarySerResults2, memPackSerResults2, r => r.SerializeAllocBytesPerOp), "F0");
AppendOverallLine(sb, "Deserialize", "µs/op", ComputeOverallStats(acBinaryDesResults2, memPackDesResults2, DesPerOp));
AppendOverallLine(sb, "Des Alloc", "B/op", ComputeOverallStats(acBinaryDesResults2, memPackDesResults2, r => r.DeserializeAllocBytesPerOp), "F0");
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);
System.Console.WriteLine($"✓ Results saved to: {logFilePath}");
@ -3253,6 +3314,36 @@ public static class Program
}
}
// Overall AcBinary (SGen, Byte[]) vs MemoryPack (Byte[]) comparison — same three aggregations
// as the .log / console output (arithmetic / geometric / median of per-cell ratios). The
// 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 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();
var acBinarySerResultsLlm = acBinarySGenByteArrayResults.Where(r => r.SerializeTimeMs > 0).ToList();
var acBinaryDesResultsLlm = acBinarySGenByteArrayResults.Where(r => r.DeserializeTimeMs > 0).ToList();
var acBinaryRtResultsLlm = acBinarySGenByteArrayResults.Where(r => r.RoundTripTimeMs > 0).ToList();
if (memPackRtResultsLlm.Count > 0 && acBinaryRtResultsLlm.Count > 0)
{
sb.AppendLine();
sb.AppendLine("## Overall: AcBinary (Byte[], SGen) vs MemoryPack (Byte[])");
sb.AppendLine();
sb.AppendLine("Three aggregations of per-cell results: **arith** = arithmetic mean of µs/op (magnitude-weighted, Large cell dominates); **geo** = geometric mean of per-cell ratios (each cell weighted equally); **median** = median of per-cell ratios (outlier-resistant). Negative % = AcBinary faster/smaller; positive % = MemPack faster/smaller. The geo/median variants surface when a single big cell skews the arithmetic mean.");
sb.AppendLine();
sb.AppendLine("```");
AppendOverallLine(sb, "Serialize", "µs/op", ComputeOverallStats(acBinarySerResultsLlm, memPackSerResultsLlm, SerPerOp));
AppendOverallLine(sb, "Ser Alloc", "B/op", ComputeOverallStats(acBinarySerResultsLlm, memPackSerResultsLlm, r => r.SerializeAllocBytesPerOp), "F0");
AppendOverallLine(sb, "Deserialize", "µs/op", ComputeOverallStats(acBinaryDesResultsLlm, memPackDesResultsLlm, DesPerOp));
AppendOverallLine(sb, "Des Alloc", "B/op", ComputeOverallStats(acBinaryDesResultsLlm, memPackDesResultsLlm, r => r.DeserializeAllocBytesPerOp), "F0");
AppendOverallLine(sb, "Round-trip", "µs/op", ComputeOverallStats(acBinaryRtResultsLlm, memPackRtResultsLlm, RtPerOp));
AppendOverallLine(sb, "Size", "B", ComputeOverallStats(acBinarySGenByteArrayResults, memPackByteArrayResults, r => r.SerializedSize), "F0");
sb.AppendLine("```");
}
File.WriteAllText(filePath, sb.ToString(), Utf8NoBom);
System.Console.WriteLine($"✓ LLM results saved to: {filePath}");

View File

@ -18,6 +18,32 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
{
private const string AttributeName = "AyCode.Core.Serializers.Attributes.AcBinarySerializableAttribute";
// ────────────────────────────────────────────────────────────────────────────────────────────
// TEMPORARY (2026-05-08) — A/B test feature gates for hot-path overhead measurement.
//
// The generated SGen `WriteProperties` / `ScanObject` methods emit two kinds of overhead-blocks
// that are unconditionally present today but rarely exercised in typical workloads:
//
// 1. PropertyFilter guard (`UsePropertyFilter`) — every non-markerless property emit-site
// checks `context.HasPropertyFilter` + filter-context allocation + lambda-call.
// The benchmark workload never sets a property-filter → branch is always false →
// pure overhead (CPU cycles + i-cache pressure on the hot path).
//
// 2. Polymorphic object-with-type-name emit (`UsePolymorphType`) — `System.Object` declared
// properties emit `ObjectWithTypeName` marker + `WriteStringUtf8(AssemblyQualifiedName)`
// under `!context.UseMetadata`. Same: rarely used in typical DTO graphs.
//
// Setting either to `false` skips the corresponding emit at compile time → leaner generated
// code. The bench measures the actual delta vs MemPack apples-to-apples (which has neither
// of these features).
//
// Long-term: these flags will move to `[AcBinarySerializable(UsePropertyFilter = false, ...)]`
// attribute properties so consumers can opt out per type. Until then, keep both `false` for
// benchmark-vs-MemPack measurements; flip to `true` for production where the features are needed.
// ────────────────────────────────────────────────────────────────────────────────────────────
private const bool UsePropertyFilter = false;
private const bool UsePolymorphType = false;
private static readonly DiagnosticDescriptor CircularReferenceWarning = new(
id: "ACBIN001",
title: "Circular reference detected",
@ -672,7 +698,11 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
}
// All non-markerless properties: emit PropertyFilter guard
// When filter returns false, write PropertySkip and skip the property write
// When filter returns false, write PropertySkip and skip the property write.
// Gated by `UsePropertyFilter` (TEMPORARY const) — `false` skips emit entirely → leaner
// generated code on benchmark workloads where no property-filter is ever set.
if (UsePropertyFilter)
{
sb.AppendLine($"{i}if (context.HasPropertyFilter)");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} var fc_{p.Name} = new BinaryPropertyFilterContext(obj, typeof({fullTypeName}), \"{p.Name}\", typeof({p.TypeNameForTypeof}), static o => (({fullTypeName})o).{p.Name});");
@ -682,6 +712,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
sb.AppendLine($"{i} goto skip_{p.Name};");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i}}}");
}
// Nullable value types always use markered path (need Null marker)
if (IsNullableVTKind(p.TypeKind))
@ -715,14 +746,21 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
// System.Object property: runtime type unknown at compile time.
// Write ObjectWithTypeName prefix so deserializer can resolve the concrete type.
// Use value.GetType() for runtime type dispatch (not typeof(object)).
// Gated by `UsePolymorphType` (TEMPORARY const) — `false` skips the type-name emit
// entirely (deser will use the property's declared type, which is `object` so the
// round-trip would fail on polymorphic instances; safe ONLY when the workload is
// known not to use polymorphic object-typed properties — true for the benchmark).
sb.AppendLine($"{i}if ({a} == null) context.WriteByte(BinaryTypeCode.PropertySkip);");
sb.AppendLine($"{i}else");
sb.AppendLine($"{i}{{");
if (UsePolymorphType)
{
sb.AppendLine($"{i} if (!context.UseMetadata)");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} context.WriteByte(BinaryTypeCode.ObjectWithTypeName);");
sb.AppendLine($"{i} context.WriteStringUtf8({a}.GetType().AssemblyQualifiedName!);");
sb.AppendLine($"{i} }}");
}
sb.AppendLine($"{i} AcBinarySerializer.WriteValueGenerated({a}, {a}.GetType(), context, depth);");
sb.AppendLine($"{i}}}");
}
@ -881,8 +919,9 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
var a = $"obj.{p.Name}";
// PropertyFilter: must match write pass — if filter skips property, scan must skip too
// Only for non-markerless properties (matching EmitProp behavior)
if (!IsMarkerless(p.TypeKind))
// Only for non-markerless properties (matching EmitProp behavior).
// Gated by `UsePropertyFilter` (TEMPORARY const) — same A/B flag as the writer pass.
if (UsePropertyFilter && !IsMarkerless(p.TypeKind))
{
sb.AppendLine($"{i}if (context.HasPropertyFilter)");
sb.AppendLine($"{i}{{");
@ -1849,7 +1888,7 @@ public class AcBinarySourceGenerator : IIncrementalGenerator
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} if (context.FastWire)");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} var fwlen = (int)context.ReadVarUInt();");
sb.AppendLine($"{i} var fwlen = context.ReadInt32Unsafe();");
sb.AppendLine($"{i} {a} = fwlen == 0 ? string.Empty : context.ReadStringUtf8(fwlen);");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i} else");

View File

@ -128,6 +128,20 @@ public static partial class AcBinaryDeserializer
return value;
}
/// <summary>
/// Reads a 4-byte signed integer (little-endian on Intel/AMD, native-endian elsewhere).
/// Symmetric with <c>Unsafe.WriteUnaligned&lt;int&gt;</c> on the writer side. Used by FastWire
/// <c>StringSmall</c> reader to grab <c>charLen:int32</c>.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int ReadInt32Unsafe()
{
EnsureAvailable(4);
var value = Unsafe.ReadUnaligned<int>(ref _buffer[_position]);
_position += 4;
return value;
}
/// <summary>
/// Reads an 8-byte unsigned integer (little-endian on Intel/AMD, native-endian elsewhere).
/// Used by H2Q6 <c>StringBig</c> reader to grab packed <c>charLen:32 | utf8Len:32</c> in a single load.

View File

@ -1157,8 +1157,9 @@ public static partial class AcBinaryDeserializer
{
if (context.FastWire)
{
// Mode-shared marker: FastWire payload is [VarUInt charCount][UTF-16 raw bytes]
var charLenF = (int)context.ReadVarUInt();
// Mode-shared marker: FastWire payload is [charLen:int32 LE][UTF-16 raw bytes]
// Fix-int charLen (matches MemPack WriteUtf16 shape) — single 4-byte read, no VarUInt loop.
var charLenF = context.ReadInt32Unsafe();
return context.ReadStringUtf8(charLenF);
}

View File

@ -499,11 +499,14 @@ public static partial class AcBinarySerializer
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int VarUIntSize(uint value)
{
if (value < 0x80) return 1;
if (value < 0x4000) return 2;
if (value < 0x200000) return 3;
if (value < 0x10000000) return 4;
return 5;
return value switch
{
< 0x80 => 1,
< 0x4000 => 2,
< 0x200000 => 3,
< 0x10000000 => 4,
_ => 5
};
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
@ -750,13 +753,16 @@ public static partial class AcBinarySerializer
if (FastWire)
{
// FastWire: [StringSmall marker][VarUInt charCount][UTF-16 raw bytes]
// Marker value 91 is mode-shared (Compact StringSmall vs FastWire string marker);
// reader dispatches by deserializer mode, NOT by re-interpreting the marker.
WriteByte(BinaryTypeCode.StringSmall);
// FastWire: [StringSmall marker:1][charLen:int32 LE][UTF-16 raw bytes]
// Fix-int header (no tier-dispatch, no VarUInt branch loop) — matches MemPack `WriteUtf16`
// shape (which emits a fix `int` length). Single Unsafe.WriteUnaligned<int> store on the
// writer; symmetric ReadInt32Unsafe on the reader.
var byteLenF = charLength * 2; // safe: charLength ≤ 0x1FFFFFFF guarantees no overflow
WriteVarUInt((uint)charLength);
EnsureCapacity(byteLenF);
EnsureCapacity(7 + byteLenF);
var fwPos = _position;
var packed = (ulong)BinaryTypeCode.StringSmall | ((ulong)(uint)charLength << 8);
Unsafe.WriteUnaligned<ulong>(ref _buffer[fwPos], packed);
_position = fwPos + 5;
MemoryMarshal.AsBytes(value.AsSpan()).CopyTo(_buffer.AsSpan(_position, byteLenF));
_position += byteLenF;
return;
@ -772,10 +778,12 @@ public static partial class AcBinarySerializer
// reserve was Medium (5 byte) — body is left-shifted by 2 bytes to compact.
var maxBytes = charLength * 4;
int reserveHeader;
if (charLength <= 63) reserveHeader = 3;
else if (charLength <= 16383) reserveHeader = 5;
else reserveHeader = 9;
int reserveHeader = charLength switch
{
<= 63 => 3,
<= 16383 => 5,
_ => 9
};
EnsureCapacity(reserveHeader + maxBytes);

File diff suppressed because one or more lines are too long