Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions dev/BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,48 @@ three things both rejected designs foundered on. Whatever else #179 wants, a
non-blocking `tryWrite()` on HTTP/1, has to be argued on its own; this measurement
does not support it.

### Where the copy stops paying

Coalescing costs one user-space copy of the chunk, so it pays only while that copy
is cheaper than the two syscalls it removes. Body held at 1 MiB, chunk size moved,
five runs per cell, median:

| 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% |
| 128 KiB | 3616 | 3206 | −11% |
| 256 KiB | 4457 | 3546 | −20% |

The crossing is between 32 and 64 KiB, so `H1_CHUNK_COALESCE_MAX` is 32 KiB and a
larger chunk keeps the three-write path.

### The shipped change, verified against the noise

Runs of the same build drift by up to 9% on this machine, which is wider than some
of the gains above, so the shipped change was re-measured by alternating the two
builds — start, three `wrk` runs, stop, swap — three rounds each.

| chunk | three writes | shipped | |
|---|---|---|---|
| 64 KiB | 2362, 2409, 2511 | 2473, 2573, 2577 | same code path either side of the threshold; the spread is the noise floor |
| 4 KiB | 197, 243, 208 | 367, 376, 377 | +81%, and every run of one build is outside the other's range |

### The header block in the same frame

The first `write()` sent the status line and headers, then the frame. Carrying
the block inside the frame removes one write and one round trip from the byte a
client waits for. Alternating builds again, three rounds, median of three runs
each:

| response | frame only | headers in the frame | |
|---|---|---|---|
| one 4 KiB chunk | 22981, 23868, 23387 | 30057, 31601, 29732 | +28.5%, the shape an SSE response has |
| one 64 KiB chunk | 16738, 16565, 16709 | 16376, 16657, 16477 | unchanged: the block is small against the frame |

## 2026-08-19 — cost of the per-chunk flush on a streamed response (#170)

Base commit 22a8d37 plus the #170 working tree. Machine: WSL2, Linux 6.6.114.1,
Expand Down
12 changes: 10 additions & 2 deletions dev/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,16 @@ designs were worked out and both fail on something mechanical.
`io_pipe_writev_cb` sends no NOTIFY and frees the request itself.

What it decides: the win needs no queue, no second writer and no per-response
structure, so it is not an argument for #179. Next step is to land the coalesced
frame as its own change, with the copy or with a new ext/async op.
structure, so it is not an argument for #179.

- [x] **Send a streamed chunk as one write below 32 KiB.** Done. The threshold is
where the copy stops paying, measured: +25% at a 32 KiB chunk, −16% at 64 KiB, so
a larger chunk keeps the copy-free three-write path. Verified against a 9% noise
floor by alternating the two builds — +81% at 4 KiB chunks, and no difference at
64 KiB, where both take the same path. Test
`tests/phpt/server/h1/029-h1-chunk-coalesce.phpt` reads the raw response and
checks the chunk-size lines on both sides of the threshold. The copy stays until
ext/async gains an awaitable vectored write.

- [ ] **Answer from the queues the connection already has.** Plaintext:
`out_pending_buf` carries a byte count, a high-water predicate on the same knob,
Expand Down
183 changes: 151 additions & 32 deletions src/http1/http1_stream.c
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,33 @@
/* Maximum hex chunk-size line (16 hex digits for 64-bit len) + CRLF. */
#define H1_CHUNK_HEADER_MAX 18

static bool h1_emit_headers_once(http1_request_ctx_t *ctx)
/* Largest frame — size line, body and CRLF together — that goes out as one
* copied write instead of three separate ones. Coalescing trades two syscalls
* and two scheduler round-trips against one copy of the chunk, and the two are
* worth the same somewhere between 32 and 64 KiB: measured at a 1 MiB body,
* +25% at a 32 KiB chunk and -16% at 64 KiB (dev/BENCHMARKS.md, 2026-08-20,
* plaintext, wrk on loopback — the crossing moves with the machine).
*
* The bound is on the frame and not on the chunk because of TLS, where
* tls_push splits anything larger than the plaintext ring: a frame one byte
* over spends a second ring cycle on a TLS record carrying six bytes. The two
* numbers are independent — one is a measured crossing, the other a buffer
* size — so the assert below catches them drifting apart rather than tying
* the plaintext decision to a TLS constant. */
#define H1_CHUNK_COALESCE_MAX (32 * 1024)

ZEND_STATIC_ASSERT(H1_CHUNK_COALESCE_MAX <= HTTP_TLS_PLAINTEXT_RING_BYTES,
"a coalesced frame must fit one TLS plaintext ring cycle");

/* The status line and headers of a streaming response, as bytes. Returns NULL
* when the response is gone or formats to nothing; the caller owns the string.
* Separate from the send so the first frame can carry the block with it. */
static zend_string *h1_streaming_headers_build(http1_request_ctx_t *ctx)
{
http_connection_t *conn = ctx->conn;

if (Z_ISUNDEF(ctx->response_zv)) {
return false;
return NULL;
}

zend_object *response_obj = Z_OBJ(ctx->response_zv);
Expand All @@ -66,15 +87,33 @@ static bool h1_emit_headers_once(http1_request_ctx_t *ctx)
zend_string *headers =
http_response_format_streaming_headers(response_obj);

if (headers == NULL || ZSTR_LEN(headers) == 0) {
if (headers != NULL) {
zend_string_release(headers);
}
if (headers != NULL && ZSTR_LEN(headers) == 0) {
zend_string_release(headers);
return NULL;
}

return headers;
}

/* Headers reached the wire: the response is a streaming one from here.
* H2 and H3 count it on their first chunk too; without the counter an H1
* stream showed up in the send/byte totals but never in
* streaming_responses_total. */
static void h1_stream_headers_committed(http1_request_ctx_t *ctx)
{
ctx->h1_stream_headers_sent = true;
http_server_on_streaming_response_started(ctx->conn->counters);
}

static bool h1_emit_headers_once(http1_request_ctx_t *ctx)
{
zend_string *headers = h1_streaming_headers_build(ctx);

if (headers == NULL) {
return false;
}

const bool ok = http_connection_send(conn, ZSTR_VAL(headers),
const bool ok = http_connection_send(ctx->conn, ZSTR_VAL(headers),
ZSTR_LEN(headers));
zend_string_release(headers);
return ok;
Expand Down Expand Up @@ -109,33 +148,49 @@ static int h1_stream_append_chunk(void *opaque, zend_string *chunk,
return HTTP_STREAM_APPEND_STREAM_DEAD;
}

/* First write() — commit status + headers with chunked framing.
/* First write() — commit status + headers with chunked framing. The block
* is built here and sent with the frame below, so time-to-first-byte costs
* one write rather than two; that matters most for SSE, where the first
* event is the whole point and is a few dozen bytes.
*
* We track wire-commit on ctx->h1_stream_headers_sent rather than
* response->committed because write() sets committed=true before
* calling us (committed means "no more setHeader / setStatusCode
* allowed", which happens at the PHP boundary, not on the wire). */
zend_string *headers = NULL;

if (!ctx->h1_stream_headers_sent) {
if (!h1_emit_headers_once(ctx)) {
headers = h1_streaming_headers_build(ctx);

if (headers == NULL) {
ctx->stream_dead = true;
zend_string_release(chunk);
return HTTP_STREAM_APPEND_STREAM_DEAD;
}

ctx->h1_stream_headers_sent = true;

/* Headers on the wire = this response is now a streaming one. H2/H3
* count it on their first chunk too; without this an H1 stream showed
* up in the send/byte counters but never in streaming_responses_total. */
http_server_on_streaming_response_started(conn->counters);
}

/* Empty chunk is legal on the wire but would be indistinguishable
* from the zero-chunk EOF marker — drop it silently. mark_ended()
* is the only place that emits the zero-chunk. */
* is the only place that emits the zero-chunk. It still commits the
* headers, which is what a handler opening a stream with one expects. */
const size_t chunk_len = ZSTR_LEN(chunk);

if (chunk_len == 0) {
zend_string_release(chunk);

if (headers != NULL) {
const bool sent = http_connection_send(conn, ZSTR_VAL(headers),
ZSTR_LEN(headers));
zend_string_release(headers);

if (!sent) {
ctx->stream_dead = true;
return HTTP_STREAM_APPEND_STREAM_DEAD;
}

h1_stream_headers_committed(ctx);
}

http_server_on_stream_send(conn->counters, 0);
return HTTP_STREAM_APPEND_OK;
}
Expand All @@ -146,19 +201,81 @@ static int h1_stream_append_chunk(void *opaque, zend_string *chunk,

if (header_len < 0 || (size_t)header_len >= sizeof(header)) {
zend_string_release(chunk);

if (headers != NULL) {
zend_string_release(headers);
}

return HTTP_STREAM_APPEND_STREAM_DEAD;
}

if (!http_connection_send(conn, header, (size_t)header_len) ||
!http_connection_send(conn, ZSTR_VAL(chunk), chunk_len) ||
!http_connection_send(conn, "\r\n", 2)) {
/* One write per frame while the copy is cheaper than the two syscalls it
* removes; a large chunk keeps the three-write path and stays copy-free.
* Each http_connection_send suspends the handler until its write
* completes, so the count of them is the count of scheduler round-trips.
*
* Both branches hand a buffer the caller owns to a write that outlives the
* call when a cancellation lands mid-flight: libuv keeps the pointer until
* its completion callback, while dispose only marks the request pending.
* Closing that needs a write which reports its status AND takes the buffer
* over — today's ABI offers one or the other, never both. */
const size_t head_len = headers != NULL ? ZSTR_LEN(headers) : 0;
const size_t frame_len = head_len + (size_t)header_len + chunk_len + 2;
const bool coalesce = frame_len <= H1_CHUNK_COALESCE_MAX;
bool frame_ok = true;

/* Too large to carry the block along: the headers go out on their own, and
* the commit is recorded the moment they land rather than after the frame.
* Anything else lets a failure in between look like headers that were
* never sent, and mark_ended would send them a second time. */
if (headers != NULL && !coalesce) {
frame_ok = http_connection_send(conn, ZSTR_VAL(headers), head_len);

if (frame_ok) {
h1_stream_headers_committed(ctx);
}
}

if (frame_ok && coalesce) {
char *const frame = emalloc(frame_len);
char *at = frame;

if (headers != NULL) {
memcpy(at, ZSTR_VAL(headers), head_len);
at += head_len;
}

memcpy(at, header, (size_t)header_len);
at += header_len;
memcpy(at, ZSTR_VAL(chunk), chunk_len);
at += chunk_len;
memcpy(at, "\r\n", 2);

frame_ok = http_connection_send(conn, frame, frame_len);
efree(frame);

if (frame_ok && headers != NULL) {
h1_stream_headers_committed(ctx);
}
} else if (frame_ok) {
frame_ok = http_connection_send(conn, header, (size_t)header_len)
&& http_connection_send(conn, ZSTR_VAL(chunk), chunk_len)
&& http_connection_send(conn, "\r\n", 2);
}

if (headers != NULL) {
zend_string_release(headers);
}

if (!frame_ok) {
/* The write is how the peer's departure becomes visible on H1 — record
* it so isWritable() can answer without a second doomed write. */
ctx->stream_dead = true;
zend_string_release(chunk);
return HTTP_STREAM_APPEND_STREAM_DEAD;
}


/* Each of those writes suspends. A cancellation that lands between them
* returns success for the writes already done, leaving the frame partly
* written — the same state a failure leaves, and it is recorded the same
Expand Down Expand Up @@ -187,6 +304,19 @@ static void h1_stream_mark_ended(void *opaque)

http_connection_t *conn = ctx->conn;

/* A frame can be left half on the wire: above the coalescing threshold it
* is three writes with a suspension between them, and a cancellation lands
* in one of those gaps. Sealing that with a terminal chunk would tell the
* peer the body ended cleanly and hand the connection on for reuse, and it
* would read the terminator as the first bytes of the chunk the size line
* promised. Refuse everything from here: no headers, no terminator, no
* keep-alive. Checked before the header commit below, so a stream that
* died after its headers landed does not send them twice. */
if (UNEXPECTED(ctx->stream_dead)) {
conn->keep_alive = false;
return;
}

/* If write() was never called but mark_ended fires anyway (rare:
* handler flipped streaming mode then immediately closed), we
* still need to commit the headers so the peer isn't left
Expand All @@ -196,18 +326,7 @@ static void h1_stream_mark_ended(void *opaque)
return;
}

ctx->h1_stream_headers_sent = true;
}

/* A chunk is three writes — size line, body, CRLF — with a suspension
* between them, so a cancellation can leave a frame half on the wire.
* Sealing that with a terminal chunk would tell the peer the body ended
* cleanly and hand the connection on for reuse, and it would read the
* terminator as the first bytes of the chunk the size line promised.
* Refuse both: no terminator, no keep-alive. */
if (UNEXPECTED(ctx->stream_dead)) {
conn->keep_alive = false;
return;
h1_stream_headers_committed(ctx);
}

/* Terminal zero-chunk. Trailers not emitted — RFC requires the
Expand Down
Loading
Loading