Skip to content

Commit b6528d1

Browse files
committed
Benchmarks: re-measure the 2026-09-25 rows on the idle machine
The ProcessAsync, --scale and EnableAsyncMethods = true rows were single runs with other sessions loading the box. Re-run idle: inline ProcessAsync rows 22.2 M to 32.1 M at 16 workers (ValueTask and Task None rows match or exceed the 31.7 M synchronous row), a real suspension 8.96 M, the scaling gate 7.1 to 7.3, Kestrel TCP with async methods 15.4 M inline and 1.29 M suspending. README tables, chart data, regenerated charts and the changelog carry the new figures; the busy-machine caveat is gone.
1 parent 979a375 commit b6528d1

18 files changed

Lines changed: 84 additions & 84 deletions

‎AustinHarris.JsonRpc.AspNetCore/README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,8 @@ invoked.
127127
connection are 256 sequential invocations, not 256 concurrent suspensions; concurrency comes from connections.
128128
Replies already finished are flushed before the connection waits on a slow method. When the connection closes,
129129
the running method is waited for and its response discarded.
130-
- **Cost:** every document then goes through `ProcessAsync`. With methods that complete inline the host measures
131-
within a few percent of the synchronous mode; a method that really suspends pays its own async state plus the
130+
- **Cost:** every document then goes through `ProcessAsync`. With methods that complete inline the TCP row measures
131+
about 7 % below the synchronous mode (15.4 M against 16.5 M); a method that really suspends pays its own async state plus the
132132
library's completion state (about 560 B) and a continuation per request. The main README's Kestrel table has
133133
both rows, measured with `TestServer_Console --kestrel 3 async`.
134134

‎CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ behaviour: a breaking change to either means a new major version.
2828
### Changed
2929

3030
- The core no longer depends on Json.NET.
31-
- `ProcessAsync` no longer serializes the process on one lock per document. The async scratch (input copy, reader, staged output) is cached one per thread in front of the shared pool, which is now the miss and overflow path only; on 16 threads the inline rows went from about 4 M to over 20 M RPC/s and a real suspension from 3.9 M to 7 M. Retention is one scratch per thread that has run `ProcessAsync` plus 64 shared, buffers at most 64 KiB each.
31+
- `ProcessAsync` no longer serializes the process on one lock per document. The async scratch (input copy, reader, staged output) is cached one per thread in front of the shared pool, which is now the miss and overflow path only; on 16 threads the inline rows went from about 4 M to 22 M to 32 M RPC/s and a real suspension from 3.9 M to 9 M. Retention is one scratch per thread that has run `ProcessAsync` plus 64 shared, buffers at most 64 KiB each.
3232
- The request path looks sessions up without creating them. A request for a session id that was never registered answers `-32601` for every call and leaves the registry untouched; sessions are created by binding and by the per-session `Config` setters. Registration adds the session before publishing the registry version, so a thread that misses its snapshot consults the master registry and cannot answer `-32601` for a session that exists.
3333
- The AspNetCore host binds every registered service, `JsonRpcService` subclasses included, to its effective session (the registration's session, then `JsonRpcOptions.SessionId`, then the default). It no longer skips a subclass on the default session.
3434
- The core package's description says "no JSON library dependency" instead of "no dependencies". The session registry uses the framework's `ConcurrentDictionary`; the `NonBlocking` package reference is gone, so the core has no dependencies on `net8.0` and `net10.0` (measured with `SessionRegistryBenchmarks`: unknown-id lookups and register/destroy cycles got faster, stable lookups and dispatch are unchanged).

‎README.md‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -479,16 +479,16 @@ The `jsonrpc` member policy (`Lenient` by default) is a compatibility setting, n
479479
| What | API and mode | RPC/s | Details |
480480
| --- | --- | ---: | --- |
481481
| Library alone, 16 threads | `Process(bytes)`, dedicated threads | 30.6 M to 35.8 M | [Sync](#sync-the-library-alone) |
482-
| Library alone, 16 workers | `ProcessAsync(bytes)`, awaited workers, methods that complete inline | 21.2 M to 25.7 M | [Async](#async-processasync-awaited-workers); the spread is across registrations, not runs |
483-
| Library alone, 16 workers, one real suspension per request | `ProcessAsync(bytes)`, `yieldsOnce` | 8.32 M | |
482+
| Library alone, 16 workers | `ProcessAsync(bytes)`, awaited workers, methods that complete inline | 22.2 M to 32.1 M | [Async](#async-processasync-awaited-workers); the spread is across registrations, not runs |
483+
| Library alone, 16 workers, one real suspension per request | `ProcessAsync(bytes)`, `yieldsOnce` | 8.96 M | |
484484
| Kestrel TCP, 256 pipelined | `EnableAsyncMethods = false` | 15.2 M to 15.5 M | [Kestrel](#kestrel-through-the-aspnetcore-package) |
485-
| Kestrel TCP, 256 pipelined | `EnableAsyncMethods = true`, methods that complete inline | 13.3 M | |
486-
| Kestrel TCP, 256 pipelined | `EnableAsyncMethods = true`, methods that suspend once | 1.13 M | |
485+
| Kestrel TCP, 256 pipelined | `EnableAsyncMethods = true`, methods that complete inline | 15.4 M | |
486+
| Kestrel TCP, 256 pipelined | `EnableAsyncMethods = true`, methods that suspend once | 1.29 M | |
487487
| Kestrel HTTP, batch of 100 per POST | `EnableAsyncMethods = false` | 12.7 M to 13.7 M | |
488488
| Kestrel HTTP, one request per POST | `EnableAsyncMethods = false` | 128 k to 168 k | HTTP/1.1 round trips dominate |
489489
| Legacy string API, thread pool | `Task<string> Process(string)`, batches of 36,000 | 12.0 M | [Legacy](#legacy-string-api-scheduled-synchronous-execution); the 1.x overloads, not the byte path |
490490

491-
All numbers below are from an AMD Ryzen 7 7800X3D (8 cores / 16 threads, 4.2 GHz), 64 GB, Windows 11, .NET 10, Release, Server GC, with the built-in serializer, measured 2026-09-23, except the `ProcessAsync` rows and the `EnableAsyncMethods = true` rows, measured 2026-09-25 on the same machine with other sessions running (single runs, so lower bounds; they are re-measured on an idle box before a release). Where a row gives two figures they are the spread over that day's runs on an otherwise idle machine; the WSL virtual machine, which takes 15 to 25 % of the box when idle, was shut down for the Kestrel and comparison runs. A single benchmark thread on this machine varies with whatever else lands on its core's SMT sibling, so the 1-thread rows are from runs on an idle core.
491+
All numbers below are from an AMD Ryzen 7 7800X3D (8 cores / 16 threads, 4.2 GHz), 64 GB, Windows 11, .NET 10, Release, Server GC, with the built-in serializer, measured 2026-09-23, except the `ProcessAsync` rows and the `EnableAsyncMethods = true` rows, measured 2026-09-25 on the same machine, idle, one 3 s run per row. Where a row gives two figures they are the spread over that day's runs on an otherwise idle machine; the WSL virtual machine, which takes 15 to 25 % of the box when idle, was shut down for the Kestrel and comparison runs. A single benchmark thread on this machine varies with whatever else lands on its core's SMT sibling, so the 1-thread rows are from runs on an idle core.
492492

493493
`TestServer_Console` is the benchmark harness. It binds one service with five small methods (`add`, `addInt`, `NullableFloatToNullableFloat`, `Test2`, `StringMe`), drives the same five requests through the server, checks every response is a `result` rather than an error, and ends each mode with a bar chart of RPC/s. For one-request timings with an allocation column, the numbers to check before merging a change to the dispatch path, see [benchmarks/Micro](benchmarks/Micro/README.md).
494494

@@ -530,13 +530,13 @@ Per-thread cost rises with thread count because the 16 threads share 8 physical
530530

531531
| Registration | 1 worker | 16 workers | B per request |
532532
| --- | ---: | ---: | ---: |
533-
| synchronous methods, None | 3.16 M | 21.2 M | 6 |
534-
| `Task<T>`, Flow | 1.84 M | 19.8 M | 251 |
535-
| `Task<T>`, None | 2.42 M | 25.4 M | 67 |
536-
| `ValueTask<T>`, Flow | 1.95 M | 21.9 M | 190 |
537-
| `ValueTask<T>`, None | 2.30 M | 25.7 M | 6 |
538-
| `yieldsOnce`, Flow | 671 k | 6.14 M | 737 |
539-
| `yieldsOnce`, None | 887 k | 8.32 M | 556 |
533+
| synchronous methods, None | 3.47 M | 24.6 M | 6 |
534+
| `Task<T>`, Flow | 2.94 M | 22.2 M | 251 |
535+
| `Task<T>`, None | 3.47 M | 30.0 M | 67 |
536+
| `ValueTask<T>`, Flow | 3.11 M | 23.4 M | 190 |
537+
| `ValueTask<T>`, None | 3.76 M | 32.1 M | 6 |
538+
| `yieldsOnce`, Flow | 915 k | 6.89 M | 741 |
539+
| `yieldsOnce`, None | 1.14 M | 8.96 M | 559 |
540540

541541
The 16-worker rows are an equal-weight mix of the five requests, except `yieldsOnce`, which is one request. Per request shape, bytes per request at one worker, including the method's own allocations (the harness prints these lines before each row):
542542

@@ -550,7 +550,7 @@ The 16-worker rows are an equal-weight mix of the five requests, except `yieldsO
550550

551551
With `RpcContextFlow.None` the dispatcher adds no allocation to a method that completes inline: the `Task<T>` None row is the service's own `Task.FromResult` (`Task<int>` for 8 comes from the runtime's cache), and 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: about 560 B per request in the `yieldsOnce` None row. The 7 to 10 M target for a hosted server applies to methods that complete inline; a method that suspends costs a continuation per request as well as those bytes.
552552

553-
Before 2.0.0, the `ProcessAsync` path was capped near 4 M RPC/s at every worker count by one lock taken per document on the shared scratch pool, which no single-threaded benchmark could see. The scratch is now cached one per thread in front of that pool. `--scale [seconds] [workers] [threshold]` is the gate that catches the next such point: the inline None rows at 1, 2 and 16 workers, three paired runs, medians, and it exits non-zero when any 16/1 ratio is below 4.0 (the lock gave 1.3; the cache gives about 9). 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, and a check 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 with a reason (per-thread, miss-path, registration-only, read-only-after-init) in `.github/request-path-sync.allowlist`.
553+
Before 2.0.0, the `ProcessAsync` path was capped near 4 M RPC/s at every worker count by one lock taken per document on the shared scratch pool, which no single-threaded benchmark could see. The scratch is now cached one per thread in front of that pool. `--scale [seconds] [workers] [threshold]` is the gate that catches the next such point: the inline None rows at 1, 2 and 16 workers, three paired runs, medians, and 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). 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, and a check 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 with a reason (per-thread, miss-path, registration-only, read-only-after-init) in `.github/request-path-sync.allowlist`.
554554

555555
### Legacy string API: scheduled synchronous execution
556556

@@ -582,8 +582,8 @@ This mode is slower than the byte modes because it measures the .NET thread pool
582582
| HTTP, 1 request per POST | 128 k to 168 k | 95 to 125 µs per round trip per client depending on the run; HTTP/1.1 request-response is the cost, not the server |
583583
| HTTP, batch of 100 per POST | 12.7 M to 13.7 M | |
584584
| TCP, 256 pipelined | 15.2 M to 15.5 M | ring-buffer clients, one thread each, streaming framer |
585-
| TCP, 256 pipelined, `EnableAsyncMethods = true`, methods that complete inline | 13.3 M | 2026-09-25, one run on a busy machine; the same run's `false` row was 14.0 M |
586-
| TCP, 256 pipelined, `EnableAsyncMethods = true`, methods that suspend once | 1.13 M | five `async Task<T>` methods awaiting `Task.Yield()` |
585+
| TCP, 256 pipelined, `EnableAsyncMethods = true`, methods that complete inline | 15.4 M | 2026-09-25, one run; `--kestrel 3` in the same session gave 16.5 M for the `false` row |
586+
| TCP, 256 pipelined, `EnableAsyncMethods = true`, methods that suspend once | 1.29 M | five `async Task<T>` methods awaiting `Task.Yield()` |
587587

588588
The TCP client keeps 256 requests in flight per connection and refills from a precomputed ring of request bytes with one `Send` per refill; the server side is the same `Process` call the HTTP endpoint makes, fed by `JsonFramer`. With `EnableAsyncMethods = true` the connection handler processes the documents of one connection one at a time, in order, so 256 pipelined requests are 256 sequential invocations and a method that suspends is paid for per request; concurrency comes from the 16 connections.
589589

@@ -648,7 +648,7 @@ simdjson was evaluated as a fourth parser and not adopted: through the only main
648648

649649
The charts, the explorer page and the figures in this file come from one data file, [benchmarks/charts/benchmarks.json](benchmarks/charts/benchmarks.json); how they are rendered and checked is under [Building](#charts).
650650

651-
On 2026-09-25 the `ProcessAsync` path was found capped near 4 M RPC/s at every worker count: every document took one lock on the shared scratch pool, invisible to the single-threaded micro-benchmarks. A one-slot per-thread cache in front of the pool took the inline rows to over 20 M at 16 workers; the `--scale` gate and the request-path allowlist exist so the next such point is caught before a release.
651+
On 2026-09-25 the `ProcessAsync` path was found capped near 4 M RPC/s at every worker count: every document took one lock on the shared scratch pool, invisible to the single-threaded micro-benchmarks. A one-slot per-thread cache in front of the pool took the inline rows to 22 M to 32 M at 16 workers, against 31.7 M for the synchronous entry point in the same session; the `--scale` gate and the request-path allowlist exist so the next such point is caught before a release.
652652

653653
The 2026-09-23 performance pass (compiled invokers that read the tokens and write the pooled buffer through direct calls instead of virtual, delegate and interface calls; a tokenizer that keeps its scanner state in locals; a last-session cache; envelope keys matched by length; a flat method table) was measured A/B in one session: the same seven runs of `--sync 2 1` went from 3.2 M to 4.1 M (median 3.6 M) before to 4.0 M to 4.8 M (median 4.4 M) after, about 20 to 25 % more on one thread. The transport rows are bound by the loopback round trips rather than by the library and moved less.
654654

‎benchmarks/Micro/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,4 @@ These rows run on one thread, so they cannot see a process-wide serialization po
2424
dotnet run -c Release --project TestServer_Console -- --scale 3 16 4.0
2525
```
2626

27-
It fails when an inline row scales less than 4× from 1 to 16 workers (the lock gave 1.3; the per-thread cache gives about 9). The pull-request build runs a diagnostic `--scale 3 4 2.0` on the shared runner and an allowlist check of every `lock`, `Interlocked`, `Volatile.Write`, thread-static and writable static field on the request-path files (`.github/request-path-sync.allowlist`, each with a reason).
27+
It fails when an inline row scales less than 4× from 1 to 16 workers (the lock gave 1.3; the per-thread cache gives about 7). The pull-request build runs a diagnostic `--scale 3 4 2.0` on the shared runner and an allowlist check of every `lock`, `Interlocked`, `Volatile.Write`, thread-static and writable static field on the request-path files (`.github/request-path-sync.allowlist`, each with a reason).

0 commit comments

Comments
 (0)