Benchmarks

2026-10-05 — Static file profiling and fix

Environment as in 2026-10-05-docker-desktop-smoke.md (Docker Desktop / WSL2, Ryzen 7 7800X3D, server on CPUs 0-3 with 4 Tokio workers, oha on 6-11, 64 connections, 10 s). Document root on a Docker volume (ext4). Smoke-test grade; ratios matter, not absolutes.

Diagnosis (scripts/profile.sh --docker static-small, before)

  • 17.2k req/s; 880k context switches in 10 s (~5 per request); 67 threads.
  • ~38 % of samples in the kernel waking/switching threads (futex_wake → try_to_wake_up, finish_task_switch), reached from StaticHandler::serve (spawn_blocking for resolve/open) and tokio::fs::File::poll_read (one blocking-pool hop per 64 KiB read plus one more for EOF).
  • readlink visible: canonicalize costs one syscall per path component.

Fix

  • Linux lookup with one openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS) from a root fd (also closes the symlink TOCTOU window; see ADR-0006).
  • Hot path on the async worker only when the kernel guarantees no blocking: RESOLVE_CACHED for the lookup, preadv2(RWF_NOWAIT) for the data; otherwise the whole resolution runs once on the blocking pool.
  • Small files (≤ 128 KiB) read in the lookup step; large files streamed with 256 KiB positional reads (inline when cached), no EOF read, no thread held between chunks.

After (same run conditions, same volume)

scenario              before rps  after rps   speed-up  before p99  after p99  nginx rps
static-small              20763      69859     3.4x       6.97 ms    2.33 ms     98259
static-1m                  2244      10393     4.6x      43.72 ms   12.28 ms     24515
tls-h2-static-small       18386      53186     2.9x       7.04 ms    2.85 ms     83439
respond (unchanged)      120050     124362        -       1.68 ms    1.76 ms    258238
proxy   (unchanged)       52974      50638        -       2.68 ms    3.02 ms     92927

Peak RSS 36 → 13 MiB. After the fix, static-small shows 70k context switches in 10 s (was 880k) and 7 threads (was 67).

Remaining gaps (recorded in ROADMAP M1)

  1. 1 MiB files (0.42× nginx): profile is now dominated by data copies (_copy_to_iter for pread, _copy_from_iter for the socket write) and page zeroing; nginx uses sendfile. Closing it needs a zero-copy body path that bypasses hyper for plain TCP — an architectural change, to be designed via ADR.
  2. Per-request overhead (respond, 0.48× nginx): no dominant user-space hotspot (largest: pipeline 1.3 %, malloc 1.3 %); scb uses ~2.9 of 4 CPUs and most kernel time is loopback wake-ups of the client. Points at scheduler/park behaviour of the work-stealing runtime vs nginx’s per-core event loops — handoff §14 says to investigate worker sharding / affinity only now that the portable design is correct.
  3. 64 KiB chunks were measured 30 % slower than 256 KiB for 1 MiB files.