Skip to content

server: pass large response payloads through by refcount instead of copying - #219

Merged
iainmcgin merged 3 commits into
mainfrom
iain/spike-rope-framing
Jul 18, 2026
Merged

server: pass large response payloads through by refcount instead of copying#219
iainmcgin merged 3 commits into
mainfrom
iain/spike-rope-framing

Conversation

@iainmcgin

Copy link
Copy Markdown
Collaborator

Summary

Eliminates the second payload-sized copy in the server's response framing. Today every streaming message (Connect, gRPC, and gRPC-Web) and every gRPC unary response is copied twice between the handler and the socket: once by message encoding, and once more when the envelope layer put_slices the encoded message into the framing buffer. For messages dominated by one large bytes field, the second copy doubles encode cost — and hands hyper/h2 nothing they can't already handle without it, since h2 chains any Buf payload above 256 bytes and writes it to the socket with vectored I/O.

With this change, payloads of at least 16 KiB on the wire (envelope::MIN_CHAIN_SIZE) are emitted as their own HTTP body data frame by reference count: the 5-byte envelope header goes into the framing buffer, and the payload Bytes passes through unmoved. Compressed payloads chain on their post-compression size, so enabling compression never makes the copy overhead worse than leaving it off.

The wire bytes are unchanged. gRPC/Connect envelope framing has always been independent of HTTP-level frame boundaries; the only observable difference is that the envelope header may arrive in a separate HTTP/2 DATA frame (or HTTP/1.1 chunk) from its payload.

Design notes

  • EnvelopeEncoder::encode_chained is the single implementation of the envelope-encode policy; the tokio_util::codec::Encoder impl delegates to it with an infinite chain threshold, so the compression decision and the chaining decision cannot drift apart.
  • BatchingEnvelopeStream stages the chained payload as pending_payload, drained ahead of any staged finalizer — order proven by streaming_error_after_chained_payload_preserves_order.
  • GrpcUnaryBody gains a second data-frame slot fed by Envelope::encode_parts.
  • The oversized-payload (> u32::MAX) check now runs before any buffer growth.

Measured on a dev machine with a chunk-shaped message (one dominant bytes field): encode+frame at 512 KiB drops from 25.3 µs to 10.6 µs (the cost of the encode copy alone); at 2 MiB from 1.24 ms to 125 µs, because the eliminated framing buffer was also a fresh multi-megabyte allocation per message.

Testing

  • 8 new unit tests: pointer identity of the chained frame, byte-identical reassembly against the contiguous path (mixed small/large streams), error-after-chained-payload frame ordering, unary three-frame ordering, compressed chaining, sub-threshold behavior unchanged.
  • Full server conformance suite: 3600 passed, 0 failed.
  • Driven live over a socket (Connect HTTP/1.1 streaming, gzip negotiation, gRPC unary over HTTP/2) with the response bytes parsed envelope-by-envelope.

Scheduling

Queued for the 0.9.0 release rather than a 0.8.x patch. Client-side request framing has the same second copy; that is a separate follow-up PR.

iainmcgin added 2 commits July 6, 2026 08:52
…opying

Envelope framing copied every encoded message into the framing buffer:
once into the batching buffer on the streaming paths, and once via
Envelope::encode on the gRPC unary path. For a message dominated by one
large bytes field this doubled the payload-sized memcpys per response.

Payloads of at least 16 KiB (envelope::MIN_CHAIN_SIZE) on the wire are
now emitted as their own HTTP body data frame: the 5-byte envelope
header goes into the framing buffer and the payload Bytes is passed
through by refcount. Envelope framing is independent of HTTP frame
boundaries, so the wire bytes are unchanged - the reassembly tests and
the full conformance suite (3600/3600) verify byte-identical output.

EnvelopeEncoder::encode_chained is the single implementation of the
encode policy (the tokio_util Encoder impl delegates with an infinite
chain threshold, so the compression and chaining decisions cannot
drift), compressed payloads chain on their post-compression size, and
the oversized-payload check now runs before any buffer growth.

Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
…aining

The staging comments still said the chained path was uncompressed-only,
which the compressed-chaining test and the changelog contradict; the
chaining tests were keyed to STREAM_BATCH_THRESHOLD, which is the flush
heuristic, not the chaining threshold - they only passed because the two
16 KiB constants happen to be equal. Reference MIN_CHAIN_SIZE directly.

Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
@iainmcgin
iainmcgin marked this pull request as ready for review July 17, 2026 23:58
@iainmcgin
iainmcgin enabled auto-merge July 17, 2026 23:58
@iainmcgin
iainmcgin added this pull request to the merge queue Jul 18, 2026
Merged via the queue into main with commit 4644145 Jul 18, 2026
14 checks passed
@iainmcgin
iainmcgin deleted the iain/spike-rope-framing branch July 18, 2026 00:43
EffortlessSteven pushed a commit to EffortlessSteven/connect-rust that referenced this pull request Aug 3, 2026
…nectrpc#232)

## Summary

Carries buffa 0.9's reference-counted encode segments from the encoder
all the way to the HTTP body, so a gRPC or gRPC-Web unary response whose
body is an `OwnedView` never copies its large fields.

A view's fields are slices into the buffer the view was decoded from. A
rope told about that buffer takes a large field by reference instead of
copying it into the encode buffer, and the segments then become their
own HTTP data frames. Past the framing threshold the encode stops
growing with the payload, because only the framing is still being
written.

Measured on a dedicated bare-metal instance
(`benches/rpc/view_rope_encode`, four-string message, contiguous vs
segmented):

| bytes per field | contiguous | segmented |
|---|---|---|
| 256 B | 171 ns | 184 ns |
| 1 KiB | 210 ns | 204 ns |
| 16 KiB | 2.29 µs | 682 ns |
| 256 KiB | 48.6 µs | 709 ns |
| 1 MiB | 363 µs | 643 ns |

The control is the part worth reading: an *unbacked* rope at 1 MiB costs
363 µs, identical to the contiguous encode. The rope machinery is free;
the reference-count capture is the entire effect.

## Where this deliberately does nothing

Segmenting is skipped wherever it would not pay, each guarded in code
rather than left to chance:

- **Owned-message bodies.** Their `string` and `bytes` fields are
`String` and `Vec<u8>`, which cannot be handed over by reference.
Routing them through a rope only adds cost.
- **Responses below the framing threshold.** Anything smaller is copied
into the framing buffer downstream regardless.
- **A response much smaller than the request it borrows from.**
Capturing would keep the whole request buffer alive until the response
finishes flushing — answering a 64 MiB upload with a 32 KiB summary
would hold 64 MiB per in-flight response. Copying is cheaper than that.
- **Connect unary, streaming, and any call through an interceptor.** See
below.

## Reach, stated plainly

This lands on gRPC and gRPC-Web unary responses. Connect unary still
flattens because its body type hardcodes `Full<Bytes>`; multiple HTTP/2
DATA frames concatenate to the same Connect body, so that path *could*
carry segments and is worth doing — Connect being the primary protocol.
Streaming re-envelopes per item and is the next piece of work. A call
with an interceptor configured takes the contiguous path, because an
interceptor may replace the whole body.

## Breaking change, confined to custom dispatch

`EncodedResponse` is now `Response<EncodedBody>` rather than
`Response<Bytes>`. This is reachable through the public `Dispatcher`
trait, so anyone implementing custom dispatch is affected: construct
with `Bytes::into()`, and recover a single buffer with
`EncodedBody::into_contiguous()`.

Interceptor authors need no change — `UnaryResponse` still carries a
contiguous `Payload`. That seam is what keeps the blast radius to
dispatch rather than every extension point.

## Builds on

Both prerequisites are now merged, and this branch is rebased onto
`main`: connectrpc#233 (the buffa 0.9 adoption, which supplies the rope and
`EncodeSink`) and connectrpc#219, whose framing emission path this reuses rather
than reimplementing.

## Testing

Server conformance 3600 passed / 0 failed, 56 test suites, lint, docs,
and `task generate:all` (no drift) all green — re-run after rebasing
onto `main`, not carried over from the stacked base.
`Envelope::encode_body_parts` has a table test sweeping empty,
sub-threshold contiguous, sub-threshold segmented, over-threshold
contiguous, two-segment and many-segment bodies, asserting in each that
the envelope header declares exactly the byte count that follows it and
that reassembly equals the contiguous envelope.

A high-effort review pass found four defects that produced correct bytes
and so would never have failed a test — buffer pinning, a gate that
compared whole-message size against a per-field threshold, tiny rope
fragments becoming their own frames, and `MaybeBorrowed` silently not
forwarding the segmented encode. All four are fixed in the final commit,
"response: only segment where it pays, and merge the fragments", whose
message carries the reasoning.

One known limitation left in place: a message that clears the gate while
none of its individual fields do lands everything in a rope tail that
grows by doubling rather than one sized allocation. buffa 0.9 exposes no
way to pre-size the tail, so it is documented where it bites rather than
worked around.

---------

Signed-off-by: Iain McGinniss <309153+iainmcgin@users.noreply.github.com>
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.

2 participants