Skip to content

Commit 9c4fc31

Browse files
Astnclaude
andcommitted
samples/WasmHost: UTF-8 buffer path, AOT publish, refreshed numbers
JavaScript can now hand the server request bytes without a string crossing the boundary: two pinned buffers are exposed once as MemoryViews through a [JSImport], and a [JSExport] processes the input buffer through the same byte-first entry point Kestrel uses. On the interpreter that is 53 us per request against 58 us for the string [JSExport] and 64 us for a plain Blazor invokeMethod add; a batch of 100 reaches 27k RPC/s. dotnet publish -c Release turns on RunAOTCompilation (wasm-tools workload needed; -p:Aot=false opts out); build and run stay on the interpreter. The page reports whether it is AOT-compiled. The wasm-tools install needs an elevated prompt on this machine, so the AOT numbers are still to be taken. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 35dbb0d commit 9c4fc31

5 files changed

Lines changed: 130 additions & 45 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ The TCP client keeps 256 requests in flight per connection and refills from a pr
284284

285285
### WebAssembly: in the browser
286286

287-
The [WasmHost sample](samples/WasmHost/README.md) compares JSON-RPC through JS interop with plain Blazor interop for the same `add(1, 2)` under the .NET 10 interpreter in Chrome. A plain `DotNet.invokeMethod` add costs about 65 µs (Blazor's JSON marshalling), a JSON-RPC document through `[JSExport]` 56 µs, a batch of 100 through `[JSExport]` 26k RPC/s, and a typed `[JSExport]` add 0.38 µs. The interpreter is the bottleneck; AOT is the lever.
287+
The [WasmHost sample](samples/WasmHost/README.md) compares JSON-RPC through JS interop with plain Blazor interop for the same `add(1, 2)` under the .NET 10 interpreter in Chrome. A plain `DotNet.invokeMethod` add costs about 64 µs (the JSON marshalling Blazor does); a JSON-RPC document written as UTF-8 straight into WebAssembly memory and run through a `[JSExport]` costs 53 µs, a batch of 100 that way reaches 27k RPC/s, and a typed `[JSExport]` add takes 0.35 µs. The interpreter is the bottleneck; `dotnet publish` AOT-compiles the sample when the `wasm-tools` workload is installed.
288288

289289
### simdjson
290290

‎samples/WasmHost/Program.cs‎

Lines changed: 60 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Buffers;
12
using System.Diagnostics;
23
using System.Runtime.InteropServices.JavaScript;
34
using AustinHarris.JsonRpc;
@@ -29,10 +30,13 @@ public class CalculatorService : JsonRpcService
2930
}
3031

3132
/// <summary>
32-
/// The JavaScript-facing surface. Two families of entry point, so the page can benchmark one against the other:
33+
/// The JavaScript-facing surface. Three ways in, so the page can benchmark one against the other:
3334
/// <list type="bullet">
34-
/// <item><see cref="Process"/> / <see cref="ProcessExported"/>: a JSON-RPC document in, the response document out.
35-
/// Any method the service exposes is reachable through this one entry point, and a batch is one call.</item>
35+
/// <item><see cref="Process"/> / <see cref="ProcessExported"/>: a JSON-RPC document as a string in, the response
36+
/// string out. Any method the service exposes is reachable through this one entry point, and a batch is one call.</item>
37+
/// <item><see cref="ProcessBytes"/>: the same through two pinned byte buffers that JavaScript writes and reads
38+
/// directly (UTF-8 in WebAssembly memory), so no string is marshalled or transcoded in either direction. This is
39+
/// the byte-first entry point the Kestrel package uses.</item>
3640
/// <item><see cref="Add"/> / <see cref="AddExported"/>: plain Blazor interop, one exported method per operation
3741
/// with typed arguments that the runtime marshals itself.</item>
3842
/// </list>
@@ -43,7 +47,7 @@ public class CalculatorService : JsonRpcService
4347
/// </summary>
4448
public static partial class JsonRpcInterop
4549
{
46-
// ---- JSON-RPC: one entry point for every method, and for batches ----
50+
// ---- JSON-RPC as strings: one entry point for every method, and for batches ----
4751

4852
[JSInvokable("Process")]
4953
public static string Process(string json) => JsonRpcProcessor.ProcessSync(json);
@@ -63,11 +67,63 @@ public static double ProcessMany(string json, int count)
6367
return sw.Elapsed.TotalMilliseconds;
6468
}
6569

70+
// ---- JSON-RPC as bytes: JavaScript writes UTF-8 into the input buffer and reads the output buffer ----
71+
72+
private const int BufferSize = 1 << 20;
73+
// Pinned so the MemoryViews handed to JavaScript stay valid for the life of the page.
74+
private static readonly byte[] _input = GC.AllocateArray<byte>(BufferSize, pinned: true);
75+
private static readonly byte[] _output = GC.AllocateArray<byte>(BufferSize, pinned: true);
76+
private static readonly FixedBufferWriter _outputWriter = new FixedBufferWriter(_output);
77+
private static readonly string _defaultSession = Handler.DefaultSessionId();
78+
79+
/// <summary>JavaScript side of the buffer hand-off: receives views of the two pinned arrays.</summary>
80+
[JSImport("buffersReady", "wasmhost")]
81+
private static partial void BuffersReady([JSMarshalAs<JSType.MemoryView>] ArraySegment<byte> input, [JSMarshalAs<JSType.MemoryView>] ArraySegment<byte> output);
82+
83+
/// <summary>Hands JavaScript zero-copy views of the request and response buffers. Call once after start-up.</summary>
84+
[JSExport]
85+
public static void ExposeBuffers() => BuffersReady(new ArraySegment<byte>(_input), new ArraySegment<byte>(_output));
86+
87+
/// <summary>
88+
/// Processes the first <paramref name="length"/> bytes of the input buffer (one document or a batch, UTF-8)
89+
/// and returns how many bytes of response were written to the output buffer (0 for a notification).
90+
/// </summary>
91+
[JSExport]
92+
public static int ProcessBytes(int length)
93+
{
94+
_outputWriter.Reset();
95+
JsonRpcProcessor.Process(_defaultSession, new ReadOnlySpan<byte>(_input, 0, length), _outputWriter);
96+
return _outputWriter.Written;
97+
}
98+
99+
/// <summary>True when the app was AOT-compiled (published with the wasm-tools workload).</summary>
100+
[JSExport]
101+
public static bool IsAot() =>
102+
#if WASM_AOT
103+
true;
104+
#else
105+
false;
106+
#endif
107+
66108
// ---- plain Blazor interop: one exported method per operation ----
67109

68110
[JSInvokable("Add")]
69111
public static double Add(double l, double r) => l + r;
70112

71113
[JSExport]
72114
public static double AddExported(double l, double r) => l + r;
115+
116+
/// <summary>An <see cref="IBufferWriter{T}"/> over a fixed array; throws if a response would not fit.</summary>
117+
private sealed class FixedBufferWriter : IBufferWriter<byte>
118+
{
119+
private readonly byte[] _buffer;
120+
public FixedBufferWriter(byte[] buffer) => _buffer = buffer;
121+
public int Written { get; private set; }
122+
public void Reset() => Written = 0;
123+
public void Advance(int count) => Written += count;
124+
public Memory<byte> GetMemory(int sizeHint = 0) => Check(sizeHint) ? _buffer.AsMemory(Written) : throw Overflow();
125+
public Span<byte> GetSpan(int sizeHint = 0) => Check(sizeHint) ? _buffer.AsSpan(Written) : throw Overflow();
126+
private bool Check(int sizeHint) => _buffer.Length - Written >= Math.Max(sizeHint, 1);
127+
private static Exception Overflow() => new InvalidOperationException("The response does not fit in the output buffer.");
128+
}
73129
}

‎samples/WasmHost/README.md‎

Lines changed: 38 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -34,52 +34,56 @@ reach .NET here, and reports calls per second, microseconds per call, and RPCs p
3434
| plain interop, `exports.JsonRpcInterop.AddExported(1,2)` | one `[JSExport]` method per operation (`System.Runtime.InteropServices.JavaScript`), which marshals each parameter directly with no JSON |
3535
| JSON-RPC, `invokeMethod('WasmHost','Process',request)` | one `[JSInvokable]` entry point for every method: a request document in, the response document out |
3636
| JSON-RPC, `invokeMethodAsync` | the same through the promise-returning call |
37-
| JSON-RPC, `exports.JsonRpcInterop.ProcessExported(request)` | the same entry point as a `[JSExport]` |
38-
| JSON-RPC, batch of 100 per call | both interop flavours with a 100-request batch document per call |
39-
| JSON-RPC in a .NET loop | `ProcessMany` runs the request N times inside .NET: the server's own cost in this runtime, no interop |
37+
| JSON-RPC, `exports.JsonRpcInterop.ProcessExported(request)` | the same entry point as a `[JSExport]`, strings marshalled by the runtime |
38+
| JSON-RPC, `ProcessBytes`, UTF-8 buffers | JavaScript writes the request bytes into a pinned buffer in WebAssembly memory (a `MemoryView` handed over once at start-up), calls a `[JSExport]` with the length, and reads the response bytes back; no string crosses the boundary and the server runs its byte-first entry point, the one the Kestrel package uses |
39+
| JSON-RPC, batch of 100 per call | the three interop flavours with a 100-request batch document per call |
40+
| JSON-RPC in a .NET loop | `ProcessMany` runs the string request N times inside .NET: the cost of the server itself in this runtime, no interop |
4041

41-
Measured on an 8-core desktop in Chrome 152 with the .NET 10 interpreter (no `wasm-tools` workload, so no
42-
AOT), 20,000 RPCs per row:
42+
Measured on an 8-core desktop in Chrome 152 with the .NET 10 interpreter (`dotnet run`, no AOT), 20,000 RPCs
43+
per row:
4344

4445
| Path | µs per call | RPC/s |
4546
|---|---:|---:|
46-
| plain interop, `invokeMethod Add` | 64.7 | 15,400 |
47-
| plain interop, `invokeMethodAsync Add` | 69.8 | 14,300 |
48-
| plain interop, `[JSExport] AddExported` | 0.38 | 2,600,000 |
49-
| JSON-RPC, `invokeMethod Process` | 171.8 | 5,800 |
50-
| JSON-RPC, `invokeMethodAsync Process` | 173.2 | 5,800 |
51-
| JSON-RPC, `[JSExport] ProcessExported` | 55.8 | 17,900 |
52-
| JSON-RPC, `invokeMethod Process`, batch of 100 | 7,328 per batch | 13,600 |
53-
| JSON-RPC, `[JSExport] ProcessExported`, batch of 100 | 3,858 per batch | 25,900 |
54-
| JSON-RPC in a .NET loop, no interop | 54.1 | 18,500 |
47+
| plain interop, `invokeMethod Add` | 63.7 | 15,700 |
48+
| plain interop, `invokeMethodAsync Add` | 68.8 | 14,500 |
49+
| plain interop, `[JSExport] AddExported` | 0.35 | 2,860,000 |
50+
| JSON-RPC, `invokeMethod Process` | 168.0 | 6,000 |
51+
| JSON-RPC, `invokeMethodAsync Process` | 175.0 | 5,700 |
52+
| JSON-RPC, `[JSExport] ProcessExported` | 58.4 | 17,100 |
53+
| JSON-RPC, `[JSExport] ProcessBytes`, UTF-8 buffers | 52.6 | 19,000 |
54+
| JSON-RPC, `invokeMethod Process`, batch of 100 | 7,706 per batch | 13,000 |
55+
| JSON-RPC, `[JSExport] ProcessExported`, batch of 100 | 3,998 per batch | 25,000 |
56+
| JSON-RPC, `[JSExport] ProcessBytes`, batch of 100, UTF-8 buffers | 3,706 per batch | 27,000 |
57+
| JSON-RPC in a .NET loop, no interop | 56.9 | 17,600 |
5558

5659
What the numbers say:
5760

5861
- **`[JSInvokable]` interop is the expensive part, not the RPC.** A plain `invokeMethod` call that adds two
59-
numbers costs about 65 µs, because the runtime's JSON marshalling of the argument array and result runs in
60-
the interpreter. Sending a whole JSON-RPC document through `[JSExport]` (56 µs) is cheaper than that, and
61-
within noise of running the server in a .NET loop with no interop at all (54 µs): with `[JSExport]` the
62-
interop is free and the JSON-RPC parse and dispatch is the entire cost.
63-
- **One entry point, batched, beats one interop call per operation.** A batch of 100 through `[JSExport]`
64-
gets to 25,900 RPC/s, above the single-call `[JSInvokable]` rate for a bare add. If the page has many calls
65-
to make at once, batch them.
66-
- **`[JSExport]` with typed arguments is the fastest way to call one method** (0.38 µs) when the method has a
62+
numbers costs about 64 µs, because the JSON marshalling of the argument array and result that the runtime
63+
does runs in the interpreter. Sending a whole JSON-RPC document through `[JSExport]` (58 µs) is cheaper
64+
than that.
65+
- **Bytes beat strings.** The UTF-8 buffer path (53 µs) is faster than the string `[JSExport]` (58 µs) and
66+
faster than the string request in a pure .NET loop (57 µs): with no string marshalled and no UTF-16 to UTF-8
67+
transcoding, the interop is gone and what remains is the parse, dispatch and response write themselves.
68+
- **One entry point, batched, beats one interop call per operation.** A batch of 100 over the byte path
69+
gets to 27,000 RPC/s (37 µs per request), well above the single-call `[JSInvokable]` rate for a bare add.
70+
If the page has many calls to make at once, batch them.
71+
- **`[JSExport]` with typed arguments is the fastest way to call one method** (0.35 µs) when the method has a
6772
fixed signature, and it is fast for one reason: there is no JSON anywhere. The generated stub takes the two
6873
doubles out of a fixed argument buffer in WebAssembly memory, calls the method and writes the double back;
6974
the interpreter executes a handful of instructions. JSON-RPC pays for its generality: any method, any
7075
parameters, batches, errors, and the same service code as the server, through one exported function.
71-
- The interpreter is the bottleneck. The same request takes about 250 ns on the .NET 10 JIT
72-
(see the top-level README); the 200× gap is interpreted .NET code, not interop or the design.
76+
- **The interpreter is the bottleneck.** The same request takes about 230 ns on the .NET 10 JIT (see the
77+
top-level README); the 200× gap is interpreted .NET code, not interop or the design.
7378

7479
How to get the most out of it, in order of payoff:
7580

76-
1. **Enter through `[JSExport]`, not `invokeMethod`.** Same document, 3× faster, no loss of generality.
77-
2. **AOT-compile the app** (`dotnet workload install wasm-tools`, then `<RunAOTCompilation>true</RunAOTCompilation>`
78-
in the project). The add stub barely changes, but the JSON-RPC rows are .NET code running interpreted,
79-
so they are the ones AOT speeds up.
80-
3. **Pass bytes, not strings.** `[JSExport]` marshals `Span<byte>` as a zero-copy view of WebAssembly memory.
81-
JavaScript can write UTF-8 into a buffer the app exposes and call an export that runs the byte-first
82-
`JsonRpcProcessor.Process(ReadOnlySpan<byte>, IBufferWriter<byte>)`, the same entry point the Kestrel
83-
package uses, with no UTF-16 transcoding on either side.
84-
4. **Batch.** Per-document setup is amortised: 26k RPC/s for batches of 100 against 18k for single calls.
85-
5. **Typed `[JSExport]` stubs for the two or three hottest methods**, JSON-RPC for everything else.
81+
1. **AOT-compile the app.** `dotnet workload install wasm-tools` (needs an elevated prompt on Windows), then
82+
`dotnet publish -c Release` produces an AOT build under `bin/Release/net10.0/publish/wwwroot` (the project
83+
turns `RunAOTCompilation` on for publish; pass `-p:Aot=false` to publish interpreted). Serve that folder
84+
with any static server and the page reports "AOT-compiled" next to "ready". The typed add stub barely
85+
changes, but the JSON-RPC rows are .NET code running interpreted, so they are the ones AOT speeds up.
86+
2. **Enter through `[JSExport]` with UTF-8 buffers, not `invokeMethod`.** Same document, 3× faster, no
87+
loss of generality: this is `ProcessBytes` above.
88+
3. **Batch.** Per-document setup is amortised.
89+
4. **Typed `[JSExport]` stubs for the two or three hottest methods**, JSON-RPC for everything else.

‎samples/WasmHost/WasmHost.csproj‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@
55
<Nullable>disable</Nullable>
66
<ImplicitUsings>enable</ImplicitUsings>
77
<IsPackable>false</IsPackable>
8-
<!-- [JSExport] methods (the direct-marshalling interop path benchmarked in index.html) need the generator's unsafe code. -->
9-
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
108
<!-- The JSON-RPC server runs inside the browser: no ASP.NET Core, no Kestrel, just the core package
119
compiled to WebAssembly and called from JavaScript through JS interop. -->
10+
<!-- [JSExport]/[JSImport] methods (the direct-marshalling interop paths benchmarked in index.html) need the generator's unsafe code. -->
11+
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
12+
<!-- `dotnet publish -c Release` AOT-compiles the app (needs `dotnet workload install wasm-tools`); `dotnet run`
13+
and `dotnet build` stay on the interpreter and need no workload. Pass -p:Aot=false to publish interpreted. -->
14+
<Aot Condition="'$(Aot)' == '' and '$(_IsPublishing)' == 'true'">true</Aot>
15+
<RunAOTCompilation Condition="'$(Aot)' == 'true'">true</RunAOTCompilation>
16+
<DefineConstants Condition="'$(Aot)' == 'true' and '$(_IsPublishing)' == 'true'">$(DefineConstants);WASM_AOT</DefineConstants>
1217
</PropertyGroup>
1318

1419
<ItemGroup>

0 commit comments

Comments
 (0)