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

HypothesisMeasurementVerdict
scb writes head and body separately (2 wake-ups)syscalls/request: scb and nginx both exactly 2 writev + 2 recvfromrejected; a small-body buffering change gave no gain and was reverted
Cross-thread wake-ups explain the gapsingle worker each: scb 83.7 µs vs nginx 55.3 µs per request, 0 futexrejected as main cause (gap remains without threads)
hyper-util’s pooled client hands every request to a separate connection taskinclusive profile: server task 55 %, upstream connection task 31 %, linked by channels and wakesconfirmed

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 us

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