Skip to content

Commit 94fed3b

Browse files
Astnclaude
andcommitted
Benchmark: gRPC for .NET rows in the compare mode
--compare now also hosts gRPC for .NET (Grpc.AspNetCore 2.84.0, HTTP/2 + protobuf) on the same Kestrel, answering the benchmark's five calls from Protos/calculator.proto, and drives it with Grpc.Net.Client in the same shape as the other rows: 16 channels with 256 calls in flight each. Two rows: unary calls, and one bidirectional stream per channel with batched writes (BufferHint on all but the last message of a refill; the server flushes when its input runs dry, the once-per-read-group flush JsonRpcConnectionHandler does). protobuf has no decimal, so Test2 uses the units/nanos DecimalValue message; nullable values use proto3 optional. Every reply is checked. Two 3 s runs: unary 142-183k RPC/s, stream 173-182k. The gRPC client shares the 8 cores with the server (there is no cheap raw client for HTTP/2 + protobuf), which the README says; one channel alone reaches about 130k unary calls/s. Also: the header line prints NuGet package versions (StreamJsonRpc 2.25.29, not the 2.25.0.0 assembly version); the chart labels a sub-million range that rounds to one value as that value; --compare help text names gRPC. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 1a32230 commit 94fed3b

8 files changed

Lines changed: 363 additions & 35 deletions

File tree

‎README.md‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ Registering a pre- or post-process handler switches the affected session onto a
233233
```
234234
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)
235235
dotnet run -c Release --project TestServer_Console -- --kestrel 3 # through the AspNetCore package, HTTP and TCP
236-
dotnet run -c Release --project TestServer_Console -- --compare 3 # the same requests through StreamJsonRpc, side by side
236+
dotnet run -c Release --project TestServer_Console -- --compare 3 # the same calls through StreamJsonRpc and gRPC for .NET, side by side
237237
dotnet run -c Release --project TestServer_Console # menu: Enter = Task mode, s = sync, k = Kestrel, x = compare, q = quit
238238
dotnet run --project samples/WasmHost # browser: "Run benchmark" on the page
239239
```
@@ -289,11 +289,11 @@ Sync beats Task mode because Task mode measures the .NET thread pool and per-req
289289

290290
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`.
291291

292-
### Versus StreamJsonRpc
292+
### Versus StreamJsonRpc and gRPC
293293

294-
[StreamJsonRpc](https://www.nuget.org/packages/StreamJsonRpc) is Microsoft's JSON-RPC library, the one behind Visual Studio and the language-server stack. `--compare` hosts both libraries on the same Kestrel TCP listener and drives them with the same pipelining client (16 connections, 256 requests in flight each), so the only variable is the library answering. StreamJsonRpc requires the `jsonrpc` member, so every request in this mode carries `"jsonrpc":"2.0"`, which is why the JSON-RPC.Net rows are a little below the other tables. Each row was run twice for 3 s; both results are shown.
294+
[StreamJsonRpc](https://www.nuget.org/packages/StreamJsonRpc) is Microsoft's JSON-RPC library, the one behind Visual Studio and the language-server stack. `--compare` hosts both libraries on the same Kestrel TCP listener and drives them with the same pipelining client (16 connections, 256 requests in flight each), so the only variable is the library answering. StreamJsonRpc requires the `jsonrpc` member, so every request in this mode carries `"jsonrpc":"2.0"`, which is why the JSON-RPC.Net rows are a little below the other tables. The same mode also hosts [gRPC for .NET](https://learn.microsoft.com/aspnet/core/grpc/) (HTTP/2, protobuf) on the same Kestrel, answering the same five calls from [calculator.proto](TestServer_Console/Protos/calculator.proto), driven by its own client with the same shape: 16 channels, 256 calls in flight each. Each row was run twice for 3 s; both results are shown.
295295

296-
![JSON-RPC.Net vs StreamJsonRpc](benchmarks/charts/compare-streamjsonrpc.svg)
296+
![JSON-RPC.Net vs StreamJsonRpc vs gRPC](benchmarks/charts/compare-streamjsonrpc.svg)
297297

298298
| Library and path | RPC/s |
299299
| --- | ---: |
@@ -304,9 +304,13 @@ The TCP client keeps 256 requests in flight per connection and refills from a pr
304304
| StreamJsonRpc over Kestrel TCP, newline framing, System.Text.Json formatter | 1.05 M to 1.10 M |
305305
| StreamJsonRpc over Kestrel TCP, `Content-Length` framing, System.Text.Json formatter | 1.14 M to 1.18 M |
306306
| StreamJsonRpc over Kestrel TCP, `Content-Length` framing, Json.NET formatter (its default) | 515 k to 518 k |
307+
| gRPC for .NET, unary calls over HTTP/2 (Grpc.Net.Client, 16 channels × 256 in flight) | 142 k to 183 k |
308+
| gRPC for .NET, one bidirectional stream per channel, 256 in flight, batched writes | 173 k to 182 k |
307309

308310
StreamJsonRpc 2.25.29, defaults apart from the formatter and framing named in each row. It is a full bidirectional RPC framework (client proxies, cancellation, progress, marshaled objects, events), and its server side has no "document in, document out" call, so its in-process row is a pair of `System.IO.Pipelines` pipes, the closest it has to a direct call. The comparison is of the server side answering the same five requests; on that measure JSON-RPC.Net is about 10× faster on the same connections with the same JSON library underneath.
309311

312+
gRPC for .NET 2.84.0 with default settings apart from Kestrel's `MaxStreamsPerConnection` (raised to 256 so the pipeline depth is not capped at 100). protobuf has no `decimal`, so `Test2` carries the units/nanos `DecimalValue` message the gRPC docs recommend; nullable values use proto3 `optional`. The gRPC rows are a different kind of measurement from the rows above them: there is no cheap raw client for HTTP/2 + protobuf, so the client is Grpc.Net.Client on the same 8 cores as the server, and the figure is what a .NET caller and a .NET service get end to end. One channel alone reaches about 130 k unary calls per second; sixteen channels do not scale much further because client and server compete for the same cores. The streaming row batches its writes the way the TCP client does (BufferHint on every message but the last of a refill), and the server flushes only when its input runs dry, the same once-per-read-group flush `JsonRpcConnectionHandler` does.
313+
310314
### WebAssembly: in the browser
311315

312316
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.

‎TestServer_Console/CompareBenchmark.cs‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using Microsoft.AspNetCore.Builder;
1414
using Microsoft.AspNetCore.Connections;
1515
using Microsoft.AspNetCore.Hosting;
16+
using Microsoft.AspNetCore.Server.Kestrel.Core;
1617
using Microsoft.Extensions.DependencyInjection;
1718
using Microsoft.Extensions.Logging;
1819
using StreamJsonRpc;
@@ -26,7 +27,8 @@ namespace TestServer_Console;
2627
/// pipelining client, so the only variable is the RPC library. Every request carries <c>"jsonrpc":"2.0"</c>
2728
/// because StreamJsonRpc requires it. StreamJsonRpc has no "document in, document out" call, so its in-process
2829
/// row runs over a pair of <see cref="Pipe"/>s, the closest thing it has to a direct call; a sequential
29-
/// proxy row shows what a typical <c>await proxy.AddAsync(1, 2)</c> costs end to end.
30+
/// proxy row shows what a typical <c>await proxy.AddAsync(1, 2)</c> costs end to end. gRPC for .NET answers the
31+
/// same five calls on an HTTP/2 listener of the same Kestrel (<see cref="GrpcCompare"/>), unary and streamed.
3032
/// </summary>
3133
internal static class CompareBenchmark
3234
{
@@ -65,7 +67,7 @@ internal static async Task RunAsync(Action<string> print, double seconds = 3, in
6567
var newLine = Requests.Select(r => Encoding.UTF8.GetBytes(r + "\n")).ToArray();
6668
var header = Requests.Select(r => Encoding.UTF8.GetBytes("Content-Length: " + Encoding.UTF8.GetByteCount(r) + "\r\n\r\n" + r)).ToArray();
6769

68-
int oursPort = KestrelBenchmark.FreePort(), sjrNewLineStjPort = KestrelBenchmark.FreePort(), sjrHeaderStjPort = KestrelBenchmark.FreePort(), sjrHeaderNewtonsoftPort = KestrelBenchmark.FreePort();
70+
int oursPort = KestrelBenchmark.FreePort(), sjrNewLineStjPort = KestrelBenchmark.FreePort(), sjrHeaderStjPort = KestrelBenchmark.FreePort(), sjrHeaderNewtonsoftPort = KestrelBenchmark.FreePort(), grpcPort = KestrelBenchmark.FreePort();
6971
var builder = WebApplication.CreateBuilder();
7072
builder.Logging.ClearProviders();
7173
builder.WebHost.ConfigureKestrel(k =>
@@ -74,14 +76,18 @@ internal static async Task RunAsync(Action<string> print, double seconds = 3, in
7476
k.Listen(IPAddress.Loopback, sjrNewLineStjPort, l => l.Run(c => ServeStreamJsonRpc(c, Framing.NewLine, Formatter.SystemTextJson)));
7577
k.Listen(IPAddress.Loopback, sjrHeaderStjPort, l => l.Run(c => ServeStreamJsonRpc(c, Framing.Header, Formatter.SystemTextJson)));
7678
k.Listen(IPAddress.Loopback, sjrHeaderNewtonsoftPort, l => l.Run(c => ServeStreamJsonRpc(c, Framing.Header, Formatter.Newtonsoft)));
79+
k.Listen(IPAddress.Loopback, grpcPort, l => l.Protocols = HttpProtocols.Http2);
80+
k.Limits.Http2.MaxStreamsPerConnection = pipeline; // Kestrel's default of 100 would cap the gRPC rows below the pipeline depth
7781
});
7882
builder.Services.AddJsonRpc();
83+
builder.Services.AddGrpc();
7984
var app = builder.Build();
85+
app.MapGrpcService<GrpcCompare.CalculatorGrpc>();
8086
await app.StartAsync();
8187

8288
try
8389
{
84-
print($"JSON-RPC.Net {typeof(JsonRpcProcessor).Assembly.GetName().Version} vs StreamJsonRpc {typeof(JsonRpc).Assembly.GetName().Version}; Kestrel TCP on loopback, {clients} clients, pipeline {pipeline}, {seconds:0.#} s per row\n");
90+
print($"JSON-RPC.Net {typeof(JsonRpcProcessor).Assembly.GetName().Version} vs StreamJsonRpc {PackageVersion(typeof(JsonRpc))} vs gRPC for .NET {PackageVersion(typeof(Grpc.Net.Client.GrpcChannel))}; Kestrel on loopback, {clients} clients, pipeline {pipeline}, {seconds:0.#} s per row\n");
8591
var rows = new List<BenchmarkRunner.ChartRow>();
8692
var session = Handler.DefaultSessionId();
8793

@@ -118,7 +124,18 @@ internal static async Task RunAsync(Action<string> print, double seconds = 3, in
118124
print($" {label} done ({count / secs:N0} RPC/s)");
119125
}
120126

121-
BenchmarkRunner.PrintBarChart("JSON-RPC.Net vs StreamJsonRpc - RPC/s", "Library / transport", rows);
127+
// ---- gRPC for .NET on the same Kestrel: HTTP/2 + protobuf, one channel per client
128+
await GrpcCompare.UnaryRun(grpcPort, clients, pipeline, 0.5);
129+
(count, secs) = await GrpcCompare.UnaryRun(grpcPort, clients, pipeline, seconds);
130+
rows.Add(new BenchmarkRunner.ChartRow("gRPC for .NET unary, HTTP/2", count / secs, $"{count,12:N0} RPCs {clients} channels, {pipeline} calls in flight each"));
131+
print($" gRPC unary done ({count / secs:N0} RPC/s)");
132+
133+
await GrpcCompare.StreamRun(grpcPort, clients, pipeline, 0.5);
134+
(count, secs) = await GrpcCompare.StreamRun(grpcPort, clients, pipeline, seconds);
135+
rows.Add(new BenchmarkRunner.ChartRow("gRPC for .NET bidirectional stream", count / secs, $"{count,12:N0} RPCs {clients} streams, {pipeline} calls in flight each"));
136+
print($" gRPC stream done ({count / secs:N0} RPC/s)");
137+
138+
BenchmarkRunner.PrintBarChart("JSON-RPC.Net vs StreamJsonRpc vs gRPC - RPC/s", "Library / transport", rows);
122139
}
123140
finally
124141
{
@@ -127,6 +144,15 @@ internal static async Task RunAsync(Action<string> print, double seconds = 3, in
127144
}
128145
}
129146

147+
/// <summary>The NuGet package version of an assembly (its informational version without the commit suffix).</summary>
148+
private static string PackageVersion(Type t)
149+
{
150+
var info = t.Assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false);
151+
var text = info.Length > 0 ? ((System.Reflection.AssemblyInformationalVersionAttribute)info[0]).InformationalVersion : t.Assembly.GetName().Version.ToString();
152+
int plus = text.IndexOf('+');
153+
return plus > 0 ? text.Substring(0, plus) : text;
154+
}
155+
130156
private static IJsonRpcMessageHandler CreateHandler(PipeWriter writer, PipeReader reader, Framing framing, Formatter formatter)
131157
{
132158
IJsonRpcMessageTextFormatter f = formatter == Formatter.SystemTextJson ? new SystemTextJsonFormatter() : new JsonMessageFormatter();

0 commit comments

Comments
 (0)