Skip to content

Commit d9a2868

Browse files
committed
AUS-1008: Add per-serializer scale diagnostics and mark the yielding row release-required
1 parent 98abfd4 commit d9a2868

7 files changed

Lines changed: 88 additions & 6 deletions

File tree

‎.github/workflows/build_pull_request.yml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ jobs:
4747
# The measurement: ProcessAsync must scale from 1 to 4 workers. A process-wide serialization point holds the ratio
4848
# near 1.3 on any core count. Diagnostic on the shared runner (its core count and isolation are not promised, so
4949
# this job is not required and continues on error); the release gate is `--scale 3 16 4.0` on the reference machine.
50+
# The per-serializer diagnostics after the gate (one run per cell, never part of the exit code) go to their own summary section.
5051
scaling:
5152
runs-on: ubuntu-latest
5253
continue-on-error: true
@@ -67,8 +68,14 @@ jobs:
6768
{
6869
echo "## ProcessAsync scaling (diagnostic, not required)"
6970
echo
70-
grep -E '^\|' scale.txt
71+
sed '/^Diagnostics/,$d' scale.txt | grep -E '^\|'
7172
echo
7273
[ "$status" -eq 0 ] && echo "pass: 4/1 at least 2.0" || echo "**flag: 4/1 below 2.0 on this runner; reproduce with --scale 3 16 4.0 on the reference machine before reading it as a regression**"
74+
echo
75+
echo "### Per-serializer diagnostics (one run per cell, not gated)"
76+
echo
77+
sed -n '/^Diagnostics/,$p' scale.txt | grep -E '^\|'
78+
echo
79+
grep -E '^Diagnostics stopped' scale.txt
7380
} >> "$GITHUB_STEP_SUMMARY"
7481
exit $status

‎.github/workflows/pages.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ on:
44
branches: [ master ]
55
paths:
66
- "README.md"
7+
- "CHANGELOG.md"
78
- "docs/**"
89
- "site/**"
910
- "AustinHarris.JsonRpc.AspNetCore/README.md"

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ behaviour: a breaking change to either means a new major version.
2727
- `protected JsonRpcService(bool autoBind)`: a subclass constructed with `base(false)` binds itself nowhere, for services that a host or an explicit `BindService` call binds.
2828
- `SECURITY.md` (private vulnerability reporting) and this changelog.
2929
- `TestServer_Console --scale` is the release gate for the `ProcessAsync` path. It measures the inline rows at 1, 2 and N workers in three paired runs, takes the medians and fails when N/1 is below the threshold. `--kestrel [seconds] async` runs the host with `EnableAsyncMethods = true`. The README adds `--async` rows for `ProcessAsync` at 1 and 16 workers. The 1.x string overloads' thread-pool benchmark is now the `t` menu entry and no longer the default.
30+
- `--scale` prints per-serializer and yielding-row diagnostics after the gate; the Kestrel `EnableAsyncMethods = true` row with methods that suspend once is a release-required regression row: re-measured before each release against the previous release's figure, with no absolute floor.
3031

3132
### Changed
3233

‎README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,7 @@ The Sync, Async, Legacy and Kestrel results were measured on 2026-09-25 on an id
619619
```
620620
dotnet run -c Release --project TestServer_Console -- --sync 3 # library only, 1..N threads (add a thread count, e.g. --sync 3 1, for one row)
621621
dotnet run -c Release --project TestServer_Console -- --async 3 16 # ProcessAsync from 16 awaited workers, one row per registration (--async 3 1 for the 1-worker column)
622-
dotnet run -c Release --project TestServer_Console -- --scale 3 16 4.0 # release gate: ProcessAsync must scale at least 4x from 1 to 16 workers
622+
dotnet run -c Release --project TestServer_Console -- --scale 3 16 4.0 # release gate: ProcessAsync must scale at least 4x from 1 to 16 workers, then per-serializer diagnostics (--no-diagnostics skips them)
623623
dotnet run -c Release --project TestServer_Console -- --kestrel 3 # through the AspNetCore package, HTTP and TCP (add `async` for EnableAsyncMethods = true)
624624
dotnet run -c Release --project TestServer_Console -- --compare 3 # the same calls through StreamJsonRpc and gRPC for .NET, side by side
625625
dotnet run -c Release --project TestServer_Console -- --sweep 2 benchmarks/charts/sweep.json # every library and transport at 1, 2, 4, 8, 16 connections; one file per run
@@ -675,7 +675,7 @@ The 16-worker rows are an equal-weight mix of the five requests, except `yieldsO
675675

676676
With `RpcContextFlow.None`, the dispatcher adds no allocation to a method that completes inline. Allocations in the `Task<T>` None row come from the service's own `Task.FromResult` (`Task<int>` for 8 comes from the runtime's cache). The 32 bytes of `StringMe` are its result string. Flow allocates the `InvocationState` and the execution-context bridge on every call, inline or not. A real suspension allocates the method's own async state plus completion state in the result writer, the request handler and the document processor. The `yieldsOnce` None row measured 559 B per request at one worker, including the method's own allocations. The 7 to 10 M target for a hosted server applies to methods that complete inline. A method that suspends also costs a continuation per request.
677677

678-
Before 2.0.0, the `ProcessAsync` path was capped near 4 M RPC/s at every worker count because each document took one lock on the shared scratch pool. No single-threaded benchmark could see this limit. Each thread now caches one scratch in front of that pool. `--scale [seconds] [workers] [threshold]` is the gate that catches the next such limit. It measures the inline None rows at 1, 2 and 16 workers in three paired runs and takes the medians. It exits non-zero when any 16/1 ratio is below 4.0. The lock gave 1.3; the cache gives 7.1 to 7.3 on the idle reference machine (the gate's medians, 2026-09-25). Run it on the reference machine before a release and paste its table into the release notes. The pull-request build runs a diagnostic `--scale 3 4 2.0` on the shared runner. It also checks that every `lock`, `Interlocked`, `Volatile.Write`, thread-static and writable static field on the request-path files of the core and both companion serializers is listed in `.github/request-path-sync.allowlist` with a reason (per-thread, miss-path, registration-only, read-only-after-init).
678+
Before 2.0.0, the `ProcessAsync` path was capped near 4 M RPC/s at every worker count because each document took one lock on the shared scratch pool. No single-threaded benchmark could see this limit. Each thread now caches one scratch in front of that pool. `--scale [seconds] [workers] [threshold]` is the gate that catches the next such limit. It measures the inline None rows at 1, 2 and 16 workers in three paired runs and takes the medians. It exits non-zero when any 16/1 ratio is below 4.0. After the gate it prints a diagnostics table that is never gated: the three inline None rows and the `yieldsOnce` None row at 1 and 16 workers under each serializer (`jsmn`, `stj`, `newtonsoft`), one run per cell, with the 16/1 ratio and the bytes per request at one worker, so that a regression in one serializer's path or in the suspending path shows up on its own. A final `--no-diagnostics` argument skips the table. The lock gave 1.3; the cache gives 7.1 to 7.3 on the idle reference machine (the gate's medians, 2026-09-25). Run it on the reference machine before a release and paste its gate table into the release notes. The same release run measures the `--kestrel 3 async` TCP row with methods that suspend once and compares it with the previous release's published figure: a drop larger than the paired-run spread is a release blocker, and the row has no absolute floor (see [Kestrel](#kestrel-through-the-aspnetcore-package)). The pull-request build runs a diagnostic `--scale 3 4 2.0` on the shared runner. It also checks that every `lock`, `Interlocked`, `Volatile.Write`, thread-static and writable static field on the request-path files of the core and both companion serializers is listed in `.github/request-path-sync.allowlist` with a reason (per-thread, miss-path, registration-only, read-only-after-init).
679679

680680
### Legacy string API: scheduled synchronous execution
681681

@@ -710,7 +710,7 @@ This mode is slower than the byte modes because it measures the cost of the .NET
710710
| TCP, 256 pipelined, `EnableAsyncMethods = true`, methods that complete inline | 15.0 M to 15.4 M | `--kestrel 3 async`, two runs; inside the spread of the `false` row |
711711
| TCP, 256 pipelined, `EnableAsyncMethods = true`, methods that suspend once | 1.25 M to 1.29 M | five `async Task<T>` methods awaiting `Task.Yield()` |
712712

713-
The TCP client keeps 256 requests in flight per connection and refills from a precomputed ring of request bytes with one `Send` per refill. On the server, `JsonFramer` feeds the same `Process` call the HTTP endpoint makes. With `EnableAsyncMethods = true`, the connection handler processes each connection's documents one at a time, in order. So 256 pipelined requests are 256 sequential invocations, and a method that suspends pays that cost per request. Concurrency comes from the 16 connections.
713+
The TCP client keeps 256 requests in flight per connection and refills from a precomputed ring of request bytes with one `Send` per refill. On the server, `JsonFramer` feeds the same `Process` call the HTTP endpoint makes. With `EnableAsyncMethods = true`, the connection handler processes each connection's documents one at a time, in order. So 256 pipelined requests are 256 sequential invocations, and a method that suspends pays that cost per request. Concurrency comes from the 16 connections. The last row is a release-required regression row: it is measured with `--kestrel 3 async` on the reference machine before every release and compared with the previous release's published figure, a drop larger than the paired-run spread blocks the release, and it has no absolute floor.
714714

715715
### Versus StreamJsonRpc and gRPC
716716

‎TestServer_Console/AsyncBenchmark.cs‎

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
using System.Threading;
77
using System.Threading.Tasks;
88
using AustinHarris.JsonRpc;
9+
using AustinHarris.JsonRpc.Jsmn;
10+
using AustinHarris.JsonRpc.Newtonsoft;
911
using AustinHarris.JsonRpc.Serialization;
12+
using AustinHarris.JsonRpc.SystemTextJson;
1013

1114
namespace TestServer_Console;
1215

@@ -31,6 +34,9 @@ internal static readonly (string shape, RpcContextFlow flow)[] Rows =
3134
/// <summary>The inline rows a process-wide serialization point shows up in first; the scaling gate runs these.</summary>
3235
internal static readonly (string shape, RpcContextFlow flow)[] InlineRows = { ("sync", RpcContextFlow.None), ("Task", RpcContextFlow.None), ("ValueTask", RpcContextFlow.None) };
3336

37+
/// <summary>The rows the per-serializer diagnostics after the gate measure: the inline None rows and the <c>yieldsOnce</c> None row.</summary>
38+
internal static readonly (string shape, RpcContextFlow flow)[] DiagnosticRows = InlineRows.Append(("yield", RpcContextFlow.None)).ToArray();
39+
3440
internal readonly record struct Measurement(long Count, double Seconds, double StartupSeconds, long AllocatedBytes)
3541
{
3642
public double RpcPerSec => Count / Seconds;
@@ -114,6 +120,66 @@ internal static async Task<bool> ScaleAsync(Action<string> print, double seconds
114120
return pass;
115121
}
116122

123+
/// <summary>
124+
/// The diagnostics printed after the scaling gate, never part of its result: under each serializer (the built-in
125+
/// jsmn, System.Text.Json, Json.NET, in that order) the inline None rows and the <c>yieldsOnce</c> None row at 1 and
126+
/// <paramref name="workers"/> workers, one run of <paramref name="seconds"/> per cell, with the N/1 ratio and the bytes
127+
/// per request at one worker as <see cref="RunAsync"/> reports them, so a regression in one serializer's path or in the
128+
/// suspending path shows up on its own. The process-wide serializer is switched with
129+
/// <see cref="Config.SetSerializer(JsonRpcSerializer)"/> for each block and restored afterwards. A failure is printed
130+
/// after the rows measured so far and is not thrown, so it cannot change the gate's exit code.
131+
/// </summary>
132+
internal static async Task ScaleDiagnosticsAsync(Action<string> print, double seconds, int workers)
133+
{
134+
var serializers = new Func<JsonRpcSerializer>[] { () => JsmnSerializer.Instance, () => new SystemTextJsonRpcSerializer(), () => new NewtonsoftJsonRpcSerializer() };
135+
var rows = new List<string>();
136+
string where = "start";
137+
Exception failure = null;
138+
var previous = Config.Serializer;
139+
print("");
140+
print($"Diagnostics (not gated): ProcessAsync(bytes) under each serializer, 1 and {workers} workers, one run of {seconds:0.#} s per cell; " +
141+
"B per request at 1 worker as --async reports it (inline rows: worker-thread counters; yieldsOnce: process-wide).\n");
142+
try
143+
{
144+
for (int k = 0; k < serializers.Length; k++)
145+
{
146+
where = $"serializer {k + 1} of {serializers.Length}";
147+
Config.SetSerializer(serializers[k]());
148+
string name = Config.Serializer.Name;
149+
rows.Add($"| **{name}** | | | | |");
150+
foreach (var (shape, flow) in DiagnosticRows)
151+
{
152+
where = $"{name} / {shape} / {flow}";
153+
string session = "async-scale-diag-" + name + "-" + shape + "-" + flow;
154+
try
155+
{
156+
Register(session, shape, flow);
157+
var rowInputs = Inputs(shape);
158+
await ValidateAndReportAllocations(print, session, shape, flow, rowInputs, report: false);
159+
bool yield = shape == "yield";
160+
await Measure(session, rowInputs, workers, Math.Min(seconds, 0.5), processWide: yield); // warm-up
161+
var one = await Measure(session, rowInputs, 1, seconds, processWide: yield);
162+
print($" {where}, {1,2} workers: {one.RpcPerSec,14:N0} RPC/s ({one.Count:N0} RPCs in {one.Seconds:F3} s, {one.BytesPerRpc:F0} B/RPC)");
163+
var many = await Measure(session, rowInputs, workers, seconds, processWide: yield);
164+
print($" {where}, {workers,2} workers: {many.RpcPerSec,14:N0} RPC/s ({many.Count:N0} RPCs in {many.Seconds:F3} s)");
165+
rows.Add($"| {shape} / {flow} | {one.RpcPerSec:N0} | {many.RpcPerSec:N0} | {many.RpcPerSec / one.RpcPerSec:F2} | {one.BytesPerRpc:F0} |");
166+
}
167+
finally { Handler.DestroySession(session); }
168+
}
169+
}
170+
}
171+
catch (Exception ex) { failure = ex; }
172+
finally { Config.SetSerializer(previous); }
173+
174+
print("");
175+
print($"| Serializer / registration | 1 worker | {workers} workers | {workers}/1 | B per request |");
176+
print("| --- | ---: | ---: | ---: | ---: |");
177+
foreach (var row in rows) print(row);
178+
print("");
179+
if (failure != null)
180+
print($"Diagnostics stopped at {where}: {failure.GetType().Name}: {failure.Message}. The gate result above stands; the diagnostics are not part of it.");
181+
}
182+
117183
private static double Median(List<double> values)
118184
{
119185
var sorted = values.OrderBy(v => v).ToArray();
@@ -126,7 +192,8 @@ private static ReadOnlyMemory<byte>[] Inputs(string shape) => shape == "yield"
126192
: BenchmarkRunner.taskInputs.Select(t => (ReadOnlyMemory<byte>)Encoding.UTF8.GetBytes(t)).ToArray();
127193

128194
/// <summary>Checks every response, warms the row, and prints the per-shape allocation of an inline document.</summary>
129-
private static async Task ValidateAndReportAllocations(Action<string> print, string session, string shape, RpcContextFlow flow, ReadOnlyMemory<byte>[] rowInputs)
195+
/// <param name="report">False checks, warms and asserts inline completion without printing the per-shape lines.</param>
196+
private static async Task ValidateAndReportAllocations(Action<string> print, string session, string shape, RpcContextFlow flow, ReadOnlyMemory<byte>[] rowInputs, bool report = true)
130197
{
131198
var expected = shape == "yield"
132199
? new[] { "{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":6}" }
@@ -148,7 +215,7 @@ private static async Task ValidateAndReportAllocations(Action<string> print, str
148215
task.GetAwaiter().GetResult();
149216
}
150217
long totalBytes = GC.GetAllocatedBytesForCurrentThread() - before;
151-
print($"{shape} / {flow} / shape {i + 1}: {totalBytes} B total over 2000 requests ({totalBytes / 2000.0:F1} B/RPC), including method allocations.");
218+
if (report) print($"{shape} / {flow} / shape {i + 1}: {totalBytes} B total over 2000 requests ({totalBytes / 2000.0:F1} B/RPC), including method allocations.");
152219
}
153220
}
154221

‎TestServer_Console/Program.cs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,17 @@ static void Main(string[] args)
2727

2828
// `dotnet run -- --scale [seconds] [workers] [threshold]` is the release gate for the ProcessAsync path:
2929
// the inline rows at 1, 2 and N workers, three paired runs, medians; exit code 1 when N/1 is below the threshold.
30+
// After the gate it prints a per-serializer diagnostics table that never changes the exit code; a final
31+
// `--no-diagnostics` argument skips it.
3032
if (args.Length > 0 && args[0] == "--scale")
3133
{
3234
double seconds = args.Length > 1 && double.TryParse(args[1], out var s) ? s : 3;
3335
int workers = args.Length > 2 && int.TryParse(args[2], out var t) ? t : 16;
3436
double threshold = args.Length > 3 && double.TryParse(args[3], out var r) ? r : 4.0;
37+
bool diagnostics = !(args.Length > 1 && args[args.Length - 1] == "--no-diagnostics");
3538
bool pass = AsyncBenchmark.ScaleAsync(Console.WriteLine, seconds, workers, threshold).GetAwaiter().GetResult();
3639
Environment.ExitCode = pass ? 0 : 1;
40+
if (diagnostics) AsyncBenchmark.ScaleDiagnosticsAsync(Console.WriteLine, seconds, workers).GetAwaiter().GetResult();
3741
return;
3842
}
3943

‎TestServer_Console/TestServer_Console.csproj‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
<ItemGroup>
3232
<ProjectReference Include="..\Json-Rpc\AustinHarris.JsonRpc.csproj" />
3333
<ProjectReference Include="..\AustinHarris.JsonRpc.AspNetCore\AustinHarris.JsonRpc.AspNetCore.csproj" />
34+
<ProjectReference Include="..\AustinHarris.JsonRpc.Newtonsoft\AustinHarris.JsonRpc.Newtonsoft.csproj" />
35+
<ProjectReference Include="..\AustinHarris.JsonRpc.SystemTextJson\AustinHarris.JsonRpc.SystemTextJson.csproj" />
3436
</ItemGroup>
3537

3638
</Project>

0 commit comments

Comments
 (0)