Send a streamed HTTP/1 chunk as one write below 32 KiB (#179) - #184
Merged
Conversation
A chunk left as three awaited writes — size line, body, CRLF — and each of them suspends the handler until its write completes. Measured at three write(2) and three scheduler round-trips per chunk, flat in the chunk size, worth about 10 us of the 18.5 a chunk costs. Coalescing copies the chunk, so it pays only while the copy is cheaper than the two syscalls it removes. The two are equal between 32 and 64 KiB: at a 1 MiB body, +25% at 32 KiB and -16% at 64 KiB. Above the threshold the frame keeps the copy-free three-write path. At a 1 MiB body: +167% at 1 KiB chunks, +65% at 4 KiB, +54% at 16 KiB. Five wrk runs per cell, median, release build (dev/BENCHMARKS.md).
…t was checked against (#179)
…nk (#179) The threshold read the chunk length, so a 32 KiB chunk built a 32774-byte frame — six bytes past HTTP_TLS_PLAINTEXT_RING_BYTES. tls_push splits at the ring, so that frame spent a second ring cycle emitting a TLS record carrying six bytes of payload, on the very size the copy was paid for. It reads the frame length now, and a static assert keeps the two numbers from drifting apart: one is a measured crossing, the other a buffer size. The comment also stops claiming a cost model the table cannot support, and names the buffer-lifetime hazard both branches share — libuv keeps the caller's pointer until its completion callback, while a cancelled request is only marked pending, and no ABI write both reports its status and takes the buffer over.
Contributor
CoverageTotal lines: 82.19% → 82.24% (+0.06 pp)
|
The first write() sent the status line and headers, then the frame, so the byte a client waits for cost two writes and two scheduler round-trips. The block is built rather than sent now and copied into the same frame when the two fit the coalescing bound. It matters most for SSE, where the first event is the whole point and is a few dozen bytes. mark_ended checks stream_dead before the header commit: a stream that died after its headers landed used to reach the commit branch and send them again.
…, #179) Three paths #177 and #179 added had no test and showed up as the coverage drop on main: the compressing wrapper's four delegating ops, the pool worker's credit-backed answers, and the HTTP/1 branches where the header block cannot ride inside the first frame. compression/052 asks isWritable, tryWrite and awaitWritable on a live gzip stream and then decodes the body, because a wrapper answering for itself is what threw away an emitted deflate block. h3/047 asks the same three across the reactor/worker split, the only path where the answers come from a credit the reactor holds. h1/029 gained two shapes: a first chunk too large to carry the headers, and an empty first chunk, which carries no frame and must still commit them. Measured on a release build with lcov: compression 75.00 -> 77.70, worker_dispatch 76.63 -> 79.62, http1_stream 62.18 -> 68.07.
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.
Addresses the measured half of #179; the issue stays open for the other half.
An HTTP/1 chunk left as three awaited writes — size line, body, CRLF
(
src/http1/http1_stream.c). Eachhttp_connection_sendsuspends the handleruntil its write completes, so the frame costs three syscalls and three scheduler
round-trips.
strace -e trace=write,epoll_pwaiton one request of four 16 KiBchunks shows exactly that, flat in the chunk size.
Below 32 KiB the three pieces are now copied into one buffer and sent as one
write.
Why a threshold and not always
Coalescing buys two syscalls and two round-trips, together about 10 µs of the
18.5 µs a chunk costs, and pays for it with one user-space copy of the chunk. The
two are equal between 32 and 64 KiB. Body held at 1 MiB, chunk size moved, five
wrkruns per cell, median, release build:So
H1_CHUNK_COALESCE_MAXis 32 KiB, and a larger frame keeps the copy-freepath. The bound is on the frame — size line, body and CRLF together — and not
on the chunk, because of TLS:
tls_pushsplits anything pastHTTP_TLS_PLAINTEXT_RING_BYTES, so a chunk-sized bound let a 32 KiB chunk builda 32774-byte frame and spend a second ring cycle emitting a TLS record carrying
six bytes. A static assert keeps the measured crossing and the ring size from
drifting apart. The sizes that stream in practice — SSE records, log lines, CSV rows, gRPC
messages — sit far below it.
The copy is there because the ABI's vectored write is fire-and-forget in both
halves:
io_pipe_writev_cb(php-src/ext/async/libuv_reactor.c:4947) sends noNOTIFY and frees the request itself, and the release callback it does invoke
carries no status (
zend_async_API.h:588). A coroutine can be resumed from thatcallback, but it cannot learn whether the write succeeded — and on this path a
failed write is what makes
isWritable()honest (#176). So removing the copyneeds a write in ext/async that reports its status; the same change would close
the buffer-lifetime hazard both branches carry today, where libuv keeps the
caller's pointer until its completion callback while a cancelled request is only
marked pending.
Evidence
Runs of one build drift by up to 9% on this machine, wider than some of the gains
above, so the change was re-measured by alternating the two builds — start, three
runs, stop, swap — three rounds each. At 4 KiB chunks: 197, 243, 208 against 367,
376, 377, every run of one outside the other's range. At 64 KiB, where both take
the same path: 2362, 2409, 2511 against 2473, 2573, 2577.
tests/phpt/server/h1/029-h1-chunk-coalesce.phptwrites 1 KiB, 32 KiB, 32 KiB + 1and 64 KiB chunks in one response, reads the raw wire and checks every chunk-size
line against what the handler wrote, plus the de-chunked body byte for byte — the
split must not be visible to a client.
tests/phpt/server/+tests/phpt/websocket/: 347 passed, 0 failed, 24 skipped.