Liking cljdoc? Tell your friends :D

Raw per-step tables — switch ladder, September 2026

Backing data for the switch-ladder entry in ../soak-results.md; procedure in ../../soak/README.md. All three phases ran 2026-09-06/07; the ladder summary is at the end.

Setup

talos-main (arm64, 5 × 4-core workers). Identical 1-CPU/1-Gi Guaranteed pods: rest-h1 and rest-h2c on worker-04 (same image, one up at a time), grpc-jvm (EXECUTOR=direct) on worker-03, the Nighthawk Job on worker-05 as a 1-CPU Guaranteed pod running ONE spinning worker (worker-05 also hosts pyroscope-0; two Guaranteed cores no longer fit there). Open loop, one nighthawk_client per 110 s step, tagged 300 s warmup step first (a fresh JVM on a 1-CPU quota spends minutes in JIT: 120 s left p99 in seconds). CPU per request from the arm's cgroup cpu.stat delta over the step's delivered count (cAdvisor is not scraped on this cluster).

Request bodies (//charts:bodies, SIZES.txt):

tierJSONprotobufJSON/pb
tiny16 B7 B2.29
realistic (target 1,024 B pb)1,309 B1,025 B1.28

Client settings, stated because they can move a knee: HTTP/1.1 256 connections; HTTP/2 8 connections × 512 streams, 4,096 in flight (server H2C_MAX_STREAMS=1024; grpc-netty has no per-connection cap — an asymmetry, disclosed); streams 20 (S) / 40 (E), 256 in flight per stream, 500 ms drain.

Versions: clj-grpc v0.1.6 (arms built from main at the run's chart, 0.2.4), Pedestal 0.8.1 / Jetty 12.0.29, grpc-java 1.83.1 / Netty 4.2.16.Final, Nighthawk fork 26d79815 (P0) for Phase A, 75d3b4b6 (P1) for Phase B, 50dce0eb (P2) for Phase C.

Phase A — transport: rest-h1 (R1, R5) vs rest-h2c (R2, R6)

Run 2026-09-06, chart 0.2.4, Nighthawk P0. Ramp 200→2400 by 200 (tiny), 100→1600 (realistic). Arm restarts during every run: 0. Job logs (gzipped) and the collector's tables.md in soak/results/2026-09-06-phase{A,B}/ and 2026-09-07-phaseC/; regenerate a table with zcat <log>.gz | soak/collect.sh <mode>.

Phase A conclusion. Switching a Pedestal/Jetty service from HTTP/1.1 to h2c on the same code buys nothing in capacity (both saturate the core at ~925 rps tiny / ~750 rps realistic), costs 3–16% more CPU per request below the knee, shaves p99 by 10–30% on the realistic body, and admits ~6–9% more at the knee. Under overload it is worse: h2c has no flat plateau on 1 KB bodies because unserved requests sit inside the server instead of failing at the client. The transport rung of the ladder is not where the gain is.

Two things to know before reading the tables:

  • Past the knee, latency is the client's queue, not the server's. Open loop with --max-active-requests means every request the server does not take waits at the client up to that cap: 256 for HTTP/1.1 (one per connection), 4,096 for h2c. That is why h1 shows ~95 ms p50 at saturation and h2c ~4.6 s — the same server, a 16× deeper client queue. Compare the arms at and below the knee; above it read only delivered/s and knee/s.
  • h2c's CPU/heap columns are n/a past 1,000 rps because the arm's /metrics thread, CPU-throttled behind thousands of queued requests, did not answer run.sh's 5 s curl. Chart 0.2.5 retries with a 30 s budget. Where present, CPU per request is Δcpu.stat usage_usec / delivered for the step.

The first R2 attempt was void (0 delivered on every step): ladder.sh scaled the arms with kubectl and the helm upgrade that starts the Job re-applied the chart's replica counts, so the Job ran against a Service with no endpoints. Fixed by carrying the pairing through Helm; the run below is the re-run.

R1 — rest-h1, HTTP/1.1, tiny (nh-rest-h1-http1-tiny-09062027)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)188.64.081338.312259.8111.43.68377.263323
200200.02.7127.5962.830.02.0081.063325
400399.62.4521.7157.610.31.5620.3100326
600598.42.5772.89378.131.61.4322.7101326
800792.68.87171.34381.447.41.25619.4101324
1000910.279.51261.89393.5489.31.09474.487324
1200919.586.81263.19393.66279.71.08572.187324
1400928.489.75269.22437.70470.71.07259.172324
1600932.292.44278.81480.02667.01.06962.276324
1800926.294.83283.72527.16872.91.07755.276324
2000931.096.16291.00543.461068.21.07077.482324
2200918.697.80303.60592.411280.51.08471.992324
2400924.698.43304.79549.811474.51.07869.192324

R2 — rest-h2c, HTTP/2 cleartext, tiny (nh-rest-h2c-http2-tiny-09062128)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.06.6925248.6638449.180.03.564100.662298
200200.03.0323.4951.630.02.2081.175299
400400.02.8128.1481.040.01.7800.275300
600600.02.9753.05120.140.01.5682.473300
800799.816.82122.91205.590.01.24049.967301
1000999.660.96213.68266.170.00.99557.179302
1200913.74686.356018.567661.42249.3n/an/a80n/a
1400913.14638.116767.777302.02450.0n/an/an/an/a
1600916.34606.926357.527705.72646.4n/an/an/an/a
1800954.04547.948541.969796.85808.8n/an/a92n/a
2000939.74600.635629.547729.841023.0n/an/an/an/a
2200915.34580.976508.516920.601247.4n/an/an/an/a
2400930.64503.115757.739715.061432.1n/an/an/an/a

R5 — rest-h1, HTTP/1.1, realistic (nh-rest-h1-http1-realistic-09062158)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)180.726.661590.692710.5719.34.584252.7117335
100100.03.4216.2961.130.02.4940.5117336
200199.83.0626.66257.260.22.0910.9107336
300300.02.8925.7372.040.01.8430.383337
400399.92.9864.67124.730.11.7927.4115338
500499.03.0488.42328.501.01.6835.8115338
600597.83.49133.57378.372.21.59311.0113338
800754.7100.80334.74597.0044.61.319143.9105338
1000754.4110.79346.73600.77244.71.320134.9105338
1200730.2116.63401.85718.73469.01.363122.991338
1400762.3114.22348.49678.63636.71.310125.6124338
1600761.2114.85375.16680.13837.91.311135.1124338

R6 — rest-h2c, HTTP/2 cleartext, realistic (nh-rest-h2c-http2-realistic-09062226)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)197.3222.7137604.0353146.032.64.17991.367308
100100.03.6513.4028.760.02.7990.367308
200200.03.2415.2736.860.02.3370.091308
300300.03.0821.2950.750.02.1260.391309
400400.03.0854.39127.170.01.9690.879309
500500.03.1561.21132.840.01.8391.595309
600600.06.17107.54183.390.01.65418.795310
800799.662.61315.79387.370.01.24438.695310
1000733.45800.989978.2514963.70229.5n/an/a69n/a
1200516.75734.1439699.0940294.68636.7n/an/an/an/a
1400544.28009.5518418.2420702.04818.5n/an/an/an/a
1600656.95801.517282.888109.42905.8n/an/an/an/a

Nighthawk's global counters for the 1,200 step, h2c vs h1 (110 s):

h2ch1
http_2xx56,84280,327
http_5xx2760
stream_resets (server RST_STREAM)750
pool_overflow (never sent)70,03551,589

Phase A, realistic tier (1.3 KB JSON) — what the switch buys

h1h2c
knee (last step with knee/s ≈ 0 and p50 < 10 ms)600600
delivered at 800 offered755800
plateau (delivered/s, steps ≥ 1,000)~750517–733, falling
p50 / p99 at 300 offered (ms)2.89 / 25.73.08 / 21.3
p50 / p99 at 500 offered (ms)3.04 / 88.43.15 / 61.2
CPU per request at 300 / 600 offered (ms)1.84 / 1.592.13 / 1.65
RSS (MB)338~310

Same shape as the tiny tier below the knee, now with the body big enough to matter: both arms knee at 600 and h2c delivers all of 800 where h1 sheds 6%, at a slightly higher CPU cost per request (+3–16%) and a slightly better p99. Above the knee the two diverge in h1's favour, and this is the Phase A finding that changes a decision: h2c on this server has no graceful plateau on 1 KB bodies. With 8 connections × 512 streams parked at the client and H2C_MAX_STREAMS=1024 at the server, Jetty carries thousands of in-flight requests it cannot serve; at 1,200 offered it delivers 517/s against h1's 730/s on the same core, answers 276 with 5xx and resets 750 streams, and p99 reaches 40 s. HTTP/1.1's plateau is flat because its overload is rejected at connection setup, before any server work; h2c's is absorbed into the server, where it costs CPU and heap. Tiny bodies do not show this (R2 held ~925/s throughout), so it is the per-stream buffering, not the framing. Bound it with a client cap (--max-active-requests near the plateau) or server admission control; neither exists in the REST arm today.

Phase A, tiny tier — what the switch buys

h1h2c
plateau (delivered/s, mean of steps ≥ 1,200)~925~925
delivered at 1,000 offered9101,000
p50 / p99 at 400 offered (ms)2.45 / 21.72.81 / 28.1
p50 / p99 at 800 offered (ms)8.9 / 17116.8 / 123
CPU per request at 400 / 1,000 offered (ms)1.56 / 1.091.78 / 1.00
RSS (MB)324~300

On 7-byte bodies the transport switch is worth nothing in capacity: both arms saturate the core at ~925 rps and CPU per request converges to ~1.0 ms either side of the knee. Below the knee h2c costs slightly more per request and is slightly slower at p50 — HTTP/2 framing overhead on a body too small to amortize it. The one difference in its favour is admission at the edge: at 1,000 offered h2c delivers all 1,000 where h1 already sheds 9%, because multiplexing keeps 4,096 requests parked instead of failing connection attempts. Validation against August: R1's ~930 plateau and its knee at 1,000 match the k6 measurement (~960, queue-death at 1,000) within the two instruments' difference in accounting.

Phase B — protocol: rest-h2c vs grpc-jvm unary (R3, R7)

Run 2026-09-06/07, chart 0.2.5, Nighthawk P1 (75d3b4b6): --grpc, raw HelloRequest bytes from --request-body-file, scored on grpc-status (benchmark.grpc_status.0 is the ok counter, latency_grpc_ok the histogram). Client settings identical to the h2c arm: 8 connections, 4,096 in flight, 300 s warmup at 200 rps, 110 s steps. Ramps extended past August's range after the fork's acceptance run found no knee at 2,400.

R3 — grpc-jvm unary, tiny (nh-grpc-jvm-grpc-unary-tiny-09062341)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.671080.101914.630.01.0941.117152
400400.01.449.5826.640.00.5070.216152
800800.01.4411.4025.590.00.3810.018152
12001200.01.3928.58100.790.00.3260.019153
16001600.01.4122.1464.590.00.2890.118153
20002000.01.4822.6545.640.00.2600.018154
24002399.91.5323.8354.810.00.2330.016154
28002799.91.6635.6179.730.10.2120.122155
32003199.91.7042.3098.570.00.1900.023156
36003592.21.8254.97411.017.70.1760.026159
40003996.31.9763.65126.083.70.1600.026159
44004399.82.0470.07172.890.10.1490.025160
48004799.82.2180.66196.460.10.1380.025160

No knee. Every step delivered its offered rate, throttling stayed at zero, and CPU per request kept falling with rate (0.51 ms at 400 → 0.14 ms at 4,800: the event loop's fixed cost amortizing), so at 4,800 rps the arm was using about two-thirds of its core. August's "~2,140 rps knee" for this arm was the closed-loop k6 driver, not the server. A follow-up run extends the ramp to 8,000 (R3b), and a two-worker cross-check by the fork session places the knee at ~10,000–11,000.

R7 — grpc-jvm unary, realistic (nh-grpc-jvm-grpc-unary-realistic-09070010)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.886146.237216.300.01.26714.219150
200200.01.769.7927.400.00.7780.019151
400400.01.6110.0120.690.00.6180.020151
600600.01.5913.7228.030.00.5550.120152
800800.01.7236.83102.800.00.5190.419152
10001000.01.6020.8646.980.00.4630.019153
12001200.01.6636.60159.610.00.4360.022153
14001400.01.8237.6989.830.00.4130.223154
16001599.91.8559.17312.900.10.3990.323154
20001999.82.3295.36346.600.10.3470.424156
24002386.44.142635.335526.2613.50.33113.335186
28002799.73.28219.32285.050.10.2740.135186
32003197.74.291096.882342.392.10.2512.136191

Delivered in full through 3,200 rps. The quota first shows at the top: 2 s throttled and p99 1.1 s at 3,200 with CPU per request at 0.25 ms (~0.8 of the core). The 2,400 step is an outlier (p99 2.6 s, 13 s throttled, heap and RSS stepping up 24→35 MB / 156→186 MB, the step after it clean) — a one-off JIT recompilation or GC event under load rather than the knee, since 2,800 delivered cleanly at lower cost. A follow-up run extends the ramp to 5,200.

R3b — grpc-jvm unary, tiny, 5,200→8,000 (nh-grpc-jvm-grpc-unary-tiny-09070039)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.661462.572302.150.01.0859.619144
52005199.42.48578.55832.670.50.1354.730167
56005599.52.4494.54163.750.40.1250.640168
60005999.72.6166.04141.220.20.1180.040169
64006391.33.24531.82894.407.70.1100.439170
68006791.22.9499.79174.407.50.1070.025170
72007191.43.30301.86544.878.50.1030.436170
76007599.13.43125.52235.950.70.0990.236170
80007976.63.80122.42272.8823.20.0950.241170

Still no knee on the arm at 8,000: CPU per request keeps falling to 0.095 ms, so the server uses ~0.76 of its core and throttling stays under a second per step. The p99 spikes at 5,200 / 6,400 / 7,200 come and go without a matching change on the arm. Nighthawk opened 4 connections for the run, not the 8 configured: for HTTP/2 --connections is a cap, and the pool adds connections only as stream demand requires (fork session's reading).

Cross-check with a two-worker driver (the fork session, same P1 image, concurrency 2, 2,048 in flight per worker, 30 s steps, no cgroup counters):

offereddelivered/sgrpc_okpool_overflowp50p99
8,0007,997239,916206.6 ms131 ms
12,00010,704321,13236,531 (10%)150 ms634 ms
16,0009,642289,249187,450 (44%)366 ms714 ms

Two workers deliver the same 8,000 as one, so the single spinning worker was not the limit there; both sequencers kept 100% of their schedule at 12k and 16k, so the shortfall past 8,000 is in-flight overflow waiting on the arm. Tiny-tier knee: ~10,000–11,000 rps per core, with delivered throughput falling past it (9.6k at 16k offered) — the same shape as the realistic tier at 5,200. The authoritative per-step CPU numbers stop at 8,000 (this run); the knee position is the cross-check's.

R7b — grpc-jvm unary, realistic, 3,600→5,200 (nh-grpc-jvm-grpc-unary-realistic-09070100)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.886385.047273.970.01.30725.321162
36003175.519.084032.1715589.70422.30.28239.844194
40003999.210.37781.651199.960.40.2166.644194
44004366.641.931300.963717.9931.30.2037.631198
48004674.7249.571666.582864.97125.20.20324.342200
52004458.1756.221563.753609.46707.70.22144.542198

The knee, found: 4,000 rps delivers in full at 0.86 of the core (6.6 s throttled, p50 10 ms); 4,400 sheds 1%; 4,800 delivers 4,675 with p50 at 250 ms; 5,200 is saturation (44 s throttled, 708/s never sent, delivered falls to 4,458). Plateau ~4,500–4,700 delivered. The 3,600 step is the fresh pod's first step after a 200 rps warmup and its 40 s of throttling and p50 19 ms are a JIT event at the jump, the same outlier shape as R7's 2,400 step; read 4,000 as the clean floor of this run.

Phase B — what the protocol switch buys (rest-h2cgrpc-jvm unary)

Same service contract (echo of the same nested Payload), same core, same client settings (8 connections, 4,096 in flight); the switch replaces Pedestal/Jetty/JSON with grpc-netty/protobuf and the body shrinks 1.28× (JSON 1,309 B → pb 1,025 B). Against both REST arms, since h1 is what existing services run and h2c is the rung just below:

rest-h1rest-h2cgrpc-jvm unaryswitch buys
tiny knee / plateau, delivered/s1,000 / ~9251,000 / ~925~10,500 / ~10,700 (2-worker cross-check)~11×
tiny CPU/req at 800 offered1.26 ms1.24 ms0.38 ms3.3× cheaper
tiny p50 / p99 at 800 (ms)8.9 / 17116.8 / 1231.4 / 11
tiny p50 / p99 at 400 (ms)2.45 / 21.72.81 / 28.11.44 / 9.6
realistic knee (full delivery, p50 < 10 ms)6006004,0006.7×
realistic plateau, delivered/s~750collapses (517)~4,6006.1×
realistic CPU/req at 600 offered1.59 ms1.65 ms0.56 ms2.9× cheaper
realistic p50 / p99 at 600 (ms)3.49 / 1346.17 / 1081.59 / 13.7
RSS at plateau (MB)338~310~195
heap at plateau (MB)~110~95~40

Three things the numbers say:

  • This rung is where the gain is. Per core, the protocol switch is worth 6× capacity on a 1 KB body and ~11× on a tiny one, with CPU per request 3× lower at the same offered rate and p99 an order of magnitude lower below REST's knee. h1 → h2c was worth nothing; h2c → gRPC is worth everything the August comparison attributed to "gRPC", and more, now that a single instrument measures both sides.
  • Overload is graceful again. Under the same 4,096-deep client queue that collapsed h2c, grpc-netty degrades to a plateau (5,200 offered → 4,458 delivered, every response grpc-status 0, zero errors): the work it cannot serve costs it ~5% of goodput, not 30%. The difference is where the unserved requests wait — Netty's event loop and HTTP/2 flow control keep them in the socket buffers, Jetty's thread pool pulls them in.
  • August under-measured gRPC by 2–4×. The k6 closed-loop "knee" at ~2,140 rps was the driver. The server's real unary capacity per core is ~4,600 rps on realistic bodies and ~10,500 on tiny ones, which also moves the August streaming-vs-unary ratio (7.5×) down toward 2–3× before Phase C measures it directly.

Disclosures: CPU per request is the arm's cgroup delta over delivered responses, so it includes the kernel's share of the arm's socket work; the tiny-tier knee comes from the fork session's two-worker cross-check (30 s steps, no cgroup counters), the ladder's own tables stop at 8,000; per-step JIT outliers (R7 2,400, R7b 3,600) are visible in the tables and excluded from the readings; grpc-netty has no per-connection stream cap where Jetty has H2C_MAX_STREAMS=1024 — with 8 connections × 512 client streams neither cap bound these runs.

Phase C — interaction model: unary vs grpc-jvm stream (R4, R8)

Run 2026-09-07, chart 0.2.6, Nighthawk P2 (50dce0eb): --grpc-stream opens N persistent bidi streams to Greeter/Chat before the step starts, schedules --rps messages per second in aggregate across them, and measures each message send→echo (benchmark_stream.message_latency). delivered/s is stream_messages_received over the step; knee/s is stream_deferred (sends that found the stream's 256 in-flight slots busy). Every stream must close grpc-status 0 with no resets, and sends must match the schedule (else the step is flagged client-limited): no step below tripped either check. All streams multiplex on one HTTP/2 connection (Nighthawk's --connections is a cap). Same warmup, steps and arm as Phase B; the arm's Chat handler echoes the same payload the unary SayHello does.

R4 — 20 streams, tiny (nh-grpc-jvm-grpc-stream-tiny-09071155)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.3517.6796.440.00.6721.218149
400400.01.209.0132.370.00.3550.421151
800800.01.077.9021.700.00.2450.221152
12001200.01.0922.4156.370.00.2402.518155
16001599.91.029.5425.730.00.1780.023156
20001999.90.9812.1952.420.00.1620.023157
24002399.91.0111.7037.970.00.1460.122157
32003199.90.9712.5629.310.00.1290.022157
40003999.91.0016.1645.320.00.1130.121157
48004799.91.0523.0758.160.00.1030.122158

R4b — 40 streams, tiny, 4,000→16,000 (nh-grpc-jvm-grpc-stream-tiny-09071218)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.3513.6794.690.00.7001.117140
40003999.71.24663.72893.320.00.1322.018153
60005999.61.0432.75257.760.00.0920.718155
80007999.71.0556.80192.270.00.0710.023155
100009999.61.1439.22272.840.00.0600.023156
1200011998.71.2173.13181.160.00.0520.016156
1400013999.61.3066.16284.410.00.0460.021157
1600015998.91.44190.82329.320.00.0410.021158

No knee: 16,000 msg/s delivered in full at p50 1.44 ms with nothing deferred and the arm at ~0.66 of its core (CPU per message still falling, 0.041 ms). The 4,000 step's p99 (664 ms) is the first-step JIT outlier seen in every fresh-pod run at the jump from the 200/s warmup. R4c extends the ramp to 32,000.

R4c — 40 streams, tiny, 18,000→32,000 (nh-grpc-jvm-grpc-stream-tiny-09071319)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.3717.4071.620.00.6740.517141
1800016592.42.091569.654424.471405.40.0474.125170
2000019994.61.79130.88360.450.00.0360.025170
2200021998.62.01157.48236.200.00.0340.024170
2400023998.82.28149.11257.610.00.0310.021171
2600025957.62.68267.60430.9241.00.0300.021171
2800027968.92.98208.89401.9529.60.0290.125171
3000029676.83.64406.73471.27282.70.0270.128172
3200031558.44.25449.35648.28439.60.0260.128173

The arm is not CPU-bound anywhere in this range: CPU per message falls to 0.026 ms (~0.82 of the core at 32,000) and throttling stays under a second. Delivery holds at 98.6% at 32,000 with p50 4.3 ms; the deferrals from 26,000 up are the client's per-stream in-flight window (256) meeting the tail — at 800 msg/s per stream a p99 of 0.45 s means ~360 in flight, so sends wait on the stream, not the server. Sends kept the schedule at every step (no client-limited flag). Tiny-tier streaming capacity is therefore > 30,000 msg/s per core, with the latency knee (p99 crossing 400 ms) at ~30,000; the 18,000 step is the first-step JIT outlier.

R8 — 20 streams, realistic (nh-grpc-jvm-grpc-stream-realistic-09071237)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.631349.192772.300.01.0198.818160
400400.01.4617.1356.430.00.5550.719164
800800.01.3214.1747.390.00.3870.219164
12001200.01.3329.3376.260.00.3430.722165
16001599.91.3521.6654.280.00.2990.023166
20001999.91.44232.28442.300.00.2820.924169
24002399.91.5481.74252.560.00.2530.121169
32003199.52.10334.94429.700.00.2130.721170
40003999.82.30222.55362.560.00.1840.424171
48004797.82.91297.07766.671.50.1620.424172

Delivered in full to 4,800 msg/s (1.5/s deferred at the top) with the arm at ~0.78 of its core. p50 stays under 3 ms; the p99 band from 2,000 up (200–330 ms) is wider than tiny's at the same rates and, with throttling under a second per step, reads as per-stream buffering of 1 KB messages behind HTTP/2 flow control rather than CPU. R8b looks for the knee.

R8b — 40 streams, realistic, 4,000→16,000 (nh-grpc-jvm-grpc-stream-realistic-09071341)

A first R8b (…-09071300) was voided: a peer session ran three 30 s Jobs against the arm during its 10,000–16,000 steps. This is the redo; its uncontaminated steps reproduce the voided run's shape (8,000 → 7,766 delivered there, 7,850 here).

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.631562.843231.840.01.0123.417155
40003893.53.115532.818384.94106.20.2034.321173
60005997.36.51275.01817.660.00.1380.322176
80007849.593.721518.675462.03148.90.1120.518181
100008173.3118.314741.4018112.051797.40.1080.120177
120008139.8273.274612.6926017.273766.70.1080.120188
140008191.3360.273172.2024009.245716.40.1070.419189
160008159.2162.233487.3021592.287751.70.1070.324192

The realistic streaming knee: 6,000 delivers in full at p50 6.5 ms; 8,000 delivers 7,850 with p50 94 ms and the first deferrals; from 10,000 up the arm holds a flat ~8,150–8,200 msg/s plateau whatever is offered. It is not the cgroup quota — CPU per message sits at 0.107 ms, ~0.87 of the core, and throttling stays under a second — it is the single event-loop thread the :direct executor runs everything on, decoding and re-encoding a 1 KB message per echo. stream_write_blocked (Envoy's connection write buffer at its high watermark) climbs from 16,040 events at 12,000 to saturation at 16,000: the backpressure is at the connection, which is where a streaming server should push it.

The collector flags the 12,000–16,000 steps unhealthy (streams closed without a grpc-status: 0, 27, 32 of 40). The Job log explains it — "13 gRPC stream(s) still open after the 500 ms drain window": with thousands of messages queued per stream the client's half-close-and-drain gives up before the echoes arrive, and the stream ends without a status. A harness artifact of overload (a longer --stream-drain-duration would clear it), not a server fault: sent and received differ by the in-flight tail only (903,760 vs 899,481 at 12,000) and stream_resets is 0 throughout. The 4,000 step is the first-step JIT outlier.

Phase C — what the interaction-model switch buys (grpc-jvm unary → stream)

Same arm, same core, same payload echoed per message, same client budget (4,096 in flight; streams add a 256 in-flight cap per stream). The switch replaces one HTTP/2 stream per request with N persistent bidi streams and a message per request. Unary numbers from Phase B; streaming from the runs above; "at matched rate" pairs steps at the same offered rate.

unarystream (20 / 40 streams)switch buys
tiny knee / plateau (per s)~10,500 / ~10,700latency knee ~30,000 / > 31,500, arm at 0.82 core~3×
tiny CPU per message at 4,800 / 8,0000.138 / 0.095 ms0.103 / 0.071 ms25% cheaper
tiny p50 / p99 at 4,800 (ms)2.21 / 80.71.05 / 23.1
tiny p50 / p99 at 16,000 (ms)— (past knee)1.44 / 191
realistic knee / plateau (per s)4,000 / ~4,600~6,500 / ~8,2001.8×
realistic CPU per message at 4,0000.216 ms0.184 ms15% cheaper
realistic p50 / p99 at 4,000 (ms)10.4 / 7822.30 / 223
realistic p50 / p99 at 2,400 (ms)4.14 / 2,635 (JIT outlier) → 3.28 / 219 at 2,8001.54 / 81.7
RSS at plateau (MB)~195~175–190
  • Streaming is worth 1.8× more capacity on 1 KB messages and ~3× on tiny ones, on top of unary gRPC, at 15–25% less CPU per message and with p50 at or under 3 ms all the way to the knee. The gain is the per-request overhead unary cannot amortize — stream setup, headers, trailers, the per-RPC bookkeeping in grpc-java — which is a fixed cost that matters more the smaller the message: 26 µs per tiny message at 32,000/s versus 107 µs per 1 KB message at the realistic plateau.
  • The realistic ceiling is the event loop, not the quota. At ~8,200 msg/s the :direct arm runs its one event-loop thread at ~0.87 core with the cgroup never throttling. That is the cost of :direct (no executor hand-off, so no parallelism either) on a 1-CPU pod; on a pod with N cores and N event loops it is N× this number, which unary — bound by per-request work spread across the same loops — would also scale.
  • August's ratios, corrected on one instrument. August put streaming at 7.5× unary gRPC and 16× REST; measured with the same Nighthawk on both sides it is 1.8× unary and 11× REST on the realistic body (~3× and

    32× on tiny). The difference is entirely August's under-measurement of unary gRPC by the k6 driver. Streaming's absolute numbers (August 15–16k tiny at 40 streams) were driver-bound too: it is > 30,000.

  • Overload behaviour is the best of the ladder: a flat plateau at any offered rate, zero errors, zero resets, backpressure at the connection. The only casualty is the harness's own drain window.

Disclosures: all streams on one HTTP/2 connection (Nighthawk's --connections is a cap); 256 in flight per stream, which shapes the deferrals from 26,000 up on tiny (tail × per-stream rate); the 0.5 s drain leaves streams unclosed past the realistic knee, flagged in the tables; the Clojure stream_driver cross-check (streamCheck) was not run — the fork's own P2 acceptance against this arm (30 s steps, 2 workers) is the independent cross-check and agrees at every shared rate (16,000 at p50 1.85 ms there, 1.44 ms here).

Re-baseline, 2026-09-07/08 — VT default, agent-free images, compiled codec

Phases A–C measured the :direct executor on chart 0.2.6, whose JVM images loaded the Pyroscope agent unconditionally and whose codec went through protobuf-java's DynamicMessage. Three things changed after that, and all three move the gRPC rows:

  • grpc-java's position on :direct. It will not be optimised further for lack of use, so the ladder's baseline executor is now the library default (virtual threads); :direct becomes the tuned variant.
  • The agent was loaded even when disabled (-javaagent in the image entrypoint). A loaded JVMTI agent turns on the JVM's virtual-thread transition hooks (JvmtiThreadState per mount, VTMS_transition), which cost only the VT arm. Chart 0.2.8 injects the agent through JAVA_TOOL_OPTIONS when profiling.enabled, and not otherwise.
  • clj-protobuf 0.2.0/0.2.1's descriptor-compiled codec replaced DynamicMessage with per-descriptor reader/writer tables over CodedInput/OutputStream.

Everything below is the same arm, node, client settings, warmup and step length as Phases A–C. Chart 0.2.8 = clj-protobuf 0.2.1 (protobuf-java 4.35.1); chart 0.2.9 = 0.2.2 (4.36.1); both agent-free.

VT (library default), unary (nh-grpc-jvm-grpc-unary-realistic-09080033)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.02.124626.584956.090.01.68915.720132
10001000.02.2024.2371.600.00.6600.820132
20001999.93.6047.55111.670.00.4341.319133
24002399.74.2569.19114.940.00.3721.019135
28002799.65.16113.30270.390.10.3271.723137
32003178.77.721841.234214.8821.10.2957.149178
36003598.47.41107.07222.350.10.2623.050177
40003999.710.24322.81684.030.20.2365.136178
44004210.021.852155.872943.61188.90.22812.938178
48004678.939.831647.051966.6083.90.20811.939176

VT (library default), 40 streams (nh-grpc-jvm-grpc-stream-realistic-09080057)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.74941.881076.950.01.13815.917130
20001994.72.161070.071191.050.00.35413.117182
40003999.03.331080.891263.670.00.2107.116149
60005999.86.82528.11620.300.00.1525.318150
80007613.11078.392186.152497.05311.10.12430.522176
100007866.01216.352209.872380.532040.90.12332.723179

:direct, unary (nh-grpc-jvm-grpc-unary-realistic-09080112)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.772222.194778.890.01.14311.919134
1000999.91.4816.0152.150.00.4550.319135
20001999.91.8950.53136.360.00.3410.218136
24002399.72.721274.612256.270.30.3209.028153
28002799.92.52139.11250.740.10.2670.328153
32003199.53.08345.31810.350.20.2411.228153
36003599.83.45182.28305.000.20.2190.229153
40003959.44.671308.233401.0640.60.2030.830158
44004399.74.81188.38347.820.20.1870.631158
48004742.06.531148.323069.8457.50.1771.131162

:direct, 40 streams (nh-grpc-jvm-grpc-stream-realistic-09080136)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.53150.011071.050.00.8283.417127
20001999.81.371668.223581.410.00.2783.318131
40003999.81.70157.69245.710.00.1741.418137
60005999.42.62164.15209.890.00.1271.015138
80007989.65.62387.30923.601.20.1000.818141
100009976.032.22285.741777.2721.70.0850.018142

What the re-baseline says

VT (default):directdirect's advantage
unary CPU/req at 2,0000.434 ms0.341 ms27%
unary CPU/req at 4,8000.208 ms0.177 ms18%
unary p50 at 4,80039.8 ms6.5 ms
stream knee / plateau6,000 / ~7,900 msg/s≥ 10,000 (still delivering)~25% capacity
stream CPU/msg at 6,0000.152 ms0.127 ms20%
RSS at plateau~180 MB~142 MB
  • The executor gap is real but smaller than the confounded runs showed. With the agent out of the image it is 15–27% CPU per request on unary and ~25% capacity on streams, not the 40–70% measured on chart 0.2.6. What survives unambiguously is the tail: VT's p50 is roughly double at every matched rate and its throttling an order of magnitude higher near the knee. For a 1-CPU pod with provably non-blocking handlers, :direct remains the better setting; for anything that may block, VT is the only safe one and now costs less than the ladder implied.
  • The ladder's gRPC rows improve. :direct streaming reaches 9,976 msg/s per core at 0.085 ms/msg where Phase C measured ~8,200 at 0.107, and unary holds 4,742 at 0.177 ms where Phase B's plateau was ~4,600 at ~0.20. The protocol and interaction-model conclusions are unchanged in direction and slightly larger in magnitude.

Compiled codec, measured on the same image (chart 0.2.7, -Dclj-protobuf.codec=dynamic as the control)

arm / modestepDynamicMessagecompiledsaving
:direct, stream3,500 msg/s0.211 ms/msg0.18413%
:direct, stream5,000 msg/s0.1610.14411%
:direct, unary2,000 rps0.359 ms/req0.3386%
:direct, unary3,000 rps0.3000.2739%
VT, stream2,000 msg/s0.4140.34916%
VT, stream5,000 msg/s0.2190.18117%
VT, unary2,000 rps0.466 ms/req0.4445%
VT, unary3,000 rps0.3580.31612%

The frame diff is the stronger evidence: on the :direct streaming path com.google.protobuf falls from 26% of samples to 2%, FieldSet, SmallSortedMap and Descriptors$…getFeatures disappear entirely, and the top cost becomes syscalls at 35%. The codec is no longer the bottleneck there — the socket is. On VT the compiled codec also moves the streaming knee past 5,000 msg/s where DynamicMessage collapsed.

Two smaller results from the same night:

  • protobuf-java 4.36.1 vs 4.35.1 (chart 0.2.9 vs 0.2.8, four runs): no measurable difference. Identical CPU per request on :direct at every matched step, 0–5% in 4.36.1's favour on VT. Take the bump for its own sake, not for throughput.
  • Netty's leak detector (io.netty.leakDetection.level=disabled vs the default): 4% of CPU per request at 1,000 rps, 2% at 2,000, ~1% at the knee, nothing measurable on streams. Worth setting explicitly; not a headline.
  • Pinning the virtual-thread scheduler to one carrier (jdk.virtualThreadScheduler.parallelism=1): no effect (0.679/0.448/0.315 vs 0.700/0.444/0.316 ms/req; streams likewise). The VT cost is per-mount, not carrier contention. Note: Helm's --set-string kept only the first flag of the pair, so maxPoolSize was left at its default; the scheduler still ran a single carrier.

Native image, for completeness (chart 0.2.6, VT default, DynamicMessage)

Four runs, soak/results/2026-09-07-native/: realistic unary knee ~1,000 rps and plateau ~1,650–1,700 at ~0.6 ms/req; realistic streaming plateau ~2,750 msg/s at 0.36 ms/msg; tiny streaming ~10,000–10,300 at 0.097; tiny unary knee ~2,800. RSS 39–60 MB below the knee against the JVM's ~140, no JIT warmup at any step, and heap growth to ~200 MB at the top tiny-unary steps. Roughly half the JVM-VT arm and a third of :direct on this hardware; it was not re-run on the agent-free charts because it has no JVM and no agent.

Direct linking at runtime, 2026-09-08

clojure.lang.Var.getRawRoot was 3.2% of samples under the compiled codec — every cross-namespace defn call pays one. rules_clj 0.2.4 adds a build-time direct_linking attribute for that, but it cannot reach the code that matters here: clj-protobuf is published to Clojars as source (its 0.2.2 jar holds ten .clj entries and no classes), so its namespaces are compiled by Clojure at load time and were never compiled ahead of time. A caller that is compiled ahead of time may not link into them at all — the class a direct call names exists only once the callee is loaded — which rules_clj refuses at build time.

What does reach it is the same option applied to the runtime compiler: -Dclojure.compiler.direct-linking=true on the arm's JVM. Everything Clojure compiles at load time then emits direct calls, and the references resolve because caller and callee share one classloader. Chart 0.2.9, stock image, one environment variable, against an identical baseline run in the same hour.

unary, baseline (nh-grpc-jvm-grpc-unary-realistic-09081120)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.833189.376125.520.01.25312.026180
10001000.01.5222.1079.130.00.4610.926181
20001999.91.9746.54122.100.00.3350.921185
30002916.04.032297.959450.2981.30.28326.629207

unary, linked (nh-grpc-jvm-grpc-unary-realistic-09081055)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.772735.474257.740.01.1939.219178
10001000.01.5421.9865.800.00.4370.419179
20001999.91.8044.85105.960.00.3240.827182
30002999.92.43104.82230.930.10.2451.829188

40 streams, baseline (nh-grpc-jvm-grpc-stream-realistic-09081132)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.54145.001005.060.00.9312.320206
20001999.81.56723.392270.430.00.3159.720218
35003499.81.5387.03261.190.00.1911.129224
50004998.02.09125.39203.190.00.1460.629226

40 streams, linked (nh-grpc-jvm-grpc-stream-realistic-09081108)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.5230.47698.710.00.7881.725167
20001999.91.30765.462349.730.00.2604.228192
35003498.81.47689.472592.601.00.1861.428197
50004999.81.7391.49221.760.00.1360.128199

What the property buys

workloadstepbaselinelinkedsaving
unary1,000 rps0.461 ms/req0.4375%
unary2,000 rps0.3350.3243%
unary3,000 rps0.2830.24513%
stream2,000 msg/s0.315 ms/msg0.26017%
stream3,500 msg/s0.1910.1863%
stream5,000 msg/s0.1460.1367%

Var.getRawRoot falls from 3.2% of samples to 1.12%, the remainder being clojure.core's own calls, which clojure.jar already ships linked. p50 improves at every matched step (unary at 3,000: 4.03 → 2.43 ms), and the linked arm delivers all 3,000 rps where the baseline sheds to 2,916. clj-protobuf's own suite — 56 tests, 637 assertions — passes under the property, and the library defines no dynamic vars, no ^:redef fns and never uses alter-var-root or with-redefs, so nothing in the measured path depends on late binding.

The two levers cover disjoint code, and neither covers both. The runtime property links what Clojure compiles at load time: clj-protobuf's codec, Pedestal, jsonista. rules_clj's direct_linking attribute links what the build compiles ahead of time: clj-grpc's own namespaces. Today the attribute cannot be used here at all — a single call in clj-grpc.service into clj-protobuf's runtime disqualifies the whole target, verified against rules_clj main — so the property is the only lever that reaches the hot path. The mechanism that would cover both is compiling a Maven source jar inside the consumer's build, sketched on rules_clj#18 and unbuilt.

Caveat for anyone extending this: the property is process-wide and changes late binding for every namespace loaded from source, so a service that redefines at runtime must not take it without checking.

A caveat on image digests — corrected 2026-09-08

An earlier version of this section said the native image is not reproducible while "the JVM and REST images are reproducible". The second half is wrong, and the way it is wrong matters more than the fact.

No image here is byte-reproducible. The same source built twice into separate output bases produces deploy jars differing in 42 entries by CRC, all anonymous function classes in clj-grpc.soak.stream-driver, clj-grpc.coldstart.steady and clj-grpc.coldstart.measure — most likely a persistent compile worker's JVM-global counters, unproven. The native image drifts most visibly, but it is not special.

And a published chart version is not immutable. Every merge to main runs the image build, re-pushes any image whose fresh digest differs from the registry's, and republishes the chart at whatever version Chart.yaml names — overwriting it in place. Combined with the above, a docs-only merge can redefine a chart that has already been published and measured against. Chart 0.2.11 pinned soak-grpc-native@sha256:eed3976a when it was published and pins 20afcb26 now, changed by two later documentation merges.

So "pin the chart version and you pin the images" does not hold, and every result in this document was recorded against a chart version rather than a digest.

What that costs these results: nothing, checked rather than assumed. For charts 0.2.8, 0.2.9, 0.2.10 and 0.2.11, the JVM and REST digests each chart pins today are identical to the ones reported when it was published; only the native arm's digest moved (in 0.2.9 and 0.2.11). Every comparison in this document runs on JVM arms, so each pair ran on the images its chart still names:

chartsoak-grpc-jvmsoak-grpc-jvm-interopsoak-restsoak-grpc-native
0.2.8b7d8ba2213895fa8a815b531
0.2.935b0180813895fa850e8c119dc475350
0.2.10313ed48013895fa8526cec7e
0.2.11313ed48079edecfa13895fa8eed3976a20afcb26

The native set elsewhere in this document ran on chart 0.2.6 as a single self-consistent group and was never diffed across charts, which was already its stated caveat.

For future campaigns, record the image digest with each run rather than the chart version.

Both fixes, done — and what survived them

The two durable options above were framed as a choice. Both were taken, and between them they leave one residue worth stating.

The compile worker was the cause, and it is fixed. Not the multiplexing I first guessed: HotSpot draws identity hash codes from a per-thread PRNG whose sequence depends on how much the JVM has already done, and Clojure's compiler walks maps keyed on those identities when it decides where to emit locals-clearing instructions. A warm worker therefore emitted different bytecode depending on which targets a namespace was compiled beside — same instructions, different order, only in fn classes with let bindings. rules_clj 0.2.5 pins the identity hash in the worker JVM. Verified twice: two builds in separate output bases now produce one digest locally, and on the arm64 runners the first graph-invalidating merge after it (ccaa8d7) rebuilt everything and changed one image of four — the native one. The JVM and REST images are byte-stable across independent builds, which is what the corrected paragraph above said they were not.

A published chart version can no longer be redefined. The build refuses to publish a chart version that already exists unless the render is identical, and it refuses before pushing any image, so a refusal costs a red build and nothing else. The remedy it prints is to bump Chart.yaml.

What survived: GraalVM. The native image is still not reproducible, and the version pinned in MODULE.bazel at the time of writing — GraalVM CE 21.0.2, checked 2026-09-08 — offers no option for it: nothing matching reproduc, deterministic or SOURCE_DATE among its 1345 expert options. That sentence is about one toolchain version and should be rechecked whenever it is bumped; later GraalVM releases may well have the option. With the chart pinning that image, every graph-invalidating change rewrote the render and forced a chart bump unrelated to the change; two bumps (0.2.14, 0.2.16) were spent that way. So the chart no longer pins the native image at all: grpc-native carries an empty image, renders nothing by default, and a native campaign passes the digest it means (NATIVE_IMAGE=…@sha256:…, see soak/README.md). The three arms the chart does pin render identically across independent builds, checked the same way the drift was found.

Net effect for a reader of this document: a chart version now names one thing for the JVM and REST arms, and native runs name their digest directly — which is what the caveat above asked for.

The typed read path, 2026-09-08/09 — interop stops paying for its reads

protoc-gen-clojure 0.6.0 adds a typed READ path to interop=true: proto->X calls the generated .getX accessors behind an instanceof guard, where every previous version routed reads through codec/get-field on both arms. The write path was already typed. This is the change the last measurement argued for — interop then won p50 by 10–55% while costing 2–9% more CPU, and reads were the untouched half.

Chart 0.2.18 rebuilds only the interop arm on 0.6.0. The control image is bit-identical to the one charts 0.2.15 and 0.2.17 pin, so the generated code is the only variable. Both arms on the library-default executor, profiled, matched steps. Two independent pairs, run 40 minutes apart, because the effect is the size of this harness's noise.

unary, realistic — pair 1 (control, then interop)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.02.073549.564620.030.01.69623.428182
1000999.92.2223.8846.240.00.6471.029185
20002000.03.5041.2676.540.00.4231.529188
30002855.317.434130.605489.56144.50.33031.862233
offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.02.253968.996084.360.01.86221.023178
10001000.01.7919.8837.010.00.6930.731182
20001999.92.4039.12124.470.00.4451.431183
30002999.84.75326.09558.010.10.3095.924193

unary, realistic — pair 2

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.02.103695.054673.240.01.68530.724177
10001000.02.1625.3354.280.00.6441.526179
20001999.93.6787.41375.290.10.4266.530185
30002999.45.0684.30167.280.10.3033.530186
offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.02.213764.784828.690.01.84843.927176
10001000.01.9119.3348.550.00.6991.430179
20001999.92.6152.37142.870.00.4442.830182
30002998.63.6776.08118.470.60.31212.122182

40 streams, realistic — pair 1

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.70445.33799.770.01.1108.023168
20001999.91.7844.6492.430.00.3241.627171
35003499.92.66424.44481.350.00.2265.427180
50004999.73.84164.52360.020.00.1690.526184
offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.78339.90704.680.01.17813.024172
20001999.91.5553.59140.130.00.3411.824177
35003499.81.93174.97267.340.00.2291.130186
50004999.42.59201.86432.390.00.1710.733195

40 streams, realistic — pair 2

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.74392.28584.940.01.11010.420175
20001999.92.05417.25592.410.00.3457.924183
35003499.82.64100.25188.140.00.2210.824186
50004999.54.41384.50516.370.00.1742.733197
offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.78527.52811.960.01.22516.824169
20001999.81.63121.85257.060.00.3529.426179
35003499.61.7466.26121.700.00.2302.127184
50004999.82.2490.01161.740.00.1751.233187

What the typed read path bought

Each cell is pair 1 / pair 2.

workloadstepcodec CPUinterop CPUcodec p50interop p50
unary1,000 rps0.647 / 0.644 ms0.693 / 0.6992.22 / 2.16 ms1.79 / 1.91
unary2,000 rps0.423 / 0.4260.445 / 0.4443.50 / 3.672.40 / 2.61
unary3,000 rps0.330* / 0.3030.309 / 0.31217.4* / 5.064.75 / 3.67
stream2,000 msg/s0.324 / 0.3450.341 / 0.3521.78 / 2.051.55 / 1.63
stream3,500 msg/s0.226 / 0.2210.229 / 0.2302.66 / 2.641.93 / 1.74
stream5,000 msg/s0.169 / 0.1740.171 / 0.1753.84 / 4.412.59 / 2.24

* The control's 3,000 step in pair 1 was degraded — 31.8 s throttled, heap 62 MB, p99 4.1 s, 2,855 delivered — and its profile shows 27.4% of samples in JIT and GC against interop's 12.4%. Pair 1 alone would have supported "interop is 6% cheaper at the knee"; pair 2, where the same step is healthy, shows interop 3% dearer. That inversion was the control having a bad step, not a result — which is what the second pair was for.

  • Unary: interop costs 3–8% more CPU and returns 15–30% lower p50, consistently across all three steps in both pairs. What remains is the write side: a generated builder allocates a builder and a message per call where the compiled codec writes into a slot array it owns, and the collector pays for that off the request path — which is also why latency improves while CPU does not.
  • Streaming: a wash on CPU, reproducibly — 1–4% across both pairs, inside the noise, with the same 20–45% p50 advantage. Typed reads closed the whole penalty here.
  • Run-to-run variance is now bounded: matched steps agree within 1–3% between pairs, so a 3–8% effect is real and a 1–4% one is not distinguishable from noise.

Where the work went, from the frames

Self time on the 5,000 msg/s streaming windows:

layercontrolinterop
clj-protobuf codec9.4%3.2%
protobuf-java + generated classes3.0%9.1%
sum12.4%12.3%
GC + JIT8.8%10.2%
syscalls30.0%33.2%

The typed path does not remove conversion work; it moves it, almost exactly one for one, out of the codec's closures into protoc's generated accessors. That is what interop=true now is on a service: the same CPU spent elsewhere, bought back as latency because less of it sits inline on the request.

What this settles

interop=true is a latency-for-CPU trade on unary and a free latency win on streaming. It is not the ceiling the descriptor-compiled codec was aimed at: the codec matches it on streaming CPU and beats it on unary CPU while giving up p50. Which arm a service prefers follows from what binds it — a 1-CPU pod near its knee keeps the codec, a latency-sensitive service with cores to spare takes interop — and the two are now within a few percent of each other rather than a factor apart, which is the real change since 0.5.1.

Two cores, 2026-09-09 — capacity follows connections, not cores

Superseded in part by the connection sweep below, which ran the deliberate experiment this section could not. The single-connection cap holds exactly as stated. The general rule does not: on this arm the climb stopped at two connections, with half a core still idle, and further connections cost CPU for no throughput. What stops it there is not established — see that section.

Every number above this line is one core. That is the right shape for comparing arms and the wrong one for sizing a pod, so this section asks the question the plan never did: what happens with two.

Chart 0.2.18 with resources.cpu=2, the arm moved to worker-02 (the only node with 2 CPU free once the native arm stopped rendering) and the REST arm scaled away, since the chart applies resources globally and a 2-CPU REST pod would not fit on worker-04. Driver unchanged on worker-05. Both executors, unary and 40-stream, profiled.

unary, realistic — :direct then virtual threads

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.8918.91376.860.01.3660.131191
20002000.01.5524.8595.830.00.3390.028192
40003999.82.80211.72522.780.10.2140.148219
60005999.15.48249.28449.920.80.1480.048238
80007951.329.15352.57799.7439.20.1280.280264
100009725.883.30676.891099.89263.90.1370.393326
offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.02.22335.63608.500.01.9520.431202
20001999.92.6928.2986.170.00.5320.039203
40003999.35.31271.58503.630.40.3130.042254
60005876.513.201141.511564.80120.70.2541.972355
80007958.131.98584.55879.5640.80.1970.676357
100009348.7324.21802.781005.16626.30.1944.876401

40 streams, realistic — :direct then virtual threads

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.557.82157.870.00.9220.131196
40003999.01.40258.59586.090.00.1940.236203
80007999.32.20145.32387.870.20.1010.038223
1200011885.65.77385.603302.49113.80.0750.042299
1600014737.9168.421431.705746.201221.20.0600.047303
2000015249.5237.041913.1917514.364668.10.0590.083304
offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.8310.2737.510.01.2500.331195
40003995.22.552462.712656.174.60.2801.527259
80007997.55.68425.16567.870.00.1510.127272
1200011552.4163.101247.671462.70377.10.1200.736355
1600013898.6513.671213.071303.902027.00.1092.237370
2000014438.1647.201211.891319.045457.70.1092.149371

The finding: the second core is not used by a single connection

cpu ms/req x delivered/s is cores consumed. Reading that rather than throughput is what makes the mechanism visible:

offered:direct unary (8 conns)VT unary (8 conns):direct stream (1 conn)VT stream (1 conn)
low0.86 cores1.250.781.12
mid1.021.570.891.39
top1.331.810.901.57

:direct streaming is flat at 0.78–0.90 cores across the whole ramp, on a two-core pod, no matter what is offered. The second core is idle. Unary on the same executor reaches 1.33.

The connection counts, read from upstream_cx_total rather than from the flags, say why — and they make the case within a single run rather than across two:

:direct unary stepconnections openedcores
2,000 rps10.68
6,000 rps50.89
10,000 rps81.33

Unary's pool grows with load because run.sh passes --max-concurrent-streams 512 against 4,096 in flight, so the client opens another connection every 512 outstanding requests. The streaming branch passed no such flag, leaving it at 2,147,483,647, so all 40 streams rode one connection at every rate — and :direct never exceeded 0.90 cores. Both runs used --concurrency 1, one client worker, so this is not the client's worker count showing through.

A connection binds to one event loop, and under :direct that loop also runs the handler, so one connection means one core. A client holding a single multiplexed connection to a four-core pod will use one core of it. Capacity scales with connections rather than with cores — and this is a property of connections rather than of streaming, since the unary arm shows the same relationship inside one run as its pool grows. (The sweep below bounds that scaling: on this arm it stops at two connections, for reasons the sweep could not pin down.)

Virtual threads do spread a single connection — 1.12 to 1.57 cores — because handlers run off the loop. And it still loses: VT spends 1.57 cores to deliver 14,438 msg/s where :direct spends 0.90 to deliver 15,250. The extra core goes into mount and unmount overhead rather than into work, which is the same conclusion the one-core runs reached, now with the mechanism visible.

What two cores changed, and what it did not

  • Scaling is sublinear: unary 4,700 → ~6,000 rps clean (knee 6,000 → 8,000), streaming ~10,000 → ~11,900 msg/s. Roughly 1.2–1.6x, not 2x. For streaming the extra throughput is GC and JIT moving off the request path onto the idle core, not parallel request service — which the cores-consumed column shows directly.
  • The executor gap widens with cores rather than closing: VT costs 44–72% more CPU per request on unary and 45–80% on streaming at two cores, against 15–27% at one. Each request still pays a mount and unmount however many carriers exist.
  • Throttling stops being the limit. At one core, throttled seconds was the first thing to move; here it is ~0 everywhere and the knee is latency growth instead.

What this section does not establish

The deliberate connection experiment did not run in this campaign. run.sh never passed --max-concurrent-streams in grpc-stream mode, so the setting was inert and the "4 connections" configuration opened one, exactly like its control (upstream_cx_total=1 in both). Fixed in chart 0.2.19, and the 1/2/4/8-connection sweep ran the same day — see the next section — driven by --max-concurrent-streams rather than --connections (which is a circuit breaker: exceeding it produces upstream_cx_overflow rather than more connections).

The rule above therefore rests on observed connection counts and their correlation with cores consumed — including within the unary run — rather than on a deliberate sweep. One caveat carried from the fork session: connections are per client worker, so the count is concurrency × ceil((streams / concurrency) / mcs), and the effective per-connection limit is the lower of the client's mcs and the server's advertised SETTINGS_MAX_CONCURRENT_STREAMS. Assert on upstream_cx_total; do not compute it.

The connection sweep, 2026-09-09 — connections carry capacity, up to a ceiling that is not the cores

The section above ended by saying the deliberate connection experiment had not run: the knob was inert, so the rule rested on connection counts observed after the fact and their correlation with cores consumed. Chart 0.2.19 made --max-concurrent-streams reach the grpc-stream branch, so the experiment ran. It confirms the sharp half of the rule, and puts a boundary on the general half that the correlation could not have shown.

Setup. One arm — grpc-jvm, EXECUTOR=direct, 2 CPU / 2 GiB Guaranteed, worker-02, REST scaled to 0 — realistic tier, 40 streams, --concurrency 1 on every run, profiling on exactly as in the two-core campaign so the agent is a constant and not a variable. The single lever is --max-concurrent-streams, which decides how many connections carry those 40 streams: 40 → 1, 20 → 2, 10 → 4, 5 → 8. Nothing client-side moves. The conns column is upstream_cx_total read back from the run (soak/collect.sh, PR #73), asserted rather than computed, and it reads 1, 2, 4 and 8 on every step of every run — the flag did what it was set to do.

The harness reproduces. conn1 carries one connection for the same reason the two-core campaign's direct-stream did (there, 40 streams under an unbounded default; here, under an explicit 40), and the two runs — a day and a chart version apart — deliver 15,294 and 15,250 msg/s at the top of the ramp. 0.3% apart, so the rest of the sweep can be read against it.

Cores consumed at matched offered load

cpu ms/req × delivered/s, the same reading that made the mechanism visible before:

offered1 conn2 conns4 conns8 conns
4,0000.860.800.800.90
8,0000.790.941.031.10
12,0000.881.101.221.27
16,0000.901.311.421.45
20,0000.921.391.571.48

One connection is flat at 0.79–0.92 cores however hard it is pushed. The pod has two. That is the previous section's claim measured directly instead of inferred, and it is the part of the rule that holds without qualification: a connection binds to one event loop, :direct runs the handler on that loop, so one connection means one core — a client holding a single multiplexed connection to a four-core pod will use one core of it.

Where it stops

The 4,000–20,000 ramp finds the knee at one connection and runs out of room above it, so every other count was re-run at 20,000–36,000. Saturated throughput is the highest rate the arm actually delivered anywhere in its runs:

connssaturated msg/scoresmsg/s per core
115,2940.9216,660
222,8661.4915,390
422,5911.6913,340
823,1331.6713,890

Capacity stops climbing at two connections. The second connection is worth +50% throughput. The third through eighth are worth nothing: 4 and 8 connections deliver within 2% of what 2 delivers, while consuming 13% more CPU to do it (1.67–1.69 cores against 1.49). Two connections is also the cheapest way to reach the ceiling, at 15,390 msg/s per core.

So the rule the previous section stated as "capacity follows connections, not cores" is half right, and the missing half matters: more connections stop helping well before the pod runs out of anything. Connections past that point are not a smaller win — they are pure overhead, and on this arm they are worse than neutral, since they push it into throttling for no throughput.

The ceiling is not a CPU ceiling, and this section does not explain it. At its best step the two-connection arm delivers 22,866 msg/s on 1.49 of its 2 cores with 0.1 s throttled in 110 s — half a core idle, no quota pressure — and its two top steps are throttled 0.0 s while still not exceeding ~22,600. The 4- and 8-connection arms are throttled 3.6–6.4 s in every top step and land in the same place. So the coincidence between "two connections" and "two cores" is exactly that on this evidence: something caps this arm near 23,000 msg/s that is not the core count, not the CPU quota, and not the driver. Per event loop it is ~0.75 core, below even the 0.92 a single loop reached, which argues against a per-loop saturation story too.

What the evidence does place is the side. Nighthawk's stream_deferred rises to 5,000–13,000/s at those steps, so the client is being back-pressured rather than failing to schedule — the limit sits on the server side of the connection. Flow-control windows, the 40 × 256 in-flight budget and a contended lock are all live candidates, and separating them needs a profile of the ceiling steps, which this campaign did not take.

The knee agrees on where the step is. One connection stops delivering the offered load between 12,000 and 16,000; every other count holds it to 16,000 and breaks between 16,000 and 20,000, regardless of whether it has 2, 4 or 8 connections — the jump happens once, between one connection and two, and nothing after that moves it.

No step was client-limited. The driver's own cgroup counters, sampled every 10 s and aligned to each step's window, put it at 0.49–0.64 cores of its Guaranteed 1 CPU with throttling under 0.45 s per 110 s step, at every connection count. A client-side plateau would have shown here as the driver saturating; it did not.

Reading caveats

  • The first step of a ramp under-reads by ~10%. conn4 delivered 19,598 msg/s at 20,000 offered when it arrived there through 4,000→16,000, and 17,731 when 20,000 was the first step after the 200-rps warmup — same arm, same four connections, four times the server-side throttling. Five minutes at 200 rps does not warm a JVM for 20,000. Take plateaus from the upper steps of a ramp, never from its first.
  • These are saturated throughputs, not clean plateaus. Under this doc's plateau rule (knee/s below 0.1% of offered) all four runs plateau at 12,000: every connection count delivers the offered load cleanly that far, and they separate only past the knee. The ramp's 4,000-rps resolution is too coarse to place the clean plateaus apart, so the table above deliberately reports what the arm delivered under saturation instead.
  • The ceiling is this arm's, and unexplained. Two connections stopped the climb here with half a core spare; nothing in this sweep says the stopping point is the core count rather than a coincidence, and nothing says where a four- or eight-core arm would stop. Treat "two connections was enough" as a measurement of this pod, not a sizing rule.
  • The concurrency control did not run. Connections can be added two ways — more streams per client worker (this sweep) or more client workers — and running both would separate "a connection" from "a client event loop". --concurrency 2 with the spin idle strategy needs two Guaranteed cores for the Job, and neither candidate node had them free (worker-05 at 3,375m of 3,950m requested, worker-04 at 2,185m). The evidence that would have been at issue — client CPU — was flat across all four connection counts.

One fork detail, verified rather than assumed

run.sh skips its per-worker rate division for grpc-stream on the strength of a comment saying --rps is aggregate there. It is: on p2-grpc-stream, process_impl.cc logs "Global targets: {streams} gRPC bidi streams and {rps} messages per second" for stream mode against connections × concurrency and rps × concurrency for every other mode, and the client-worker path divides both options_.streams() / concurrency and requestsPerSecond() / concurrency behind a stream-mode guard. Both --streams and --rps are global in that mode; --concurrency would not have changed the offered load.

Logs and tables.md in soak/results/2026-09-09-connections/.

A shared monitor on the encode path, 2026-09-09 — a cap, but not the ceiling

The connection sweep left one question open: what holds a two-connection arm to ~22,900 msg/s while half a core sits idle and nothing is throttled. The clj-protobuf session answered a different question — why their own encode benchmark would not scale — and the answer was a candidate for this one.

They found two process-wide Collections.synchronizedMaps on the compiled codec's per-message path: one reached from .build via initialized?, one the parser registry on every decode. synchronizedMap takes the monitor on reads, so a cache hit still serializes. Their isolation is the convincing part — .build scales 0.31× from 1 to 8 threads while .buildPartial, identical but for that call, scales cleanly, and protoc's own .build gets 4.72×. Negative scaling is a monitor convoy and not much else.

A fully serialized section caps throughput independently of thread count, which is exactly the signature the sweep could not explain: 2, 4 and 8 connections landing within 2.4% of each other at three different CPU costs.

The test

The chart already carries an interop arm built from protoc-gen-clojure's interop=true output, which builds through protoc's generated classes and never touches either monitor. So: the same ramp, the same two connections, one arm each, paired in one session.

Two setup details decide whether this measures anything. The chart leaves the interop arm on the library default executor (virtual threads) while every number above was measured on :direct, so both arms were pinned to :direct — otherwise executor and codec move together. And the compiled arm was re-run here rather than compared against conn2-top from four hours earlier; it landed at 23,051 against that run's 22,866, 0.8% apart, which is the harness reproducing for the third time today.

offeredcompiledcoresms/msginteropcoresms/msgΔ tputΔ cpu/msg
20,00018,0561.500.08318,7931.450.077+4.1%−7.2%
24,00022,0191.520.06922,7411.410.062+3.3%−10.1%
28,00022,1591.460.06625,2871.470.058+14.1%−12.1%
32,00022,3931.480.06624,3861.390.057+8.9%−13.6%
36,00023,0511.500.06524,1611.380.057+4.8%−12.3%

Read the CPU column, not the throughput column. Both arms are past the knee at every step here, where delivered rate is noisy — hence the 3.3–14.1% swing. CPU per message is steady: interop is 10–14% cheaper at every step above the first. That is the shape a contended monitor produces, the compiled arm paying park/unpark that interop never pays.

It is the monitor, at frame level

The banked ceiling profiles show the call chain present in one arm and structurally absent in the other:

framecompiledinterop
java/util/Collections$SynchronizedMap.get0.85%absent
clj_protobuf/impl/message$initialized_QMARK_0.24%absent
clj_protobuf/impl/message/CompiledMessage.isInitialized0.14%absent
com/acme/greeter/Item.isInitialized (protoc's own)absent0.04%

Protoc's generated class does the same check about six times cheaper on-CPU, and without a monitor at all.

The other corroboration is a sign flip. At 1 CPU the same two images measured interop 3–8% dearer on unary and level on streaming. At 2 cores interop is 10–14% cheaper. A sign change between one core and two, on unchanged images, is hard to explain by anything except contention — which cannot exist on one core.

What this does not explain — the ceiling

Interop plateaus too: ~25,000 msg/s at 1.47 of 2 cores, half a core idle, zero throttling, while touching neither monitor. So the monitor is a cap and not the cap. Removing it is worth ~10% here, not the ~35% that reaching 2.0 cores would imply, and whatever holds two connections to ~1.47 cores sits upstream of both monitors.

That was predicted before the run by the session that found the lock, which is the main reason to trust the framing rather than the convenient reading: a severe first bottleneck hides whatever is behind it, and clearing it reveals the next one rather than the ceiling.

Version boundary

Everything above is chart 0.2.19, which pins clj-protobuf 0.2.2 on both arms (soak-grpc-jvm@sha256:4cfdadac, verified against the running pods rather than the chart). The fix landed upstream as 3ce5ed7 ("codec: no process-wide monitor on the per-message path", clj-protobuf #40) at 16:01 UTC — after the v0.2.4 tag, so it is unreleased, and chart 0.2.20 does not carry it. Upstream measures encode scaling 0.99× → 8.48× and single-thread throughput up 23%, since an uncontended monitor is not free either.

So this table is a before-number against a known defect. When 0.2.5 lands, the interop-over-compiled gap should shrink toward the 1–4% the 1-CPU runs showed; if it does not, the remainder is something other than the monitor.

One caveat that limits all of the above: the two arms differ in more than the monitor — different generated code throughout. What licenses attributing this gap to contention is the 1-CPU control where they measured level, plus the frame table, not the ramp alone.

Logs, tables and banked flamebearers (both arms and the driver, via soak/save-flames.sh) in soak/results/2026-09-09-lock-ab/.

The ladder — what is on the table for an existing REST service

Per core, 1-CPU pods, one instrument, each rung differing from the one below in exactly one thing. "Capacity" is the plateau of delivered requests or messages per second; "cost" is the arm's CPU per request at 600 offered (realistic) / 800 (tiny), where every arm is below its knee.

rungswitchrealistic (1.3 KB JSON / 1 KB pb) capacitycost at 600p99 at 600tiny capacitymigration cost
0REST HTTP/1.1 (today)~750 rps1.59 ms134 ms~925
1→ h2c~750 (collapses under overload)1.65 ms108 ms~925a config flag on the server; clients must speak h2c
2→ gRPC unary~4,700 (6×)0.56 ms13.7 ms~10,700 (11×)new clients, protobuf schema, serialization; API shape unchanged
3→ gRPC stream~10,000 (13×)~0.4 ms*~15 ms*> 31,500 (> 34×)API contract changes: persistent connections, message ordering, backpressure

* streaming at 600 msg/s is below any measured step (400: 0.555 ms, 800: 0.387 ms, p99 17 / 14 ms); interpolated. The gRPC capacities are the re-baselined :direct numbers from the section above (chart 0.2.8, compiled codec, agent-free); Phases B and C measured ~4,600 and ~8,200 on chart 0.2.6. With the library's default VT executor the same rows read ~4,700 unary (at 18–27% more CPU per request) and ~7,900 streaming.

The money is on rung 2. Rung 1 buys nothing and costs a little; rung 3 buys 1.8× more on top of rung 2 (3× on tiny) at the price of a different API contract. For a service at REST's knee today, moving to gRPC unary frees ~85% of its cores at the same load; streaming frees ~90%. What is not on the table anywhere in the ladder: the JIT warmup of a fresh 1-CPU JVM pod (minutes, every arm) and the first-step outliers it leaves, which are a deployment concern (warm before serving) rather than a protocol one.

These are per-core figures, and rungs 2 and 3 only reach them if the client opens enough connections. Every row above was measured on a 1-CPU pod, where the distinction cannot appear. It appears immediately at two: a connection binds to one event loop, and under :direct that loop also runs the handler, so a client holding one multiplexed connection to a multi-core pod uses one core of it — flat at 0.79–0.92 cores however hard it is pushed. A second connection took the same arm from 15,294 to 22,866 msg/s; a third through eighth bought nothing and cost 13% more CPU. So the ladder's ratios are a per-core property, and turning them into pod capacity means sizing the client's connection count too — asserted from upstream_cx_total, never computed from flags. See "Two cores" and "The connection sweep" above.

Three levers apply on top of any gRPC rung, measured separately and roughly additive over disjoint code: direct linking on the arm's JVM (5–13% CPU on unary, 3–17% per streamed message), clj-protobuf's compiled codec (6–17%), and the executor choice (:direct against the default virtual threads, worth 15–27% CPU and ~25% stream capacity, with the gap widening as cores are added). protoc-gen-clojure's typed interop=true path is not a fourth: it trades 3–8% more CPU on unary for 15–45% lower p50.

Where the 1.59 → 0.56 ms goes is the next section.

Profiled repeats — where the per-request cost goes

Pyroscope's Java agent (async-profiler 2.9.1, itimer at 100 Hz, in-process so kernel frames appear as their libc entry points) on the JVM arms, chart 0.2.6 with profiling.enabled=true. Two questions: does the agent change the numbers, and what is each arm doing per request.

Agent overhead — R3 repeated with the agent on (nh-grpc-jvm-grpc-unary-tiny-09071406)

offereddelivered/sp50 msp99 msp999 msknee/scpu ms/reqthrottled sheap MBrss MB
200 (warmup)200.01.661625.032046.230.01.1724.221159
400400.01.4511.6733.990.00.5370.124159
800800.01.4410.8328.260.00.3830.024160
12001200.01.3916.78157.720.00.3310.020160
16001599.91.4015.4671.220.00.2980.022160
20002000.01.5032.1880.250.00.2700.623162
24002399.91.5743.12235.780.10.2410.319162
28002799.91.7771.98180.980.10.2140.025164
32003199.91.7886.88200.470.10.1950.025166
36003599.81.8669.97180.810.10.1810.021170
40003995.21.9983.80273.974.70.1660.122172
44004399.72.0749.24124.400.10.1530.021172
48004799.72.2372.66257.360.20.1440.023173

Against the unprofiled R3 (Phase B): delivered identical at every step, p50 within 0.1 ms, CPU per request +1–6% (0.507 → 0.537 ms at 400, 0.138 → 0.144 at 4,800; median +3%), p99 inside run-to-run noise (better at five steps, worse at seven). The REST arm's ramped repeat (below) costs 1.3% more CPU per request at 600 than its unprofiled run. The agent is cheap enough that shares can be read; absolute costs below are the profiled run's own cpu ms/req, so they carry the agent's few percent.

Attribution at matched moderate load

One ramped run per arm, the last step read: REST h1 at 600 rps (nh-rest-h1-http1-realistic-09071619), gRPC unary at 3,000 (nh-grpc-jvm-grpc-unary-realistic-09071632), gRPC stream at 5,000 msg/s over 40 streams (nh-grpc-jvm-grpc-stream-realistic-09071644), all on the 1 KB body and all at ~0.75–0.8 of the arm's knee. (A first attempt with a single step straight after the 200 rps warmup was discarded: it profiled the first-step JIT outlier — 23% of unary's samples in the C2 compiler and GC, delivered down 15% — the same artifact every fresh-pod run shows. Its logs are kept under single-step/.) Cells are share of samples · ms per request — the share times the step's measured CPU per request.

layer (self time)REST h1 @600 (1.61 ms/req)gRPC unary @3,000 (0.27 ms/req)gRPC stream @5,000 (0.16 ms/msg)
syscalls: writev / read / epoll / futex28% · 0.45020% · 0.05424% · 0.039
Clojure runtime (maps, Vars, keywords, seqs)25% · 0.4085% · 0.0145% · 0.009
Java std (collections, strings, atomics, locks)15% · 0.23910% · 0.02810% · 0.016
Jetty10% · 0.168
Pedestal / Ring4% · 0.056
JSON (jsonista / Jackson)4% · 0.063
Netty21% · 0.05713% · 0.021
grpc-java8% · 0.0234% · 0.006
protobuf-java (descriptor-driven access)20% · 0.05426% · 0.041
JIT + GC (libjvm)7% · 0.1105% · 0.0156% · 0.010
JVM dispatch stubs5% · 0.0762% · 0.0061% · 0.002
other (copy/intrinsic stubs, unresolved)3% · 0.0427% · 0.01910% · 0.016
application code

What the three columns say:

  • REST's extra ~1.3 ms per request is not JSON. Parsing and printing the 1.3 KB body cost 0.06 ms (4%). The cost is the request pipeline around it: the Clojure runtime at 0.41 ms — persistent-map assoc/valAt, Var and keyword lookups, lazy seqs, i.e. Pedestal's interceptor chain building and reading the request and response maps — plus 0.24 ms of Java collections and locks under it, 0.17 ms of Jetty, and 0.45 ms of syscalls. Application code is 0.1%.
  • Syscalls are 8× more expensive per request on REST (0.45 vs 0.054 ms): HTTP/1.1 writes each response with its own writev on its own connection (writev alone is 13% of REST), and Jetty's thread-pool hand-off shows as pthread_cond_signal/futex, where Netty's event loop batches frames onto one multiplexed socket with no hand-off (:direct).
  • On the gRPC arms the biggest software cost is protobuf, and it is the generic path. 20–26% of samples sit in Descriptors$FieldDescriptor.getType, getFeatures, SmallSortedMap, FieldSet and CodedInputStream.readPrimitiveField beneath clj_protobuf.codec/proto-value and get-field: descriptor-driven field access, not generated-class parsing. That is exactly what the typed interop=true emitter path (protoc-gen-clojure 0.5.1) removes — the clj-protobuf suite measured its encode at 412 ns vs 650 ns for this path on a deep shape — so ~0.04–0.05 ms per request is on the table on both gRPC arms without touching the transport.
  • Streaming's gain over unary is visible as grpc-java shrinking from 8.4% (0.023 ms) to 3.8% (0.006 ms): per-RPC setup, headers, trailers and GrpcHttp2InboundHeaders handling amortized over a stream. Netty's share drops too (0.057 → 0.021 ms) as frames batch. What is left at 0.16 ms is protobuf + syscalls + copies — the message itself.
  • JIT + GC is 5–7% everywhere at steady state; the same arms show 20–50% in the compiler during the first step after a rate jump, which is the outlier the tables exclude and the profiles above avoid.

Logs (gzipped) and tables.md in soak/results/2026-09-07-profiled/; the attribution reads are reproducible with soak/pyro.py <service> <from> <until> against the arm's <arm>-java service in Pyroscope for the step's #NH-STEP window.

Can you improve this documentation?Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close