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

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,600 (6×)0.56 ms13.7 ms~10,700 (11×)new clients, protobuf schema, serialization; API shape unchanged
3→ gRPC stream~8,200 (11×)~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 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.

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