diff --git a/.github/request-path-sync.allowlist b/.github/request-path-sync.allowlist new file mode 100644 index 0000000..86f28ad --- /dev/null +++ b/.github/request-path-sync.allowlist @@ -0,0 +1,35 @@ +# Every `lock (`, `Interlocked.`, `Volatile.Write`, `[ThreadStatic]` field and writable static field in the +# request-path files (core and both companion serializers), with the reason it is allowed there. +# Format: file | the code line, trimmed, without its trailing comment | reason, starting with its tag: +# per-thread the field is thread-static, so no line is shared +# miss-path touched only when a per-thread cache misses or overflows, never by a warm inline document +# registration-only written when methods or sessions are bound, never by a request +# read-only-after-init written once at startup, then only read +# shared-write a request can write it and every core reads it: the reason must say why it is tolerated +# Checked by .github/scripts/check_request_path_sync.py in the pull-request workflow. The measurement that backs this +# list is `TestServer_Console --scale` (README, Benchmarks). A `[ThreadStatic]` attribute on its own line is not +# listed; the field line under it is. +Json-Rpc/JsonRpcProcessor.Async.cs | private static int _count; | miss-path: the shared pool's fill count, read and written only under lock (Pool) +Json-Rpc/JsonRpcProcessor.Async.cs | [ThreadStatic] private static AsyncScratch _slot; | per-thread: the one-slot cache of an idle async scratch +Json-Rpc/JsonRpcProcessor.Async.cs | lock (Pool) | miss-path: AsyncScratch.Rent, taken only when the thread's slot is empty (cold thread, nested or overlapping document) +Json-Rpc/JsonRpcProcessor.Async.cs | lock (Pool) | miss-path: AsyncScratch.Return, taken only when the completing thread's slot is already occupied; the array is the bounded overflow +Json-Rpc/JsonRpcProcessor.cs | [ThreadStatic] private static Scratch _current; | per-thread: the synchronous scratch +Json-Rpc/Handler.Async.cs | [ThreadStatic] private static InvocationState __unflowedState; | per-thread: the invocation frame of a RpcContextFlow.None call +Json-Rpc/Handler.cs | private static int _sessionHandlerMasterVersion = 1; | registration-only: written by Interlocked.Increment when a session is created or destroyed; requests read it to validate their per-thread snapshot +Json-Rpc/Handler.cs | Interlocked.Increment(ref _sessionHandlerMasterVersion); | registration-only: GetSessionHandler creating a session and DestroySession; never on the request path +Json-Rpc/Handler.cs | private static Dictionary _sessionHandlersLocal; | per-thread ([ThreadStatic] on the line above): the thread's snapshot of the session registry +Json-Rpc/Handler.cs | private static int _sessionHandlerLocalVersion = 0; | per-thread ([ThreadStatic] on the line above): the snapshot's version +Json-Rpc/Handler.cs | private static string _lastSessionId; | per-thread ([ThreadStatic] on the line above): the last-session cache +Json-Rpc/Handler.cs | private static Handler _lastSessionHandler; | per-thread ([ThreadStatic] on the line above): the last-session cache +Json-Rpc/Handler.cs | private static InvocationState __state; | per-thread ([ThreadStatic] on the line above): the current invocation frame +Json-Rpc/Jsmn/JsmnSerializer.cs | [ThreadStatic] private static JsmnTokenizer _scratch; | per-thread: the tokenizer scratch +Json-Rpc/Jsmn/JsmnSerializer.cs | [ThreadStatic] private static bool _scratchInUse; | per-thread: re-entrancy flag for the tokenizer scratch +Json-Rpc/Serialization/Utf8KeyTable.cs | lock (_writeLock) | registration-only: Set, Remove, Clear and ReplaceAll rebuild a snapshot under the lock; requests read the published snapshot without it +Json-Rpc/Serialization/Utf8KeyTable.cs | System.Threading.Volatile.Write(ref _snapshot, snapshot); | registration-only: publishes a rebuilt snapshot; read-only afterwards +Json-Rpc/RpcBinding.cs | lock (_sync) | registration-only: RpcBinding.Dispose unbinding an interface tree +AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs | [ThreadStatic] private static Utf8JsonWriter _cachedWriter; | per-thread: the cached writer +AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs | [ThreadStatic] private static JsonSerializerOptions _cachedWriterOptions; | per-thread: the options the cached writer was built with +AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs | [ThreadStatic] private static bool _cachedWriterInUse; | per-thread: re-entrancy flag for the cached writer +AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs | [ThreadStatic] private static byte[] _scratch; | per-thread: transcoding scratch +AustinHarris.JsonRpc.SystemTextJson/SystemTextJsonRpcSerializer.cs | private static Entry _last; | shared-write: TypeInfo's last-options cache, rewritten whenever the options differ from the previous call, so a per-request write when two serializers with different options serve requests concurrently; tolerated because one options object is the common case; the per-thread copy is the first candidate of the 2026-09-25 P6 resolution, pending its A/B +AustinHarris.JsonRpc.Newtonsoft/NewtonsoftJsonRpcSerializer.cs | [ThreadStatic] private static Scratch _current; | per-thread: the Json.NET scratch diff --git a/.github/scripts/check_request_path_sync.py b/.github/scripts/check_request_path_sync.py new file mode 100644 index 0000000..1755f20 --- /dev/null +++ b/.github/scripts/check_request_path_sync.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Every synchronization point and every writable static on the request path must be listed, with a reason, in +.github/request-path-sync.allowlist: `lock (`, `Interlocked.`, `Volatile.Write`, `[ThreadStatic]` fields and static +fields that are not readonly, in the request-path files of the core and of both companion serializers. + +A process-wide serialization point on the per-document path caps ProcessAsync at a few million requests per second +on every core count (the AsyncScratch pool lock, 2026-09-25), and the single-threaded micro-benchmarks cannot see +it; a static that one request writes and every core reads costs the same kind of cache-line traffic without a lock. +This check is a review aid: it catches a new lock, atomic or writable static on those files and asks for a written +reason (per-thread, miss-path, registration-only, read-only-after-init). It is not the measurement; +`TestServer_Console --scale` is (a probe moved behind an allowed lock would pass here and fail there). Mutable +objects reached through readonly references are outside its reach and belong to review. + +Usage: python3 .github/scripts/check_request_path_sync.py (from the repository root; exit 1 on any unlisted use) +""" +import glob +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +ALLOWLIST = os.path.join(ROOT, ".github", "request-path-sync.allowlist") +PATTERNS = ["Json-Rpc/JsonRpcProcessor*.cs", "Json-Rpc/Handler*.cs", "Json-Rpc/Invocation/*.cs", "Json-Rpc/Jsmn/*.cs", + "Json-Rpc/Serialization/*.cs", "Json-Rpc/JsonRpcContext.cs", "Json-Rpc/JsonRpcRequestId.cs", "Json-Rpc/RpcBinding.cs", + "AustinHarris.JsonRpc.SystemTextJson/*.cs", "AustinHarris.JsonRpc.Newtonsoft/*.cs"] +USE = re.compile(r"\block\s*\(|\bInterlocked\.|\bVolatile\.Write") +# A static field declaration that is not readonly or const: `[ThreadStatic] private static T name;` or `static T name = ...;`. +# Expression-bodied members (`=>`), methods, classes, events and operators are not fields. +FIELD = re.compile(r"^(?:\[ThreadStatic\]\s*)?(?:(?:public|private|internal|protected|new|volatile)\s+)*static\s+" + r"(?!readonly\b|const\b|class\b|void\b|partial\b|event\b|explicit\b|implicit\b|operator\b)" + r"(?!.*=>)(?!.*\()[\w<>\[\],.?\s]+?\s+\w+\s*(?:=[^;]*)?;$") + + +def load_allowlist(): + """{(file, code line stripped): reason}; lines are `file | code | reason`, `#` comments and blanks ignored.""" + allowed = {} + with open(ALLOWLIST, encoding="utf-8") as f: + for n, line in enumerate(f, 1): + line = line.strip() + if not line or line.startswith("#"): + continue + parts = [p.strip() for p in line.split("|", 2)] + if len(parts) != 3 or not all(parts): + sys.exit(f"{ALLOWLIST}:{n}: expected `file | code | reason`") + allowed[(parts[0].replace("\\", "/"), parts[1])] = parts[2] + return allowed + + +def main(): + allowed = load_allowlist() + seen = set() + problems = [] + for pattern in PATTERNS: + for path in sorted(glob.glob(os.path.join(ROOT, pattern))): + rel = os.path.relpath(path, ROOT).replace("\\", "/") + with open(path, encoding="utf-8-sig") as f: + for n, line in enumerate(f, 1): + code = line.split("//", 1)[0].strip() + if not (USE.search(code) or FIELD.match(code)): + continue + key = (rel, code) + if key in allowed: + seen.add(key) + else: + problems.append(f"{rel}:{n}: `{code}` is not in {os.path.relpath(ALLOWLIST, ROOT)}; add it with a reason or take it off the request path") + for key in allowed: + if key not in seen: + problems.append(f"{os.path.relpath(ALLOWLIST, ROOT)}: `{key[1]}` in {key[0]} no longer exists; remove the entry") + for p in problems: + print(p) + if problems: + return 1 + print(f"request-path synchronization: {len(seen)} listed uses, none unlisted") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 62975b2..4495e8b 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -34,3 +34,41 @@ jobs: run: dotnet test AustinHarris.JsonRpcTestN --configuration Release --no-build # Pull requests do not publish. Packages go out from the master workflow through Trusted Publishing, whose # nuget.org policy is bound to build_publish_master.yml; nothing else can push. + + # Every lock and Interlocked on the request-path files must be listed with a reason (a process-wide lock on the + # per-document path once capped ProcessAsync at 4 M RPC/s on every core count). A review aid, not the measurement. + request-path-sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Synchronization on the request path is allowlisted + run: python3 .github/scripts/check_request_path_sync.py + + # The measurement: ProcessAsync must scale from 1 to 4 workers. A process-wide serialization point holds the ratio + # near 1.3 on any core count. Diagnostic on the shared runner (its core count and isolation are not promised, so + # this job is not required and continues on error); the release gate is `--scale 3 16 4.0` on the reference machine. + scaling: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v7 + - name: Setup .NET + uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + dotnet-version: 10.0.x + - name: Build the harness + run: dotnet build TestServer_Console --configuration Release + - name: ProcessAsync scales from 1 to 4 workers (4/1 at least 2.0) + run: | + set +e + dotnet run -c Release --no-build --project TestServer_Console -- --scale 3 4 2.0 | tee scale.txt + status=${PIPESTATUS[0]} + { + echo "## ProcessAsync scaling (diagnostic, not required)" + echo + grep -E '^\|' scale.txt + echo + [ "$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**" + } >> "$GITHUB_STEP_SUMMARY" + exit $status diff --git a/AustinHarris.JsonRpc.AspNetCore/README.md b/AustinHarris.JsonRpc.AspNetCore/README.md index 97b8f9d..a542e71 100644 --- a/AustinHarris.JsonRpc.AspNetCore/README.md +++ b/AustinHarris.JsonRpc.AspNetCore/README.md @@ -156,9 +156,15 @@ invoked. - **HTTP:** the call is cancelled when the client disconnects (`HttpContext.RequestAborted`). Notifications are awaited and still answer `204`. The body reader stays leased until the invocation finishes. -- **Raw connections:** documents are processed one at a time, in order. Replies already finished are flushed - before the connection waits on a slow method. When the connection closes, the running method is waited for and - its response discarded. +- **Raw connections:** documents are processed one at a time, in order. On one connection, 256 pipelined requests + are 256 sequential invocations, not 256 concurrent suspensions. Concurrency comes from connections. + Replies already finished are flushed before the connection waits on a slow method. When the connection closes, + the handler waits for the running method to finish and discards its response. +- **Cost:** every document then goes through `ProcessAsync`. With methods that complete inline, the TCP row measured + 15.0 M to 15.4 M against 14.3 M to 16.5 M for the synchronous mode on the same day, inside its spread. A method that + suspends pays for its own async state, the library's completion state and a continuation per request; the main README's + Async table measured 559 B per request for the yielding None row in process at one worker, including the service's own + allocations. The main README's Kestrel table has both rows, measured with `TestServer_Console --kestrel 3 async`. A method receives the token by declaring a `[JsonRpcCancellation] CancellationToken` parameter; see [Asynchronous methods and cancellation](https://github.com/Astn/JSON-RPC.NET#asynchronous-methods-and-cancellation) diff --git a/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs b/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs index f21c5e7..eb4fd67 100644 --- a/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs +++ b/AustinHarris.JsonRpcTestN/AsyncInvocationTests.cs @@ -24,6 +24,12 @@ public sealed class AsyncInvocationTests [TearDown] public void TearDown() => Handler.DestroySession(_session); private void Bind(string name, Delegate method, RpcContextFlow flow = RpcContextFlow.Flow) => ServiceBinder.BindMethod(_session, name, method, contextFlow: flow); private Task Run(string json, JsonRpcSerializer serializer = null, object context = null, CancellationToken token = default) => JsonRpcProcessor.ProcessAsync(_session, json, context, serializer, token); + private string Sync(string json, object context = null) + { + var output = new ArrayBufferWriter(); + JsonRpcProcessor.Process(_session, Encoding.UTF8.GetBytes(json).AsSpan(), output, context); + return Encoding.UTF8.GetString(output.WrittenSpan); + } private static TaskCompletionSource Gate() => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); private static string Request(string method, string parameters = null, string id = "1") => "{\"method\":\"" + method + "\"" + (parameters == null ? "" : ",\"params\":" + parameters) + (id == null ? "" : ",\"id\":" + id) + "}"; private static void Error(string json, int code) => Assert.AreEqual(code, (int)JObject.Parse(json)["error"]["code"], json); @@ -309,6 +315,51 @@ public async Task FlowFrame_IsClearedForEscapedExecutionContext() await escaped; } + [Test] + public async Task FlowScope_CompletedOnAnotherThread_LeavesThatThreadItsOwnFrame() + { + // A hooked Flow invocation suspends here and completes on a pool thread, where its scope is + // disposed. That thread must keep its own frame afterwards: when it was handed this thread's + // frame instead, a synchronous dispatch on each thread saved and restored the same frame, and + // the interleaving below left this thread's method reading no id at all. + var handler = Handler.GetSessionHandler(_session); + handler.SetPreProcessHandler((request, context) => null); + int completedOn = 0; + handler.SetPostProcessHandler((request, response, context) => { if (request.Method == "suspend") completedOn = Environment.CurrentManagedThreadId; return null; }); + var suspended = new TaskCompletionSource(); + Bind("suspend", new Func>(() => suspended.Task)); + var entered = new ManualResetEventSlim(); + var release = new ManualResetEventSlim(); + var left = new ManualResetEventSlim(); + Bind("hold", new Func(() => { entered.Set(); release.Wait(); return 1; })); + var mine = new object(); + Bind("peek", new Func(() => + { + release.Set(); + left.Wait(); + return (Handler.RpcRequestId().IsAbsent ? 0 : 1) + (ReferenceEquals(Handler.RpcContext(), mine) ? 2 : 0); + })); + Bind("warm", new Func(() => 1)); + Sync(Request("warm")); // this thread owns a frame before the suspension, as any thread that has dispatched does + var pending = Run(Request("suspend")); + int completingThread = 0; + var other = Task.Run(() => + { + completingThread = Environment.CurrentManagedThreadId; + suspended.SetResult(7); + Sync(Request("hold", id: "2")); + left.Set(); + }); + // Block, do not await, until the other thread is inside "hold": an awaited continuation could be + // run on that thread, ahead of "hold", and wait for itself. + entered.Wait(); + var response = Sync(Request("peek", id: "\"mine\""), mine); + await other; + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":1}", await pending); + Assume.That(completedOn, Is.EqualTo(completingThread), "the completion did not run the scope's cleanup on the completing thread"); + Assert.AreEqual("{\"jsonrpc\":\"2.0\",\"result\":3,\"id\":\"mine\"}", response, "this thread's method sees its own id and context while the other thread dispatches"); + } + [TestCaseSource(nameof(Serializers))] public async Task Batch_IsSequential_AwaitsNotifications_AndIsolatesFaults(string name) { diff --git a/AustinHarris.JsonRpcTestN/AsyncScratchCacheTests.cs b/AustinHarris.JsonRpcTestN/AsyncScratchCacheTests.cs new file mode 100644 index 0000000..239b8c6 --- /dev/null +++ b/AustinHarris.JsonRpcTestN/AsyncScratchCacheTests.cs @@ -0,0 +1,185 @@ +using System; +using System.Buffers; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AustinHarris.JsonRpc; +using AustinHarris.JsonRpc.Serialization; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace AustinHarris.JsonRpcTestN +{ + /// + /// The async scratch lease behind ProcessAsync: a one-slot per-thread cache in front of the shared pool. + /// The lease is exclusive and transferable, so these cases exercise every way a lease can leave the thread that + /// rented it or overlap another lease on that thread, and check that every document still answers correctly. + /// + [TestFixture] + public sealed class AsyncScratchCacheTests + { + private string _session; + [SetUp] public void SetUp() => _session = "scratch-" + Guid.NewGuid().ToString("N"); + [TearDown] public void TearDown() => Handler.DestroySession(_session); + private void Bind(string name, Delegate method, RpcContextFlow flow = RpcContextFlow.None) => ServiceBinder.BindMethod(_session, name, method, contextFlow: flow); + private Task Run(string json, JsonRpcSerializer serializer = null, CancellationToken token = default) => JsonRpcProcessor.ProcessAsync(_session, json, null, serializer, token); + private static string Request(string method, string parameters = null, string id = "1") => "{\"method\":\"" + method + "\"" + (parameters == null ? "" : ",\"params\":" + parameters) + ",\"id\":" + id + "}"; + private static int Result(string json) => (int)JObject.Parse(json)["result"]; + private static TaskCompletionSource Gate() => new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + [Test] + public async Task ReentrantDocument_InsideARunningMethod_OnOneThread() + { + // The outer document holds its lease while the inner one rents: the inner must not receive the + // outer's scratch. Inline and suspended outer methods, many times over, so the slot is reused. + Bind("inner", new Func(n => n * 2)); + Bind("outer", new Func>(async n => + { + var inner = await JsonRpcProcessor.ProcessAsync(_session, Request("inner", "[" + n + "]", "2")).ConfigureAwait(false); + return Result(inner) + 1; + })); + Bind("outerYield", new Func>(async n => + { + await Task.Yield(); + var inner = await JsonRpcProcessor.ProcessAsync(_session, Request("inner", "[" + n + "]", "3")).ConfigureAwait(false); + return Result(inner) + 1; + })); + for (int i = 0; i < 300; i++) + { + Assert.AreEqual(2 * i + 1, Result(await Run(Request("outer", "[" + i + "]")))); + Assert.AreEqual(2 * i + 1, Result(await Run(Request("outerYield", "[" + i + "]")))); + Assert.AreEqual(2 * i, Result(await Run(Request("inner", "[" + i + "]")))); + } + } + + [Test] + public async Task OverlappingDocuments_OnOneThread_SuspendedThenInline() + { + // Document A suspends and is completed elsewhere; document B runs inline on the same thread + // meanwhile and returns its lease into the slot. Both answer, and the slot keeps working after. + var gate = Gate(); + Bind("wait", new Func>(async () => { await gate.Task.ConfigureAwait(false); return 7; })); + Bind("now", new Func(n => n)); + var pendingA = Run(Request("wait")); + Assert.IsFalse(pendingA.IsCompleted); + for (int i = 0; i < 50; i++) Assert.AreEqual(i, Result(await Run(Request("now", "[" + i + "]", "2")))); + gate.SetResult(0); + Assert.AreEqual(7, Result(await pendingA)); + for (int i = 0; i < 50; i++) Assert.AreEqual(i, Result(await Run(Request("now", "[" + i + "]", "2")))); + } + + [Test] + public async Task CrossThreadCompletion_ManyWorkers_EveryDocumentAnswers() + { + Bind("yield", new Func>(async n => { await Task.Yield(); return n + 1; })); + Bind("hop", new Func>(n => Task.Run(() => n + 2))); + Bind("inline", new Func(n => n + 3)); + var counts = await Task.WhenAll(Enumerable.Range(0, 16).Select(w => Task.Run(async () => + { + int ok = 0; + for (int i = 0; i < 400; i++) + { + int n = w * 1000 + i; + var (name, expected) = (i % 3) switch { 0 => ("yield", n + 1), 1 => ("hop", n + 2), _ => ("inline", n + 3) }; + if (Result(await Run(Request(name, "[" + n + "]")).ConfigureAwait(false)) == expected) ok++; + } + return ok; + }))); + Assert.AreEqual(16 * 400, counts.Sum()); + } + + [Test] + public async Task CancellationAfterTransfer_CommitsNothing_AndTheThreadKeepsWorking() + { + var gate = Gate(); + Bind("wait", new Func>(async () => { await gate.Task.ConfigureAwait(false); return 7; })); + Bind("now", new Func(() => 1)); + using var cts = new CancellationTokenSource(); + using var output = new PooledByteBufferWriter(); + var pending = JsonRpcProcessor.ProcessAsync(_session, new ReadOnlyMemory(System.Text.Encoding.UTF8.GetBytes(Request("wait"))), output, cancellationToken: cts.Token); + Assert.IsFalse(pending.IsCompleted); + cts.Cancel(); + gate.SetResult(0); + Assert.CatchAsync(async () => await pending); + Assert.AreEqual(0, output.WrittenCount, "cancellation commits no response bytes"); + for (int i = 0; i < 20; i++) Assert.AreEqual(1, Result(await Run(Request("now")))); + } + + [Test] + public async Task ThrowingReaderRelease_SurfacesOnce_AndTheNextDocumentGetsAFreshLease() + { + Bind("now", new Func(() => 1)); + var serializer = new ReleaseThrowingSerializer(SerializerCatalog.Create("jsmn")) { ThrowOnRelease = true }; + // The damaged scratch is disposed, not cached, so the failure is not repeated by the next document. + Assert.ThrowsAsync(async () => await Run(Request("now"), serializer)); + serializer.ThrowOnRelease = false; + Assert.AreEqual(1, Result(await Run(Request("now"), serializer))); + Assert.AreEqual(1, Result(await Run(Request("now"), serializer))); + Assert.AreEqual(2, serializer.ReadersCreated, "the first reader went with its scratch; the second is cached and reused"); + } + + [Test] + public async Task OversizedDocuments_AreTrimmedOnReturn_AndSmallOnesFollow() + { + Bind("echo", new Func(s => s)); + Bind("now", new Func(() => 1)); + var big = new string('x', 70 * 1024); + for (int round = 0; round < 3; round++) + { + var json = await Run(Request("echo", "[\"" + big + "\"]")); + Assert.AreEqual(big, (string)JObject.Parse(json)["result"]); + Assert.AreEqual(1, Result(await Run(Request("now")))); + } + var sequence = new ReadOnlySequence(System.Text.Encoding.UTF8.GetBytes(Request("echo", "[\"" + big + "\"]"))); + using var output = new PooledByteBufferWriter(); + await JsonRpcProcessor.ProcessAsync(_session, sequence, output); + Assert.AreEqual(big, (string)JObject.Parse(output.ToString())["result"]); + Assert.AreEqual(1, Result(await Run(Request("now")))); + } + + private sealed class ReleaseThrowingSerializer : JsonRpcSerializer + { + private readonly JsonRpcSerializer _inner; + internal bool ThrowOnRelease; + internal int ReadersCreated; + internal ReleaseThrowingSerializer(JsonRpcSerializer inner) { _inner = inner; } + public override string Name => "release-throws"; + public override JsonRpcRequestReader CreateReader() { ReadersCreated++; return new Reader(this, _inner.CreateReader()); } + public override T Read(ReadOnlySpan bytes) => _inner.Read(bytes); + public override object Read(ReadOnlySpan bytes, Type type) => _inner.Read(bytes, type); + public override void Write(IBufferWriter output, T value) => _inner.Write(output, value); + public override void Write(IBufferWriter output, object value, Type type) => _inner.Write(output, value, type); + + private sealed class Reader : JsonRpcRequestReader + { + private readonly ReleaseThrowingSerializer _owner; + private readonly JsonRpcRequestReader _inner; + internal Reader(ReleaseThrowingSerializer owner, JsonRpcRequestReader inner) { _owner = owner; _inner = inner; } + public override bool TryParse(ReadOnlyMemory bytes, out string error) => _inner.TryParse(bytes, out error); + public override ReadOnlyMemory Document => _inner.Document; + public override bool IsBatch => _inner.IsBatch; + public override int Count => _inner.Count; + public override bool Select(int index) => _inner.Select(index); + public override bool HasMethod => _inner.HasMethod; + public override string Method => _inner.Method; + public override ReadOnlySpan MethodUtf8 => _inner.MethodUtf8; + public override JsonRpcIdKind IdKind => _inner.IdKind; + public override ReadOnlySpan IdRaw => _inner.IdRaw; + public override object IdValue => _inner.IdValue; + public override JsonRpcParamsKind ParamsKind => _inner.ParamsKind; + public override int ParamCount => _inner.ParamCount; + public override ReadOnlySpan ParamNameUtf8(int i) => _inner.ParamNameUtf8(i); + public override ReadOnlySpan ParamRaw(int i) => _inner.ParamRaw(i); + public override bool ParamIsNull(int i) => _inner.ParamIsNull(i); + public override T ReadParam(int i) => _inner.ReadParam(i); + public override object ReadParam(int i, Type type) => _inner.ReadParam(i, type); + public override object ParamsValue => _inner.ParamsValue; + public override void Release() + { + _inner.Release(); + if (_owner.ThrowOnRelease) throw new InvalidOperationException("Release failed"); + } + } + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f05bef..17a2936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,10 +25,12 @@ behaviour: a breaking change to either means a new major version. - `AddJsonRpcService(ServiceLifetime, sessionId)` and the matching `AddJsonRpcServicesFromAssembly` overload: scoped and transient services are resolved per call from `HttpContext.RequestServices`, or from a scope the raw connection handler opens per document and publishes as `IServiceProvidersFeature`; a batch shares one scope. `JsonRpcOptions.ServiceProviderSelector` locates the provider from a custom context. A lifetime that conflicts with the container's registration, a non-singleton `JsonRpcService` subclass, and a `ContextFactory` without a selector are refused at registration or startup. - `protected JsonRpcService(bool autoBind)`: a subclass constructed with `base(false)` binds itself nowhere, for services that a host or an explicit `BindService` call binds. - `SECURITY.md` (private vulnerability reporting) and this changelog. +- `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. ### Changed - The core no longer depends on Json.NET. +- `ProcessAsync` no longer serializes the process on one lock per document. Each thread now caches one async scratch (input copy, reader, staged output) in front of the shared pool, which handles only misses and overflow. At 16 workers, the inline rows went from about 4 M to 22.2 M to 32.1 M RPC/s across the registrations, and the row with a real suspension from 3.9 M to 8.96 M (one run per row, 2026-09-25). The library retains one scratch per thread that has run `ProcessAsync` plus 64 shared, with buffers at most 64 KiB each. - 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. - 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. - 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). @@ -49,6 +51,7 @@ behaviour: a breaking change to either means a new major version. - A trailing notification in a batch no longer leaves a dangling comma. - `async void` methods are rejected at registration. +- A method registered with `RpcContextFlow.Flow` that suspends under a pre- or post-processing hook and completes on another thread no longer gives the completing thread the starting thread's frame. Previously, 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 new cross-thread `ProcessAsync` test found this bug. The frame is now restored through the ambient value alone. ### Security diff --git a/Json-Rpc/Handler.Async.cs b/Json-Rpc/Handler.Async.cs index 983278a..31340bc 100644 --- a/Json-Rpc/Handler.Async.cs +++ b/Json-Rpc/Handler.Async.cs @@ -26,6 +26,10 @@ private static void Changed(AsyncLocalValueChangedArgs change) // Only the async dispatcher uses this scope. The thread frame is restored before returning // an incomplete operation; a flowing frame is owned by that operation until terminal cleanup. + // A None scope is always disposed on the thread that created it. A Flow scope may be disposed on the + // thread that completed the method (HandleBoxedAsync awaits with a live scope), so Dispose never + // writes the captured thread frame back: restoring the ambient value brings back the frame the + // current thread had before the flowing one arrived, whichever thread that is. private readonly struct AsyncScope : IDisposable { internal readonly InvocationState Frame; @@ -59,13 +63,11 @@ internal AsyncScope(object context, JsonRpcRequestReader reader, bool flow) public void Dispose() { - if (FlowFrame == null) - { - Frame.Context = _context; Frame.Exception = _exception; Frame.Reader = _reader; - } if (AsyncAmbient.Current.Value != _parent) AsyncAmbient.Current.Value = _parent; + if (FlowFrame != null) return; + Frame.Context = _context; Frame.Exception = _exception; Frame.Reader = _reader; // A None scope may have created the reusable thread frame; keep it when there was no parent. - if (_thread != null || FlowFrame != null) __state = _thread; + if (_thread != null) __state = _thread; } } diff --git a/Json-Rpc/JsonRpcProcessor.Async.cs b/Json-Rpc/JsonRpcProcessor.Async.cs index ad4b018..564ed31 100644 --- a/Json-Rpc/JsonRpcProcessor.Async.cs +++ b/Json-Rpc/JsonRpcProcessor.Async.cs @@ -188,13 +188,21 @@ private static void CommitAsyncDocument(AsyncScratch scratch, IBufferWriter 0) @@ -264,12 +280,22 @@ internal void Return() Output = new PooledByteBufferWriter(4096); } else Output.Clear(); + // Only a scratch whose reader released cleanly is cached; a throwing Release leaves reusable + // false and the scratch is disposed below instead of being published in a damaged state. bool retained = false; if (reusable) { - lock (Pool) + if (_slot == null) + { + _slot = this; + retained = true; + } + else { - if (_count < Pool.Length) { Pool[_count++] = this; retained = true; } + lock (Pool) + { + if (_count < Pool.Length) { Pool[_count++] = this; retained = true; } + } } } if (!retained) diff --git a/README.md b/README.md index 8c73a25..75d9f02 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,32 @@ JSON-RPC.Net is a [JSON-RPC 2.0](https://www.jsonrpc.org/specification) server for .NET. You give it a request document and it gives you the response document, bytes in and bytes out; the transport is yours. Host it in Kestrel, a console app, sockets, pipes, or a Blazor WebAssembly page. -Version 2.0 rebuilt the pipeline around UTF-8 bytes and made the JSON serializer pluggable. The core depends on no JSON library: Json.NET and System.Text.Json ship as separate packages, and the built-in serializer needs neither. On one core it answers a small request in about 217 ns, with no allocation for numeric parameters. A Kestrel host on an 8-core desktop answers over 14 million requests per second over pipelined TCP. [Benchmarks](#benchmarks) gives the method and the full tables. +Version 2.0 rebuilt the pipeline around UTF-8 bytes and made the JSON serializer pluggable. The core depends on no JSON library: Json.NET and System.Text.Json ship as separate packages, and the built-in serializer needs neither. On one core it answers a small request in about 214 ns, with no allocation for numeric parameters. The library alone answers over 30 million requests per second on an 8-core desktop, ten times what 1.2.3 does on the same machine. Over pipelined TCP, a Kestrel host answers 14.3 M to 16.5 M requests per second. [Benchmarks](#benchmarks) gives the method and the full tables. + +## Performance + +2.0 answers the same five requests 4.3 times faster than 1.2.3 through the 1.x string API and 10.3 times faster through the byte entry points its hosts use. The synchronous byte path handles 31.7 M requests per second on 16 threads of one 8-core desktop, with no allocation for a numeric request. All results come from the same machine, the same requests and one session, one run per row. + + + + JSON-RPC.Net 1.2.3 and 2.0 on one machine, one run per row on 2026-09-25: 3.08 M requests per second 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 2.0 byte entry points + + +| Path | RPC/s | Against 1.2.3 | +| --- | ---: | ---: | +| 1.2.3, `Task Process(string)`, thread pool, best batch size | 3.08 M | | +| 2.0, the same string API and the same loop | 13.3 M | 4.3× | +| 2.0, `Process(bytes)`, 16 dedicated threads | 31.7 M | 10.3× | +| 2.0, `ProcessAsync(bytes)`, 16 awaited workers | 32.1 M | 10.4× | + +The request path uses a span tokenizer over the UTF-8 bytes and invokers compiled against the concrete reader and writer. It writes the response straight into a pooled buffer, with no `Task`, result string or continuation per request. A numeric request never touches the GC. Over Kestrel TCP, the AspNetCore package holds 14.3 M to 16.5 M with 256 requests in flight per connection. [Benchmarks](#benchmarks) has the full tables, conditions and the ranges over each day's runs. The [explorer](https://astn.github.io/JSON-RPC.NET/benchmarks/charts/explorer.html) has the exact values. `benchmarks/Baseline` re-runs the 1.2.3 row from NuGet with the same loop as the 2.0 harness. + It is a server only. There are no client proxies and no server-to-client calls. If you need a bidirectional RPC framework, look at [StreamJsonRpc](https://www.nuget.org/packages/StreamJsonRpc); the benchmarks compare the two. This README and the package guides are also published at [astn.github.io/JSON-RPC.NET](https://astn.github.io/JSON-RPC.NET/). What the library does by default and what it leaves to you is under [Security](#security). +- [Performance](#performance) - [Packages](#packages) - [Requirements](#requirements) - [Installation](#installation) @@ -286,7 +306,9 @@ In the second `-32602` row, "could not convert" means the serializer refused the ## Asynchronous methods and cancellation -A method may return `Task`, `Task`, `ValueTask` or `ValueTask`. Call it through `JsonRpcProcessor.ProcessAsync`. The processor awaits the method and writes its result; `Task` and `ValueTask` answer `null`. A method that completes synchronously runs inline, allocates nothing on the library's side, and the byte overloads then return `Task.CompletedTask`. +A method may return `Task`, `Task`, `ValueTask` or `ValueTask`. Call it through `JsonRpcProcessor.ProcessAsync`. The processor awaits the method and writes its result; `Task` and `ValueTask` answer `null`. A method that completes synchronously runs inline and the byte overloads then return `Task.CompletedTask`. + +**Cost.** With `RpcContextFlow.None`, the built-in numeric fast path adds no dispatcher allocation when the invocation completes inline. The service's own allocations (a `Task.FromResult`, a result string) are separate and included in the harness figures. Flow allocates an `InvocationState` even when the call completes inline. A method that suspends may allocate its own async state plus completion state in the result writer, the request handler and the document processor. The yielding benchmark measured 559 B per request under None in a single run at one worker on 2026-09-25, including the service's allocations. A suspension also costs a continuation per request. The figures are in [Async](#async-processasync-awaited-workers). ```csharp [JsonRpcMethod("lookup")] @@ -476,28 +498,35 @@ The `jsonrpc` member policy (`Lenient` by default) is a compatibility setting, n ## Benchmarks -| What | RPC/s | Details | -| --- | ---: | --- | -| Library alone, 16 threads | 30.6 M to 35.8 M | [Sync](#sync-the-library-alone) | -| Kestrel TCP, 256 pipelined | 15.2 M to 15.5 M | [Kestrel](#kestrel-through-the-aspnetcore-package) | -| Kestrel HTTP, batch of 100 per POST | 12.7 M to 13.7 M | | -| Kestrel HTTP, one request per POST | 128 k to 168 k | HTTP/1.1 round trips dominate | +| What | API and mode | RPC/s | Details | +| --- | --- | ---: | --- | +| Library alone, 16 threads | `Process(bytes)`, dedicated threads | 31.7 M to 36.4 M | [Sync](#sync-the-library-alone) | +| 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 | +| Library alone, 16 workers, one real suspension per request | `ProcessAsync(bytes)`, `yieldsOnce` | 8.96 M | | +| Kestrel TCP, 256 pipelined | `EnableAsyncMethods = false` | 14.3 M to 16.5 M | [Kestrel](#kestrel-through-the-aspnetcore-package) | +| Kestrel TCP, 256 pipelined | `EnableAsyncMethods = true`, methods that complete inline | 15.0 M to 15.4 M | | +| Kestrel TCP, 256 pipelined | `EnableAsyncMethods = true`, methods that suspend once | 1.25 M to 1.29 M | | +| Kestrel HTTP, batch of 100 per POST | `EnableAsyncMethods = false` | 12.8 M to 14.5 M | | +| Kestrel HTTP, one request per POST | `EnableAsyncMethods = false` | 123 k to 182 k | HTTP/1.1 round trips dominate | +| Legacy string API, thread pool | `Task Process(string)`, batches of 6,000 | 12.4 M to 13.3 M | [Legacy](#legacy-string-api-scheduled-synchronous-execution); the 1.x overloads, not the byte path | -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. 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. +The Sync, Async, Legacy and Kestrel results were measured on 2026-09-25 on an idle 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. Throughput ranges are the low and high over that day's runs: three runs for Sync, Legacy and default-mode Kestrel, two for the `EnableAsyncMethods = true` rows. Each Async throughput cell is one 3 s run; the summary's inline Async spread is across registrations, not runs. Each Sync ns figure is the reported cost from one of that row's runs. The StreamJsonRpc and gRPC comparison and its in-process rows are from 2026-09-23, with the WSL virtual machine shut down. Their ranges cover two runs; the comparison's single throughput figure is the value both runs rounded to. The WebAssembly results are also from 2026-09-23: one interpreter run and the better of two AOT runs per row. The connection sweep is a separate session with WSL and other work active, so its absolute figures are not comparable with the tables. 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. `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). ``` 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) -dotnet run -c Release --project TestServer_Console -- --async 3 1 # ProcessAsync, Flow and None separately; no published figures yet -dotnet run -c Release --project TestServer_Console -- --kestrel 3 # through the AspNetCore package, HTTP and TCP +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) +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 +dotnet run -c Release --project TestServer_Console -- --kestrel 3 # through the AspNetCore package, HTTP and TCP (add `async` for EnableAsyncMethods = true) dotnet run -c Release --project TestServer_Console -- --compare 3 # the same calls through StreamJsonRpc and gRPC for .NET, side by side 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 -dotnet run -c Release --project TestServer_Console # menu: Enter = Task mode, s = sync, k = Kestrel, x = compare, q = quit +dotnet run -c Release --project TestServer_Console # menu: Enter = Process(bytes), a = ProcessAsync(bytes), t = legacy string API, k = Kestrel, x = compare, q = quit +dotnet run -c Release --project benchmarks/Baseline # the last 1.x release (1.2.3 from NuGet) through the same loop as the t entry dotnet run --project samples/WasmHost # browser: "Run benchmark" on the page ``` -`--async [seconds] [workers]` uses `ProcessAsync` with separate sessions and the same five wire names as `--sync`. It reports synchronous returns through the new API, completed `Task` and inline `ValueTask`, with Flow and None registrations reported separately, plus a `yieldsOnce` shape that awaits `Task.Yield()`. Responses and ids are validated before timing, and allocation totals include the service methods' own allocations. +The three modes measure different things and are named for the entry point they call. `Process(bytes), dedicated threads` measures the library alone. `ProcessAsync(bytes), awaited workers` measures the entry point an asynchronous host calls. `Legacy Process(string), scheduled synchronous work` measures the 1.x string overloads through the thread pool. The Kestrel rows say whether `EnableAsyncMethods` was on. ### Sync: the library alone @@ -510,32 +539,60 @@ dotnet run --project samples/WasmHost # browser: " | Threads | RPC/s | ns per request per thread | Allocations per request | | ---: | ---: | ---: | --- | -| 1 | 4.5 M to 4.6 M | 217 | 0 bytes for numeric shapes, one string for `StringMe` | -| 2 | 7.6 M to 9.5 M | 222 | | -| 4 | 16.4 M to 17.4 M | 230 | | -| 8 | 25.1 M to 26.8 M | 298 | | -| 16 | 30.6 M to 35.8 M | 446 | | +| 1 | 4.6 M to 5.0 M | 214 | 0 bytes for numeric shapes, one string for `StringMe` | +| 2 | 8.5 M to 9.1 M | 235 | | +| 4 | 16.6 M to 17.1 M | 234 | | +| 8 | 22.8 M to 27.7 M | 292 | | +| 16 | 31.7 M to 36.4 M | 439 | | Per-thread cost rises with thread count because the 16 threads share 8 physical cores. -### Task: scheduled synchronous execution +### Async: ProcessAsync, awaited workers -The default mode submits batches through the `Task`-returning `Process` overload from every core at once, the way an async host would, so it pays for the thread-pool hop, a `Task`, a result string and a continuation per request. Each batch size is repeated for at least half a second after a one-second warm-up. Throughput peaks once a batch is large enough to keep every core busy and falls off again when hundreds of thousands of requests are queued at once: +`--async [seconds] [workers]` calls the byte-level `JsonRpcProcessor.ProcessAsync` from `Task.Run` workers that await each call. It uses the same five requests and loop shape as `--sync`. Workers start behind a barrier, stop on a shared flag and walk the inputs with an index. Worker startup is reported separately. Each row registers the five methods with one return shape and one `RpcContextFlow`. The `yieldsOnce` rows use one method that awaits `Task.Yield()`, giving a real suspension per request. Responses are checked before timing. The bytes column reports allocations per request at one worker, including the service method's own allocations. The inline rows use exact per-thread counts, and the `yieldsOnce` rows use the process-wide counter. + +| Registration | 1 worker | 16 workers | B per request | +| --- | ---: | ---: | ---: | +| synchronous methods, None | 3.47 M | 24.6 M | 6 | +| `Task`, Flow | 2.94 M | 22.2 M | 251 | +| `Task`, None | 3.47 M | 30.0 M | 67 | +| `ValueTask`, Flow | 3.11 M | 23.4 M | 190 | +| `ValueTask`, None | 3.76 M | 32.1 M | 6 | +| `yieldsOnce`, Flow | 915 k | 6.89 M | 741 | +| `yieldsOnce`, None | 1.14 M | 8.96 M | 559 | + +The 16-worker rows are an equal-weight mix of the five requests, except `yieldsOnce`, which is one request. The table below gives bytes per request for each request shape at one worker, including the method's own allocations. The harness prints these lines before each row. + +| Registration | `add` | `addInt` | nullable float | decimal | `StringMe` | +| --- | ---: | ---: | ---: | ---: | ---: | +| synchronous methods, None | 0 | 0 | 0 | 0 | 32 | +| `Task`, Flow | 256 | 184 | 256 | 272 | 288 | +| `Task`, None | 72 | 0 | 72 | 88 | 104 | +| `ValueTask`, Flow | 184 | 184 | 184 | 184 | 216 | +| `ValueTask`, None | 0 | 0 | 0 | 0 | 32 | + +With `RpcContextFlow.None`, the dispatcher adds no allocation to a method that completes inline. Allocations in the `Task` None row come from the service's own `Task.FromResult` (`Task` 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. + +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). + +### Legacy string API: scheduled synchronous execution + +The `t` menu entry submits batches through the 1.x `Task Process(string)` overload from every core at once. This compatibility string API pays for transcoding, a thread-pool hop, a `Task`, a result string and a continuation per request. An asynchronous host uses the byte entry points measured in the Sync and Async tables above. Each batch size is repeated for at least half a second after a one-second warm-up. Throughput peaks once a batch is large enough to keep every core busy and falls off again when hundreds of thousands of requests are queued at once: | Batch size | RPC/s | | ---: | ---: | -| 50 | 1.5 M | -| 300 | 7.8 M | -| 6,000 | 10.9 M | -| 36,000 | 12.0 M | -| 252,000 | 7.6 M | -| 2,016,000 | 7.2 M | +| 50 | 1.6 M to 2.0 M | +| 300 | 8.0 M to 9.2 M | +| 6,000 | 12.4 M to 13.3 M | +| 36,000 | 11.6 M to 13.3 M | +| 252,000 | 7.5 M to 7.8 M | +| 2,016,000 | 7.5 M to 8.0 M | -Task mode is slower than sync mode because it measures the .NET thread pool and a `Task`, a string and a continuation per request as much as the library. The AspNetCore host does not use that string path or allocate a result string: it awaits transport reads and flushes without a thread per connection, as the next table shows. +This mode is slower than the byte modes because it measures the cost of the .NET thread pool and a `Task`, a string and a continuation per request as much as the library itself. The AspNetCore host does not use that string path or allocate a result string. It awaits transport reads and flushes without a thread per connection, as the next table shows. ### Kestrel: through the AspNetCore package -`--kestrel` starts a real Kestrel on loopback with `MapJsonRpc` and `JsonRpcConnectionHandler`, then drives it with 16 clients on the same machine, so every figure is an upper bound for one box talking to itself. The in-process rows are the same requests through the byte entry point with no transport at all, for scale. +`--kestrel [seconds]` starts a real Kestrel on loopback with `MapJsonRpc` and `JsonRpcConnectionHandler`, then drives it with 16 clients on the same machine. Every figure is therefore an upper bound for one box talking to itself. The in-process rows show the same requests through the byte entry point with no transport, to give a sense of scale. The `EnableAsyncMethods = false` rows are the default, where the host calls `Process`. `--kestrel 3 async` runs the same host with `EnableAsyncMethods = true` and sends every document through `ProcessAsync`. It also adds a TCP row whose five methods await `Task.Yield()` before answering. @@ -544,12 +601,14 @@ Task mode is slower than sync mode because it measures the .NET thread pool and | Transport | RPC/s | Note | | --- | ---: | --- | -| in-process, 16 threads | 30.8 M to 31.3 M | | -| 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 | -| HTTP, batch of 100 per POST | 12.7 M to 13.7 M | | -| TCP, 256 pipelined | 15.2 M to 15.5 M | ring-buffer clients, one thread each, streaming framer | +| in-process, 16 threads | 33.8 M to 37.1 M | | +| HTTP, 1 request per POST | 123 k to 182 k | 88 to 130 µs per round trip per client depending on the run; HTTP/1.1 request-response is the cost, not the server | +| HTTP, batch of 100 per POST | 12.8 M to 14.5 M | | +| TCP, 256 pipelined | 14.3 M to 16.5 M | ring-buffer clients, one thread each, streaming framer | +| 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 | +| TCP, 256 pipelined, `EnableAsyncMethods = true`, methods that suspend once | 1.25 M to 1.29 M | five `async Task` methods awaiting `Task.Yield()` | -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`. +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. ### Versus StreamJsonRpc and gRPC @@ -593,7 +652,7 @@ StreamJsonRpc's server side has no "document in, document out" call, so its in-p - Every library and transport by client connections, 1 to 16: three panels on a shared log axis, one per library, with a marker shape and dash per setting and whiskers spanning two runs + Every library and transport by client connections, 1 to 16: three panels on a shared log axis, one per library, with a marker shape and dash per setting and whiskers spanning five runs `--sweep` runs every one of those paths at 1, 2, 4, 8 and 16 client connections (gRPC: channels) and writes one JSON file per run; the chart above is five 2 s runs per point, the marker at the median and the whisker from the lowest to the highest run. It is a separate session from the tables: the WSL virtual machine was running and other work was active, so its absolute figures sit below the table rows (JSON-RPC.Net over TCP 10.9 M to 13.0 M at 16 connections against 13.7 M to 14.6 M in the table), and its gRPC unary figure runs higher (360 k to 404 k against 192 k to 198 k; the cause is not pinned down, and the table keeps the `--compare` figure). What the sweep adds is the shape: JSON-RPC.Net over TCP and batched HTTP climb almost linearly with connections, StreamJsonRpc gains 8 to 10× from one connection to sixteen, and gRPC's .NET client is flat from two channels on because it competes with the server for the same eight cores. @@ -612,6 +671,10 @@ simdjson was evaluated as a fourth parser and not adopted: through the only main 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). +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, which the single-threaded micro-benchmarks could not detect. A one-slot per-thread cache in front of the pool took the inline rows to 22.2 M to 32.1 M at 16 workers (one run per registration), against 31.7 M for the synchronous entry point in the same session. The `--scale` gate and the request-path allowlist exist to catch the next such limit before a release. + +The Performance section at the top reports one session on 2026-09-25. The last 1.x release on NuGet, 1.2.3, was driven by the same loop as the `t` entry ([benchmarks/Baseline](benchmarks/Baseline/Program.cs)). It reached 3.08 M at its best batch size of 1,200 and fell to 1.5 M at two million. In the same session, 2.0 peaked at 13.3 M through the same string API, and the byte entry points ran at 31.7 M and 32.1 M. + 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. Under the previous harness (one pass per batch, workstation GC) the two-million batch ran at about 525,000 RPC/s on 1.3 and 1,584,906 RPC/s on 2.0 on the same machine. The 1.x figure published earlier in this README was measured while the benchmark service was not bound, so every request took the "method not found" path; the benchmark now prints the responses so that cannot go unnoticed. diff --git a/TestServer_Console/AsyncBenchmark.cs b/TestServer_Console/AsyncBenchmark.cs index 7046e73..2e7262c 100644 --- a/TestServer_Console/AsyncBenchmark.cs +++ b/TestServer_Console/AsyncBenchmark.cs @@ -1,72 +1,205 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using AustinHarris.JsonRpc; using AustinHarris.JsonRpc.Serialization; namespace TestServer_Console; +/// +/// ProcessAsync under load: the same five requests as --sync through the byte entry point, driven by +/// awaited workers on the thread pool. Each row registers the five methods with one return shape (synchronous, +/// completed Task<T>, inline ValueTask<T>) and one ; the +/// yield row is one method that awaits Task.Yield(), a real suspension per request. The timing loop +/// has the shape of the synchronous harness (workers start behind a barrier, stop on a shared flag, walk the inputs +/// with an index) so that a difference between the two modes is a difference between the two entry points. +/// internal static class AsyncBenchmark { + internal static readonly (string shape, RpcContextFlow flow)[] Rows = + { + ("sync", RpcContextFlow.None), + ("Task", RpcContextFlow.Flow), ("Task", RpcContextFlow.None), + ("ValueTask", RpcContextFlow.Flow), ("ValueTask", RpcContextFlow.None), + ("yield", RpcContextFlow.Flow), ("yield", RpcContextFlow.None), + }; + + /// The inline rows a process-wide serialization point shows up in first; the scaling gate runs these. + internal static readonly (string shape, RpcContextFlow flow)[] InlineRows = { ("sync", RpcContextFlow.None), ("Task", RpcContextFlow.None), ("ValueTask", RpcContextFlow.None) }; + + internal readonly record struct Measurement(long Count, double Seconds, double StartupSeconds, long AllocatedBytes) + { + public double RpcPerSec => Count / Seconds; + public double BytesPerRpc => Count == 0 ? 0 : AllocatedBytes / (double)Count; + } + internal static async Task RunAsync(Action print, double seconds, int workers) { if (seconds <= 0 || workers <= 0) throw new ArgumentOutOfRangeException(nameof(seconds)); - var inputs = BenchmarkRunner.taskInputs.Select(t => (ReadOnlyMemory)Encoding.UTF8.GetBytes(t)).ToArray(); - var expected = BenchmarkRunner.taskInputs.Select(t => JsonRpcProcessor.ProcessSync(t)).ToArray(); - foreach (var shape in new[] { "sync", "Task", "ValueTask", "yield" }) - foreach (var flow in shape == "sync" ? new[] { RpcContextFlow.None } : new[] { RpcContextFlow.Flow, RpcContextFlow.None }) + var summary = new List(); + foreach (var (shape, flow) in Rows) { string session = "async-benchmark-" + shape + "-" + flow; try { Register(session, shape, flow); - var rowInputs = shape == "yield" ? new[] { (ReadOnlyMemory)Encoding.UTF8.GetBytes("{\"method\":\"yieldsOnce\",\"id\":6}") } : inputs; - var rowExpected = shape == "yield" ? new[] { "{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":6}" } : expected; - using (var output = new PooledByteBufferWriter()) + var rowInputs = Inputs(shape); + await ValidateAndReportAllocations(print, session, shape, flow, rowInputs); + bool yield = shape == "yield"; + var m = await Measure(session, rowInputs, workers, seconds, processWide: yield); + string label = yield ? "one request (yieldsOnce)" : "equal-weight mix of five requests"; + print($"{shape} / {flow}: {m.Count:N0} RPCs, {workers} workers, {m.Seconds:F3} s, {m.RpcPerSec:N0} RPC/s; worker startup {m.StartupSeconds * 1000:F1} ms; " + + $"{(yield ? "process-wide" : "worker-thread")} allocation total {m.AllocatedBytes:N0} B ({m.BytesPerRpc:F0} B/RPC, including method and harness allocations); {label}."); + summary.Add(new BenchmarkRunner.ChartRow($"{shape} / {flow}", m.RpcPerSec, $"{m.Count,12:N0} RPCs {m.BytesPerRpc,6:F0} B/RPC")); + } + finally { Handler.DestroySession(session); } + } + BenchmarkRunner.PrintBarChart($"ProcessAsync(bytes), awaited workers - {Config.Serializer.Name} - {workers} worker{(workers == 1 ? "" : "s")} - RPC/s by registration", "Registration", summary); + } + + /// + /// The scaling gate: the inline None rows at 1, 2 and workers, + /// paired runs, medians, and the 2/1 and N/1 ratios. Returns false when any N/1 ratio is below + /// . A process-wide serialization point on the async path holds the ratio near 1.3 + /// on any core count; the per-thread cache measured about 6.8 on 16 threads. + /// + internal static async Task ScaleAsync(Action print, double seconds, int workers, double threshold, int runs = 3) + { + if (workers < 2) throw new ArgumentOutOfRangeException(nameof(workers)); + var counts = new[] { 1, 2, workers }.Distinct().ToArray(); + print($"Scaling check: ProcessAsync(bytes), {string.Join(", ", counts)} workers, {runs} paired runs of {seconds:0.#} s per cell, medians; {Environment.ProcessorCount} logical processors; gate {workers}/1 >= {threshold:0.0#}.\n"); + var results = new Dictionary<(string, RpcContextFlow, int), List>(); + foreach (var (shape, flow) in InlineRows) + { + string session = "async-scale-" + shape + "-" + flow; + try + { + Register(session, shape, flow); + var rowInputs = Inputs(shape); + await ValidateAndReportAllocations(print, session, shape, flow, rowInputs); + await Measure(session, rowInputs, workers, Math.Min(seconds, 1)); // warm the pool threads and the JIT + for (int run = 1; run <= runs; run++) + foreach (int w in counts) { - for (int i = 0; i < rowInputs.Length; i++) - { - output.Clear(); - await JsonRpcProcessor.ProcessAsync(session, rowInputs[i], output); - if (output.ToString() != rowExpected[i]) throw new InvalidOperationException("Unexpected response: " + output); - for (int warm = 0; warm < 500; warm++) { output.Clear(); await JsonRpcProcessor.ProcessAsync(session, rowInputs[i], output); } - if (shape != "yield") - { - long before = GC.GetAllocatedBytesForCurrentThread(); - for (int n = 0; n < 2000; n++) - { - output.Clear(); - var task = JsonRpcProcessor.ProcessAsync(session, rowInputs[i], output); - if (!task.IsCompleted) throw new InvalidOperationException("Expected inline completion."); - task.GetAwaiter().GetResult(); - } - long totalBytes = GC.GetAllocatedBytesForCurrentThread() - before; - print($"{shape} / {flow} / shape {i + 1}: {totalBytes} B total over 2000 requests ({totalBytes / 2000.0:F1} B/RPC), including method allocations."); - } - } + var m = await Measure(session, rowInputs, w, seconds); + print($" run {run}: {shape} / {flow}, {w,2} workers: {m.RpcPerSec,14:N0} RPC/s ({m.Count:N0} RPCs in {m.Seconds:F3} s, startup {m.StartupSeconds * 1000:F1} ms)"); + if (!results.TryGetValue((shape, flow, w), out var list)) results[(shape, flow, w)] = list = new List(); + list.Add(m.RpcPerSec); } - var start = Stopwatch.GetTimestamp(); - long beforeProcess = GC.GetTotalAllocatedBytes(true); - var counts = await Task.WhenAll(Enumerable.Range(0, workers).Select(_ => Task.Run(async () => - { - long count = 0; - using var output = new PooledByteBufferWriter(); - while ((Stopwatch.GetTimestamp() - start) / (double)Stopwatch.Frequency < seconds) - { - output.Clear(); - await JsonRpcProcessor.ProcessAsync(session, rowInputs[count % rowInputs.Length], output).ConfigureAwait(false); - count++; - } - return count; - }))); - double elapsed = (Stopwatch.GetTimestamp() - start) / (double)Stopwatch.Frequency; - long allocations = GC.GetTotalAllocatedBytes(true) - beforeProcess; - print($"{shape} / {flow}: {counts.Sum():N0} RPCs, {workers} workers, {elapsed:F3} s, {counts.Sum() / elapsed:N0} RPC/s; process allocation total {allocations:N0} B."); } finally { Handler.DestroySession(session); } } + + bool pass = true; + print(""); + print($"| Registration | 1 worker | 2 workers | {workers} workers | 2/1 | {workers}/1 | gate |"); + print("| --- | ---: | ---: | ---: | ---: | ---: | --- |"); + foreach (var (shape, flow) in InlineRows) + { + double one = Median(results[(shape, flow, 1)]); + double two = Median(results[(shape, flow, 2)]); + double many = Median(results[(shape, flow, workers)]); + bool ok = many / one >= threshold; + pass &= ok; + print($"| {shape} / {flow} | {one:N0} | {two:N0} | {many:N0} | {two / one:F2} | {many / one:F2} | {(ok ? "pass" : "FAIL")} |"); + } + print(""); + print(pass ? $"Scaling check passed: every inline row scales at least {threshold:0.0#}x from 1 to {workers} workers." + : $"Scaling check FAILED: an inline row scales less than {threshold:0.0#}x from 1 to {workers} workers; look for a process-wide serialization point on the ProcessAsync path."); + print("The 2/1 ratio is diagnostic (the lock was already visible at two workers, about 1.3); it is not gated."); + return pass; + } + + private static double Median(List values) + { + var sorted = values.OrderBy(v => v).ToArray(); + int n = sorted.Length; + return n % 2 == 1 ? sorted[n / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2; + } + + private static ReadOnlyMemory[] Inputs(string shape) => shape == "yield" + ? new[] { (ReadOnlyMemory)Encoding.UTF8.GetBytes("{\"method\":\"yieldsOnce\",\"id\":6}") } + : BenchmarkRunner.taskInputs.Select(t => (ReadOnlyMemory)Encoding.UTF8.GetBytes(t)).ToArray(); + + /// Checks every response, warms the row, and prints the per-shape allocation of an inline document. + private static async Task ValidateAndReportAllocations(Action print, string session, string shape, RpcContextFlow flow, ReadOnlyMemory[] rowInputs) + { + var expected = shape == "yield" + ? new[] { "{\"jsonrpc\":\"2.0\",\"result\":7,\"id\":6}" } + : BenchmarkRunner.taskInputs.Select(t => JsonRpcProcessor.ProcessSync(t)).ToArray(); + using var output = new PooledByteBufferWriter(); + for (int i = 0; i < rowInputs.Length; i++) + { + output.Clear(); + await JsonRpcProcessor.ProcessAsync(session, rowInputs[i], output); + if (output.ToString() != expected[i]) throw new InvalidOperationException("Unexpected response: " + output); + for (int warm = 0; warm < 500; warm++) { output.Clear(); await JsonRpcProcessor.ProcessAsync(session, rowInputs[i], output); } + if (shape == "yield") continue; + long before = GC.GetAllocatedBytesForCurrentThread(); + for (int n = 0; n < 2000; n++) + { + output.Clear(); + var task = JsonRpcProcessor.ProcessAsync(session, rowInputs[i], output); + if (!task.IsCompleted) throw new InvalidOperationException("Expected inline completion."); + task.GetAwaiter().GetResult(); + } + long totalBytes = GC.GetAllocatedBytesForCurrentThread() - before; + print($"{shape} / {flow} / shape {i + 1}: {totalBytes} B total over 2000 requests ({totalBytes / 2000.0:F1} B/RPC), including method allocations."); + } + } + + /// + /// One timed cell. Workers are Task.Run tasks that await each call; they start together behind a barrier, + /// the clock starts once every worker is ready, and they stop on a shared flag. Startup is reported separately. + /// + /// Count allocations process-wide (the yield row: its continuations run on other pool threads, so + /// a worker's thread-local counter is meaningless) instead of summing the worker threads' own counters (exact for inline rows). + internal static async Task Measure(string session, ReadOnlyMemory[] rowInputs, int workers, double seconds, bool processWide = false) + { + int stop = 0; + var counts = new long[workers * 16]; // padded to avoid false sharing + var allocs = new long[workers * 16]; + using var ready = new Barrier(workers + 1); + var startup = Stopwatch.StartNew(); + var tasks = new Task[workers]; + for (int w = 0; w < workers; w++) + { + int slot = w * 16; + tasks[w] = Task.Run(async () => + { + using var output = new PooledByteBufferWriter(); + ready.SignalAndWait(); + // Per worker thread, exact for inline rows (the process-wide counter under-reports at this rate). + long allocated = GC.GetAllocatedBytesForCurrentThread(); + long n = 0; + int i = 0; + while (Volatile.Read(ref stop) == 0) + { + output.Clear(); + await JsonRpcProcessor.ProcessAsync(session, rowInputs[i], output).ConfigureAwait(false); + if (++i == rowInputs.Length) i = 0; + n++; + } + counts[slot] = n; + allocs[slot] = GC.GetAllocatedBytesForCurrentThread() - allocated; + }); + } + ready.SignalAndWait(); + double startupSeconds = startup.Elapsed.TotalSeconds; + long before = GC.GetTotalAllocatedBytes(false); + var sw = Stopwatch.StartNew(); + await Task.Delay(TimeSpan.FromSeconds(seconds)); + Volatile.Write(ref stop, 1); + await Task.WhenAll(tasks); + sw.Stop(); + long processAllocated = GC.GetTotalAllocatedBytes(false) - before; + long total = 0, allocatedTotal = 0; + for (int w = 0; w < workers; w++) { total += counts[w * 16]; allocatedTotal += allocs[w * 16]; } + return new Measurement(total, sw.Elapsed.TotalSeconds, startupSeconds, processWide ? processAllocated : allocatedTotal); } private static void Register(string session, string shape, RpcContextFlow flow) diff --git a/TestServer_Console/KestrelBenchmark.cs b/TestServer_Console/KestrelBenchmark.cs index b833417..888e475 100644 --- a/TestServer_Console/KestrelBenchmark.cs +++ b/TestServer_Console/KestrelBenchmark.cs @@ -27,14 +27,17 @@ namespace TestServer_Console; /// answering the same five requests over HTTP (one request per POST, and a batch per POST) and over a raw /// TCP connection with pipelined requests. Clients and server share the machine, so each figure is an /// upper bound on what one box can do talking to itself; the in-process rows are the same requests through -/// the byte entry point with no transport at all, for scale. +/// the byte entry point with no transport at all, for scale. With asyncMethods the host runs with +/// EnableAsyncMethods = true, so every document goes through ProcessAsync, and a second TCP row +/// answers the same five calls from methods that await Task.Yield() once: a real suspension per request. /// internal static class KestrelBenchmark { private static readonly byte[] ResultPrefix = Encoding.UTF8.GetBytes("{\"jsonrpc\":\"2.0\",\"result\":"); private static readonly byte[] BatchResultPrefix = Encoding.UTF8.GetBytes("[{\"jsonrpc\":\"2.0\",\"result\":"); + private const string YieldPrefix = "yield."; - internal static async Task RunAsync(Action print, double seconds = 3, int clients = 0, int pipeline = 256) + internal static async Task RunAsync(Action print, double seconds = 3, int clients = 0, int pipeline = 256, bool asyncMethods = false) { print ??= Console.WriteLine; if (clients <= 0) clients = Environment.ProcessorCount; @@ -51,17 +54,18 @@ internal static async Task RunAsync(Action print, double seconds = 3, in k.Listen(IPAddress.Loopback, 0); k.Listen(IPAddress.Loopback, tcpPort, l => l.UseConnectionHandler()); }); - builder.Services.AddJsonRpc(); + builder.Services.AddJsonRpc(o => o.EnableAsyncMethods = asyncMethods); var app = builder.Build(); app.MapJsonRpc("/rpc"); await app.StartAsync(); + if (asyncMethods) BindYieldingMethods(); try { var addresses = app.Services.GetRequiredService().Features.Get().Addresses; var httpUrl = addresses.First(a => !a.EndsWith(":" + tcpPort)) + "/rpc"; - print($"Kestrel on {httpUrl} (HTTP) and 127.0.0.1:{tcpPort} (TCP), {clients} clients, pipeline {pipeline}, {seconds:0.#} s per row\n"); + print($"Kestrel on {httpUrl} (HTTP) and 127.0.0.1:{tcpPort} (TCP), {clients} clients, pipeline {pipeline}, {seconds:0.#} s per row, EnableAsyncMethods = {(asyncMethods ? "true" : "false")}\n"); var rows = new List(); var session = Handler.DefaultSessionId(); @@ -90,18 +94,46 @@ internal static async Task RunAsync(Action print, double seconds = 3, in // TCP, pipelined. TcpRun(tcpPort, inputs, clients, 0.5, pipeline, ResultPrefix); (count, secs) = TcpRun(tcpPort, inputs, clients, seconds, pipeline, ResultPrefix); - rows.Add(new BenchmarkRunner.ChartRow($"TCP, {pipeline} pipelined", count / secs, $"{count,12:N0} RPCs")); + rows.Add(new BenchmarkRunner.ChartRow($"TCP, {pipeline} pipelined{(asyncMethods ? ", inline methods" : "")}", count / secs, $"{count,12:N0} RPCs")); print($" TCP done ({count / secs:N0} RPC/s)"); - BenchmarkRunner.PrintBarChart($"Kestrel benchmark - {Config.Serializer.Name} - RPC/s by transport", "Transport", rows); + if (asyncMethods) + { + // The same five calls, each answered by a method that awaits Task.Yield() before returning. + var yieldInputs = inputs.Select(i => Encoding.UTF8.GetBytes(Encoding.UTF8.GetString(i).Replace("\"method\":\"", "\"method\":\"" + YieldPrefix))).ToArray(); + TcpRun(tcpPort, yieldInputs, clients, 0.5, pipeline, ResultPrefix); + (count, secs) = TcpRun(tcpPort, yieldInputs, clients, seconds, pipeline, ResultPrefix); + rows.Add(new BenchmarkRunner.ChartRow($"TCP, {pipeline} pipelined, yielding methods", count / secs, $"{count,12:N0} RPCs")); + print($" TCP yielding done ({count / secs:N0} RPC/s)"); + } + + BenchmarkRunner.PrintBarChart($"Kestrel benchmark - {Config.Serializer.Name} - EnableAsyncMethods = {(asyncMethods ? "true" : "false")} - RPC/s by transport", "Transport", rows); } finally { await app.StopAsync(); await app.DisposeAsync(); + if (asyncMethods) UnbindYieldingMethods(); } } + private static readonly string[] YieldNames = { "add", "addInt", "NullableFloatToNullableFloat", "Test2", "StringMe" }; + + /// The five benchmark methods as async Task<T> methods that yield once, on the default session under a prefix. + private static void BindYieldingMethods() + { + ServiceBinder.BindMethod(YieldPrefix + "add", new Func>(async (l, r) => { await Task.Yield(); return l + r; })); + ServiceBinder.BindMethod(YieldPrefix + "addInt", new Func>(async (l, r) => { await Task.Yield(); return l + r; })); + ServiceBinder.BindMethod(YieldPrefix + "NullableFloatToNullableFloat", new Func>(async a => { await Task.Yield(); return a; })); + ServiceBinder.BindMethod(YieldPrefix + "Test2", new Func>(async x => { await Task.Yield(); return x; })); + ServiceBinder.BindMethod(YieldPrefix + "StringMe", new Func>(async x => { await Task.Yield(); return x; })); + } + + private static void UnbindYieldingMethods() + { + foreach (var name in YieldNames) ServiceBinder.UnbindMethod(YieldPrefix + name); + } + internal static byte[] BuildBatch(byte[][] inputs, int size) { var sb = new StringBuilder("["); diff --git a/TestServer_Console/Program.cs b/TestServer_Console/Program.cs index 1a3adb6..30b653f 100644 --- a/TestServer_Console/Program.cs +++ b/TestServer_Console/Program.cs @@ -1,4 +1,4 @@ -using System; +using System; using AustinHarris.JsonRpc; using Hardware.Info; @@ -12,9 +12,11 @@ class Program static void Main(string[] args) { - // When stdout is a pipe (CI, `dotnet run | tee`) there is no console buffer to clear or reposition. services = new object[] { new CalculatorService() }; - // `dotnet run -- --async [seconds] [workers]` exercises real asynchronous invocation. + // Before any mode runs: the awaited-worker modes (--async, --scale) start their workers on the pool. + System.Threading.ThreadPool.SetMinThreads(Environment.ProcessorCount * 3, Environment.ProcessorCount * 3); + + // `dotnet run -- --async [seconds] [workers]` drives ProcessAsync from awaited workers (the Async table). if (args.Length > 0 && args[0] == "--async") { double seconds = args.Length > 1 && double.TryParse(args[1], out var s) ? s : 3; @@ -23,13 +25,24 @@ static void Main(string[] args) return; } + // `dotnet run -- --scale [seconds] [workers] [threshold]` is the release gate for the ProcessAsync path: + // the inline rows at 1, 2 and N workers, three paired runs, medians; exit code 1 when N/1 is below the threshold. + if (args.Length > 0 && args[0] == "--scale") + { + double seconds = args.Length > 1 && double.TryParse(args[1], out var s) ? s : 3; + int workers = args.Length > 2 && int.TryParse(args[2], out var t) ? t : 16; + double threshold = args.Length > 3 && double.TryParse(args[3], out var r) ? r : 4.0; + bool pass = AsyncBenchmark.ScaleAsync(Console.WriteLine, seconds, workers, threshold).GetAwaiter().GetResult(); + Environment.ExitCode = pass ? 0 : 1; + return; + } + var interactive = !Console.IsOutputRedirected; if (interactive) Console.Clear(); IHardwareInfo hardwareInfo = new HardwareInfo(); hardwareInfo.RefreshAll(); HardwarePrinter.PrintHardware(hardwareInfo); - System.Threading.ThreadPool.SetMinThreads(Environment.ProcessorCount * 3, Environment.ProcessorCount * 3); - Console.WriteLine("Setting task pool size to {0}", Environment.ProcessorCount * 4); + Console.WriteLine("Thread pool minimum set to {0}", Environment.ProcessorCount * 3); // `dotnet run -- --sync [seconds] [threads]` runs the direct synchronous benchmark and exits (CI / scripted runs). if (args.Length > 0 && args[0] == "--sync") @@ -40,11 +53,13 @@ static void Main(string[] args) return; } - // `dotnet run -- --kestrel [seconds]` hosts the AspNetCore package in-process and drives it over HTTP and TCP. + // `dotnet run -- --kestrel [seconds] [async]` hosts the AspNetCore package in-process and drives it over HTTP and TCP; + // `async` turns EnableAsyncMethods on and adds a TCP row with yielding methods. if (args.Length > 0 && args[0] == "--kestrel") { double seconds = args.Length > 1 && double.TryParse(args[1], out var s) ? s : 3; - KestrelBenchmark.RunAsync(Console.WriteLine, seconds).GetAwaiter().GetResult(); + bool asyncMethods = args.Length > 2 && args[2] == "async"; + KestrelBenchmark.RunAsync(Console.WriteLine, seconds, asyncMethods: asyncMethods).GetAwaiter().GetResult(); return; } @@ -67,13 +82,18 @@ static void Main(string[] args) PrintOptions(); for (string line = Console.ReadLine(); line != null && line != "q"; line = Console.ReadLine()) { - if (!interactive && string.IsNullOrWhiteSpace(line)) + if (string.IsNullOrWhiteSpace(line) || line.StartsWith("s", StringComparison.CurrentCultureIgnoreCase)) { - BenchmarkRunner.Benchmark(Console.WriteLine); + BenchmarkRunner.BenchmarkSync(Console.WriteLine); } - else if (line.StartsWith("s", StringComparison.CurrentCultureIgnoreCase)) + else if (line.StartsWith("a", StringComparison.CurrentCultureIgnoreCase)) { - BenchmarkRunner.BenchmarkSync(Console.WriteLine); + AsyncBenchmark.RunAsync(Console.WriteLine, 3, 1).GetAwaiter().GetResult(); + AsyncBenchmark.RunAsync(Console.WriteLine, 3, Environment.ProcessorCount).GetAwaiter().GetResult(); + } + else if (line.StartsWith("t", StringComparison.CurrentCultureIgnoreCase)) + { + LegacyStringBenchmark(interactive, hardwareInfo); } else if (line.StartsWith("k", StringComparison.CurrentCultureIgnoreCase)) { @@ -83,37 +103,45 @@ static void Main(string[] args) { CompareBenchmark.RunAsync(Console.WriteLine).GetAwaiter().GetResult(); } - else if (string.IsNullOrWhiteSpace(line)) - { - Console.CursorVisible = false; - HardwarePrinter.PrintHardware(hardwareInfo); - var pos = Console.CursorTop; - BenchmarkRunner.Benchmark((update) => - { - // Clear the console from pos to current position first - var currentline = Console.CursorTop; - var fullLine = new string(' ', Console.WindowWidth); - Console.SetCursorPosition(0, pos); - for (int i = 0; i < currentline-pos; i++) - { - Console.WriteLine( fullLine); - } - - Console.SetCursorPosition(0, pos); - Console.WriteLine(update); - }); - Console.CursorVisible = true; - } else if (line.StartsWith("c", StringComparison.CurrentCultureIgnoreCase)) ConsoleInput(); PrintOptions(); } } + /// The 1.x string overloads through the thread pool, with live progress when there is a console to reposition. + private static void LegacyStringBenchmark(bool interactive, IHardwareInfo hardwareInfo) + { + if (!interactive) + { + BenchmarkRunner.Benchmark(Console.WriteLine); + return; + } + Console.CursorVisible = false; + HardwarePrinter.PrintHardware(hardwareInfo); + var pos = Console.CursorTop; + BenchmarkRunner.Benchmark((update) => + { + // Clear the console from pos to current position first + var currentline = Console.CursorTop; + var fullLine = new string(' ', Console.WindowWidth); + Console.SetCursorPosition(0, pos); + for (int i = 0; i < currentline - pos; i++) + { + Console.WriteLine(fullLine); + } + + Console.SetCursorPosition(0, pos); + Console.WriteLine(update); + }); + Console.CursorVisible = true; + } + private static void PrintOptions() { - Console.WriteLine("Hit Enter to run the Task-based benchmark"); - Console.WriteLine("'s' to run the direct synchronous benchmark (bytes in, bytes out)"); + Console.WriteLine("Hit Enter (or 's') for Process(bytes), dedicated threads: the library alone, the Sync table (--sync)"); + Console.WriteLine("'a' for ProcessAsync(bytes), awaited workers, at 1 and " + Environment.ProcessorCount + " workers: the Async table (--async)"); + Console.WriteLine("'t' for Legacy Process(string), scheduled synchronous work: the 1.x string overloads through the thread pool, not the byte path"); Console.WriteLine("'k' to run the Kestrel benchmark (AspNetCore package over HTTP and TCP)"); Console.WriteLine("'x' to compare against StreamJsonRpc and gRPC for .NET (same calls, same Kestrel)"); Console.WriteLine("'c' to start reading console input"); @@ -127,9 +155,5 @@ private static void ConsoleInput() JsonRpcProcessor.Process(line).ContinueWith(response => Console.WriteLine( response.Result )); } } - - } - - } diff --git a/benchmarks/Baseline/Baseline.csproj b/benchmarks/Baseline/Baseline.csproj new file mode 100644 index 0000000..66364bf --- /dev/null +++ b/benchmarks/Baseline/Baseline.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + false + disable + true + false + $(NoWarn);NU1701;NU1902;NU1903 + + + + + diff --git a/benchmarks/Baseline/Program.cs b/benchmarks/Baseline/Program.cs new file mode 100644 index 0000000..f0d992e --- /dev/null +++ b/benchmarks/Baseline/Program.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using AustinHarris.JsonRpc; + +namespace Baseline +{ + // The five benchmark methods, as TestServer_Console/service.cs declares them. + public class CalculatorService : JsonRpcService + { + [JsonRpcMethod] private double add(double l, double r) => l + r; + [JsonRpcMethod] private int addInt(int l, int r) => l + r; + [JsonRpcMethod] public float? NullableFloatToNullableFloat(float? a) => a; + [JsonRpcMethod] public decimal? Test2(decimal x) => x; + [JsonRpcMethod] public string StringMe(string x) => x; + } + + public static class Program + { + private static readonly string[] Inputs = + { + "{\"method\":\"add\",\"params\":[1,2],\"id\":1}", + "{\"method\":\"addInt\",\"params\":[1,7],\"id\":2}", + "{\"method\":\"NullableFloatToNullableFloat\",\"params\":[1.23],\"id\":3}", + "{\"method\":\"Test2\",\"params\":[3.456],\"id\":4}", + "{\"method\":\"StringMe\",\"params\":[\"Foo\"],\"id\":5}" + }; + + public static int Main() + { + var service = new CalculatorService(); + var version = typeof(JsonRpcProcessor).Assembly.GetName().Version; + Console.WriteLine($"AustinHarris.JsonRpc {version} from NuGet, {Environment.ProcessorCount} logical cores, server GC {System.Runtime.GCSettings.IsServerGC}"); + foreach (var input in Inputs) + { + var response = JsonRpcProcessor.Process(input).Result; + Console.WriteLine($" {input} -> {response}"); + if (response.Contains("\"error\"")) { Console.WriteLine("a request answered with an error; the service is not bound"); return 1; } + } + + // The same shape as TestServer_Console's legacy mode: a one-second warm-up on the measured path, then + // eight batch sizes (50, 100, 300, 1,200, 6,000, 36,000, 252,000, 2,016,000), each repeated until the + // iteration has run for at least half a second; RPC/s is total requests over total time. + var warm = Stopwatch.StartNew(); + while (warm.Elapsed < TimeSpan.FromSeconds(1)) RunBatch(5000); + + var rows = new List<(int size, double rps)>(); + int cnt = 50; + for (int iteration = 1; iteration <= 8; iteration++) + { + cnt *= iteration; + int batches = 0; + var sw = Stopwatch.StartNew(); + do { RunBatch(cnt); batches++; } while (sw.Elapsed < TimeSpan.FromMilliseconds(500)); + sw.Stop(); + double rps = (long)cnt * batches / sw.Elapsed.TotalSeconds; + rows.Add((cnt, rps)); + Console.WriteLine($"#{iteration} batch {cnt,10:N0}: {rps,14:N0} RPC/s ({(long)cnt * batches,12:N0} RPCs in {sw.Elapsed.TotalSeconds:F3} s)"); + } + double peak = 0; int peakSize = 0; + foreach (var (size, rps) in rows) if (rps > peak) { peak = rps; peakSize = size; } + Console.WriteLine($"peak {peak:N0} RPC/s at batch {peakSize:N0}"); + GC.KeepAlive(service); + return 0; + } + + private static void RunBatch(int count) + { + int pending = count; + using (var done = new ManualResetEventSlim(false)) + { + Action> onDone = _ => { if (Interlocked.Decrement(ref pending) == 0) done.Set(); }; + Parallel.For(0, count, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount * 2 }, i => + { + JsonRpcProcessor.Process(Inputs[i % 5]).ContinueWith(onDone, TaskContinuationOptions.ExecuteSynchronously); + }); + done.Wait(); + } + } + } +} diff --git a/benchmarks/Micro/README.md b/benchmarks/Micro/README.md index 160dd7b..270a67b 100644 --- a/benchmarks/Micro/README.md +++ b/benchmarks/Micro/README.md @@ -18,3 +18,11 @@ Benchmark classes: - `AsyncDispatchBenchmarks`: a synchronous method through `Process` and `ProcessAsync`, `Task` and `ValueTask` methods that complete inline, and a method that yields once, each with the default `RpcContextFlow.None` and with `RpcContextFlow.Flow`. The inline default rows should allocate nothing; the `Flow` rows 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: + +```bash +dotnet run -c Release --project TestServer_Console -- --scale 3 16 4.0 +``` + +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. 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. diff --git a/benchmarks/charts/benchmarks.json b/benchmarks/charts/benchmarks.json index e6acc0f..59c2ad8 100644 --- a/benchmarks/charts/benchmarks.json +++ b/benchmarks/charts/benchmarks.json @@ -3,14 +3,34 @@ "machine": "AMD Ryzen 7 7800X3D (8 cores / 16 threads, 4.2 GHz), 64 GB, Windows 11", "runtime": ".NET 10, Release, Server GC, built-in serializer", "sets": { + "headline": { + "title": "JSON-RPC.Net 1.2.3 and 2.0 on one machine", + "date": "2026-09-25", + "source": "README.md, Performance table; branch async-scratch-cache", + "harness": "benchmarks/Baseline (1.2.3 from NuGet), then TestServer_Console menu entry t, --sync 3 16 and --async 3 16, one session", + "workload": "the five benchmark requests; the string rows are the best batch size of the thread-pool loop, the byte rows a loop on 16 dedicated threads or 16 awaited workers", + "conditions": "idle box; one run per row", + "policy": "one run per row, all in one session; the day-to-day ranges are the sync, async, legacy and kestrel sets", + "groups": [ + { "heading": "1.2.3, the last 1.x release on NuGet", "rule": false, "series": ["headline-123-string"] }, + { "heading": "2.0, the same string API and the same loop", "rule": false, "series": ["headline-20-string"] }, + { "heading": "2.0, the byte entry points", "rule": false, "series": ["headline-20-sync", "headline-20-async"] } + ], + "series": [ + { "id": "headline-123-string", "label": "Task Process(string), thread pool", "note": "best batch size, 1,200; Json.NET on every request", "family": "scale", "low": 3080000, "high": 3080000, "status": "single", "published": "3.08 M" }, + { "id": "headline-20-string", "label": "Task Process(string), thread pool", "note": "best batch size, 36,000; the 1.x overloads, kept for compatibility", "family": "ours", "low": 13300000, "high": 13300000, "status": "single", "published": "13.3 M" }, + { "id": "headline-20-sync", "label": "Process(bytes), 16 dedicated threads", "note": "the library alone; 0 B per numeric request", "family": "ours", "low": 31700000, "high": 31700000, "status": "single", "published": "31.7 M" }, + { "id": "headline-20-async", "label": "ProcessAsync(bytes), 16 awaited workers", "note": "ValueTask methods, RpcContextFlow.None; 6 B per request", "family": "ours", "low": 32100000, "high": 32100000, "status": "single", "published": "32.1 M" } + ] + }, "sync": { "title": "JSON-RPC.Net alone, by worker threads", - "date": "2026-09-23", - "source": "README.md, Sync table; commit 192a97f", + "date": "2026-09-25", + "source": "README.md, Sync table; branch async-scratch-cache", "harness": "TestServer_Console --sync 3, and --sync 2 for single rows", "workload": "the five benchmark requests through the byte-level JsonRpcProcessor.Process in a loop; no scheduler, no transport", - "conditions": "idle box, WSL VM shut down; the 1-thread row from an idle core", - "policy": "observed low and high over the day's runs; run count not recorded; ns per request is the harness's reported figure for one run", + "conditions": "idle box; three 3 s runs of --sync 3 in one session", + "policy": "low and high over three runs; ns per request is the harness's reported figure for one of them", "x": { "name": "worker threads", "values": [1, 2, 4, 8, 16] }, "series": [ { @@ -18,32 +38,106 @@ "label": "JSON-RPC.Net, byte entry point", "family": "ours", "points": [ - { "low": 4500000, "high": 4600000, "status": "range", "published": "4.5 M to 4.6 M", "ns": 217 }, - { "low": 7600000, "high": 9500000, "status": "range", "published": "7.6 M to 9.5 M", "ns": 222 }, - { "low": 16400000, "high": 17400000, "status": "range", "published": "16.4 M to 17.4 M", "ns": 230 }, - { "low": 25100000, "high": 26800000, "status": "range", "published": "25.1 M to 26.8 M", "ns": 298 }, - { "low": 30600000, "high": 35800000, "status": "range", "published": "30.6 M to 35.8 M", "ns": 446 } + { "low": 4570000, "high": 5000000, "status": "range", "published": "4.6 M to 5.0 M", "ns": 214 }, + { "low": 8500000, "high": 9100000, "status": "range", "published": "8.5 M to 9.1 M", "ns": 235 }, + { "low": 16600000, "high": 17100000, "status": "range", "published": "16.6 M to 17.1 M", "ns": 234 }, + { "low": 22800000, "high": 27700000, "status": "range", "published": "22.8 M to 27.7 M", "ns": 292 }, + { "low": 31700000, "high": 36400000, "status": "range", "published": "31.7 M to 36.4 M", "ns": 439 } + ] + } + ] + }, + "async16": { + "title": "ProcessAsync, 16 awaited workers, by registration", + "date": "2026-09-25", + "source": "README.md, Async table, 16-worker column; branch async-scratch-cache", + "harness": "TestServer_Console --async 3 16", + "workload": "the five benchmark requests (yieldsOnce: one request) through the byte-level JsonRpcProcessor.ProcessAsync from Task.Run workers that await each call", + "conditions": "idle box; one 3 s run per row", + "policy": "one run per row", + "groups": [ + { "heading": "methods that complete inline, RpcContextFlow.None", "rule": false, "series": ["async16-sync-none", "async16-task-none", "async16-valuetask-none"] }, + { "heading": "methods that complete inline, RpcContextFlow.Flow", "rule": false, "series": ["async16-task-flow", "async16-valuetask-flow"] }, + { "heading": "one real suspension per request (yieldsOnce)", "rule": true, "series": ["async16-yield-none", "async16-yield-flow"] } + ], + "series": [ + { "id": "async16-sync-none", "label": "synchronous methods, None", "note": "6 B per request, the StringMe result string", "family": "ours", "low": 24600000, "high": 24600000, "status": "single", "published": "24.6 M" }, + { "id": "async16-task-none", "label": "Task, None", "note": "67 B per request, the service's own Task.FromResult", "family": "ours", "low": 30000000, "high": 30000000, "status": "single", "published": "30.0 M" }, + { "id": "async16-valuetask-none", "label": "ValueTask, None", "note": "6 B per request", "family": "ours", "low": 32100000, "high": 32100000, "status": "single", "published": "32.1 M" }, + { "id": "async16-task-flow", "label": "Task, Flow", "note": "251 B per request", "family": "ours", "low": 22200000, "high": 22200000, "status": "single", "published": "22.2 M" }, + { "id": "async16-valuetask-flow", "label": "ValueTask, Flow", "note": "190 B per request", "family": "ours", "low": 23400000, "high": 23400000, "status": "single", "published": "23.4 M" }, + { "id": "async16-yield-none", "label": "yieldsOnce, None", "note": "559 B per request", "family": "ours", "low": 8960000, "high": 8960000, "status": "single", "published": "8.96 M" }, + { "id": "async16-yield-flow", "label": "yieldsOnce, Flow", "note": "741 B per request", "family": "ours", "low": 6890000, "high": 6890000, "status": "single", "published": "6.89 M" } + ] + }, + "async1": { + "title": "ProcessAsync, one awaited worker, by registration", + "date": "2026-09-25", + "source": "README.md, Async table, 1-worker column; branch async-scratch-cache", + "harness": "TestServer_Console --async 3 1", + "workload": "as async16, one worker", + "conditions": "idle box; one 3 s run per row", + "policy": "one run per row", + "groups": [ + { "heading": "methods that complete inline, RpcContextFlow.None", "rule": false, "series": ["async1-sync-none", "async1-task-none", "async1-valuetask-none"] }, + { "heading": "methods that complete inline, RpcContextFlow.Flow", "rule": false, "series": ["async1-task-flow", "async1-valuetask-flow"] }, + { "heading": "one real suspension per request (yieldsOnce)", "rule": true, "series": ["async1-yield-none", "async1-yield-flow"] } + ], + "series": [ + { "id": "async1-sync-none", "label": "synchronous methods, None", "family": "ours", "low": 3470000, "high": 3470000, "status": "single", "published": "3.47 M" }, + { "id": "async1-task-none", "label": "Task, None", "family": "ours", "low": 3470000, "high": 3470000, "status": "single", "published": "3.47 M" }, + { "id": "async1-valuetask-none", "label": "ValueTask, None", "family": "ours", "low": 3760000, "high": 3760000, "status": "single", "published": "3.76 M" }, + { "id": "async1-task-flow", "label": "Task, Flow", "family": "ours", "low": 2940000, "high": 2940000, "status": "single", "published": "2.94 M" }, + { "id": "async1-valuetask-flow", "label": "ValueTask, Flow", "family": "ours", "low": 3110000, "high": 3110000, "status": "single", "published": "3.11 M" }, + { "id": "async1-yield-none", "label": "yieldsOnce, None", "family": "ours", "low": 1140000, "high": 1140000, "status": "single", "published": "1.14 M" }, + { "id": "async1-yield-flow", "label": "yieldsOnce, Flow", "family": "ours", "low": 915000, "high": 915000, "status": "single", "published": "915 k" } + ] + }, + "legacy": { + "title": "Legacy string API through the thread pool, by batch size", + "date": "2026-09-25", + "source": "README.md, Legacy table; branch async-scratch-cache", + "harness": "TestServer_Console, menu entry t", + "workload": "the five benchmark requests through the 1.x Task Process(string) overload, submitted in batches from every core through Parallel.For", + "conditions": "idle box; three runs of the t entry in one session", + "policy": "low and high over three runs", + "x": { "name": "batch size", "values": [50, 300, 6000, 36000, 252000, 2016000] }, + "series": [ + { + "id": "legacy-string", + "label": "JSON-RPC.Net, 1.x string overloads", + "family": "ours", + "points": [ + { "low": 1570000, "high": 2030000, "status": "range", "published": "1.6 M to 2.0 M" }, + { "low": 7990000, "high": 9190000, "status": "range", "published": "8.0 M to 9.2 M" }, + { "low": 12360000, "high": 13260000, "status": "range", "published": "12.4 M to 13.3 M" }, + { "low": 11570000, "high": 13290000, "status": "range", "published": "11.6 M to 13.3 M" }, + { "low": 7470000, "high": 7800000, "status": "range", "published": "7.5 M to 7.8 M" }, + { "low": 7490000, "high": 8030000, "status": "range", "published": "7.5 M to 8.0 M" } ] } ] }, "kestrel": { "title": "JSON-RPC.Net by transport", - "date": "2026-09-23", - "source": "README.md, Kestrel table; commit 192a97f", + "date": "2026-09-25", + "source": "README.md, Kestrel table; branch async-scratch-cache", "harness": "TestServer_Console --kestrel 3", "workload": "AustinHarris.JsonRpc.AspNetCore on Kestrel, loopback, 16 clients on the server's 8 cores", - "conditions": "WSL VM shut down; two 3 s runs", - "policy": "observed low and high over two runs", + "conditions": "idle box; three 3 s runs of --kestrel 3 and two of --kestrel 3 async, one session", + "policy": "low and high over the runs: three for the default rows, two for the EnableAsyncMethods = true rows", "groups": [ - { "heading": "", "rule": false, "series": ["ours-http-1", "ours-http-100", "ours-tcp"] }, + { "heading": "EnableAsyncMethods = false (the default)", "rule": false, "series": ["ours-http-1", "ours-http-100", "ours-tcp"] }, + { "heading": "EnableAsyncMethods = true: every document through ProcessAsync", "rule": false, "series": ["ours-tcp-async-inline", "ours-tcp-async-yield"] }, { "heading": "Reference: no transport", "rule": true, "series": ["ours-inproc-16"] } ], "series": [ - { "id": "ours-http-1", "label": "HTTP, 1 request per POST", "note": "each client awaits one POST at a time; 95 to 125 µs per round trip", "family": "ours", "low": 128000, "high": 168000, "status": "range", "published": "128 k to 168 k" }, - { "id": "ours-http-100", "label": "HTTP, batch of 100 per POST", "note": "one POST outstanding per client, 100 RPCs in it", "family": "ours", "low": 12700000, "high": 13700000, "status": "range", "published": "12.7 M to 13.7 M" }, - { "id": "ours-tcp", "label": "TCP, 256 pipelined", "note": "JsonRpcConnectionHandler, 256 requests in flight per connection", "family": "ours", "low": 15200000, "high": 15500000, "status": "range", "published": "15.2 M to 15.5 M" }, - { "id": "ours-inproc-16", "label": "in-process, 16 threads", "note": "the same requests through the byte entry point", "family": "scale", "low": 30800000, "high": 31300000, "status": "range", "published": "30.8 M to 31.3 M" } + { "id": "ours-http-1", "label": "HTTP, 1 request per POST", "note": "each client awaits one POST at a time; 88 to 130 µs per round trip", "family": "ours", "low": 123000, "high": 182000, "status": "range", "published": "123 k to 182 k" }, + { "id": "ours-http-100", "label": "HTTP, batch of 100 per POST", "note": "one POST outstanding per client, 100 RPCs in it", "family": "ours", "low": 12800000, "high": 14500000, "status": "range", "published": "12.8 M to 14.5 M" }, + { "id": "ours-tcp", "label": "TCP, 256 pipelined", "note": "JsonRpcConnectionHandler, 256 requests in flight per connection", "family": "ours", "low": 14300000, "high": 16500000, "status": "range", "published": "14.3 M to 16.5 M" }, + { "id": "ours-tcp-async-inline", "label": "TCP, 256 pipelined, methods that complete inline", "note": "--kestrel 3 async", "family": "ours", "low": 15000000, "high": 15400000, "status": "range", "published": "15.0 M to 15.4 M" }, + { "id": "ours-tcp-async-yield", "label": "TCP, 256 pipelined, methods that suspend once", "note": "five async Task methods awaiting Task.Yield(); documents are sequential per connection", "family": "ours", "low": 1250000, "high": 1290000, "status": "range", "published": "1.25 M to 1.29 M" }, + { "id": "ours-inproc-16", "label": "in-process, 16 threads", "note": "the same requests through the byte entry point", "family": "scale", "low": 33800000, "high": 37100000, "status": "range", "published": "33.8 M to 37.1 M" } ] }, "compare": { diff --git a/benchmarks/charts/compare-connections-dark.svg b/benchmarks/charts/compare-connections-dark.svg index 98891d0..706d09f 100644 --- a/benchmarks/charts/compare-connections-dark.svg +++ b/benchmarks/charts/compare-connections-dark.svg @@ -1,5 +1,5 @@ - -Every library and transport, by client connectionsSame five calls, same Kestrel, clients on the server's 8 cores, 2026-09-23. TCP and gRPC keep 256 requests in flight per connection; HTTP clients await one POST at a time (1 or 100 RPCs). 5 runs of 2 s per point; whiskers span the runs. Data revision a4f53e6a848f. + +Every library and transport, by client connectionsSame five calls, same Kestrel, clients on the server's 8 cores, 2026-09-23. TCP and gRPC keep 256 requests in flight per connection; HTTP clients await one POST at a time (1 or 100 RPCs). 5 runs of 2 s per point; whiskers span the runs. Data revision eacac12e8d55. Every library and transport, by client connections Same five calls, same Kestrel, clients on the server's 8 cores, 2026-09-23. TCP and gRPC keep 256 requests in flight diff --git a/benchmarks/charts/compare-connections.svg b/benchmarks/charts/compare-connections.svg index 29194c5..661419a 100644 --- a/benchmarks/charts/compare-connections.svg +++ b/benchmarks/charts/compare-connections.svg @@ -1,5 +1,5 @@ - -Every library and transport, by client connectionsSame five calls, same Kestrel, clients on the server's 8 cores, 2026-09-23. TCP and gRPC keep 256 requests in flight per connection; HTTP clients await one POST at a time (1 or 100 RPCs). 5 runs of 2 s per point; whiskers span the runs. Data revision a4f53e6a848f. + +Every library and transport, by client connectionsSame five calls, same Kestrel, clients on the server's 8 cores, 2026-09-23. TCP and gRPC keep 256 requests in flight per connection; HTTP clients await one POST at a time (1 or 100 RPCs). 5 runs of 2 s per point; whiskers span the runs. Data revision eacac12e8d55. Every library and transport, by client connections Same five calls, same Kestrel, clients on the server's 8 cores, 2026-09-23. TCP and gRPC keep 256 requests in flight diff --git a/benchmarks/charts/compare-streamjsonrpc-dark.svg b/benchmarks/charts/compare-streamjsonrpc-dark.svg index d427a26..1ccb1fd 100644 --- a/benchmarks/charts/compare-streamjsonrpc-dark.svg +++ b/benchmarks/charts/compare-streamjsonrpc-dark.svg @@ -1,5 +1,5 @@ - -JSON-RPC.Net vs StreamJsonRpc vs gRPC for .NET, 16 connectionsSame five calls, same Kestrel, 16 connections or channels with 256 requests in flight each, two 3 s runs. Every request carries "jsonrpc":"2.0" (StreamJsonRpc requires it). Intervals span the runs; a dot is a single value. Data revision a4f53e6a848f. + +JSON-RPC.Net vs StreamJsonRpc vs gRPC for .NET, 16 connectionsSame five calls, same Kestrel, 16 connections or channels with 256 requests in flight each, two 3 s runs. Every request carries "jsonrpc":"2.0" (StreamJsonRpc requires it). Intervals span the runs; a dot is a single value. Data revision eacac12e8d55. JSON-RPC.Net vs StreamJsonRpc vs gRPC for .NET, 16 connections Same five calls, same Kestrel, 16 connections or channels with 256 requests in flight each, two 3 s runs. diff --git a/benchmarks/charts/compare-streamjsonrpc.svg b/benchmarks/charts/compare-streamjsonrpc.svg index f283484..c1cc29b 100644 --- a/benchmarks/charts/compare-streamjsonrpc.svg +++ b/benchmarks/charts/compare-streamjsonrpc.svg @@ -1,5 +1,5 @@ - -JSON-RPC.Net vs StreamJsonRpc vs gRPC for .NET, 16 connectionsSame five calls, same Kestrel, 16 connections or channels with 256 requests in flight each, two 3 s runs. Every request carries "jsonrpc":"2.0" (StreamJsonRpc requires it). Intervals span the runs; a dot is a single value. Data revision a4f53e6a848f. + +JSON-RPC.Net vs StreamJsonRpc vs gRPC for .NET, 16 connectionsSame five calls, same Kestrel, 16 connections or channels with 256 requests in flight each, two 3 s runs. Every request carries "jsonrpc":"2.0" (StreamJsonRpc requires it). Intervals span the runs; a dot is a single value. Data revision eacac12e8d55. JSON-RPC.Net vs StreamJsonRpc vs gRPC for .NET, 16 connections Same five calls, same Kestrel, 16 connections or channels with 256 requests in flight each, two 3 s runs. diff --git a/benchmarks/charts/explorer.html b/benchmarks/charts/explorer.html index f1323dc..a633976 100644 --- a/benchmarks/charts/explorer.html +++ b/benchmarks/charts/explorer.html @@ -5,7 +5,7 @@ JSON-RPC.NET benchmark explorer - +