BenchmarkDotNet timings of one request on one core, per request shape, with an allocation column. Use these to judge a change to the dispatch path; use TestServer_Console --sync for throughput under load and the tables in the main README.
dotnet run -c Release --project benchmarks/Micro -- --filter '*'Useful arguments: --filter 'AustinHarris.JsonRpc.Micro.DispatchBenchmarks.*' for one class (*Dispatch* would also match AsyncDispatchBenchmarks), --job short for a quick look (3 warm-up and 3 measured iterations), --disasm to print the JIT disassembly of each benchmark.
Benchmark classes:
DispatchBenchmarks: the five shapes of the console harness (add,addInt, nullable float, decimal, string), a batch of the five, and a notification, throughJsonRpcProcessor.Processwith the built-in serializer and a class registered with[JsonRpcMethod].InterfaceBindingBenchmarks: the same five shapes through a contract registered withServiceBinder.BindInterface, plus one and two levels of interface-typed properties (Calc.addInt,Admin.Calc.addInt). Interface rows should match the class rows ofDispatchBenchmarks; the tree rows pay only for the longer method name.BindingComparisonBenchmarks:addInt, decimal and string through the same class registered with[JsonRpcMethod]and throughBindInterface, in one process, so a drift of the machine between runs cannot masquerade as a binding cost.SessionRegistryBenchmarks: the session registry on the request path (last hit, snapshot, unknown id, register/lookup/destroy, one request end to end), each quiet and with a background thread registering and destroying sessions (Churn), which forces the snapshot refresh on every lookup. Use it to judge a change to the registry or its dictionary type: theChurnrows show what registry changes cost requests.LifetimeBenchmarks:addIntthrough an instance-bound registration, through a resolver that returns a cached instance (ServiceBinder.BindService(session, type, resolve), the seam behindAddJsonRpcService<T>(ServiceLifetime)), and through a resolver that asks a Microsoft.Extensions.DependencyInjection scope for a scoped or a transient service with the scope created and disposed around the document, as the raw connection handler does; plus that scope alone. The instance row must matchDispatchBenchmarks.AddIntand allocate nothing; the other rows are the price of opting into scoped and transient lifetimes.AsyncDispatchBenchmarks: a synchronous method throughProcessandProcessAsync,Task<int>andValueTask<int>methods that complete inline, and a method that yields once, each with the defaultRpcContextFlow.Noneand withRpcContextFlow.Flow. The inline default rows should allocate nothing; theFlowrows pay for the execution-context bridge; the yielding rows show the cost of a real suspension.
Read the Allocated column first: a non-zero value on a numeric shape means the request touched the GC, which the fast path must not do. Then compare Mean, but only between runs on an idle machine or within one run: background load biases ratios as well as absolute numbers, which is why BindingComparisonBenchmarks puts both registrations in one process.
These rows run on one thread, so they cannot detect a process-wide serialization point. The AsyncScratch pool lock capped ProcessAsync at about 4 M RPC/s on every core count while every inline None row here stayed at 0 B and the same mean. Before a release, and after any change to dispatch, pooling or the async path, also run the scaling gate on the reference machine and paste its table into the release notes:
dotnet run -c Release --project TestServer_Console -- --scale 3 16 4.0It 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. It also checks that every lock, Interlocked, Volatile.Write, thread-static and writable static field on the request-path files is listed in .github/request-path-sync.allowlist with a reason.