Assemble streamed messages per message instead of slicing a per-stream read buffer - #302
Conversation
|
We benchmarked #302 against How it was measuredOne harness compiled into all three builds drives the real server Memory
CPUReader step per message, jemalloc; glibc in parentheses where it changes the picture.
("Previous dropped/held" = whether the consumer still holds the previous message's End-to-endunary, server_stream, client_stream ×10 and bidi: within ±1–2% on either allocator. Where #302 loses
Where #301 losesMessages on the 64 KiB threshold regrow from zero every time (+18–24% CPU). Drop-aligned MiB streams are +22–27% under jemalloc only; the earlier "+33–38%" doesn't reproduce as stated, and with the previous message held #301 beats main. A held zero-copy message still owns the doubled buffer, and since client-side release is per decode rather than per frame, a held previous message or coalesced framing leaves the client holding 131–167 KB — bounded, but the silent-when-wrong mode of a threshold. Caveats
Harness, raw logs and the full write-up are available on request. |
…ffer The server request reader (BodyReader) and the client response stream (ServerStream) accumulated body frames in one per-stream BytesMut and handed each message out as a split_to().freeze() slice of it. That buffer grows to the largest message, never shrinks, and stays referenced by the reader until the stream ends, so a long-lived stream that once carried a multi-MiB message kept that allocation alive after the handler dropped it. EnvelopeAssembler replaces it: it carries at most the 5 envelope-header bytes between frames, rejects len > max_message_size on the header before allocating, assembles a payload larger than 4 KiB into its own Vec grown as min(declared, 2x received) and hands it out as an exact, uniquely owned Bytes, and copies payloads of 4 KiB or less into an 8 KiB per-stream slab that is reused in place once the messages cut from it are dropped. An idle stream retains at most that slab; a held message retains exactly itself (large) or at most its slab (small). gRPC-Web trailer frames come out of the same assembler as ordinary 0x80 envelopes. Envelope::decode* and the public API are unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Ryan Brewster <rpb@anthropic.com>
e17e930 to
7f7b24c
Compare
|
Updated per the discussion with @iainmcgin: the 8 KiB small-message slab is folded into this PR (payloads ≤ 4 KiB are cut from a per-stream slab, larger ones keep the exact per-message |
The server request reader (
BodyReader) and the client response stream (ServerStream) accumulated body frames in a per-streamBytesMutand handed each message out as asplit_to().freeze()slice of it. That buffer grows to the largest message, never shrinks, and stays referenced by the reader until the stream ends — so a long-lived stream that once carried a multi-MiB message keeps that allocation alive after the handler has dropped the message (#301 measured 32 MiB/stream; GiBs per pod in the reported service). Every multi-frame message was also copied through a shared doubling buffer.This replaces the buffer with a small
EnvelopeAssemblerfed one transport frame at a time:len > max_message_sizebefore allocating (sameresource_exhaustedtext asEnvelope::decode_with_limit);Vecper message whose capacity grows asmin(declared, 2 × received)— a peer that sends 5 bytes cannot make the receiver allocatemax_message_size, and the final allocation is exact — and emitted asBytes::from(vec): the message is the sole owner of its memory (is_unique()) and the reader retains nothing of it;split_to().freeze(). That costs oneSharedallocation per slab rather than per message, andBytesMut::reservereclaims the slab in place once every message cut from it has been dropped (otherwise the reader rolls a fresh 8 KiB block). Steady-state small messages therefore do not allocate; a held small message keeps at most its 8 KiB slab alive; an idle stream retains at most one slab. The slab size is a compile-time constant checked againstbytes' original-capacity bucketing;0x80envelopes, which deletes the client's sentinel peeks andmax_buf_sizeaccounting; trailers are capped at the parser's existing 1 MiBMAX_GRPC_WEB_TRAILER_SIZEon the header;Envelope::decode*and all public API are untouched;EnvelopeDecoder(crate-private) is now frame-fed and returnsDecoded::{Message, EndStream}instead of implementingtokio_util'sDecoder.is_unique()benches/rpcunary / streaming armsclient_stream_many_small−2…−3.5%Trade-offs, stated plainly: an idle stream that has carried a small message now keeps up to 8 KiB (10k idle streams ≈ 80 MiB) where the first revision of this PR kept 0 and
mainkeeps up to 2× its largest message; and because each large message is a fresh exact allocation,client_stream_large(10 × 1 MiB) is +10–19% under glibc malloc end-to-end (page-fault/munmap churn) while flat-to-slightly-faster under jemalloc — see the perf evaluation comment for the full matrix.Compatibility: no public API or wire change; error codes and texts preserved except that the client-side "response buffer exceeds limit" error no longer exists (memory is bounded per envelope, whose over-limit header reports "message size N exceeds limit M" as before) and a gRPC-Web trailer frame larger than 1 MiB now fails on its header with
resource_exhausted. The workspacebytesfloor moves to 1.6.1 only because the new tests rely onBytes::is_uniquebeing correct forBytesMut-derived values. Conformance (v1.0.5): server 3600, client connect 2580 / grpc 1454 / grpc-web 2838 passed, 0 failed.Alternative to #301, and built on it — its analysis of the pinning mechanism and its
is_unique()tests are what this builds on; the difference is that the invariant is structural rather than restored by a 64 KiB release heuristic, so the completing-frame-carries-the-next-header case, all-large streams, and large h1 chunks need no special handling.