Skip to content

ProcessAsync: per-thread scratch cache, scaling gate, async rows, frame fix - #154

Merged
Astn merged 7 commits into
masterfrom
async-scratch-cache
Sep 25, 2026
Merged

Astn merged 7 commits into
masterfrom
async-scratch-cache

Conversation

@Astn

@Astn Astn commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Why

The best row did not fall from 30 M+ to about 10 M requests per second. The synchronous byte row still measures 31 M on this machine. The ~10 M figure came from the legacy string API's scheduled thread-pool mode, which the harness ran by default. The regression was in ProcessAsync, which was capped near 4 M RPC/s on every core count because every document took the AsyncScratch pool lock. The single-threaded micro-benchmarks cannot see a process-wide serialization point.

Two debate rounds (P1 to P5, then P6 and P7 on thread-local copies of shared data) settled the design; this PR implements every accepted resolution.

Library

  • AsyncScratch per-thread slot. Each thread keeps one idle scratch in a [ThreadStatic] slot in front of the 64-slot locked pool, which now handles only misses and overflow. Rent clears the slot before exposing the lease. Return caches only a reusable scratch (reader released, buffers at most 64 KiB) and disposes the rest. This retains one scratch per thread that has run ProcessAsync plus 64 shared.
  • Frame fix in Handler.AsyncScope.Dispose. The new cross-thread test found that cleanup of a Flow method under a pre/post hook gave the completing thread the starting thread's frame after a suspension. The two threads then shared one frame, so RpcContext, RpcRequestId and RpcSetException on either could read or clear the other's state during concurrent dispatch. The scope now restores the frame through the ambient value alone. FlowScope_CompletedOnAnotherThread_LeavesThatThreadItsOwnFrame reproduces it deterministically and fails on master.

Harness, CI, docs

  • The harness adds TestServer_Console --async N W, --scale [seconds] [workers] [threshold] and --kestrel [seconds] [async]. The release gate measures inline rows at 1, 2 and N workers in three paired runs, takes the medians and fails with exit code 1 below the threshold. The legacy string benchmark moves to the t menu entry, and Enter runs the synchronous byte API.
  • .github/request-path-sync.allowlist lists every lock, Interlocked, Volatile.Write, [ThreadStatic] field and writable static on the request-path files (core and both companion serializers), each with a reason. A checker job verifies the list. A non-required scaling job runs --scale 3 4 2.0 and writes its table to the step summary.
  • The README adds an async section with measurements at 1 and 16 workers, per-shape allocations, the cost of a real suspension and Kestrel EnableAsyncMethods rows. The PR also updates the charts and explorer sets, CHANGELOG, Micro and AspNetCore READMEs.

Numbers

The Sync, Async, Legacy and Kestrel tables are from 2026-09-25 on the idle reference machine: three runs of --sync 3, the t entry and --kestrel 3, two of --kestrel 3 async, one 3 s run per --async row. The Performance table is one run per row in one session that day. The earlier busy-machine figures and the 2026-09-23 rows of those tables are gone from the README and the chart data, so the prose and the tables no longer mix days. The StreamJsonRpc and gRPC comparison, its in-process rows and the WebAssembly rows keep their 2026-09-23 results, and the conditions paragraph now says so per set.

Row Before After
ProcessAsync, inline methods, 16 workers 3.8 to 4.2 M 22.2 to 32.1 M
ProcessAsync, one real suspension, 16 workers 3.9 M 8.96 M
--scale 3 16 4.0, 16/1 ratio 1.3 7.1 to 7.3 (pass)
Process bytes, 16 threads, same session 31.7 M 31.7 M
Kestrel TCP, EnableAsyncMethods = true, inline 13.3 M (busy) 15.0 M to 15.4 M; the false row 14.3 M to 16.5 M
Kestrel TCP, EnableAsyncMethods = true, suspend once 1.13 M (busy) 1.25 M to 1.29 M
Process bytes, 16 threads, three runs 30.6 M to 35.8 M (2026-09-23) 31.7 M to 36.4 M
Legacy string API, best batch size 12.0 M (2026-09-23) 12.4 M to 13.3 M

The ValueTask<T> None row through ProcessAsync is above the synchronous entry point at 16 workers (32.1 M against 31.7 M) and the Task<T> None row just below it (30.0 M). The synchronous-methods row is at 24.6 M, about 78 % of it. The synchronous table, the default-mode Kestrel rows and the legacy table were re-measured the same day (three runs each) and replace the 2026-09-23 rows.

Performance section

The README (and so the docs site's front page) now opens with a Performance section. Its headline chart and table compare 1.2.3 against 2.0 on one machine in one session, with 3.08 M through the 1.2.3 string API, 13.3 M through the same API on 2.0, and 31.7 M and 32.1 M through the byte entry points. A paragraph explains why, followed by links to the full tables and the explorer. The 1.2.3 row comes from a new benchmarks/Baseline project that references the 1.2.3 package from NuGet and drives it with the same loop as the harness's legacy entry. Both sides of the comparison therefore use one harness shape on one day. The new linear bar chart in render.py (headline-1x-vs-2) is covered by render.py --check and two tests.

Tests

1163 pass on net8.0 and net10.0 in three consecutive full runs. New coverage includes AsyncScratchCacheTests (re-entrant, overlapping, 16 workers x 400 documents, cancellation after transfer, throwing reader release, oversized trimming) and the frame regression test above. render.py --check, test_render.py and the allowlist checker pass.

Follow-ups (P2, P6, P7 resolutions)

  • Profile the async residual on the synchronous-methods row, which reaches 24.6 M through ProcessAsync against 31.7 M direct. That is about 78 %, just under the 80 % trigger. The ValueTask<T> None row is above the synchronous row and the Task<T> None row within 6 % of it.
  • Test STJ TypeInfo<T>._last as a thread-static copy in an A/B. The merge bar is at least 5 % target gain beyond noise, with at most 2 % control regression.
  • Add a miss/overflow counter on the AsyncScratch fallback before any second slot or striping. The named-parameter map is deferred.

…me fix

The 30 M+ synchronous row never regressed; the ~10 M figure was the legacy
string API's thread-pool mode, and ProcessAsync itself was capped near 4 M
RPC/s on every core count by the AsyncScratch pool lock taken once per
document.

Library
- AsyncScratch keeps one idle scratch per thread in a [ThreadStatic] slot in
  front of the 64-slot locked pool, which becomes the miss and overflow path.
  Rent clears the slot before exposing the lease; Return caches only a
  reusable scratch (reader released, buffers at most 64 KiB) and disposes the
  rest. Retention is one scratch per thread that has run ProcessAsync plus
  64 shared.
- Handler.AsyncScope.Dispose no longer writes the captured thread frame back.
  A Flow scope under a pre/post hook that suspends is disposed on the
  completing thread; that thread was handed the starting thread's frame, and
  the two then shared it, so RpcContext, RpcRequestId and RpcSetException on
  either could read or clear the other's during concurrent dispatch. The
  ambient restore already brings back the current thread's own frame.

Harness (TestServer_Console)
- --async N W: ProcessAsync rows at W awaited workers with the same loop as
  --sync (barrier start, exact allocation accounting per row).
- --scale [seconds] [workers] [threshold]: the release gate; inline rows at
  1, 2 and N workers, three paired runs, medians, exit 1 below the threshold.
- --kestrel [seconds] [async]: the host with EnableAsyncMethods = true, with
  an inline row and a yielding-methods row.
- The legacy string API's thread-pool benchmark moves to the 't' menu entry;
  Enter runs the synchronous byte API.

CI and docs
- .github/request-path-sync.allowlist and its checker: every lock,
  Interlocked, Volatile.Write, [ThreadStatic] field and writable static on
  the request-path files (core and both companion serializers) is listed
  with a reason. A non-required scaling job runs --scale 3 4 2.0.
- README: async section with the 1- and 16-worker rows, per-shape allocation
  table, the cost of a real suspension, Kestrel async rows; charts and the
  explorer gain the async and legacy sets; CHANGELOG, Micro and AspNetCore
  READMEs updated.

Tests
- AsyncScratchCacheTests: re-entrant, overlapping, cross-thread (16 workers x
  400 documents), cancellation after transfer, throwing reader release and
  oversized-document trimming.
- AsyncInvocationTests.FlowScope_CompletedOnAnotherThread_LeavesThatThreadItsOwnFrame:
  deterministic reproduction of the frame bug.

Measured on a busy machine (single runs, to be re-measured idle before a
release): ProcessAsync inline rows 21.2 to 25.7 M RPC/s at 16 workers (was
3.8 to 4.2 M), a real suspension 8.3 M (was 3.9 M); --scale 3 16 4.0 passes
with 16/1 ratios of 9.1 to 9.6.
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.
A Performance section under the introduction: a linear bar chart and a
table of the last 1.x release on NuGet against 2.0, measured on one machine
in one session (3.08 M through the 1.2.3 string API, 13.3 M through the
same API on 2.0, 31.7 M and 32.1 M through the byte entry points), why the
difference exists, and links to the full tables and the explorer.

benchmarks/Baseline references AustinHarris.JsonRpc 1.2.3 from NuGet and
drives it with the same loop as the harness's legacy entry, so the 1.x row
is measured the same way as the 2.0 rows. render.py gains headline_chart
(linear axis, multiples of the first row) and the headline data set; the
explorer lists it; the README check covers its figures.
Shorter sentences and plainer verbs in the README, CHANGELOG, AspNetCore and Micro READMEs; no figure, identifier or link changed. Two factual slips fixed on the way: the Kestrel introduction no longer says every row ran with EnableAsyncMethods = false, and the Micro README's 0 B claim is scoped to the inline None rows.
The Sync, Legacy and default-mode Kestrel rows were from 2026-09-23 and the
ProcessAsync and EnableAsyncMethods = true rows from 2026-09-25, so the prose
mixed the days. All of them are now the 2026-09-25 idle-machine runs: three runs
of --sync 3, the t entry and --kestrel 3, two of --kestrel 3 async, one session.
Every row that had two figures keeps the low and high over those runs; the
async Kestrel rows become ranges; the intro, Performance section, conditions
paragraph, AspNetCore and WasmHost READMEs quote the tables. Charts and the
explorer re-rendered (data revision eacac12e8d55).
Conflicts: the DI paragraphs in README.md and the AspNetCore README keep the
lifetimes text from master; the pull-request workflow keeps master's note that
pull requests no longer publish and this branch's allowlist and scaling jobs.
From an audit of every performance figure quoted outside a table: the
Performance paragraph says 4.3 and 10.3 times like the table; the conditions
paragraph states each set's date and run policy (the comparison, in-process and
WebAssembly sets stay on 2026-09-23); the yielding row's allocation is the
table's 559 B at one worker, including the service's own; the async ranges use
the table's precision; the sweep chart's whiskers span five runs, not two; the
WasmHost conditions say which column is one run and which the better of two.
@Astn
Astn merged commit 48839fb into master Sep 25, 2026
4 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant