You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
HttpArena: static files over HTTP/2 and production-stack below the field #102
Seen in HttpArena's first run of the anole entry (MDA2AV/HttpArena#1608, merged 2026-10-23; entry pinned to v0.1.2; reference box: Threadripper PRO 3995WX, 64 cores/128 threads, io_uring).
static-h2-256: 324,706 (Bun 614k, leaders ~1.6M), static-h2-1024: 224,542 with 2.7 GiB; static-tls-1024: 420,862 (fib 1.23M); production-stack: 114,735 at 256 (7th of 8; leaders 208-256k); gateway-64 6th of 11 (178k vs 380k). Check the static handler (open/stat/sendfile per request, no fd or metadata cache, precompressed sibling lookups) and memory per HTTP/2 stream for big bodies; production-stack combines JWT + Redis + Postgres behind a proxy.
Notes from my performance audit (2026-10-23), to keep in mind while fixing this. Measured on Anole 480fd09 (0.3.0) with the zio fork at 88f6d69, using the same HttpArena entry built against that commit (the published entry is still on v0.1.2). Labels: [Measured] reproduced locally (a 4-core VM with the load generator on the same box, so only ratios, CPU per request and I/O operations per request count, not absolute rps; SQE/req = io_uring submissions per request read from the rings' SqTail, mmap/req from strace -f -c); [Code] checked in the code; [Hypothesis] fits the arena data but needs a 64-core run or a profile.
K7: static files are re-opened, re-stat'ed and re-read on every request [Measured + Code]
/static/app.js, .br variant 47 KB:
SQE/req
CPU/req
JSON over TLS (h1), for comparison
2.0
16.4 µs
static over TLS (h1)
13.0
61 µs
static over h2/TLS (20-file rotation)
8.1
80 µs
Static.serve (src/http/core/server/static.zig:100-160): sendFile(plain) = openat + fstat; for each precompressed candidate sendFile(path.br) = another openat + fstat, then close the first; sendFileBody reads the file through the Io in file_piece chunks (response.zig:2033-2065); close. Each is an io_uring operation with a park/resume and a loop tick, and the ETag string is formatted per request. Over TLS the bytes are then copied file → write buffer → SSL_write → BIO pending → socket. On h3 the sink buffer is capped at 4 KiB, so a 47 KB file takes 12 read round trips (h3/server.zig:2941).
Leaders (fib, agensio, ioxide, cnetmod, vanilla) serve precompressed bytes from memory; vanilla snapshots files and re-checks them every 100 ms, with kTLS + sendfile for bodies ≥ 16 KiB and the header sent MSG_MORE; zix prewarms its static cache at startup.
Fixes:
A static cache inside staticDir: path → {bytes per encoding, content type, ETag, Last-Modified, length}, bounded by total size, revalidated by statx at most every N ms (or inotify). The body becomes a borrowed slice: one write and zero file syscalls per hit. Serve from memory up to X MB; keep sendfile for large plain-TCP files.
Pre-render the response head per (file, encoding) and pre-format the ETag.
Over TLS, pass the cached slice to SSL_write in 16 KiB chunks; kTLS later (BoringSSL doesn't do kTLS natively, but the traffic secrets can be exported).
h3: lift the 4 KiB sink cap for static bodies or hand nghttp3 the cached slice with a no-copy data reader.
Also relevant here
static-h2 (225-325K, 2.7 GiB at 1024 connections): the allocator findings (K2, on HttpArena: unary gRPC at 76k req/s with p99 5 s and ~3 cores busy (leaders ~7M) #99) apply: the h2 per-stream 32 KiB out_queue_len + 16 KiB body_writer_len (h2.zig:91-95) put one streamed response's arena over SmpAllocator's 32 KiB mmap line, and BodyBuffer.nextSegment doubles segments up to 64 KiB in the stream arena.
Reference for K7 from kaitk/goose-http at 6b566a7 and plod's entry. The board figures are HttpArena results on its 3995WX harness, not my measurements.
Freshness: a stat per request (size, ns mtime and inode, for every candidate including .gz/.br) instead of a revalidation interval. The rules say the next response after a replacement must carry the new bytes. inotify on Linux comes later as an optimisation, with stat as the fallback.
Shape: one process-wide cache, not a copy per executor. It is built on the atomic-pointer + quiescent-state helper that the S1.5 safety retrofit builds first, so K7 lands after that helper. It is on by default, with its budget stated and printed at startup.
kTLS + sendfile: a candidate to measure, not struck. goose measured +10% on small responses and -44% at 16 KB with user-space sends (goose-tls decisions.md#L25-L40). aegon, whose HttpArena description lists kTLS TX offload, is ahead of this entry on every TLS profile.
My audit note above put json-tls's 1.88 s p99 on K5. K5 doesn't apply (#101). HTTP/1.1-over-TLS CPU now has its own issue: #130.
K7: files are re-opened and re-read on every request.
On epoll, every open and read goes through the blocking pool (ev.thread_pool.ThreadPool.submit). Its threads' futex wait is 55% of all off-CPU time, at 11 context switches per request, and the kernel spends 105k instructions per request.
io_uring avoids the handoff and serves 35% more on the same core.
The same handoff shows on h3-static: 19 context switches per request on epoll, which fits the 4 KiB h3 sink cap's extra read round trips.
The body also lands in the request arena first, at 41 KB per request in Response.write, from StreamSink.flushFn and .body.
BoringSSL allocates per write.bssl::tls_write_app_data mallocs 10.5 KB per request.
Memory per stream: 208 MB peak for 2,048 streams (64 × 32) is about 100 KB per open stream. That fits the 2.7 GiB the entry used at 1024 connections.
What changes
Nothing in the plan. These are the K7 baseline on the box.
A cache hit should remove the blocking-pool handoff on epoll and the first arena copy on both backends. The other two copies go with the takeover's zero-copy DATA (K6 fix 4).
The after run is bench/atlas/atlas.sh h2-static h3-static.
Update from the v0.5.0 run on HttpArena's machine (MDA2AV/HttpArena#1692, saved): static-h2 is 352k / 284k req/s at 256 / 1024 connections (+9% / +26% against v0.1.2) and production-stack 109k / 108k. Still well below the field: plod serves ~915-938k on static-h2 and 207-253k on production-stack, zix 1.65-1.69M on static-h2. static-h2 also uses 1.3-3.0 GiB. Keeping this open for the static cache (audit K7), which serves static files from memory as the leaders do; production-stack needs its own profile (JWT, cache lookup and Postgres in one request).
Activity
xuckless commentedon Oct 23, 2026
Notes from my performance audit (2026-10-23), to keep in mind while fixing this. Measured on Anole
480fd09(0.3.0) with the zio fork at88f6d69, using the same HttpArena entry built against that commit (the published entry is still on v0.1.2). Labels: [Measured] reproduced locally (a 4-core VM with the load generator on the same box, so only ratios, CPU per request and I/O operations per request count, not absolute rps; SQE/req = io_uring submissions per request read from the rings'SqTail, mmap/req fromstrace -f -c); [Code] checked in the code; [Hypothesis] fits the arena data but needs a 64-core run or a profile.K7: static files are re-opened, re-stat'ed and re-read on every request [Measured + Code]
/static/app.js,.brvariant 47 KB:Static.serve(src/http/core/server/static.zig:100-160):sendFile(plain)=openat+fstat; for each precompressed candidatesendFile(path.br)= anotheropenat+fstat, then close the first;sendFileBodyreads the file through theIoinfile_piecechunks (response.zig:2033-2065); close. Each is an io_uring operation with a park/resume and a loop tick, and the ETag string is formatted per request. Over TLS the bytes are then copied file → write buffer →SSL_write→ BIO pending → socket. On h3 the sink buffer is capped at 4 KiB, so a 47 KB file takes 12 read round trips (h3/server.zig:2941).Leaders (fib, agensio, ioxide, cnetmod, vanilla) serve precompressed bytes from memory; vanilla snapshots files and re-checks them every 100 ms, with kTLS +
sendfilefor bodies ≥ 16 KiB and the header sentMSG_MORE; zix prewarms its static cache at startup.Fixes:
staticDir: path → {bytes per encoding, content type, ETag, Last-Modified, length}, bounded by total size, revalidated bystatxat most every N ms (or inotify). The body becomes a borrowed slice: one write and zero file syscalls per hit. Serve from memory up to X MB; keepsendfilefor large plain-TCP files.SSL_writein 16 KiB chunks; kTLS later (BoringSSL doesn't do kTLS natively, but the traffic secrets can be exported).Also relevant here
out_queue_len+ 16 KiBbody_writer_len(h2.zig:91-95) put one streamed response's arena over SmpAllocator's 32 KiBmmapline, andBodyBuffer.nextSegmentdoubles segments up to 64 KiB in the stream arena.xuckless commentedon Oct 28, 2026
Reference for K7 from kaitk/goose-http at 6b566a7 and plod's entry. The board figures are HttpArena results on its 3995WX harness, not my measurements.
goose-http's file cache (decisions.md#L1124-L1166, file.goose#L176-L280):
With it, plod went from 408K to 938K on static-h2 and from 438K to 918K on static-tls (plod tasks/047-httparena.md#L92-L100).
What changes in K7 (S2):
.gz/.br) instead of a revalidation interval. The rules say the next response after a replacement must carry the new bytes. inotify on Linux comes later as an optimisation, with stat as the fallback.My audit note above put json-tls's 1.88 s p99 on K5. K5 doesn't apply (#101). HTTP/1.1-over-TLS CPU now has its own issue: #130.
xuckless commentedon Oct 31, 2026
These are numbers from the S2 flame-graph atlas, run on v0.4.1 (c36cca0) on my x86 box.
Accept-Encoding: br;q=1, gzip;q=0.8, over TLS h2 at c64 m32. One executor on one core, epoll and io_uring.What this confirms from the audit
K7: files are re-opened and re-read on every request.
ev.thread_pool.ThreadPool.submit). Its threads' futex wait is 55% of all off-CPU time, at 11 context switches per request, and the kernel spends 105k instructions per request.The response body is copied at least three times on its way to the socket (the "3-4 times" in K6 on HttpArena: unary gRPC at 76k req/s with p99 5 s and ~3 cores busy (leaders ~7M) #99):
StreamSink.put → Io.TypeErasedQueue.putLockedprovideData → Queue.getUncancelabledusty_tls.bioWriteunderBIO_write_exThe body also lands in the request arena first, at 41 KB per request in
Response.write, fromStreamSink.flushFnand.body.BoringSSL allocates per write.
bssl::tls_write_app_datamallocs 10.5 KB per request.Memory per stream: 208 MB peak for 2,048 streams (64 × 32) is about 100 KB per open stream. That fits the 2.7 GiB the entry used at 1024 connections.
What changes
bench/atlas/atlas.sh h2-static h3-static.xuckless commentedon Nov 1, 2026
Update from the v0.5.0 run on HttpArena's machine (MDA2AV/HttpArena#1692, saved): static-h2 is 352k / 284k req/s at 256 / 1024 connections (+9% / +26% against v0.1.2) and production-stack 109k / 108k. Still well below the field: plod serves ~915-938k on static-h2 and 207-253k on production-stack, zix 1.65-1.69M on static-h2. static-h2 also uses 1.3-3.0 GiB. Keeping this open for the static cache (audit K7), which serves static files from memory as the leaders do; production-stack needs its own profile (JWT, cache lookup and Postgres in one request).