Skip to content

HTTP/2 M4 + M5: 10% fewer instructions per request on the new engine, and the client off nghttp2 - #228

Merged
xuckless merged 26 commits into
mainfrom
s2/h2-m4
Nov 14, 2026
Merged

xuckless merged 26 commits into
mainfrom
s2/h2-m4

Conversation

@xuckless

Copy link
Copy Markdown
Owner

This is HTTP/2 takeover M4, the performance batch on the new engine, and M5, the HTTP/2 client moved onto Anole's own protocol core (research/h2h3-takeover/PLAN.md). Measured against main 521d885 (M3).

What's in it

  • Arenas (Request arenas: Zig 0.17's lock-free ArenaAllocator costs 6-9% of HTTP/2 CPU #171): LocalArena replaces std's lock-free arena for request, h2 stream and h3 stream arenas. It is Zig 0.17's arena without its atomics, under the MIT licence, with an entry in THIRD_PARTY_NOTICES. Debug builds panic if two calls overlap. permessage-deflate over h2/h3 no longer allocates from the stream's arena, and the h3 first body buffer no longer comes from it on a foreign executor. Docs state the req.arena rule.
  • HPACK field memo: a small per-thread memo of encoded fields. Secrets (authorization, cookie, set-cookie, never-indexed fields) are never stored.
  • Window and bursts: WINDOW_UPDATE batching was already in place, so this adds only its test. The spare Pending cap now follows max_concurrent_streams (up to 128), and it is trimmed back when the burst ends.
  • TLS records: dusty_tls gathers slices into records before SSL_write. Records are 16 KiB − 128, so BoringSSL's per-record write buffer stays in a 16 KiB allocation.
  • Heap (HTTP/2 heap high-water climbs with throughput (1.5 GB on v0.5.0, 1.86 GB with the 0.5.1 changes, json-h2c at 25,600 streams) #179, HttpArena: static files over HTTP/2 and production-stack below the field #102 groundwork): a stream's first arena node is 2 KiB, and request header tables start at 8 entries and grow on demand.
  • h2-budgets CI job: count-only tests: no allocations per warm request, at most one write per read, and no body/out allocation above malloc_max.
  • M5 client: src/h2/client.zig now runs on conn.zig (role .client) instead of nghttp2. The public API is unchanged.

Verification

  • On the box: test, core-test, capi-test, test-flags, test -Dhttp2=false, lint, ReleaseSafe test and test-stress.
  • h2spec 147/147 × 3 (h2c shared, h2c-only, TLS).
  • gRPC interop both directions (grpc-go client to our server, our client to grpc-go), h2c and TLS, plus grpc's HTTP/2 checks.

Measurements (bench/results/20261114-h2-m4-ab/)

Fixed-count instructions:u per request, 1 executor, 2 reps:

cell instructions:u per request
h2c baseline 13,254 → 11,924 (−10.0%)
unary gRPC 23,165 → 20,849 (−10.0%)
h2 TLS json 151,390 → 136,033 (−10.1%); TLS records/req 5.92 → 0.58
h2c json /json/50 −1.4%
h1 /json/50 −0.2% (no regression)
our gRPC client, per call 31,400 → 22,248 (−29%); CPU/call −10%

Rested A/B, 5 executors. The load generator caps most cells at about 300k req/s, so CPU per request is the signal there: −9 to −14% on h2c, gRPC and h2 TLS json.

Memory:

  • The first build raised h2 TLS json RSS by 20%. The cause: a full 16 KiB record made BoringSSL's write buffer a 32 KiB allocation, which dirtied pages in the streams' size class. 846bc4c fixes it.
  • Three interleaved reps after the fix overlap with M3 on RSS (612 vs 626 MB mean), peak and slab virtual. req/s is +1.1%.

Follow-ups

Closes #171.

…, :status without formatting

hpack.Encoder.encode keeps each short, indexable field's encoded bytes in a
per-thread memo of 32 slots (about 6.3 KiB in .tbss, nothing per connection),
keyed by name and value and checked in full, so a hit gives the bytes the
uncached path writes. Date and content-length need no special case: a miss
per new value. Never-indexed fields and fields over 96 bytes skip it.
Encoder.encodeStatus writes :status as one indexed byte for the static
codes, as h2o and nginx do, instead of bufPrint and the generic lookup.

Research: research/s2/h2-m4/headcache.md (the PLAN's block cache would miss
on every Date and content-length).
…ield checks, 1xx and no-body heads, GOAWAY's refused streams, no reset budget
…rough

dusty_tls Writer.drain topped up only the first slice and then sealed
every other slice as its own record, so an HTTP/2 flush round over 16 KiB
cost one record per frame header, HEADERS block and DATA piece (the M3
gate: SealScatter 94 -> 1,015 ins/req on h2 TLS json). It now copies
slices into the lent write buffer up to a record before each SSL_write,
as nginx's ngx_ssl_send_chain does, and passes slices of a record or more
through uncopied in whole records; the final slice, with the buffer
empty, goes through as before, so its last record can still be borrowed.
No new buffer. test_hooks.staged_bytes counts the copies.

h2 tests: a counter test bounds the socket writes of a round of 32
responses over h2c (7 in 3 reads: zio's 8 iovecs per sendmsg).
… by name

Review of the M4 HeadCache. The direct-mapped memo let two hot fields in
one slot evict each other on every response (content-length: 13, server:
anole and grpc-accept-encoding share one of 32 slots). It is now 16 sets
of four slots with CLOCK replacement: a miss that finds every slot of its
set hit since the last clear stores nothing, so a one-off content-length
or request id no longer evicts hot fields. A test runs eleven real gRPC
and h2c fields plus two one-off fields per response and checks that every
hot field hits once warm.

Cookies of any length, set-cookie and proxy-authorization are no longer
kept, which the never-index rule let through. The key cap is 64 bytes,
so the table is about 8.4 KiB of TLS per thread. The comment now says
glibc zeroes it on every thread, so it is not resident only once used.
…o a burst of 100 responses allocates nothing (M4); WINDOW_UPDATE per-KiB test
The previous commit said slices of a record or more pass through
uncopied. That holds only with nothing staged: on HTTP/2 every DATA
payload follows its 9-byte header, so put tops the record up with it and
copies the DATA, as nginx's ngx_ssl_send_chain does unless its buffer is
empty. Keep the copy (sealing the small prefix alone would bring back
the small records), fix the doc comments and the test name, and pin the
h2 shape in `gathered`: 4 x (9, 16 KiB) is 5 records, 65,536 bytes staged.
… bodies by reference with retiring, waits at the server's stream limit, lifetime and #148 write-sharing tests
…the EStream spares, so a burst's high-water mark doesn't stay on a connection with an open stream (M4 review)
…ead, no block handed back once the stream is gone, SETTINGS without MAX_CONCURRENT_STREAMS lift the limit, GOAWAY refuses in one walk
…ll, growing for a bigger exchange (#179)

Every stream open at once and every spare keeps its arena's node. The node started at the 8 KiB limit and an ordinary exchange (h2load's baseline) used 2.9 KB of it, over a third of that the 32-entry request header table and the 16-entry response one: a touched 4 KiB page per stream, 495 KB per connection at -m100 (research/s2/h2-m4/heap.md). The request table now starts at 8 entries and grows to max_header_count (Headers.addRequestFieldUpTo/addUpTo), a sink response's at 8, and a new stream's node at the 2 KiB class, which holds the baseline exchange (1.8 KB); a stream that needs more grows its node once, at its reset, up to the same 8 KiB limit as before.
… a small header table (#179, review)

The review of c5ac6e2 found two problems. First, a 2 KiB first node that grows at reset leaves the old node and the spill on SmpAllocator's free lists, because it resizes only within a class. Nothing in steady state reuses them, so json-h2c's exchanges (3.7-7 KB) cost about twice the resident bytes per stream. The node is back at arena_retain_limit; the small header tables stay. Second, ResponseCache.serve replayed kept headers with a raw headers.put, so a hit with more headers than the table starts with (16 over HTTP/1.1, 8 for every sink response: h2, h3, the nghttp2 engine, anole.testing, Lambda) answered 500. It now grows the table as res.header does (Response._putHeader). Headers.addUpTo returns OutOfMemory for a failed grow, not TooManyHeaders, so the h2 engine keeps its OOM path instead of a 431.
…stays a 16 KiB allocation (M4 RSS)

The gathered 16 KiB records made BoringSSL's write buffer (malloc'd per
SSL_write in ssl_buffer.cc EnsureCap, freed after the flush: record + seal
overhead + 7) a 16,429-byte request, which Zig's libc malloc (SmpAllocator +
16-byte header, musl builds) puts in the 32 KiB class: the class of an HTTP/2
stream's grown arena node. LIFO reuse handed each buffer the slot a stream
had just freed and wrote 20 KiB of it, so every in-flight stream's slot
became that resident: h2 TLS json RSS +20% (bench/results/20261114-h2-m4-ab),
same virtual size, more pages touched per slab. h2c has no such buffer.

record_len = 16 KiB - 128 for every SSL_write (gathered, passed through,
writeAll's chunks); worst case 16,256 + 57 + 27 + 7 + 16 = 16,363 bytes.
gather_len = 3 * record_len keeps the three-record gather sends. The test
pins the largest BIO write + slack + malloc header within 16 KiB (failed
first at 16,406); gathered/borrowSizes expectations follow record_len.
…heduler's

The spare Pending nodes keep the most a burst ever had alive at once, and
that peak is how many responses were queued before the flusher framed
them: 23 to 52 of 100 on the box, varying run to run. The old test's
round 2 allocated whenever its peak beat rounds 0 and 1 (6 of 10 runs
under taskset -c 0,1; CI's ubuntu core-test). The warm-up now shuts the
server's send windows so all 100 responses are queued at once, and each
burst waits until the server let its streams go (test_freed), so the
measured one starts with every spare back.
…s reported, not left for nobody

The shelf's driver can cancel a waiting task twice in one pass: the
watch's park delay and a lazy deadline the sweep finds past. The read
reports one error; Burst.waitInput let the watch claim it and returned,
and the caller's re-set of its deadline could not take the other back
(retractAutoCancel refuses once an error was reported since the poke)
while Lazy.retract dropped its claim. The pending error then failed the
next wait as nobody's cancel, which the HTTP/2 reader takes for the
server stopping: a connection with a TLS record half read was never
closed at its keep-alive (macOS CI, h2_park_test). waitInput now takes a
second pending cancel at once, for the caller's Timer.fired to claim.
Failing-first test in park_test (lookAtWatches is pub for it).
… not after (a handler that detached first was waited for twice)
@xuckless
xuckless merged commit 3ed9209 into main Nov 14, 2026
34 of 36 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Request arenas: Zig 0.17's lock-free ArenaAllocator costs 6-9% of HTTP/2 CPU

1 participant