Benchmarks

Instructions per request (callgrind) and nginx comparison — 2026-10-09

The VM’s throughput noise (CV 5–10 %) hides changes of a few percent, so user-space work per request was measured with callgrind: the server runs under valgrind --tool=callgrind, 500 warm-up requests, counters zeroed, 4000 requests (oha -c 4), dump. Only user-space instructions are counted (no kernel). rep stosb iterations count as instructions, so large memsets look more expensive than they are.

Both experimental settings on (http1_server, upstream_client); hello.txt (static small file) and /api/x (proxy to the bench upstream).

buildstaticproxy
h1fix (audit-fixed baseline)27 90648 104
opt3: fewer allocations27 40345 091
opt5: one lazily moved head timer per connection26 93744 900
opt6: header slots and positions not zeroed per message22 49535 697
opt7: lookups only for fields the request carries22 04934 440
nginx 1.31 (1 worker, master_process off)14 61230 148

opt6 removes two zeroed 4 KiB arrays per parsed head (request and upstream response); much of the drop is rep stosb counting, the real saving is a few hundred cycles and 8 KiB less L1 traffic per head.

Where the static request’s 22k instructions go (opt6): http crate (HeaderMap insert, hash, lookups; header name/value validation) 3.7k, scalws_h1 2.4k, pipeline 2.3k, scalws_static 2.1k, libc (memcpy: future/struct moves) 1.9k, mimalloc 1.9k, tokio 1.5k, httparse 1.1k. Proxied requests read the clock ~17 times (tokio’s park loop ~5, the rest in the pipeline, own client and pool).

nginx comparison (opt6, MODE=quick RUNS=3, results: [private host path])

scenarioscalws rpsnginx 1.24nginx 1.31scalws cpu µs/reqnginx 1.31 cpu µs/req
static-small95 03198 68898 41034.331.9
static-1m8 4288 7508 794304323
respond94 62797 185100 25323.223.8
proxy78 714 (CV 9.7 %)88 39692 19643.238.6
cache-hit95 34897 12293 04724.837.2
tls-h2-static-small64 48461 23260 38841.346.9
php31 31124 00322 65683.5103.4

The VM spread is larger than most of the gaps (the proxy runs were 78.4k, 78.7k and 92.6k); a bare-metal run with the load generator on separate cores is needed to settle the static, respond and proxy lanes.