Skip to content

Inline BytesMut's specialized BufMut methods and add put_u8 fast paths - #850

Open
rpb-ant wants to merge 1 commit into
tokio-rs:masterfrom
rpb-ant:bytesmut-inline-put
Open

rpb-ant wants to merge 1 commit into
tokio-rs:masterfrom
rpb-ant:bytesmut-inline-put

Conversation

@rpb-ant

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

Copy link
Copy Markdown

Motivation

put, put_slice, and put_bytes are the only BufMut methods on BytesMut without #[inline]

Without it every put_slice into a BytesMut is an out-of-line call (even with LTO, since folding in reserve_inner bumps it past the normal instruction limit). put_u8 (which defaults to put_slice(&[n])) additionally pays a one-byte memcpy and a second capacity check in advance_mut, which hurts byte-at-a-time writers such as varint encoders.

Summary

Adds #[inline] to the three methods, and a put_u8/put_i8 override on BytesMut

Clean-instance numbers (Amazon Linux 2023, rustc 1.95.0, one pinned core, median of 3 passes of cargo bench --bench bytes_mut, ns/iter):

bench c8i.xlarge (Xeon 6975P-C) master → branch c8g.xlarge (Graviton4) master → branch
put_u8_bytes_mut 402 → 66 (6.1×) 641 → 92 (6.9×)
put_slice_bytes_mut 14.1 → 2.9 (4.8×) 12.5 → 6.0 (2.1×)
bytes_mut_extend 457 → 66 (6.9×) 613 → 92 (6.6×)
put_u8_vec 66 → 66 253 → 92 (2.7×)
put_u8_vec_push (baseline) 66 → 66 92 → 92
put_slice_vec 2.7 → 2.7 4.5 → 4.5
every other row within ±3 % within ±5 %

<BytesMut as BufMut>::{put, put_slice, put_bytes} were the only BufMut
methods on BytesMut without #[inline] (tokio-rs#595 inlined extend_from_slice and
noted these as the follow-up; tokio-rs#459 did the same for Vec<u8>). Without it,
and often even with LTO once reserve_inner has been folded in, every
put_slice into a BytesMut is an out-of-line call, and put_u8 (trait
default: put_slice(&[n])) additionally pays a one-byte memcpy and a
second capacity check in advance_mut. Byte-at-a-time writers such as
varint encoders are dominated by this.

Adds #[inline] to the three methods and a put_u8/put_i8 override on
BytesMut and Vec<u8> shaped like Vec::push.

benches/bytes_mut.rs (x86_64, pinned cores):
  put_slice_bytes_mut   16.97 ns -> 3.18 ns   (put_slice_vec: 2.8)
  put_u8_bytes_mut      492 ns   -> 69 ns     (put_u8_vec_push: 70)
  put_u8_vec            205 ns   -> 70 ns
@rpb-ant

rpb-ant commented Sep 10, 2026

Copy link
Copy Markdown
Author

I was a bit surprised, personally, about the put_u8_vec difference between c8i.xlarge (which shows no change) and c8g.xlarge (which shows a 2.7x improvement).

I'm admittedly a bit out of my depth here, but the assembly looks like this at master:

loop: cmp  [rbx], rsi          ; cap == len ?
        je   grow
        mov  rax, [rbx+8]        ; ptr
        mov  byte [rax+rsi], 0x78
        mov  rsi, [rbx+0x10]     ; RELOAD len from memory
        inc  rsi
        mov  [rbx+0x10], rsi     ; store len
        dec  ebp / jne loop

Graviton4 (c8g, 253 → 92 ns) pays the forwarding round-trip every byte. Granite Rapids (c8i, 66 → 66 ns) apparently predicts and forwards the same-address store+load at near-zero cost.

The new assembly (with this change) is streamlined and performs well on both architectures.

loop: mov  rax, [rbx+8]
        mov  byte [rax+r14], 0x78
        inc  r14                 ; len lives in r14
        mov  [rbx+0x10], r14
        ...  cmp r14, [rbx] / jne loop

@seanmonstar
seanmonstar enabled auto-merge (squash) September 10, 2026 19:31
iainmcgin added a commit to fallintoplace/buffa that referenced this pull request Sep 20, 2026
…nthropics#437)

**`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.

---------

Co-authored-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.

1 participant