Repository navigation
Conversation
…, :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
…s out of the stream's arena (#171)
…hout its atomics (#171)
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)
… allocation) and the h2-budgets CI job (M4)
…efore another executor can write (#171)
…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.
…nes both added it)
…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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
LocalArenareplaces 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 thereq.arenarule.max_concurrent_streams(up to 128), and it is trimmed back when the burst ends.malloc_max.src/h2/client.zignow runs onconn.zig(role.client) instead of nghttp2. The public API is unchanged.Verification
test,core-test,capi-test,test-flags,test -Dhttp2=false,lint, ReleaseSafetestandtest-stress.Measurements (bench/results/20261114-h2-m4-ab/)
Fixed-count instructions:u per request, 1 executor, 2 reps:
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:
Follow-ups
max_iovecs_len8 → 64, so a plaintext flush leaves in one sendmsg. h2c json makes 0.655 sendmsg per request today. This goes upstream-first.Closes #171.