Benchmarks
2026-10-06 — Reverse-proxy profile and fix (Docker Desktop / WSL2)
Question: why is scb’s plain reverse proxy (13-byte upstream answers) ≈0.5× nginx while
static files and PHP/Node/Python are at or above nginx? Tool:
scripts/proxy-compare.sh --docker (same load for both, perf counters per request).
Hypotheses tested
| Hypothesis | Measurement | Verdict |
|---|---|---|
| scb writes head and body separately (2 wake-ups) | syscalls/request: scb and nginx both exactly 2 writev + 2 recvfrom | rejected; a small-body buffering change gave no gain and was reverted |
| Cross-thread wake-ups explain the gap | single worker each: scb 83.7 µs vs nginx 55.3 µs per request, 0 futex | rejected as main cause (gap remains without threads) |
| hyper-util’s pooled client hands every request to a separate connection task | inclusive profile: server task 55 %, upstream connection task 31 %, linked by channels and wakes | confirmed |
Fix
Upstream HTTP/1.1 connections are now driven inside the request’s own task
(crates/scb-proxy/src/inline.rs): the request future polls the connection while
waiting for the head; the response body polls it while being read and returns it to a
LIFO pool when complete. Stale idle connections are detected before use; bodyless
requests are replayed once on a fresh connection. Found and fixed during the change:
the request line must be origin-form (/path?q), not the absolute URI.
rps CPU/request vs nginx
before (hyper-util) 44 688 69.8 us 0.51x
after (inline pool) 57 118 59.0 us 0.66x
nginx 86 145 40.2 usAll tests pass (WebSocket, HTTPS upstreams, timeouts, PHP/Node/Python managed runtimes); a 4-minute soak with every scenario stayed flat (RSS 29.6 → 29.4 MiB, fds 237 → 239).
Per-core runtimes — measured, not adopted
Upper bound of a thread-per-core model, measured with four single-thread processes pinned to one core each vs one process with four work-stealing threads (same total load, two runs): 57.5–58.4 k vs 63.3–64.8 k rps, ≈ +10 %. A first run showed 1.86×, which did not reproduce (noise right after a host memory-pressure event). Given the complexity (SO_REUSEPORT listeners per core, cross-runtime pools, coordinated drain), the change is not made now; the remaining gap is spread over hyper’s two HTTP/1 state machines, header processing and allocation.