encode: vectored (rope) output via an EncodeSink abstraction - #284
Conversation
Encoding was locked to contiguous output: Message::write_to and every encode helper took &mut impl BufMut, so a message dominated by one large bytes field always memcpy'd the payload into the output buffer - even when the field was already a refcounted bytes::Bytes, and even though consumers like hyper/h2 accept segmented bodies and vector-write them. EncodeSink replaces the BufMut bound on all encode paths. A blanket impl over BufMut keeps every existing caller source-compatible and byte-identical (const IS_SEGMENTED = false folds the zero-copy branch away, so contiguous sinks keep the exact pre-change code). Rope is the segmented sink: small writes coalesce into a tail buffer, large bytes::Bytes fields become refcount-shared segments via the new ProtoBytes::as_shared hook, and large borrowed view fields are captured zero-copy through an optional backing-buffer containment check shared with view::bytes_from_source. RopeBuf adapts a finished rope to bytes::Buf with chunks_vectored support and releases consumed segments eagerly. Generated write_to bodies emit put_shared_bytes_field for bytes fields in all contexts (singular, optional, repeated, oneof, and map values via the shared-aware codecs); Vec<u8> fields and view slices keep the copy path. Manual Message/ViewEncode impls must update their signature bound; this is the breaking half of the change and rides the 0.9.0 bundle. Measured on the chunk-shaped benchmark: encoding a message with a 512 KiB payload drops from 10.6 us (contiguous) to ~150 ns through a Rope - payload-size-independent - while contiguous encode_to_bytes is unchanged within noise. Differential tests pin byte-identical wire output, pointer identity of shared segments, and the zero-copy view re-encode path.
|
All contributors have signed the CLA ✍️ ✅ |
|
[claude code] Metal bench confirmation for the numbers flagged in the PR body. Setup: quiesced bare-metal host (turbo disabled, Contiguous-path regression check — full
Encode: mean +0.07%, range [−1.27%, +1.59%]. The decode control — code this PR does not touch — spans [−6.94%, +2.03%] on the same box (packed_tile/decode −6.9%, log_record/decode +2.0%), so every encode delta is inside the residual layout/measurement noise envelope. No measurable contiguous-path regression; the Rope absolute numbers (chunk-shaped message, one dominant
Rope encode is payload-size-independent (CI widths < 0.1%), confirming the O(header) claim on quiet hardware. This closes the "re-confirm on the quiesced benchmark host" item from the PR body. |
Conflicts (all BufMut -> EncodeSink bound swaps meeting main's changes): - buffa-descriptor/src/reflect/dynamic.rs: kept main's value-shape validation (pool param, skip rule, docs) with the EncodeSink bound on encode/encode_field. - buffa/src/view.rs: kept main's UnknownFieldsView records refactor with the EncodeSink bound on write_to. Regenerated checked-in code matches the merged codegen (no drift). :house: Remote-Dev: homespace
Summary
Adds vectored ("rope") encode: a message whose dominant content is one large
bytesfield can now be encoded without copying the payload at all. Draft for design review — targeted at the 0.9.0 breaking bundle.Today every encode is contiguous:
Message::write_toand all the encode helpers take&mut impl BufMut, so a large payload is always memcpy'd into the output buffer, even when the field already holds a refcountedbytes::Bytesand the consumer (hyper/h2, any vectored writer) would happily take segments.Design
EncodeSinkreplaces theBufMutbound on every encode path. A blanket impl overBufMutkeeps all existing callers source-compatible (encode_to_vec,encode_to_bytes,msg.encode(&mut vec)unchanged), andconst IS_SEGMENTED = falseconst-folds the zero-copy branch away, so contiguous sinks compile to the exact pre-change code — verified at ±noise on the benchmark. The trait deliberately exposes only theBufMutsubset the encoders use.Ropeis the segmented sink: tags/varints/small fields coalesce into a tail buffer;bytes::Bytesfields at or abovemin_segment(default 4 KiB) become refcount-shared segments via the new providedProtoBytes::as_sharedhook.Rope::with_backingextends this to views: large borrowed&[u8]fields that lie inside the decode source buffer are captured zero-copy through a containment-checkedslice_ref(shared with the existingbytes_from_sourcehelper), making decode→re-encode relays fully zero-copy.RopeBufadapts a finished rope tobytes::Bufwith achunks_vectoredoverride, so the whole rope reaches a vectored writer in one call; consumed segments are released eagerly.put_shared_bytes_fieldforbytesfields in every context (singular, optional, repeated, oneof, and map values through the shared-aware codecs).Vec<u8>fields and view slices keep the copy path —as_sharedis a compile-timeNonethere.Why breaking
Manual
Message/ViewEncodeimplementations must change theirwrite_tobound (impl BufMut→impl EncodeSink), and generated code must be regenerated — the same class of churn as the other queued 0.9.0 items, hence one bundle.cargo semver-checkspasses (196/196), but note its known blind spot: trait-method bound changes on implementors are exactly what it does not model; the changelog fragment declares the break explicitly.Numbers (dev machine, chunk-shaped message: 32 B digest + u64 + one
bytespayload)encode_to_bytesRopeRope encode is payload-size-independent — the ~150 ns is the two-pass size computation plus the tail buffer and one refcount. Contiguous
encode_to_bytesis unchanged within run-to-run noise. Worth re-confirming on the quiesced benchmark host and against the full benchmark suite before merge.Testing
Rope/RopeBuf(segment ordering, backing containment, Buf contract, Default parity, reborrow forwarding, eager segment release) and 7 integration tests through real generated code (pointer-identity for singular/repeated/oneof/map fields, byte-identical wire output vs contiguous, zero-copy view re-encode,Vec<u8>fallback).check-nostdclean (chunks_vectoredis std-gated); clippy clean; MSRV-1.75 compatible.Notes for review
put_shared_bytes_fieldcall sites; the reviewable core isbuffa/src/encode_sink.rs, thetypes.rsadditions, and the two codegen files.examples/loggingandexamples/bsr-quickstartgot the signature swap in their checked-in generated code, but both examples were already broken against current main for unrelated reasons (map hasher drift;MessageViewtrait drift vs the pinned BSR plugin) — examples aren't built in CI. Worth a separate cleanup.