Skip to content

Send a streamed HTTP/1 chunk as one write below 32 KiB (#179) - #184

Merged
EdmondDantes merged 6 commits into
mainfrom
179-coalesce-h1-chunk
Aug 20, 2026
Merged

Send a streamed HTTP/1 chunk as one write below 32 KiB (#179)#184
EdmondDantes merged 6 commits into
mainfrom
179-coalesce-h1-chunk

Conversation

@EdmondDantes

Copy link
Copy Markdown
Contributor

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). Each http_connection_send suspends the handler
until its write completes, so the frame costs three syscalls and three scheduler
round-trips. strace -e trace=write,epoll_pwait on one request of four 16 KiB
chunks 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
wrk runs per cell, median, release build:

chunk three writes one write gain
1 KiB 49 132 +167%
4 KiB 226 374 +65%
16 KiB 857 1319 +54%
32 KiB 1486 1860 +25%
64 KiB 2606 2197 −16%
256 KiB 4457 3546 −20%

So H1_CHUNK_COALESCE_MAX is 32 KiB, and a larger frame keeps the copy-free
path. The bound is on the frame — size line, body and CRLF together — and not
on the chunk, because of TLS: tls_push splits anything past
HTTP_TLS_PLAINTEXT_RING_BYTES, so a chunk-sized bound let a 32 KiB chunk build
a 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 no
NOTIFY 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 that
callback, but it cannot learn whether the write succeeded — and on this path a
failed write is what makes isWritable() honest (#176). So removing the copy
needs 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.phpt writes 1 KiB, 32 KiB, 32 KiB + 1
and 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.

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).
…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.
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Coverage

Total lines: 82.19% → 82.24% (+0.06 pp)

File Baseline Current Δ Touched
src/compression/http_compression_response.c 75.68% 78.38% +2.70 pp
src/core/http_connection.c 75.26% 75.55% +0.29 pp
src/core/worker_dispatch.c 76.63% 79.62% +2.99 pp
src/http1/http1_stream.c 66.23% 71.43% +5.19 pp
src/http1/http_parser.c 84.06% 83.68% -0.38 pp
src/http3/http3_callbacks.c 83.28% 83.17% -0.11 pp
src/http3/http3_listener.c 77.93% 76.88% -1.05 pp
src/http_response.c 86.96% 87.23% +0.27 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.
@EdmondDantes
EdmondDantes merged commit daee916 into main Aug 20, 2026
8 checks passed
@EdmondDantes
EdmondDantes deleted the 179-coalesce-h1-chunk branch August 20, 2026 14:19
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.

1 participant