Skip to content

encode: encode_to_bytes writes through Vec<u8>, not BytesMut - #437

Merged
iainmcgin merged 4 commits into
mainfrom
encode-to-bytes-vec
Sep 10, 2026
Merged

encode: encode_to_bytes writes through Vec<u8>, not BytesMut#437
iainmcgin merged 4 commits into
mainfrom
encode-to-bytes-vec

Conversation

@rpb-ant

@rpb-ant rpb-ant commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

encode_to_bytes was 3–4× slower than encode_to_vec on every benchmark shape, with or without LTO; it is now within noise of it. Pure performance change; no behavioural or wire impact.

Why

encode_to_bytes built a BytesMut::with_capacity(size), wrote the message through it, and froze it. bytes does not mark <BytesMut as BufMut>::put_slice #[inline] (it does for Vec<u8>), BytesMut has no put_u8 override, and LLVM folds reserve_inner into put_slice, so it stays out of line even under fat LTO. Every tag and varint byte our encoders write through put_u8 therefore became a call, a reserve check, a one-byte libc memcpy, and an advance_mut re-check; through Vec<u8> the same write is a compare and a store.

The doc comment already said encode_to_bytes is "equivalent to Bytes::from(self.encode_to_vec())" — this makes the implementation literally that. The upstream fix is proposed separately in tokio-rs/bytes#850; this change is worthwhile regardless, since buffa cannot make its users take a newer bytes, and the Vec path is never slower.

Change

  • Message::{encode_to_bytes, try_encode_to_bytes}, ViewEncode::{encode_to_bytes, try_encode_to_bytes} and the generated lazy-view inherent methods delegate to their Vec twins and convert with Bytes::from. Output bytes and the returned Bytes representation are unchanged: BytesMut::freeze on a vec-backed buffer already went through From<Vec<u8>> for Bytes, and with len == capacity (guaranteed by the two-pass size) that conversion allocates nothing.
  • Rope's tail writes go through the inherent, inlined BytesMut::extend_from_slice instead of its BufMut impl; to_contiguous_bytes builds a Vec.
  • New benchmarks/buffa/benches/encode_sink.rs compares the sinks — encode_to_vec vs encode_to_bytes, encode into a pre-sized Vec vs BytesMut, and "frame a header then encode into your own BytesMut" vs "frame then put_slice(encode_to_vec())" — on log_record, api_response, google_message1 and the log_record view. A bench-nolto profile and task bench-encode-sink run it at both the LTO bench profile and the profile a downstream cargo build --release gets.

Results

encode_to_bytes, main → this change, criterion median per dataset batch, one pinned core (no-LTO / fat-LTO).

Development machine (Xeon 8488C):

shape main this change encode_to_vec (reference)
log_record 21.4 / 23.3 µs 6.46 / 6.04 µs 6.1 / 5.9 µs
api_response 8.31 / 8.15 µs 2.17 / 1.97 µs 2.0 / 1.9 µs
google_message1 257 / 264 ns 64 / 64 ns 61 / 60 ns
log_record (view) 20.9 / 20.4 µs 5.62 / 5.51 µs 5.5 / 5.4 µs

Clean EC2 instances (Amazon Linux 2023, rustc 1.95.0):

shape c8i.xlarge (Xeon 6975P-C) c8g.xlarge (Graviton4)
log_record 16.8 / 18.4 µs → 5.83 / 5.62 µs 24.7 / 24.4 µs → 8.84 / 8.16 µs
api_response 6.21 / 7.01 µs → 1.98 / 1.87 µs 8.01 / 8.44 µs → 2.94 / 2.66 µs
google_message1 199 / 216 ns → 60 / 64 ns 305 / 309 ns → 101 / 95 ns
log_record (view) 16.0 / 17.5 µs → 5.28 / 4.83 µs 23.3 / 23.0 µs → 7.74 / 7.12 µs

encode_to_vec on the same EC2 runs: 5.4–5.6 / 8.0–8.6 µs, 1.8–2.0 / 2.6–2.8 µs, 58–63 / 89–98 ns — so encode_to_bytes is now within ~5 % of it on both architectures, and LTO did not rescue the old path on either.

Callers that encode straight into their own BytesMut still pay the slow sink until bytes changes; the bench's last two rows show that encoding to a Vec and appending it with one put_slice is ~2.7× faster even counting the copy (log_record: 21.9 µs vs 8.0 µs).

Compatibility

Patch-level. No API or wire change; regenerated lazy-view code is source-compatible (method signatures unchanged) and previously generated code keeps working against the new runtime.

Testing

cargo test -p buffa -p buffa-codegen -p buffa-test, cargo clippy -p buffa -p buffa-codegen --all-targets -- -D warnings, cargo fmt --check. The existing encode_to_bytes_over_limit_panics and two-pass ledger tests cover the delegated paths; benchmarks as above.

Follow-ups (not in this PR)

  • encode_to_vec can write through Vec::spare_capacity_mut() (&mut [MaybeUninit<u8>] implements BufMut) and set_len once, since the size is exact: measured 1.25× (varint-heavy) to 3.2× (string-heavy) over the current Vec path with generated code unchanged, for one line of unsafe. Separate PR with its own safety argument.
  • Rope's tail as a Vec<u8>: a further ~1.3–1.5× on byte-heavy tails, at ≤2 small allocations per flushed segment.

`<BytesMut as BufMut>::put_slice` is not `#[inline]` and LLVM folds
`reserve_inner` into it, so it stays out of line even under fat LTO: every
tag and varint byte written through a `BytesMut` was a call, a reserve
check, a one-byte libc memcpy and an `advance_mut` re-check. `Vec<u8>`'s
impl inlines to a compare and a store.

`encode_to_bytes` / `try_encode_to_bytes` on `Message`, `ViewEncode` and
generated lazy views now delegate to the `Vec` entry points and convert
with `Bytes::from`, which is what `BytesMut::freeze` on a vec-backed
buffer did anyway (same `Bytes` representation; no allocation when
`len == capacity`, which the two-pass size guarantees). `Rope`'s tail is
written through the inlined inherent `BytesMut::extend_from_slice`.

benchmarks/buffa gains `benches/encode_sink.rs` (Vec vs BytesMut sinks,
`encode_to_vec` vs `encode_to_bytes`, and the "frame into your own
BytesMut" case) plus a `bench-nolto` profile and `task bench-encode-sink`
to run it at both profiles. encode_to_bytes, main -> this change, median
per dataset batch on pinned cores (no-LTO / fat-LTO):

  log_record        21.4 / 23.3 us  ->  6.46 / 6.04 us   (encode_to_vec: 6.1 / 5.9)
  api_response      8.31 / 8.15 us  ->  2.17 / 1.97 us   (encode_to_vec: 2.0 / 1.9)
  google_message1    257 /  264 ns  ->    64 /   64 ns   (encode_to_vec:  61 /  60)
  log_record view   20.9 / 20.4 us  ->  5.62 / 5.51 us
@github-actions

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@rpb-ant rpb-ant changed the title encode: encode_to_bytes writes through Vec<u8>, not BytesMut encode: encode_to_bytes writes through Vec<u8>, not BytesMut Sep 10, 2026
iainmcgin
iainmcgin previously approved these changes Sep 10, 2026
- encode_sink bench: gated behind a non-default `encode_sink` feature so
  its IDs stay out of `task bench` and saved baselines; throughput is
  payload bytes like the rest of the suite; the sink rows reuse one
  pre-grown buffer so they differ only in per-put cost; media_frame added
  as the bytes-heavy control; shape named google_message1_proto3.
- bench-nolto inherits release (cargo's defaults by construction);
  task bench-encode-sink keeps the two profiles' criterion results apart.
- Comments that said LTO closes the gap now say what the data says; the
  encode_to_vec rationale and debug_assert_two_pass doc no longer describe
  encode_to_bytes as a direct body; the *_to_bytes docs state they are
  defined via *_to_vec.
- Tests: encode into a caller's BytesMut matches encode_to_vec (the one
  place the blanket BufMut sink is still exercised); generated lazy-view
  encode_to_bytes/try_encode_to_bytes; Rope::put_u64_le bytes.
- Changelog scoped to the tag-dense shapes measured.
@iainmcgin

Copy link
Copy Markdown
Collaborator

[claude code]

Reviewed at Iain's request and, with rpb's OK, pushed the follow-ups directly (afaea86, be192a9). The library change itself checked out on every axis we looked at: Bytes::from(encode_to_vec()) is representation-identical to the old BytesMut::with_capacity(size) … freeze() (bytes 1.11's freeze on a vec-backed buffer already ends in From<Vec<u8>>, and len == cap takes the promotable no-allocation branch), the Rope put_* rewrites are byte-identical to the BufMut defaults they replace, no wire / no_std / MSRV / regen impact, and the try_….map(Bytes::from) Result hop is not a cost (the old body already ended in the same out-of-line Bytes::from). connect-rust benefits with no change: its client hot path and codec.rs both call encode_to_bytes. (One connect-rust follow-up: response.rs hand-copies the old BytesMut body for view responses on the premise that "the work is the same either way", which this PR makes false.)

What the follow-up commits change, all outside the runtime except comments:

  • Bench wiring. encode_sink is gated behind a non-default encode_sink feature: as submitted it ran in every task bench (first, by name order), and task bench-compare against any baseline saved before this PR panicked in criterion's strict compare on the first encode_sink/* ID and fail-fasted before protobuf/reflect ran. task bench-encode-sink now keeps the two profiles' criterion results in separate directories (they shared one, so the second run overwrote the first's baseline and every printed change: was LTO-vs-no-LTO). bench-nolto is inherits = "release" so it is cargo's release defaults by construction. Throughput is payload bytes like the rest of the suite; the "presized" rows reuse one buffer so they differ only in per-put cost; media_frame added as the bytes-heavy control; shape named google_message1_proto3 to match the suite.
  • Comments that contradicted the data. The bench header / profile comment / Rope comment said fat LTO mostly closes the gap; your own tables (and the metal run below) say it does not. The encode_to_vec rationale no longer says "Same for encode_to_bytes", debug_assert_two_pass's doc no longer lists encode_to_bytes as a direct caller, and the *_to_bytes rustdoc says they are defined via *_to_vec (so an override of encode_to_vec must not call encode_to_bytes).
  • Tests. After this PR nothing in CI encoded into a BytesMut at all, so buffa-test now checks encode(&mut BytesMut) (varint/zigzag/fixed32/fixed64/length-delimited) against encode_to_vec; the generated lazy-view encode_to_bytes/try_encode_to_bytes get their first in-tree call; Rope::put_u64_le's bytes are asserted.
  • Changelog scoped to the shapes measured, with the media_frame ratio.

Metal numbers (quieted c7i.metal-24xl, turbo off, one pinned core, SMT sibling offlined; criterion median per dataset batch; main = 1458d1b runtime with this bench file, PR = be192a9):

shape profile encode_to_bytes main → PR encode_to_vec main ratio PR vs vec
log_record no-LTO 29.2 → 9.89 µs 9.55 µs 3.1× +3.6%
log_record fat LTO 30.8 → 9.47 µs 9.07 µs 3.4× +4.4%
api_response no-LTO 11.4 → 3.24 µs 3.11 µs 3.7× +4.2%
api_response fat LTO 11.1 → 3.05 µs 2.93 µs 3.8× +4.1%
google_message1_proto3 no-LTO 355 → 99 ns 97 ns 3.7× +2.1%
google_message1_proto3 fat LTO 363 → 94.8 ns 94.3 ns 3.8× +0.5%
media_frame no-LTO 32.3 → 20.3 µs 19.0 µs 1.7× +6.8%
media_frame fat LTO 31.1 → 19.7 µs 18.7 µs 1.7× +5.3%
log_record (view) no-LTO 27.9 → 8.63 µs 8.46 µs 3.3× +2.0%
log_record (view) fat LTO 28.9 → 8.30 µs 8.33 µs 3.5× −0.4%

So: confirmed on both profiles, LTO rescues nothing (main's LTO encode_to_bytes is if anything slightly slower than no-LTO), and the residual over encode_to_vec is the Bytes::from conversion. For callers framing into their own BytesMut, encode-then-put_slice is 2.4× faster than encoding in place on log_record (29.5 vs 12.4 µs) and api_response (11.7 vs 4.87 µs), 2.9× on google_message1, and a wash on media_frame (31.6 vs 31.1 µs), which is what you would expect once the payload is mostly KB-scale slices.

One design note for a follow-up rather than this PR: the root cause is the blanket impl<T: BufMut> EncodeSink for T forwarding put_u8 to BufMut::put_u8. Rewriting that one method as self.chunk_mut().write_byte(0, v); unsafe { self.advance_mut(1) } (all #[inline] on BytesMut/Vec/slices since bytes 1.0) leaves the Vec<u8> path instruction-identical and turns BytesMut's per-byte call into an inline store — prototyped in a scratch crate — which would fix every msg.encode(&mut bytes_mut) caller (tokio-util Encoder users included) without waiting for tokio-rs/bytes#850, and would make the Rope special-casing unnecessary. One SAFETY-commented unsafe; the encode_into_bytesmut_reused row is the ready-made measurement for it.

The push dismissed the existing approval, so this needs a re-stamp.

@iainmcgin
iainmcgin added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit 053fcf3 Sep 10, 2026
11 checks passed
@iainmcgin
iainmcgin deleted the encode-to-bytes-vec branch September 10, 2026 23:26
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 10, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants