[LOADED_DOCS: 3 files, no new loads]
Refactor AcBinaryHubProtocol for thread safety - Removed shared _currentHeaderContext; header context is now passed as a parameter through Parse* and ReadArguments/ReadSingleArgument methods, and stored per-binder for chunked messages. - Updated AyCodeBinaryHubProtocol to use the new header context flow for type resolution and argument deserialization. - Added concurrency tests to verify protocol instance safety under multi-threaded use and prevent state corruption or type resolution races. - Improved documentation and comments to clarify the stateless, concurrency-safe design.
This commit is contained in:
parent
6f5c57af6a
commit
4e91d24fdb
|
|
@ -65,31 +65,70 @@ public static class Program
|
|||
// Set console encoding to UTF-8 for proper Unicode character display
|
||||
System.Console.OutputEncoding = Encoding.UTF8;
|
||||
|
||||
var mode = args.Length > 0 ? args[0].ToLower() : "all";
|
||||
// Setup validation — abort BEFORE any benchmark logic if MemoryPack baseline is invalid.
|
||||
// Done early so user is told immediately, not after warmup.
|
||||
ValidateMemoryPackSetup();
|
||||
|
||||
if (mode == "quick")
|
||||
// Determine layer (which test data to run) and opMode (ser/des/all).
|
||||
// CLI args take precedence; if no args, show interactive menu.
|
||||
string layer;
|
||||
string opMode = "all";
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
WarmupIterations = 5;
|
||||
TestIterations = 100;
|
||||
BenchmarkSamples = 3;
|
||||
mode = "all";
|
||||
var selection = ShowInteractiveMenu();
|
||||
if (selection == null) return; // user pressed Q
|
||||
layer = selection;
|
||||
}
|
||||
|
||||
// Profiler mode: warmup only, then exit (for memory profiler analysis)
|
||||
if (mode == "profiler")
|
||||
else
|
||||
{
|
||||
RunProfilerMode();
|
||||
return;
|
||||
var arg = args[0].ToLower();
|
||||
|
||||
// Profiler mode: warmup only, then exit (for memory profiler analysis)
|
||||
if (arg == "profiler")
|
||||
{
|
||||
RunProfilerMode();
|
||||
return;
|
||||
}
|
||||
|
||||
// Quick mode: short warmup, few iterations, small sample count
|
||||
if (arg == "quick")
|
||||
{
|
||||
WarmupIterations = 5;
|
||||
TestIterations = 100;
|
||||
BenchmarkSamples = 3;
|
||||
layer = "all";
|
||||
}
|
||||
else if (arg is "core" or "comprehensive" or "edge" or "all")
|
||||
{
|
||||
layer = arg;
|
||||
}
|
||||
else if (arg is "ser" or "serialize")
|
||||
{
|
||||
opMode = "serialize";
|
||||
layer = "all";
|
||||
}
|
||||
else if (arg is "des" or "deserialize")
|
||||
{
|
||||
opMode = "deserialize";
|
||||
layer = "all";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Backwards compat: unknown arg → treat as layer keyword
|
||||
layer = arg;
|
||||
}
|
||||
}
|
||||
|
||||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
|
||||
System.Console.WriteLine("║ COMPREHENSIVE SERIALIZER BENCHMARK SUITE ║");
|
||||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
|
||||
var allResults = new List<BenchmarkResult>();
|
||||
var testDataSets = BenchmarkTestDataProvider.CreateTestDataSets();
|
||||
var allTestDataSets = BenchmarkTestDataProvider.CreateTestDataSets();
|
||||
var testDataSets = FilterByLayer(allTestDataSets, layer);
|
||||
|
||||
System.Console.WriteLine($"Mode: {mode} | Iterations: {TestIterations} | Warmup: {WarmupIterations} | Samples: {BenchmarkSamples} (median)");
|
||||
System.Console.WriteLine($"Build: {BuildConfiguration} | .NET: {Environment.Version} | Test Type: {testDataSets.FirstOrDefault()?.TypeName ?? "unknown"}");
|
||||
System.Console.WriteLine($"Layer: {layer} | OpMode: {opMode} | Iterations: {TestIterations} | Warmup: {WarmupIterations} | Samples: {BenchmarkSamples} (median)");
|
||||
System.Console.WriteLine($"Build: {BuildConfiguration} | .NET: {Environment.Version} | Test Type: {testDataSets.FirstOrDefault()?.TypeName ?? "unknown"} | Test Cells: {testDataSets.Count}/{allTestDataSets.Count}");
|
||||
System.Console.WriteLine();
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
|
|
@ -98,7 +137,7 @@ public static class Program
|
|||
System.Console.WriteLine($"TEST DATA: {testData.DisplayName}");
|
||||
System.Console.WriteLine($"{'═'.ToString().PadRight(70, '═')}");
|
||||
|
||||
var results = RunBenchmarksForTestData(testData, mode);
|
||||
var results = RunBenchmarksForTestData(testData, opMode);
|
||||
allResults.AddRange(results);
|
||||
}
|
||||
|
||||
|
|
@ -172,6 +211,19 @@ public static class Program
|
|||
var results = new List<BenchmarkResult>();
|
||||
var serializers = CreateSerializers(testData);
|
||||
|
||||
// Round-trip correctness check — once per (cell × serializer), BEFORE warmup. Aborts the entire benchmark on failure.
|
||||
System.Console.WriteLine("Verifying round-trip correctness...");
|
||||
foreach (var serializer in serializers)
|
||||
{
|
||||
if (!serializer.VerifyRoundTrip())
|
||||
{
|
||||
System.Console.Error.WriteLine($"❌ FATAL: Round-trip verification FAILED for {serializer.Name} on {testData.DisplayName}");
|
||||
System.Console.Error.WriteLine("Benchmark numbers from a serializer with broken round-trip would be meaningless. Aborting.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
}
|
||||
System.Console.WriteLine("✓ All serializers passed round-trip verification.");
|
||||
|
||||
// Warmup all serializers
|
||||
System.Console.WriteLine($"Warming up ({WarmupIterations} iterations)...");
|
||||
foreach (var serializer in serializers)
|
||||
|
|
@ -183,7 +235,7 @@ public static class Program
|
|||
Thread.Sleep(3000);
|
||||
|
||||
// Run benchmarks
|
||||
System.Console.WriteLine($"Running benchmarks ({TestIterations} iterations)...\n");
|
||||
System.Console.WriteLine($"Running benchmarks ({TestIterations} iterations × {BenchmarkSamples} samples median)...\n");
|
||||
|
||||
foreach (var serializer in serializers)
|
||||
{
|
||||
|
|
@ -198,11 +250,14 @@ public static class Program
|
|||
if (mode is "all" or "serialize" or "ser")
|
||||
{
|
||||
result.SerializeTimeMs = RunTimed(() => serializer.Serialize(), TestIterations);
|
||||
// Dedicated alloc-only sample (separate from timing samples; keeps timing pure)
|
||||
result.SerializeAllocBytesPerOp = MeasureAllocation(() => serializer.Serialize(), TestIterations);
|
||||
}
|
||||
|
||||
if (mode is "all" or "deserialize" or "des")
|
||||
{
|
||||
result.DeserializeTimeMs = RunTimed(() => serializer.Deserialize(), TestIterations);
|
||||
result.DeserializeAllocBytesPerOp = MeasureAllocation(() => serializer.Deserialize(), TestIterations);
|
||||
}
|
||||
|
||||
results.Add(result);
|
||||
|
|
@ -289,6 +344,115 @@ public static class Program
|
|||
: (times[samples / 2 - 1] + times[samples / 2]) / 2.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Measures per-call allocation in bytes after a clean GC. Single dedicated sample (no median) — keeps timing samples pure.
|
||||
/// </summary>
|
||||
private static long MeasureAllocation(Action action, int iterations)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
var before = GC.GetAllocatedBytesForCurrentThread();
|
||||
for (var i = 0; i < iterations; i++) action();
|
||||
var after = GC.GetAllocatedBytesForCurrentThread();
|
||||
return (after - before) / iterations;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions VerifyJsonOpts = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip equality check: serialize both via System.Text.Json (canonical form) and compare strings.
|
||||
/// Slower than property-by-property compare, but universal — works for any object graph without custom comparer.
|
||||
/// </summary>
|
||||
private static bool DeepEqualsViaJson(object? a, object? b)
|
||||
{
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
var jsonA = JsonSerializer.Serialize(a, VerifyJsonOpts);
|
||||
var jsonB = JsonSerializer.Serialize(b, VerifyJsonOpts);
|
||||
return jsonA == jsonB;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates MemoryPack setup at startup. Aborts the benchmark if TestOrder is not [MemoryPackable].
|
||||
/// Without this attribute, MemoryPack falls back to runtime resolver (slower) — comparison would be INVALID.
|
||||
/// </summary>
|
||||
private static void ValidateMemoryPackSetup()
|
||||
{
|
||||
var typesToCheck = new[] { typeof(TestOrder) };
|
||||
foreach (var type in typesToCheck)
|
||||
{
|
||||
var hasAttr = type.GetCustomAttributes(typeof(MemoryPackableAttribute), inherit: true).Any();
|
||||
if (!hasAttr)
|
||||
{
|
||||
System.Console.Error.WriteLine($"❌ FATAL: {type.FullName} is not [MemoryPackable] — MemoryPack would fall back to runtime resolver, comparison is INVALID for SGen-vs-SGen claim.");
|
||||
System.Console.Error.WriteLine("Add [MemoryPackable] to the type and any nested types referenced from it.");
|
||||
Environment.Exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interactive menu shown when no CLI args. Returns the layer keyword (core/comprehensive/edge/all) or null on Quit.
|
||||
/// </summary>
|
||||
private static string? ShowInteractiveMenu()
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("╔══════════════════════════════════════════════════════════╗");
|
||||
System.Console.WriteLine("║ AcBinary Benchmark Suite ║");
|
||||
System.Console.WriteLine("╚══════════════════════════════════════════════════════════╝");
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine("Select benchmark layer:");
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine(" [1] Core — daily iteration");
|
||||
System.Console.WriteLine(" [2] Comprehensive — release validation");
|
||||
System.Console.WriteLine(" [3] Edge cases — refactor verification");
|
||||
System.Console.WriteLine(" [A] All layers");
|
||||
System.Console.WriteLine(" [Q] Quit");
|
||||
System.Console.Write("\nSelection: ");
|
||||
var key = System.Console.ReadKey(intercept: false).KeyChar;
|
||||
System.Console.WriteLine();
|
||||
return char.ToLower(key) switch
|
||||
{
|
||||
'1' => "core",
|
||||
'2' => "comprehensive",
|
||||
'3' => "edge",
|
||||
'a' => "all",
|
||||
'q' => null,
|
||||
_ => "core"
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filters test data sets by layer keyword. Layered approach lets you run only what's needed for the iteration cadence.
|
||||
/// P1: only "Core" data exists (Small/Medium/Large/Repeated/Deep). Comprehensive and Edge layers will be expanded in P2.
|
||||
/// </summary>
|
||||
private static List<TestDataSet> FilterByLayer(List<TestDataSet> all, string layer)
|
||||
{
|
||||
if (layer == "all") return all.ToList();
|
||||
|
||||
var coreNames = new[] { "Small", "Medium", "Large", "Repeated", "Deep" };
|
||||
// P2 will add: "Flat", "Polymorphic", "Collection", "Numeric", "NonAscii", etc.
|
||||
var comprehensiveExtras = new string[] { /* P2 */ };
|
||||
// P3 will add: "ColdStart", "VeryLarge", "PathologicalString", etc.
|
||||
var edgeExtras = new string[] { /* P3 */ };
|
||||
|
||||
bool StartsWithAny(string name, string[] prefixes) => prefixes.Any(p => name.StartsWith(p));
|
||||
|
||||
return layer switch
|
||||
{
|
||||
"core" => all.Where(t => StartsWithAny(t.Name, coreNames)).ToList(),
|
||||
"comprehensive" => all.Where(t => StartsWithAny(t.Name, coreNames) || StartsWithAny(t.Name, comprehensiveExtras)).ToList(),
|
||||
"edge" => all.Where(t => StartsWithAny(t.Name, coreNames) || StartsWithAny(t.Name, comprehensiveExtras) || StartsWithAny(t.Name, edgeExtras)).ToList(),
|
||||
_ => all.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serializer Implementations
|
||||
|
|
@ -301,6 +465,8 @@ public static class Program
|
|||
void Warmup(int iterations);
|
||||
void Serialize();
|
||||
void Deserialize();
|
||||
/// <summary>Round-trip correctness check — called once per cell before warmup. Returns true if Serialize+Deserialize preserves data.</summary>
|
||||
bool VerifyRoundTrip();
|
||||
}
|
||||
|
||||
private sealed class AcBinaryBenchmark : ISerializerBenchmark
|
||||
|
|
@ -346,6 +512,13 @@ public static class Program
|
|||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => AcBinaryDeserializer.Deserialize<TestOrder>(_serialized, _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var bytes = AcBinarySerializer.Serialize(_order, _options);
|
||||
var roundTripped = AcBinaryDeserializer.Deserialize<TestOrder>(bytes, _options);
|
||||
return DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MemoryPackBenchmark : ISerializerBenchmark
|
||||
|
|
@ -377,6 +550,13 @@ public static class Program
|
|||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => MemoryPackSerializer.Deserialize<TestOrder>(_serialized);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var bytes = MemoryPackSerializer.Serialize(_order);
|
||||
var roundTripped = MemoryPackSerializer.Deserialize<TestOrder>(bytes);
|
||||
return DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MessagePackBenchmark : ISerializerBenchmark
|
||||
|
|
@ -418,6 +598,13 @@ public static class Program
|
|||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => MessagePackSerializer.Deserialize<TestOrder>(_serialized, _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var bytes = MessagePackSerializer.Serialize(_order, _options);
|
||||
var roundTripped = MessagePackSerializer.Deserialize<TestOrder>(bytes, _options);
|
||||
return DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class AcBinaryBufferWriterBenchmark : ISerializerBenchmark
|
||||
|
|
@ -459,6 +646,14 @@ public static class Program
|
|||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => AcBinaryDeserializer.Deserialize<TestOrder>(_serialized, _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var bw = new ArrayBufferWriter<byte>();
|
||||
AcBinarySerializer.Serialize(_order, bw, _options);
|
||||
var roundTripped = AcBinaryDeserializer.Deserialize<TestOrder>(bw.WrittenSpan.ToArray(), _options);
|
||||
return DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SystemTextJsonBenchmark : ISerializerBenchmark
|
||||
|
|
@ -499,6 +694,13 @@ public static class Program
|
|||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
public void Deserialize() => System.Text.Json.JsonSerializer.Deserialize<TestOrder>(_serialized, _options);
|
||||
|
||||
public bool VerifyRoundTrip()
|
||||
{
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(_order, _options);
|
||||
var roundTripped = System.Text.Json.JsonSerializer.Deserialize<TestOrder>(json, _options);
|
||||
return DeepEqualsViaJson(_order, roundTripped);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
@ -513,6 +715,8 @@ public static class Program
|
|||
public int SerializedSize { get; set; }
|
||||
public double SerializeTimeMs { get; set; }
|
||||
public double DeserializeTimeMs { get; set; }
|
||||
public long SerializeAllocBytesPerOp { get; set; }
|
||||
public long DeserializeAllocBytesPerOp { get; set; }
|
||||
public double RoundTripTimeMs => SerializeTimeMs + DeserializeTimeMs;
|
||||
}
|
||||
|
||||
|
|
@ -520,7 +724,9 @@ public static class Program
|
|||
{
|
||||
var ser = result.SerializeTimeMs > 0 ? $"{result.SerializeTimeMs,8:F2} ms" : " N/A";
|
||||
var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs,8:F2} ms" : " N/A";
|
||||
System.Console.WriteLine($" {result.SerializerName,-25} | Size: {result.SerializedSize,8:N0} | Ser: {ser} | Des: {des}");
|
||||
var serAlloc = result.SerializeTimeMs > 0 ? $"{result.SerializeAllocBytesPerOp,8:N0} B/op" : " N/A";
|
||||
var desAlloc = result.DeserializeTimeMs > 0 ? $"{result.DeserializeAllocBytesPerOp,8:N0} B/op" : " N/A";
|
||||
System.Console.WriteLine($" {result.SerializerName,-25} | Size: {result.SerializedSize,8:N0} | Ser: {ser} ({serAlloc}) | Des: {des} ({desAlloc})");
|
||||
}
|
||||
|
||||
private static void PrintGroupedResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
|
||||
|
|
@ -547,7 +753,8 @@ public static class Program
|
|||
foreach (var testData in testDataSets)
|
||||
{
|
||||
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.RoundTripTimeMs).ToList();
|
||||
var msgPackResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerMessagePack);
|
||||
// Baseline switched MessagePack → MemoryPack: MemoryPack is the SOTA performance leader.
|
||||
var memPackResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerMemoryPack);
|
||||
var acBinaryResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerAcBinaryDefault);
|
||||
|
||||
System.Console.WriteLine($"\n┌─ {testData.DisplayName} ─".PadRight(98, '─') + "┐");
|
||||
|
|
@ -562,24 +769,24 @@ public static class Program
|
|||
var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs:F2} ms" : "N/A";
|
||||
var rt = result.RoundTripTimeMs > 0 ? $"{result.RoundTripTimeMs:F2} ms" : "N/A";
|
||||
|
||||
// Highlight MessagePack and AcBinary (Default) with win/lose colors
|
||||
var isHighlighted = result.SerializerName is SerializerMessagePack or SerializerAcBinaryDefault;
|
||||
// Highlight MemoryPack (baseline) and AcBinary (Default) with win/lose colors
|
||||
var isHighlighted = result.SerializerName is SerializerMemoryPack or SerializerAcBinaryDefault;
|
||||
var prefix = isHighlighted ? "│►" : "│ ";
|
||||
var suffix = isHighlighted ? "◄│" : " │";
|
||||
|
||||
// Color logic: Green = winner (faster), Red = loser (slower)
|
||||
if (isHighlighted && msgPackResult != null && acBinaryResult != null)
|
||||
if (isHighlighted && memPackResult != null && acBinaryResult != null)
|
||||
{
|
||||
var isMsgPack = result.SerializerName == SerializerMessagePack;
|
||||
var msgPackFaster = msgPackResult.RoundTripTimeMs < acBinaryResult.RoundTripTimeMs;
|
||||
var isMemPack = result.SerializerName == SerializerMemoryPack;
|
||||
var memPackFaster = memPackResult.RoundTripTimeMs < acBinaryResult.RoundTripTimeMs;
|
||||
|
||||
if (isMsgPack)
|
||||
if (isMemPack)
|
||||
{
|
||||
System.Console.ForegroundColor = msgPackFaster ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.ForegroundColor = memPackFaster ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
}
|
||||
else
|
||||
{
|
||||
System.Console.ForegroundColor = msgPackFaster ? ConsoleColor.Red : ConsoleColor.Green;
|
||||
System.Console.ForegroundColor = memPackFaster ? ConsoleColor.Red : ConsoleColor.Green;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -591,16 +798,26 @@ public static class Program
|
|||
}
|
||||
}
|
||||
|
||||
// Footer row: AcBinary (Default) vs MessagePack comparison per column
|
||||
if (msgPackResult != null && acBinaryResult != null)
|
||||
// Allocation summary row (per-op allocation in bytes; lower is better)
|
||||
System.Console.WriteLine($"├{"─".PadRight(6, '─')}┼{"─".PadRight(27, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(14, '─')}┤");
|
||||
foreach (var result in testResults)
|
||||
{
|
||||
var sizePct = (acBinaryResult.SerializedSize / (double)msgPackResult.SerializedSize - 1) * 100;
|
||||
var serPct = msgPackResult.SerializeTimeMs > 0 ? (acBinaryResult.SerializeTimeMs / msgPackResult.SerializeTimeMs - 1) * 100 : 0;
|
||||
var desPct = msgPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / msgPackResult.DeserializeTimeMs - 1) * 100 : 0;
|
||||
var rtPct = msgPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / msgPackResult.RoundTripTimeMs - 1) * 100 : 0;
|
||||
if (result.SerializerName is not (SerializerMemoryPack or SerializerAcBinaryDefault)) continue;
|
||||
var serAlloc = result.SerializeTimeMs > 0 ? $"{result.SerializeAllocBytesPerOp:N0} B/op" : "N/A";
|
||||
var desAlloc = result.DeserializeTimeMs > 0 ? $"{result.DeserializeAllocBytesPerOp:N0} B/op" : "N/A";
|
||||
System.Console.WriteLine($"│ alloc │ {result.SerializerName,-25} │ {"",10} │ {serAlloc,12} │ {desAlloc,12} │ {"",12} │");
|
||||
}
|
||||
|
||||
// Footer row: AcBinary (Default) vs MemoryPack comparison per column
|
||||
if (memPackResult != null && acBinaryResult != null)
|
||||
{
|
||||
var sizePct = (acBinaryResult.SerializedSize / (double)memPackResult.SerializedSize - 1) * 100;
|
||||
var serPct = memPackResult.SerializeTimeMs > 0 ? (acBinaryResult.SerializeTimeMs / memPackResult.SerializeTimeMs - 1) * 100 : 0;
|
||||
var desPct = memPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / memPackResult.DeserializeTimeMs - 1) * 100 : 0;
|
||||
var rtPct = memPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / memPackResult.RoundTripTimeMs - 1) * 100 : 0;
|
||||
|
||||
System.Console.WriteLine($"├{"─".PadRight(6, '─')}┴{"─".PadRight(27, '─')}┼{"─".PadRight(12, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(14, '─')}┼{"─".PadRight(13, '─')}┤");
|
||||
System.Console.Write($"│ ► Default vs {SerializerMessagePack,-19} │ ");
|
||||
System.Console.Write($"│ ► Default vs {SerializerMemoryPack,-19} │ ");
|
||||
|
||||
// Size
|
||||
System.Console.ForegroundColor = sizePct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
|
|
@ -677,61 +894,81 @@ public static class Program
|
|||
if (fastestRt != null)
|
||||
System.Console.WriteLine($"{"Fastest Round-trip",-20} │ {fastestRt.Name,-25} │ {fastestRt.AvgTime,15:F2} ms");
|
||||
|
||||
// Overall AcBinary Default vs MessagePack comparison
|
||||
var msgPackSerResults = results.Where(r => r.SerializerName == SerializerMessagePack && r.SerializeTimeMs > 0).ToList();
|
||||
var msgPackDesResults = results.Where(r => r.SerializerName == SerializerMessagePack && r.DeserializeTimeMs > 0).ToList();
|
||||
var msgPackRtResults = results.Where(r => r.SerializerName == SerializerMessagePack && r.RoundTripTimeMs > 0).ToList();
|
||||
// Overall AcBinary Default vs MemoryPack comparison (baseline switched MessagePack → MemoryPack as SOTA reference)
|
||||
var memPackSerResults = results.Where(r => r.SerializerName == SerializerMemoryPack && r.SerializeTimeMs > 0).ToList();
|
||||
var memPackDesResults = results.Where(r => r.SerializerName == SerializerMemoryPack && r.DeserializeTimeMs > 0).ToList();
|
||||
var memPackRtResults = results.Where(r => r.SerializerName == SerializerMemoryPack && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
var acBinarySerResults = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.SerializeTimeMs > 0).ToList();
|
||||
var acBinaryDesResults = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.DeserializeTimeMs > 0).ToList();
|
||||
var acBinaryRtResults = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
// Skip comparison if no data available
|
||||
if (msgPackRtResults.Count == 0 || acBinaryRtResults.Count == 0)
|
||||
if (memPackRtResults.Count == 0 || acBinaryRtResults.Count == 0)
|
||||
{
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine($"── {SerializerAcBinaryDefault} vs {SerializerMessagePack} (Overall) ──");
|
||||
System.Console.WriteLine($"── {SerializerAcBinaryDefault} vs {SerializerMemoryPack} (Overall) ──");
|
||||
System.Console.WriteLine(" (Comparison requires both serialize and deserialize data)");
|
||||
return;
|
||||
}
|
||||
|
||||
var msgPackAvgSer = msgPackSerResults.Count > 0 ? msgPackSerResults.Average(r => r.SerializeTimeMs) : 0;
|
||||
var msgPackAvgDes = msgPackDesResults.Average(r => r.DeserializeTimeMs);
|
||||
var msgPackAvgRt = msgPackRtResults.Average(r => r.RoundTripTimeMs);
|
||||
var msgPackAvgSize = results.Where(r => r.SerializerName == SerializerMessagePack).Average(r => r.SerializedSize);
|
||||
var memPackAvgSer = memPackSerResults.Count > 0 ? memPackSerResults.Average(r => r.SerializeTimeMs) : 0;
|
||||
var memPackAvgDes = memPackDesResults.Average(r => r.DeserializeTimeMs);
|
||||
var memPackAvgRt = memPackRtResults.Average(r => r.RoundTripTimeMs);
|
||||
var memPackAvgSize = results.Where(r => r.SerializerName == SerializerMemoryPack).Average(r => r.SerializedSize);
|
||||
var memPackAvgSerAlloc = memPackSerResults.Count > 0 ? memPackSerResults.Average(r => r.SerializeAllocBytesPerOp) : 0;
|
||||
var memPackAvgDesAlloc = memPackDesResults.Count > 0 ? memPackDesResults.Average(r => r.DeserializeAllocBytesPerOp) : 0;
|
||||
|
||||
var acBinaryAvgSer = acBinarySerResults.Count > 0 ? acBinarySerResults.Average(r => r.SerializeTimeMs) : 0;
|
||||
var acBinaryAvgDes = acBinaryDesResults.Average(r => r.DeserializeTimeMs);
|
||||
var acBinaryAvgRt = acBinaryRtResults.Average(r => r.RoundTripTimeMs);
|
||||
var acBinaryAvgSize = results.Where(r => r.SerializerName == SerializerAcBinaryDefault).Average(r => r.SerializedSize);
|
||||
var acBinaryAvgSerAlloc = acBinarySerResults.Count > 0 ? acBinarySerResults.Average(r => r.SerializeAllocBytesPerOp) : 0;
|
||||
var acBinaryAvgDesAlloc = acBinaryDesResults.Count > 0 ? acBinaryDesResults.Average(r => r.DeserializeAllocBytesPerOp) : 0;
|
||||
|
||||
System.Console.WriteLine();
|
||||
System.Console.WriteLine($"── {SerializerAcBinaryDefault} vs {SerializerMessagePack} (Overall) ──");
|
||||
System.Console.WriteLine($"── {SerializerAcBinaryDefault} vs {SerializerMemoryPack} (Overall) ──");
|
||||
|
||||
// Only show serialize comparison if data available
|
||||
if (msgPackAvgSer > 0 && acBinaryAvgSer > 0)
|
||||
if (memPackAvgSer > 0 && acBinaryAvgSer > 0)
|
||||
{
|
||||
var serPctAll = (acBinaryAvgSer / msgPackAvgSer - 1) * 100;
|
||||
var serPctAll = (acBinaryAvgSer / memPackAvgSer - 1) * 100;
|
||||
System.Console.ForegroundColor = serPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.WriteLine($" Serialize: {serPctAll:+0;-0}% ({acBinaryAvgSer:F2} ms vs {msgPackAvgSer:F2} ms)");
|
||||
System.Console.WriteLine($" Serialize: {serPctAll:+0;-0}% ({acBinaryAvgSer:F2} ms vs {memPackAvgSer:F2} ms)");
|
||||
System.Console.ResetColor();
|
||||
}
|
||||
|
||||
var desPctAll = (acBinaryAvgDes / msgPackAvgDes - 1) * 100;
|
||||
var rtPctAll = (acBinaryAvgRt / msgPackAvgRt - 1) * 100;
|
||||
var sizePctAll = (acBinaryAvgSize / msgPackAvgSize - 1) * 100;
|
||||
var desPctAll = (acBinaryAvgDes / memPackAvgDes - 1) * 100;
|
||||
var rtPctAll = (acBinaryAvgRt / memPackAvgRt - 1) * 100;
|
||||
var sizePctAll = (acBinaryAvgSize / memPackAvgSize - 1) * 100;
|
||||
|
||||
System.Console.ForegroundColor = desPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.WriteLine($" Deserialize: {desPctAll:+0;-0}% ({acBinaryAvgDes:F2} ms vs {msgPackAvgDes:F2} ms)");
|
||||
System.Console.WriteLine($" Deserialize: {desPctAll:+0;-0}% ({acBinaryAvgDes:F2} ms vs {memPackAvgDes:F2} ms)");
|
||||
System.Console.ResetColor();
|
||||
|
||||
System.Console.ForegroundColor = rtPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.WriteLine($" Round-trip: {rtPctAll:+0;-0}% ({acBinaryAvgRt:F2} ms vs {msgPackAvgRt:F2} ms)");
|
||||
System.Console.WriteLine($" Round-trip: {rtPctAll:+0;-0}% ({acBinaryAvgRt:F2} ms vs {memPackAvgRt:F2} ms)");
|
||||
System.Console.ResetColor();
|
||||
|
||||
System.Console.ForegroundColor = sizePctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.WriteLine($" Size: {sizePctAll:+0;-0}% ({acBinaryAvgSize:F0} B vs {msgPackAvgSize:F0} B)");
|
||||
System.Console.WriteLine($" Size: {sizePctAll:+0;-0}% ({acBinaryAvgSize:F0} B vs {memPackAvgSize:F0} B)");
|
||||
System.Console.ResetColor();
|
||||
|
||||
// Allocation comparison: byte[] API allocates the output array on both sides — delta shows serializer-overhead diff.
|
||||
if (memPackAvgSerAlloc > 0 && acBinaryAvgSerAlloc > 0)
|
||||
{
|
||||
var serAllocPct = (acBinaryAvgSerAlloc / memPackAvgSerAlloc - 1) * 100;
|
||||
System.Console.ForegroundColor = serAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.WriteLine($" Ser Alloc: {serAllocPct:+0;-0}% ({acBinaryAvgSerAlloc:F0} B/op vs {memPackAvgSerAlloc:F0} B/op)");
|
||||
System.Console.ResetColor();
|
||||
}
|
||||
if (memPackAvgDesAlloc > 0 && acBinaryAvgDesAlloc > 0)
|
||||
{
|
||||
var desAllocPct = (acBinaryAvgDesAlloc / memPackAvgDesAlloc - 1) * 100;
|
||||
System.Console.ForegroundColor = desAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
|
||||
System.Console.WriteLine($" Des Alloc: {desAllocPct:+0;-0}% ({acBinaryAvgDesAlloc:F0} B/op vs {memPackAvgDesAlloc:F0} B/op)");
|
||||
System.Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
|
||||
|
|
@ -791,58 +1028,60 @@ public static class Program
|
|||
sb.AppendLine();
|
||||
}
|
||||
|
||||
// CSV-like data for easy import
|
||||
// CSV-like data for easy import (now includes per-op allocation columns)
|
||||
sb.AppendLine("=== RAW DATA (CSV) ===");
|
||||
sb.AppendLine("TestData,Serializer,Size,SerializeMs,DeserializeMs,RoundTripMs");
|
||||
sb.AppendLine("TestData,Serializer,Size,SerializeMs,DeserializeMs,RoundTripMs,SerializeAllocBytesPerOp,DeserializeAllocBytesPerOp");
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).ToList();
|
||||
foreach (var result in testResults)
|
||||
{
|
||||
sb.AppendLine($"{result.TestDataName},{result.SerializerName},{result.SerializedSize},{result.SerializeTimeMs:F2},{result.DeserializeTimeMs:F2},{result.RoundTripTimeMs:F2}");
|
||||
sb.AppendLine($"{result.TestDataName},{result.SerializerName},{result.SerializedSize},{result.SerializeTimeMs:F2},{result.DeserializeTimeMs:F2},{result.RoundTripTimeMs:F2},{result.SerializeAllocBytesPerOp},{result.DeserializeAllocBytesPerOp}");
|
||||
}
|
||||
}
|
||||
sb.AppendLine();
|
||||
|
||||
// Formatted results
|
||||
sb.AppendLine("=== FORMATTED RESULTS BY TEST DATA ===");
|
||||
sb.AppendLine($"(►) = Highlighted: {SerializerMessagePack} (baseline) and {SerializerAcBinaryDefault}");
|
||||
sb.AppendLine($"(►) = Highlighted: {SerializerMemoryPack} (baseline) and {SerializerAcBinaryDefault}");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.RoundTripTimeMs).ToList();
|
||||
var msgPackResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerMessagePack);
|
||||
var memPackResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerMemoryPack);
|
||||
var acBinaryResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerAcBinaryDefault);
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"--- {testData.DisplayName} ---");
|
||||
sb.AppendLine($"{"#",-4} {"Serializer",-26} {"Size",-12} {"Serialize",-14} {"Deserialize",-14} {"Round-trip",-14}");
|
||||
sb.AppendLine(new string('-', 86));
|
||||
sb.AppendLine($"{"#",-4} {"Serializer",-26} {"Size",-12} {"Serialize",-14} {"Deserialize",-14} {"Round-trip",-14} {"SerAlloc",-12} {"DesAlloc",-12}");
|
||||
sb.AppendLine(new string('-', 110));
|
||||
|
||||
var rank = 1;
|
||||
foreach (var result in testResults)
|
||||
{
|
||||
var isHighlighted = result.SerializerName is SerializerMessagePack or SerializerAcBinaryDefault;
|
||||
var isHighlighted = result.SerializerName is SerializerMemoryPack or SerializerAcBinaryDefault;
|
||||
var prefix = isHighlighted ? "► " : " ";
|
||||
|
||||
var size = $"{result.SerializedSize:N0}";
|
||||
var ser = result.SerializeTimeMs > 0 ? $"{result.SerializeTimeMs:F2} ms" : "N/A";
|
||||
var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs:F2} ms" : "N/A";
|
||||
var rt = result.RoundTripTimeMs > 0 ? $"{result.RoundTripTimeMs:F2} ms" : "N/A";
|
||||
var serAlloc = result.SerializeTimeMs > 0 ? $"{result.SerializeAllocBytesPerOp:N0} B" : "N/A";
|
||||
var desAlloc = result.DeserializeTimeMs > 0 ? $"{result.DeserializeAllocBytesPerOp:N0} B" : "N/A";
|
||||
|
||||
sb.AppendLine($"{rank++,2} {prefix}{result.SerializerName,-24} {size,-12} {ser,-14} {des,-14} {rt,-14}");
|
||||
sb.AppendLine($"{rank++,2} {prefix}{result.SerializerName,-24} {size,-12} {ser,-14} {des,-14} {rt,-14} {serAlloc,-12} {desAlloc,-12}");
|
||||
}
|
||||
|
||||
// Summary row for this test data
|
||||
if (msgPackResult != null && acBinaryResult != null)
|
||||
// Summary row for this test data (vs MemoryPack — baseline switched MessagePack → MemoryPack)
|
||||
if (memPackResult != null && acBinaryResult != null)
|
||||
{
|
||||
var sizePct = (acBinaryResult.SerializedSize / (double)msgPackResult.SerializedSize - 1) * 100;
|
||||
var serPct = msgPackResult.SerializeTimeMs > 0 ? (acBinaryResult.SerializeTimeMs / msgPackResult.SerializeTimeMs - 1) * 100 : 0;
|
||||
var desPct = msgPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / msgPackResult.DeserializeTimeMs - 1) * 100 : 0;
|
||||
var rtPct = msgPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / msgPackResult.RoundTripTimeMs - 1) * 100 : 0;
|
||||
var sizePct = (acBinaryResult.SerializedSize / (double)memPackResult.SerializedSize - 1) * 100;
|
||||
var serPct = memPackResult.SerializeTimeMs > 0 ? (acBinaryResult.SerializeTimeMs / memPackResult.SerializeTimeMs - 1) * 100 : 0;
|
||||
var desPct = memPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / memPackResult.DeserializeTimeMs - 1) * 100 : 0;
|
||||
var rtPct = memPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / memPackResult.RoundTripTimeMs - 1) * 100 : 0;
|
||||
|
||||
sb.AppendLine($" {SerializerAcBinaryDefault} vs {SerializerMessagePack}: Size {sizePct:+0;-0}% │ Ser {serPct:+0;-0}% │ Des {desPct:+0;-0}% │ RT {rtPct:+0;-0}%");
|
||||
sb.AppendLine($" {SerializerAcBinaryDefault} vs {SerializerMemoryPack}: Size {sizePct:+0;-0}% │ Ser {serPct:+0;-0}% │ Des {desPct:+0;-0}% │ RT {rtPct:+0;-0}%");
|
||||
}
|
||||
|
||||
//sb.AppendLine($"GrowBufferCount: {AcBinarySerializer.GrowBufferCount}");
|
||||
|
|
@ -850,42 +1089,50 @@ public static class Program
|
|||
}
|
||||
|
||||
|
||||
// Summary comparison
|
||||
// Summary comparison (vs MemoryPack)
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"=== {SerializerAcBinaryDefault} vs {SerializerMessagePack} (Overall) ===");
|
||||
sb.AppendLine($"=== {SerializerAcBinaryDefault} vs {SerializerMemoryPack} (Overall) ===");
|
||||
|
||||
var msgPackSerResults2 = results.Where(r => r.SerializerName == SerializerMessagePack && r.SerializeTimeMs > 0).ToList();
|
||||
var msgPackDesResults2 = results.Where(r => r.SerializerName == SerializerMessagePack && r.DeserializeTimeMs > 0).ToList();
|
||||
var msgPackRtResults2 = results.Where(r => r.SerializerName == SerializerMessagePack && r.RoundTripTimeMs > 0).ToList();
|
||||
var memPackSerResults2 = results.Where(r => r.SerializerName == SerializerMemoryPack && r.SerializeTimeMs > 0).ToList();
|
||||
var memPackDesResults2 = results.Where(r => r.SerializerName == SerializerMemoryPack && r.DeserializeTimeMs > 0).ToList();
|
||||
var memPackRtResults2 = results.Where(r => r.SerializerName == SerializerMemoryPack && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
var acBinarySerResults2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.SerializeTimeMs > 0).ToList();
|
||||
var acBinaryDesResults2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.DeserializeTimeMs > 0).ToList();
|
||||
var acBinaryRtResults2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.RoundTripTimeMs > 0).ToList();
|
||||
|
||||
if (msgPackSerResults2.Count > 0 && acBinarySerResults2.Count > 0)
|
||||
if (memPackSerResults2.Count > 0 && acBinarySerResults2.Count > 0)
|
||||
{
|
||||
var msgPackAvgSer2 = msgPackSerResults2.Average(r => r.SerializeTimeMs);
|
||||
var memPackAvgSer2 = memPackSerResults2.Average(r => r.SerializeTimeMs);
|
||||
var acBinaryAvgSer2 = acBinarySerResults2.Average(r => r.SerializeTimeMs);
|
||||
sb.AppendLine($" Serialize: {((acBinaryAvgSer2 / msgPackAvgSer2 - 1) * 100):+0;-0}% ({acBinaryAvgSer2:F2} ms vs {msgPackAvgSer2:F2} ms)");
|
||||
var memPackAvgSerAlloc2 = memPackSerResults2.Average(r => r.SerializeAllocBytesPerOp);
|
||||
var acBinaryAvgSerAlloc2 = acBinarySerResults2.Average(r => r.SerializeAllocBytesPerOp);
|
||||
sb.AppendLine($" Serialize: {((acBinaryAvgSer2 / memPackAvgSer2 - 1) * 100):+0;-0}% ({acBinaryAvgSer2:F2} ms vs {memPackAvgSer2:F2} ms)");
|
||||
if (memPackAvgSerAlloc2 > 0)
|
||||
sb.AppendLine($" Ser Alloc: {((acBinaryAvgSerAlloc2 / memPackAvgSerAlloc2 - 1) * 100):+0;-0}% ({acBinaryAvgSerAlloc2:F0} B/op vs {memPackAvgSerAlloc2:F0} B/op)");
|
||||
}
|
||||
|
||||
if (msgPackDesResults2.Count > 0 && acBinaryDesResults2.Count > 0)
|
||||
if (memPackDesResults2.Count > 0 && acBinaryDesResults2.Count > 0)
|
||||
{
|
||||
var msgPackAvgDes2 = msgPackDesResults2.Average(r => r.DeserializeTimeMs);
|
||||
var memPackAvgDes2 = memPackDesResults2.Average(r => r.DeserializeTimeMs);
|
||||
var acBinaryAvgDes2 = acBinaryDesResults2.Average(r => r.DeserializeTimeMs);
|
||||
sb.AppendLine($" Deserialize: {((acBinaryAvgDes2 / msgPackAvgDes2 - 1) * 100):+0;-0}% ({acBinaryAvgDes2:F2} ms vs {msgPackAvgDes2:F2} ms)");
|
||||
var memPackAvgDesAlloc2 = memPackDesResults2.Average(r => r.DeserializeAllocBytesPerOp);
|
||||
var acBinaryAvgDesAlloc2 = acBinaryDesResults2.Average(r => r.DeserializeAllocBytesPerOp);
|
||||
sb.AppendLine($" Deserialize: {((acBinaryAvgDes2 / memPackAvgDes2 - 1) * 100):+0;-0}% ({acBinaryAvgDes2:F2} ms vs {memPackAvgDes2:F2} ms)");
|
||||
if (memPackAvgDesAlloc2 > 0)
|
||||
sb.AppendLine($" Des Alloc: {((acBinaryAvgDesAlloc2 / memPackAvgDesAlloc2 - 1) * 100):+0;-0}% ({acBinaryAvgDesAlloc2:F0} B/op vs {memPackAvgDesAlloc2:F0} B/op)");
|
||||
}
|
||||
|
||||
if (msgPackRtResults2.Count > 0 && acBinaryRtResults2.Count > 0)
|
||||
if (memPackRtResults2.Count > 0 && acBinaryRtResults2.Count > 0)
|
||||
{
|
||||
var msgPackAvgRt2 = msgPackRtResults2.Average(r => r.RoundTripTimeMs);
|
||||
var memPackAvgRt2 = memPackRtResults2.Average(r => r.RoundTripTimeMs);
|
||||
var acBinaryAvgRt2 = acBinaryRtResults2.Average(r => r.RoundTripTimeMs);
|
||||
sb.AppendLine($" Round-trip: {((acBinaryAvgRt2 / msgPackAvgRt2 - 1) * 100):+0;-0}% ({acBinaryAvgRt2:F2} ms vs {msgPackAvgRt2:F2} ms)");
|
||||
sb.AppendLine($" Round-trip: {((acBinaryAvgRt2 / memPackAvgRt2 - 1) * 100):+0;-0}% ({acBinaryAvgRt2:F2} ms vs {memPackAvgRt2:F2} ms)");
|
||||
}
|
||||
|
||||
var msgPackAvgSize2 = results.Where(r => r.SerializerName == SerializerMessagePack).Average(r => r.SerializedSize);
|
||||
var memPackAvgSize2 = results.Where(r => r.SerializerName == SerializerMemoryPack).Average(r => r.SerializedSize);
|
||||
var acBinaryAvgSize2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault).Average(r => r.SerializedSize);
|
||||
sb.AppendLine($" Size: {((acBinaryAvgSize2 / msgPackAvgSize2 - 1) * 100):+0;-0}% ({acBinaryAvgSize2:F0} B vs {msgPackAvgSize2:F0} B)");
|
||||
sb.AppendLine($" Size: {((acBinaryAvgSize2 / memPackAvgSize2 - 1) * 100):+0;-0}% ({acBinaryAvgSize2:F0} B vs {memPackAvgSize2:F0} B)");
|
||||
|
||||
File.WriteAllText(logFilePath, sb.ToString(), Utf8NoBom);
|
||||
System.Console.WriteLine($"✓ Results saved to: {logFilePath}");
|
||||
|
|
@ -901,6 +1148,7 @@ public static class Program
|
|||
var testTypeName = testDataSets.FirstOrDefault()?.TypeName ?? "unknown";
|
||||
sb.AppendLine($"# AcBinary Benchmark {BuildConfiguration} {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
|
||||
sb.AppendLine($"Iterations: {TestIterations} | Warmup: {WarmupIterations} | Samples: {BenchmarkSamples} (median) | .NET: {Environment.Version} | TestType: {testTypeName}");
|
||||
sb.AppendLine($"Baseline: {SerializerMemoryPack} (SOTA reference) | Verified: round-trip correctness checked once per cell before warmup");
|
||||
|
||||
// Options summary
|
||||
var optionsMap = results
|
||||
|
|
@ -917,12 +1165,12 @@ public static class Program
|
|||
sb.AppendLine($"- **{name}**: {opts}");
|
||||
}
|
||||
|
||||
// Flat results table sorted by test data then round-trip
|
||||
// Flat results table sorted by test data then round-trip (now includes Alloc columns)
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("## Results");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("TestData | Serializer | Size(B) | Ser(ms) | Deser(ms) | RT(ms)");
|
||||
sb.AppendLine("---|---|---|---|---|---");
|
||||
sb.AppendLine("TestData | Serializer | Size(B) | Ser(ms) | Deser(ms) | RT(ms) | SerAlloc(B/op) | DesAlloc(B/op)");
|
||||
sb.AppendLine("---|---|---|---|---|---|---|---");
|
||||
|
||||
foreach (var testData in testDataSets)
|
||||
{
|
||||
|
|
@ -937,7 +1185,9 @@ public static class Program
|
|||
var ser = r.SerializeTimeMs > 0 ? r.SerializeTimeMs.ToString("F2", inv) : "-";
|
||||
var des = r.DeserializeTimeMs > 0 ? r.DeserializeTimeMs.ToString("F2", inv) : "-";
|
||||
var rt = r.RoundTripTimeMs > 0 ? r.RoundTripTimeMs.ToString("F2", inv) : "-";
|
||||
sb.AppendLine($"{r.TestDataName} | {r.SerializerName} | {r.SerializedSize} | {ser} | {des} | {rt}");
|
||||
var serAlloc = r.SerializeTimeMs > 0 ? r.SerializeAllocBytesPerOp.ToString(inv) : "-";
|
||||
var desAlloc = r.DeserializeTimeMs > 0 ? r.DeserializeAllocBytesPerOp.ToString(inv) : "-";
|
||||
sb.AppendLine($"{r.TestDataName} | {r.SerializerName} | {r.SerializedSize} | {ser} | {des} | {rt} | {serAlloc} | {desAlloc}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -143,6 +143,86 @@ Assigning a `BufferWriterBinaryOutput` value creates an independent copy. State
|
|||
|
||||
A single instance must not use context + standalone modes simultaneously — buffer states desynchronize. One mode per lifecycle phase; `FlushAndReset()` as boundary between modes.
|
||||
|
||||
## Configuration / Options
|
||||
|
||||
### ACCORE-BIN-I-L8N5: AcBinarySerializerOptions thread-safety — mutable properties on shared instances
|
||||
|
||||
**Status:** Open
|
||||
**Affects:** `AcBinarySerializerOptions` — all `set;` properties (`UseMetadata`, `UseGeneratedCode`, `WireMode`, `UseStringInterning`, `BufferWriterChunkSize`, `UseCompression`) and any holder that retains a shared instance (e.g. DI-scoped serializer wrapper, long-lived service field, `AcBinaryHubProtocol._options`).
|
||||
|
||||
The options class exposes mutable properties. When a consumer shares one options instance across concurrent `Serialize` / `Deserialize` calls — common with DI-singleton services, hot-path-cached options, or any long-lived holder — a runtime property change is observable mid-operation by other in-flight calls. Result: invariant violations, mismatched encoding decisions, intermittent output / deserialization corruption.
|
||||
|
||||
Worsened by `AcBinaryHubProtocol` ctor (`AyCode.Services/SignalRs/AcBinaryHubProtocol.cs` sor 141), which mutates the caller-provided options reference (`_options.BufferWriterChunkSize = options.BufferSize;`) — the caller's external reference becomes a side-channel for the protocol's internal config.
|
||||
|
||||
A `volatile` field on the holder side (e.g. `_options`) only protects reference replacement, not property-level mutation; an external reference still in scope can be `serOpts.UseGeneratedCode = false;` mid-parse on another thread.
|
||||
|
||||
**Impact:** Latent — corruption is intermittent and timing-dependent; very hard to reproduce without targeted stress. The NuGet contract worsens this: the package cannot constrain how consumers scope their options instances.
|
||||
|
||||
**Possible fix directions:**
|
||||
- **Defensive copy on ingress** — `Clone()` on `AcBinarySerializerOptions`; every API that retains an options instance clones it on entry. External mutation becomes invisible to the holder.
|
||||
- **Immutable record refactor** — `set;` → `init;` on all configuration properties; mutation requires `with`-expression which produces a new instance.
|
||||
- **Read-only flag pattern** — à la `JsonSerializerOptions.MakeReadOnly()`. The holder calls `MakeReadOnly()` on entry; subsequent mutation throws.
|
||||
|
||||
### ACCORE-BIN-I-C5R7: CheckDuplicatePropName=false silently corrupts on FNV-1a hash collision
|
||||
|
||||
**Status:** Open
|
||||
**Affects:** `AcBinarySerializerOptions.CheckDuplicatePropName` when set to `false`
|
||||
**Path:** `BINARY_FEATURES.md` sor 104, `BINARY_OPTIONS.md` sor 43
|
||||
|
||||
The default value (`true`) throws `InvalidOperationException` on FNV-1a property-name hash collision within a type. When set to `false` (the docs explicitly recommend this for production performance), collisions are silently accepted — the second property's hash overwrites the first in the lookup table, and the wrong property setter is invoked during deserialization. Result: **silent data corruption** between the colliding properties.
|
||||
|
||||
The `BINARY_OPTIONS.md` doc gives two contradictory recommendations on the same flag (*"risk of data corruption"* + *"Disable in production for performance"*) without a single decision rule. NuGet consumers reading either passage in isolation can reach opposite conclusions about safety.
|
||||
|
||||
**Impact:** Latent — FNV-1a + typical property names rarely collide, but applications with many SGen types eventually hit one. Detection requires a separate property-by-property comparison after round-trip; the serializer surfaces no signal.
|
||||
|
||||
**Possible fix directions:**
|
||||
- **Doc harmonization** — single decision rule ("always `true`; perf cost is negligible vs. corruption risk").
|
||||
- **Wider hash** — replace FNV-1a with xxHash3-128 (or similar) for collision-free property identification.
|
||||
- **Disambiguate by index** — store property index alongside hash so a collision is detectable at deserialization without throwing on serialization.
|
||||
|
||||
### ACCORE-BIN-I-P2H8: MaxDepth cut-off Null indistinguishable from real null
|
||||
|
||||
**Status:** Open
|
||||
**Affects:** `AcBinarySerializerOptions.MaxDepth` (and any preset using a non-default value, e.g. `ShallowCopy` preset has `MaxDepth=0`)
|
||||
**Path:** `BINARY_OPTIONS.md` sor 67
|
||||
|
||||
When the object graph exceeds `MaxDepth`, deeper objects/collections are written as `Null(76)` — **the same byte as a genuine null value**. The deserializer cannot distinguish "depth-cut-off null" from "real null" → silent data loss without any signal at the receive side.
|
||||
|
||||
**Impact:** Latent for the default `MaxDepth=255` (real graphs rarely hit). Severe with explicit lower limits — `ShallowCopy` preset (`MaxDepth=0`) silently drops every nested object on the receive side without an exception. Detection requires a separate depth-aware comparison.
|
||||
|
||||
**Possible fix directions:**
|
||||
- **Dedicated `DepthExceeded` wire marker** (distinct from `Null(76)`) — wire-format breaking change, major version.
|
||||
- **Configurable policy on cut-off** — `MaxDepthBehavior.WriteNull` (today's default) / `Throw` / `Log+WriteNull`. Non-breaking opt-in.
|
||||
|
||||
### ACCORE-BIN-I-W3F4: PropertyFilter + UseMetadata=false silently corrupts via index drift
|
||||
|
||||
**Status:** Open
|
||||
**Affects:** `AcBinarySerializerOptions.PropertyFilter` combined with `UseMetadata=false`
|
||||
**Path:** `BINARY_OPTIONS.md` sor 93
|
||||
|
||||
When the serializer applies a `PropertyFilter`, excluded properties are completely absent from the stream — no marker, no placeholder. The deserializer must apply an **identical** filter, OR rely on `UseMetadata=true` property-name hash matching. If neither condition holds, positional indices on the receive side mis-match: property A's value lands in property B's setter → silent data corruption.
|
||||
|
||||
**Impact:** Severe in NuGet contexts — the package cannot enforce symmetric filter configuration on both ends. A common pattern ("send-side filter to drop sensitive fields") silently corrupts cross-deployment if the receiver isn't aware to mirror.
|
||||
|
||||
**Possible fix directions:**
|
||||
- **Emit `PropertySkip(102)` marker** for filtered slots — the marker already exists on the wire, verify the write path uses it for filtered properties.
|
||||
- **Auto-promote to `UseMetadata=true`** when `PropertyFilter` is set (with a warning) — opt-out via explicit override.
|
||||
- **Validate at serialize entry** — `PropertyFilter != null && !UseMetadata` → throw `InvalidOperationException` with guidance.
|
||||
|
||||
### ACCORE-BIN-I-J6T9: Non-IId circular references silently truncated when ThrowOnCircularReference=false
|
||||
|
||||
**Status:** Open
|
||||
**Affects:** `AcBinarySerializerOptions.ThrowOnCircularReference=false` combined with `ReferenceHandling != None`
|
||||
**Path:** `BINARY_OPTIONS.md` sor 30
|
||||
|
||||
With `ThrowOnCircularReference=false` + reference handling enabled, **only `IId`-implementing types are tracked for cycle detection**. Non-`IId` circular references hit `MaxDepth` before being detected → silent truncation at the depth boundary, no exception, no log.
|
||||
|
||||
**Impact:** Borderline — explicit opt-in (developer must set `ThrowOnCircularReference=false`), and most domain models avoid non-`IId` cycles. But UI tree models (parent ↔ children with no `Id`), graph data structures, and self-referencing config nodes trigger this path silently.
|
||||
|
||||
**Possible fix directions:**
|
||||
- **Universal cycle detection** — track all reference types for cycle detection regardless of `IId`-ness when `ThrowOnCircularReference=false` (deduplication remains `IId`-only — cycle detection becomes universal).
|
||||
- **Diagnostic event** — surface "non-`IId` cycle dropped at depth N" as an `Action<CycleDroppedDiagnostic>?` on options, opt-in.
|
||||
|
||||
## Cross-cutting (canonical home: `../XCUT/`)
|
||||
|
||||
### ACCORE-XCUT-I-X8Q1: JSON-in-Binary request parameters — cross-ref
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
using System.Buffers;
|
||||
using AyCode.Services.SignalRs;
|
||||
using Microsoft.AspNetCore.SignalR.Protocol;
|
||||
|
||||
namespace AyCode.Services.Server.Tests.SignalRs;
|
||||
|
||||
/// <summary>
|
||||
/// Concurrency / thread-safety stress tests for <see cref="AcBinaryHubProtocol"/> — verify a
|
||||
/// single shared protocol instance can serve many threads concurrently without per-message state
|
||||
/// corruption. Regression guard for the per-message header-context race fix (header context is
|
||||
/// now stack-only on the non-chunked path and per-binder on the chunked path; never on a shared
|
||||
/// instance field).
|
||||
///
|
||||
/// <para>Mirrors the SignalR DI-singleton scenario: one <see cref="AyCodeBinaryHubProtocol"/>
|
||||
/// instance, many threads invoking <c>WriteMessage</c> + <c>TryParseMessage</c> in parallel with
|
||||
/// distinct payloads. If the prior <c>_currentHeaderContext</c> race were present, threads would
|
||||
/// see each other's header context (typically a wrong type AQN) → mismatched parse output, type
|
||||
/// cast exception, or null where a value is expected.</para>
|
||||
///
|
||||
/// <para>The <see cref="TestInvocationBinder"/> declares the data arg as <c>typeof(object)</c>,
|
||||
/// which forces <see cref="AyCodeBinaryHubProtocol.ReadSingleArgument"/> down the
|
||||
/// header-context-driven type-resolution path — exactly the path the race used to corrupt.</para>
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class AcBinaryHubProtocolConcurrencyTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task ConcurrentRoundTrip_SharedProtocolInstance_NoStateCorruption()
|
||||
{
|
||||
// Single shared protocol instance — DI-singleton style, the production-realistic NuGet shape.
|
||||
var protocol = new AyCodeBinaryHubProtocol();
|
||||
var binder = new TestInvocationBinder();
|
||||
|
||||
const int threadCount = 16;
|
||||
const int iterationsPerThread = 200;
|
||||
|
||||
var tasks = new Task[threadCount];
|
||||
for (var t = 0; t < threadCount; t++)
|
||||
{
|
||||
var threadIdx = t;
|
||||
tasks[t] = Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < iterationsPerThread; i++)
|
||||
{
|
||||
var dataId = threadIdx * 10000 + i;
|
||||
|
||||
// Distinct payload per (thread, iteration) — race-induced corruption produces
|
||||
// detectable mismatch in the assertions below.
|
||||
var msg = new InvocationMessage("OnReceiveMessage", new object?[]
|
||||
{
|
||||
dataId, // arg[0]: int (messageTag)
|
||||
(int?)i, // arg[1]: int? (requestId)
|
||||
new SignalParams(), // arg[2]: SignalParams
|
||||
$"data-t{threadIdx}-i{i}" // arg[3]: object → string (header-context-typed)
|
||||
});
|
||||
|
||||
var writer = new ArrayBufferWriter<byte>(8192);
|
||||
protocol.WriteMessage(msg, writer);
|
||||
|
||||
var seq = new ReadOnlySequence<byte>(writer.WrittenMemory);
|
||||
var success = protocol.TryParseMessage(ref seq, binder, out var parsed);
|
||||
|
||||
Assert.IsTrue(success, $"thread={threadIdx} iter={i}: TryParseMessage returned false");
|
||||
Assert.IsInstanceOfType<InvocationMessage>(parsed,
|
||||
$"thread={threadIdx} iter={i}: parsed message wrong type");
|
||||
|
||||
var inv = (InvocationMessage)parsed!;
|
||||
Assert.AreEqual(4, inv.Arguments.Length,
|
||||
$"thread={threadIdx} iter={i}: argument count mismatch");
|
||||
Assert.AreEqual(dataId, (int)inv.Arguments[0]!,
|
||||
$"thread={threadIdx} iter={i}: arg[0] (messageTag) mismatch — possible race");
|
||||
Assert.AreEqual(i, (int?)inv.Arguments[1],
|
||||
$"thread={threadIdx} iter={i}: arg[1] (requestId) mismatch");
|
||||
Assert.IsInstanceOfType<SignalParams>(inv.Arguments[2],
|
||||
$"thread={threadIdx} iter={i}: arg[2] not SignalParams — header-context race?");
|
||||
Assert.AreEqual($"data-t{threadIdx}-i{i}", inv.Arguments[3] as string,
|
||||
$"thread={threadIdx} iter={i}: arg[3] (data) mismatch — header-context race?");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task ConcurrentRoundTrip_VariedPayloadTypes_HeaderContextResolvedPerMessage()
|
||||
{
|
||||
// Stresses the header-context Type-resolution path more aggressively: each iteration
|
||||
// alternates between distinct payload types (string / int / int[]). A header-context
|
||||
// race would resolve the wrong type AQN → InvalidCastException or silent type-mismatch.
|
||||
var protocol = new AyCodeBinaryHubProtocol();
|
||||
var binder = new TestInvocationBinder();
|
||||
|
||||
const int threadCount = 8;
|
||||
const int iterationsPerThread = 150;
|
||||
|
||||
var tasks = new Task[threadCount];
|
||||
for (var t = 0; t < threadCount; t++)
|
||||
{
|
||||
var threadIdx = t;
|
||||
tasks[t] = Task.Run(() =>
|
||||
{
|
||||
for (var i = 0; i < iterationsPerThread; i++)
|
||||
{
|
||||
var dataId = threadIdx * 10000 + i;
|
||||
object data = (i % 3) switch
|
||||
{
|
||||
0 => $"str-t{threadIdx}-i{i}",
|
||||
1 => dataId,
|
||||
_ => new[] { dataId, threadIdx, i }
|
||||
};
|
||||
|
||||
var msg = new InvocationMessage("OnReceiveMessage", new object?[]
|
||||
{
|
||||
dataId, (int?)i, new SignalParams(), data
|
||||
});
|
||||
|
||||
var writer = new ArrayBufferWriter<byte>(8192);
|
||||
protocol.WriteMessage(msg, writer);
|
||||
|
||||
var seq = new ReadOnlySequence<byte>(writer.WrittenMemory);
|
||||
Assert.IsTrue(protocol.TryParseMessage(ref seq, binder, out var parsed),
|
||||
$"thread={threadIdx} iter={i}: TryParseMessage returned false");
|
||||
|
||||
var inv = (InvocationMessage)parsed!;
|
||||
Assert.AreEqual(dataId, (int)inv.Arguments[0]!,
|
||||
$"thread={threadIdx} iter={i}: messageTag mismatch");
|
||||
|
||||
// Validate the header-context-driven type was resolved correctly per-message.
|
||||
switch (i % 3)
|
||||
{
|
||||
case 0:
|
||||
Assert.AreEqual($"str-t{threadIdx}-i{i}", inv.Arguments[3] as string,
|
||||
$"thread={threadIdx} iter={i}: string payload mismatch — header-context race?");
|
||||
break;
|
||||
case 1:
|
||||
Assert.AreEqual(dataId, (int)inv.Arguments[3]!,
|
||||
$"thread={threadIdx} iter={i}: int payload mismatch — header-context race?");
|
||||
break;
|
||||
default:
|
||||
var arr = inv.Arguments[3] as int[];
|
||||
Assert.IsNotNull(arr,
|
||||
$"thread={threadIdx} iter={i}: int[] payload null — header-context race?");
|
||||
Assert.AreEqual(3, arr.Length);
|
||||
Assert.AreEqual(dataId, arr[0]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
}
|
||||
|
|
@ -93,14 +93,6 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
/// </summary>
|
||||
private readonly ConditionalWeakTable<IInvocationBinder, AsyncChunkState> _chunkStates;
|
||||
|
||||
/// <summary>
|
||||
/// Opaque context produced by <see cref="ReadHeader"/> for the currently-parsed message.
|
||||
/// Set by parse methods (ParseInvocation, ParseStreamInvocation, ParseStreamItem, ParseCompletion)
|
||||
/// right after reading the per-message header. Derived protocols can read this to customize
|
||||
/// argument deserialization (e.g., type resolution when <c>targetType == typeof(object)</c>).
|
||||
/// </summary>
|
||||
protected object? _currentHeaderContext;
|
||||
|
||||
private sealed class AsyncChunkState
|
||||
{
|
||||
public HubMessage PartialMessage = null!;
|
||||
|
|
@ -110,6 +102,14 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
public SegmentBufferReader Buffer = null!;
|
||||
public Task<object?>? DeserTask;
|
||||
|
||||
/// <summary>
|
||||
/// Per-binder header context — the opaque object returned by <see cref="ReadHeader"/> for
|
||||
/// the currently chunked message. Persisted across CHUNK_START → CHUNK_DATA × N → CHUNK_END
|
||||
/// boundaries inside the per-binder <see cref="AsyncChunkState"/> entry, so derived classes
|
||||
/// can consume it during chunked deserialization without sharing state across connections.
|
||||
/// </summary>
|
||||
public object? HeaderContext;
|
||||
|
||||
/// <summary>
|
||||
/// Total bytes of chunk frame data already consumed from the input stream
|
||||
/// (including [201][UINT16] framing headers + data bytes).
|
||||
|
|
@ -675,12 +675,16 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
|
||||
r.TryRead(out var msgType);
|
||||
|
||||
// The header context (out _) is intentionally discarded on the non-chunked path —
|
||||
// it lives only on the stack frame of the Parse* call and is consumed inline by the
|
||||
// ReadArguments / ReadSingleArgument calls inside that frame. No instance state means
|
||||
// no race even when this protocol instance is shared across threads (NuGet contract).
|
||||
return msgType switch
|
||||
{
|
||||
MsgInvocation => ParseInvocation(ref r, binder),
|
||||
MsgStreamInvocation => ParseStreamInvocation(ref r, binder),
|
||||
MsgStreamItem => ParseStreamItem(ref r, binder),
|
||||
MsgCompletion => ParseCompletion(ref r, binder),
|
||||
MsgInvocation => ParseInvocation(ref r, binder, out _),
|
||||
MsgStreamInvocation => ParseStreamInvocation(ref r, binder, out _),
|
||||
MsgStreamItem => ParseStreamItem(ref r, binder, out _),
|
||||
MsgCompletion => ParseCompletion(ref r, binder, out _),
|
||||
MsgCancelInvocation => ParseCancelInvocation(ref r),
|
||||
MsgPing => PingMessage.Instance,
|
||||
MsgClose => ParseClose(ref r),
|
||||
|
|
@ -724,9 +728,9 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
target, paramTypes.Count, string.Join(", ", typeNames), remaining);
|
||||
}
|
||||
|
||||
private HubMessage ParseInvocation(ref SequenceReader<byte> r, IInvocationBinder binder)
|
||||
private HubMessage ParseInvocation(ref SequenceReader<byte> r, IInvocationBinder binder, out object? headerContext)
|
||||
{
|
||||
_currentHeaderContext = ReadHeader(ref r);
|
||||
headerContext = ReadHeader(ref r);
|
||||
|
||||
var invocationId = ReadNullableString(ref r);
|
||||
var target = ReadString(ref r);
|
||||
|
|
@ -734,7 +738,7 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
|
||||
LogParseInvocation(target, paramTypes, r.Remaining);
|
||||
|
||||
var args = ReadArguments(ref r, paramTypes);
|
||||
var args = ReadArguments(ref r, paramTypes, headerContext);
|
||||
var streamIds = ReadStringArray(ref r);
|
||||
var headers = ReadHeaders(ref r);
|
||||
|
||||
|
|
@ -744,14 +748,14 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
return msg;
|
||||
}
|
||||
|
||||
private HubMessage ParseStreamInvocation(ref SequenceReader<byte> r, IInvocationBinder binder)
|
||||
private HubMessage ParseStreamInvocation(ref SequenceReader<byte> r, IInvocationBinder binder, out object? headerContext)
|
||||
{
|
||||
_currentHeaderContext = ReadHeader(ref r);
|
||||
headerContext = ReadHeader(ref r);
|
||||
|
||||
var invocationId = ReadString(ref r);
|
||||
var target = ReadString(ref r);
|
||||
var paramTypes = binder.GetParameterTypes(target);
|
||||
var args = ReadArguments(ref r, paramTypes);
|
||||
var args = ReadArguments(ref r, paramTypes, headerContext);
|
||||
var streamIds = ReadStringArray(ref r);
|
||||
var headers = ReadHeaders(ref r);
|
||||
|
||||
|
|
@ -761,13 +765,13 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
return msg;
|
||||
}
|
||||
|
||||
private HubMessage ParseStreamItem(ref SequenceReader<byte> r, IInvocationBinder binder)
|
||||
private HubMessage ParseStreamItem(ref SequenceReader<byte> r, IInvocationBinder binder, out object? headerContext)
|
||||
{
|
||||
_currentHeaderContext = ReadHeader(ref r);
|
||||
headerContext = ReadHeader(ref r);
|
||||
|
||||
var invocationId = ReadString(ref r);
|
||||
var itemType = binder.GetStreamItemType(invocationId);
|
||||
var item = ReadSingleArgument(ref r, itemType);
|
||||
var item = ReadSingleArgument(ref r, itemType, headerContext);
|
||||
var headers = ReadHeaders(ref r);
|
||||
|
||||
var msg = new StreamItemMessage(invocationId, item);
|
||||
|
|
@ -776,9 +780,9 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
return msg;
|
||||
}
|
||||
|
||||
private HubMessage ParseCompletion(ref SequenceReader<byte> r, IInvocationBinder binder)
|
||||
private HubMessage ParseCompletion(ref SequenceReader<byte> r, IInvocationBinder binder, out object? headerContext)
|
||||
{
|
||||
_currentHeaderContext = ReadHeader(ref r);
|
||||
headerContext = ReadHeader(ref r);
|
||||
|
||||
var invocationId = ReadString(ref r);
|
||||
var error = ReadNullableString(ref r);
|
||||
|
|
@ -790,7 +794,7 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
if (hasResult)
|
||||
{
|
||||
var resultType = binder.GetReturnType(invocationId);
|
||||
result = ReadSingleArgument(ref r, resultType);
|
||||
result = ReadSingleArgument(ref r, resultType, headerContext);
|
||||
}
|
||||
|
||||
var headers = ReadHeaders(ref r);
|
||||
|
|
@ -974,24 +978,29 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
|
||||
_logger?.LogDebug("ParseAsyncChunkStart innerMsgType={InnerMsgType}", originalMsgType);
|
||||
|
||||
// Parse the original message normally — -1 marker becomes StreamedArgPlaceholder in ReadArguments
|
||||
var partialMessage = originalMsgType switch
|
||||
// Parse the original message normally — -1 marker becomes StreamedArgPlaceholder in ReadArguments.
|
||||
// The header context returned by Parse* is captured locally and persisted on the per-binder
|
||||
// AsyncChunkState below, so it survives the CHUNK_START → CHUNK_DATA × N → CHUNK_END boundary
|
||||
// without any shared instance state (race-mentes on a shared protocol instance).
|
||||
HubMessage? partialMessage;
|
||||
object? headerContext;
|
||||
switch (originalMsgType)
|
||||
{
|
||||
MsgInvocation => ParseInvocation(ref r, binder),
|
||||
MsgStreamInvocation => ParseStreamInvocation(ref r, binder),
|
||||
MsgStreamItem => ParseStreamItem(ref r, binder),
|
||||
MsgCompletion => ParseCompletion(ref r, binder),
|
||||
_ => null
|
||||
};
|
||||
case MsgInvocation: partialMessage = ParseInvocation(ref r, binder, out headerContext); break;
|
||||
case MsgStreamInvocation: partialMessage = ParseStreamInvocation(ref r, binder, out headerContext); break;
|
||||
case MsgStreamItem: partialMessage = ParseStreamItem(ref r, binder, out headerContext); break;
|
||||
case MsgCompletion: partialMessage = ParseCompletion(ref r, binder, out headerContext); break;
|
||||
default: return null;
|
||||
}
|
||||
|
||||
if (partialMessage == null) return null;
|
||||
|
||||
// Find the placeholder arg and its target type
|
||||
var (args, streamedIndex, streamedType) = FindStreamedArgSlot(partialMessage, binder);
|
||||
|
||||
// Derived classes can override ResolveStreamedArgType to consult _currentHeaderContext
|
||||
// (set by ReadHeader) or any other per-message state.
|
||||
streamedType = ResolveStreamedArgType(streamedType);
|
||||
// Derived classes can override ResolveStreamedArgType to consult the header context
|
||||
// (returned by ReadHeader) for per-message type resolution.
|
||||
streamedType = ResolveStreamedArgType(streamedType, headerContext);
|
||||
|
||||
_logger?.LogDebug("ParseAsyncChunkStart chunk mode activated streamedIndex={StreamedIndex} streamedType={StreamedType}",
|
||||
streamedIndex, streamedType.Name);
|
||||
|
|
@ -1002,6 +1011,7 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
Args = args,
|
||||
StreamedArgIndex = streamedIndex,
|
||||
StreamedArgType = streamedType,
|
||||
HeaderContext = headerContext,
|
||||
Buffer = new SegmentBufferReader(_options.BufferWriterChunkSize * 2, _logger)
|
||||
// DeserTask started lazily in TryParseChunkData after first chunk is written
|
||||
};
|
||||
|
|
@ -1157,7 +1167,7 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
externalBytes += LengthPrefixSize + argBytes;
|
||||
}
|
||||
|
||||
private object?[] ReadArguments(ref SequenceReader<byte> r, IReadOnlyList<Type> paramTypes)
|
||||
private object?[] ReadArguments(ref SequenceReader<byte> r, IReadOnlyList<Type> paramTypes, object? headerContext)
|
||||
{
|
||||
var count = (int)ReadVarUInt(ref r);
|
||||
|
||||
|
|
@ -1171,7 +1181,7 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
|
||||
LogDiagnostic($"[AcBinaryHubProtocol] arg[{i}] targetType={targetType.Name}; remaining={r.Remaining}");
|
||||
|
||||
args[i] = ReadSingleArgument(ref r, targetType);
|
||||
args[i] = ReadSingleArgument(ref r, targetType, headerContext);
|
||||
OnArgumentRead(args[i], i);
|
||||
}
|
||||
|
||||
|
|
@ -1181,17 +1191,22 @@ public class AcBinaryHubProtocol : IHubProtocol
|
|||
protected virtual void OnArgumentRead(object? value, int index) { }
|
||||
|
||||
/// <summary>
|
||||
/// Override to resolve typeof(object) to a concrete type (e.g., from SignalParams).
|
||||
/// Called after FindStreamedArgSlot in chunked deserialization.
|
||||
/// Override to resolve typeof(object) to a concrete type. Called after FindStreamedArgSlot in
|
||||
/// chunked deserialization with the header context returned by <see cref="ReadHeader"/> for
|
||||
/// the same message — derived classes can use it for per-message type resolution without
|
||||
/// touching shared instance state.
|
||||
/// </summary>
|
||||
protected virtual Type ResolveStreamedArgType(Type binderType) => binderType;
|
||||
protected virtual Type ResolveStreamedArgType(Type binderType, object? headerContext) => binderType;
|
||||
|
||||
/// <summary>
|
||||
/// Reads a length-prefixed argument and deserializes it from the pipe's backing buffer.
|
||||
/// Zero-copy: SequenceReader slices the pipe's own memory, TryGetArray gives the backing byte[].
|
||||
/// SignalDataType enables eager deserialization of response data to the server's actual type.
|
||||
/// The <paramref name="headerContext"/> is the opaque object returned by <see cref="ReadHeader"/>
|
||||
/// for the same message — derived classes can use it to drive per-message decoding decisions
|
||||
/// (e.g. raw-bytes vs typed deserialization, target-type override) without touching shared
|
||||
/// instance state.
|
||||
/// </summary>
|
||||
protected virtual object? ReadSingleArgument(ref SequenceReader<byte> r, Type targetType)
|
||||
protected virtual object? ReadSingleArgument(ref SequenceReader<byte> r, Type targetType, object? headerContext)
|
||||
{
|
||||
r.TryReadLittleEndian(out int argLength);
|
||||
if (argLength == 0) return null;
|
||||
|
|
|
|||
|
|
@ -47,9 +47,11 @@ public class AyCodeBinaryHubProtocol : AcBinaryHubProtocol
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opaque context produced by <see cref="ReadHeader"/> and stashed in
|
||||
/// <see cref="AcBinaryHubProtocol._currentHeaderContext"/>. Consumed by
|
||||
/// <see cref="ReadSingleArgument"/> and <see cref="ResolveStreamedArgType"/>.
|
||||
/// Opaque context produced by <see cref="ReadHeader"/> and threaded through Parse* /
|
||||
/// ReadArguments / ReadSingleArgument as a parameter (or persisted on the per-binder
|
||||
/// <c>AsyncChunkState</c> for the chunked path). Consumed by
|
||||
/// <see cref="ReadSingleArgument"/> and <see cref="ResolveStreamedArgType"/>. Stack-only
|
||||
/// in flight — no shared instance state, race-mentes on a shared protocol instance.
|
||||
/// </summary>
|
||||
private sealed class HeaderContext
|
||||
{
|
||||
|
|
@ -148,11 +150,11 @@ public class AyCodeBinaryHubProtocol : AcBinaryHubProtocol
|
|||
/// Prefers the concrete type from the wire header (set by <see cref="ReadHeader"/>) when present,
|
||||
/// otherwise falls back to the binder-provided type (base behavior).
|
||||
/// </summary>
|
||||
protected override Type ResolveStreamedArgType(Type binderType)
|
||||
protected override Type ResolveStreamedArgType(Type binderType, object? headerContext)
|
||||
{
|
||||
if (_currentHeaderContext is HeaderContext hctx && hctx.Type != null)
|
||||
if (headerContext is HeaderContext hctx && hctx.Type != null)
|
||||
return hctx.Type;
|
||||
return base.ResolveStreamedArgType(binderType);
|
||||
return base.ResolveStreamedArgType(binderType, headerContext);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -165,7 +167,7 @@ public class AyCodeBinaryHubProtocol : AcBinaryHubProtocol
|
|||
/// <item>Fall through to base typed deserialization against the binder-provided target type.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
protected override object? ReadSingleArgument(ref SequenceReader<byte> r, Type targetType)
|
||||
protected override object? ReadSingleArgument(ref SequenceReader<byte> r, Type targetType, object? headerContext)
|
||||
{
|
||||
r.TryReadLittleEndian(out int argLength);
|
||||
if (argLength == 0)
|
||||
|
|
@ -189,7 +191,7 @@ public class AyCodeBinaryHubProtocol : AcBinaryHubProtocol
|
|||
if (argReader.TryPeek(out byte tag) && tag == BinaryTypeCode.ByteArray)
|
||||
return SequenceToByteArray(argSlice.Slice(1));
|
||||
|
||||
var hctx = _currentHeaderContext as HeaderContext;
|
||||
var hctx = headerContext as HeaderContext;
|
||||
|
||||
// 2. Header ConsumerDeserialize: no tag on wire (isAcBinary path on server),
|
||||
// consumer wants raw byte[] — return as-is without deserialization.
|
||||
|
|
|
|||
Loading…
Reference in New Issue