ProcessAsync: per-thread scratch cache, scaling gate, async rows, frame fix - #154
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 theAsyncScratchpool 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
AsyncScratchper-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 runProcessAsyncplus 64 shared.Handler.AsyncScope.Dispose. The new cross-thread test found that cleanup of aFlowmethod under a pre/post hook gave the completing thread the starting thread's frame after a suspension. The two threads then shared one frame, soRpcContext,RpcRequestIdandRpcSetExceptionon 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_LeavesThatThreadItsOwnFramereproduces it deterministically and fails on master.Harness, CI, docs
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 thetmenu entry, and Enter runs the synchronous byte API..github/request-path-sync.allowlistlists everylock,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-requiredscalingjob runs--scale 3 4 2.0and writes its table to the step summary.EnableAsyncMethodsrows. 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, thetentry and--kestrel 3, two of--kestrel 3 async, one 3 s run per--asyncrow. 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.ProcessAsync, inline methods, 16 workersProcessAsync, one real suspension, 16 workers--scale 3 16 4.0, 16/1 ratioProcessbytes, 16 threads, same sessionEnableAsyncMethods = true, inlinefalserow 14.3 M to 16.5 MEnableAsyncMethods = true, suspend onceProcessbytes, 16 threads, three runsThe
ValueTask<T>None row throughProcessAsyncis above the synchronous entry point at 16 workers (32.1 M against 31.7 M) and theTask<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/Baselineproject 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 inrender.py(headline-1x-vs-2) is covered byrender.py --checkand 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.pyand the allowlist checker pass.Follow-ups (P2, P6, P7 resolutions)
ProcessAsyncagainst 31.7 M direct. That is about 78 %, just under the 80 % trigger. TheValueTask<T>None row is above the synchronous row and theTask<T>None row within 6 % of it.TypeInfo<T>._lastas 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.AsyncScratchfallback before any second slot or striping. The named-parameter map is deferred.