Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changes/unreleased/changed-20260910-encode-to-bytes-vec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
kind: Changed
body: |-
**`encode_to_bytes` / `try_encode_to_bytes` encode into a `Vec<u8>` and convert with `Bytes::from` instead of writing through a `BytesMut`** (#437), on `Message`, `ViewEncode`, and generated lazy views; `Rope`'s tail writes go through `BytesMut::extend_from_slice` for the same reason. `bytes` does not inline `<BytesMut as BufMut>::put_slice`, so every tag and varint byte written through a `BytesMut` was an out-of-line call: `encode_to_bytes` was 3–4× slower than `encode_to_vec` on the tag-dense benchmark shapes (`log_record`, `api_response`, `google_message1`), with or without LTO, and 1.7× slower on the bytes-heavy `media_frame` shape; it is now within ~5% of `encode_to_vec` on all of them. Output bytes and the returned `Bytes` representation are unchanged (`BytesMut::freeze` on a vec-backed buffer already went through `Bytes::from(Vec<u8>)`). Callers that `encode` directly into their own `BytesMut` still pay the slow sink; encoding to a `Vec` and appending it with one `put_slice` is faster even counting the copy — see `benchmarks/buffa/benches/encode_sink.rs`.
time: 2026-09-10T17:40:00.000000000+00:00
15 changes: 15 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,21 @@ tasks:
cmds:
- cargo bench -- --baseline {{.CLI_ARGS}}

bench-encode-sink:
desc: >-
Compare encode sinks (Vec<u8> vs BytesMut, encode_to_vec vs
encode_to_bytes) at both the fat-LTO bench profile and the profile a
downstream `cargo build --release` gets. The two runs keep separate
criterion result directories (target/criterion, target/criterion-nolto)
so a baseline saved by one is never compared against the other. Extra
args go to criterion, e.g. task bench-encode-sink -- --save-baseline main
dir: benchmarks/buffa
cmds:
- cargo bench --features encode_sink --bench encode_sink -- {{.CLI_ARGS}}
- cmd: cargo bench --features encode_sink --bench encode_sink --profile bench-nolto -- {{.CLI_ARGS}}
env:
CRITERION_HOME: target/criterion-nolto

bench-iso:
desc: >-
Run ONE message's benchmarks in isolation — only that message's decoder is
Expand Down
11 changes: 6 additions & 5 deletions benchmarks/buffa/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 21 additions & 3 deletions benchmarks/buffa/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ buffa-descriptor = { path = "../../buffa-descriptor", features = ["reflect", "js
buffa-smolstr = { path = "../../examples/buffa-smolstr", features = ["serde"], optional = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
bytes = "1"
smallvec = { version = "1", features = ["serde"], optional = true }

[build-dependencies]
Expand All @@ -39,6 +40,9 @@ reflect = []
lazy = []
# `iso` gates the per-message isolated targets out of the default `task bench` run.
iso = []
# `encode_sink` gates the sink-comparison bench (`task bench-encode-sink`) out of
# the default run for the same reason: its IDs must not enter saved baselines.
encode_sink = []

[[bench]]
name = "protobuf"
Expand Down Expand Up @@ -90,9 +94,11 @@ name = "mesh"
harness = false
required-features = ["iso", "mesh"]

# Build at the release profile (lto, single codegen unit) for a fair cross-impl
# comparison — the same profile a consumer building in release gets, and the one
# the per-release benchmark history pins. These crates are excluded from the root
# Build with fat LTO and a single codegen unit for a fair, reproducible
# cross-impl comparison — the profile a consumer who opts into `lto = true`
# gets, and the one the per-release benchmark history pins. (A plain downstream
# `cargo build --release` is thin-local LTO at 16 codegen units; see
# `bench-nolto` below.) These crates are excluded from the root
# workspace, so [profile.*] in a parent manifest is ignored; the empty [workspace]
# table makes each crate its own workspace root, which is what lets [profile.bench]
# take effect here. Without it cargo silently builds at cgu=16/lto=off.
Expand All @@ -102,7 +108,19 @@ required-features = ["iso", "mesh"]
lto = true
codegen-units = 1

# What a downstream crate gets from a plain `cargo build --release` (cargo's
# release defaults, no `[profile.release]` keys of its own): cross-crate calls
# that are not `#[inline]` stay out of line. `benches/encode_sink.rs` is run at
# both profiles to show that fat LTO does not rescue the `BytesMut` sink.
[profile.bench-nolto]
inherits = "release"

[[bench]]
name = "column_batch"
harness = false
required-features = ["iso", "column_batch"]

[[bench]]
name = "encode_sink"
harness = false
required-features = ["encode_sink", "api_response", "log_record", "google_message1", "media_frame"]
188 changes: 188 additions & 0 deletions benchmarks/buffa/benches/encode_sink.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
// Encode-sink comparison: the same messages written through `Vec<u8>` vs
// `BytesMut`, and through the `encode_to_vec` / `encode_to_bytes` entry
// points.
//
// Why this exists: `BufMut for BytesMut` does not mark `put_slice` (and hence
// the default `put_u8`) `#[inline]`, and LLVM folds `reserve_inner` into it, so
// every tag and varint byte written through a `BytesMut` is an out-of-line
// call — with or without fat LTO (quieted c7i.metal, both profiles:
// `encode_to_bytes` through `BytesMut` was 3.1–3.9x slower than
// `encode_to_vec` on the tag-dense shapes and 1.7x on bytes-heavy
// `media_frame`).
// `Vec<u8>`'s impl is inlined and compiles to a plain store. `encode_to_bytes`
// therefore encodes into a `Vec<u8>` and converts (zero-copy); this bench is
// the reproduction and the guard, and the `bytesmut` rows show what a caller
// encoding into their own `BytesMut` still pays.
//
// Not part of the default `task bench` run (gated behind the `encode_sink`
// feature so its IDs never enter the suite's saved baselines):
//
// cargo bench --features encode_sink --bench encode_sink
// cargo bench --features encode_sink --bench encode_sink --profile bench-nolto
//
// or `task bench-encode-sink`, which runs both and keeps their criterion
// results in separate directories.
use buffa::{Message, MessageView, ViewEncode};
use bytes::{BufMut, BytesMut};
use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput};

use bench_buffa::bench::__buffa::view::LogRecordView;
use bench_buffa::bench::{ApiResponse, LogRecord, MediaFrame};
use bench_buffa::benchmarks::BenchmarkDataset;
use bench_buffa::proto3::GoogleMessage1;

fn load(data: &[u8]) -> BenchmarkDataset {
BenchmarkDataset::decode_from_slice(data).expect("dataset")
}

/// Throughput is input payload bytes, as in every other `buffa/<shape>/*` row,
/// so MB/s is comparable across benches.
fn total_payload_bytes(d: &BenchmarkDataset) -> u64 {
d.payload.iter().map(|p| p.len() as u64).sum()
}

fn sinks<M: Message + Default>(c: &mut Criterion, name: &str, data: &[u8]) {
let ds = load(data);
let msgs: Vec<M> = ds
.payload
.iter()
.map(|p| M::decode_from_slice(p).unwrap())
.collect();
let sizes: Vec<usize> = msgs.iter().map(|m| m.encoded_len() as usize).collect();
let max_size = sizes.iter().copied().max().unwrap_or(0);

let mut g = c.benchmark_group(format!("encode_sink/{name}"));
g.throughput(Throughput::Bytes(total_payload_bytes(&ds)));

// The two library entry points. `encode_to_vec` is also the `encode` row
// of `benches/protobuf.rs`; it is repeated here so one report carries the
// reference next to `encode_to_bytes`.
g.bench_function("encode_to_vec", |b| {
b.iter(|| {
for m in &msgs {
black_box(m.encode_to_vec());
}
})
});
g.bench_function("encode_to_bytes", |b| {
b.iter(|| {
for m in &msgs {
black_box(m.encode_to_bytes());
}
})
});

// `encode` (size pass + write pass) into one reused, pre-grown sink of
// each type, so the rows differ only in the sink's per-`put` cost: no
// allocation inside the loop, and the size pass is common to both.
g.bench_function("encode_into_vec_reused", |b| {
let mut buf: Vec<u8> = Vec::with_capacity(max_size);
b.iter(|| {
for m in &msgs {
buf.clear();
m.encode(&mut buf);
black_box(&buf);
}
})
});
g.bench_function("encode_into_bytesmut_reused", |b| {
let mut buf = BytesMut::with_capacity(max_size);
b.iter(|| {
for m in &msgs {
buf.clear();
m.encode(&mut buf);
black_box(&buf);
}
})
});

// A caller that frames into its own `BytesMut` (a 5-byte envelope header
// followed by the message, as an RPC codec does) keeps the slow sink even
// after the `encode_to_bytes` change; these rows show what such a caller
// pays and what encoding to a `Vec` first and copying once costs instead.
// Both write 5 bytes per message beyond the payload throughput above.
g.bench_function("frame_header_then_encode_into_bytesmut", |b| {
b.iter(|| {
for (m, &n) in msgs.iter().zip(&sizes) {
let mut buf = BytesMut::with_capacity(5 + n);
buf.put_u8(0);
buf.put_u32(n as u32);
m.encode(&mut buf);
black_box(buf);
}
})
});
g.bench_function("frame_header_then_put_encoded_vec", |b| {
b.iter(|| {
for (m, &n) in msgs.iter().zip(&sizes) {
let mut buf = BytesMut::with_capacity(5 + n);
buf.put_u8(0);
buf.put_u32(n as u32);
buf.put_slice(&m.encode_to_vec());
black_box(buf);
}
})
});
g.finish();
}

fn view_sinks(c: &mut Criterion) {
let ds = load(include_bytes!("../../datasets/log_record.pb"));
let views: Vec<LogRecordView<'_>> = ds
.payload
.iter()
.map(|p| LogRecordView::decode_view(p).unwrap())
.collect();
let mut g = c.benchmark_group("encode_sink/log_record_view");
g.throughput(Throughput::Bytes(total_payload_bytes(&ds)));
g.bench_function("encode_to_vec", |b| {
b.iter(|| {
for v in &views {
let out = v.encode_to_vec();
debug_assert_eq!(out.len(), v.encoded_len() as usize);
black_box(out);
}
})
});
g.bench_function("encode_to_bytes", |b| {
b.iter(|| {
for v in &views {
black_box(v.encode_to_bytes());
}
})
});
g.finish();
}

fn run(c: &mut Criterion) {
// Tag-dense shapes, where the per-byte sink cost dominates: string-heavy
// (many short length-delimited fields), nested/mixed, and dense small
// scalars.
sinks::<LogRecord>(
c,
"log_record",
include_bytes!("../../datasets/log_record.pb"),
);
sinks::<ApiResponse>(
c,
"api_response",
include_bytes!("../../datasets/api_response.pb"),
);
sinks::<GoogleMessage1>(
c,
"google_message1_proto3",
include_bytes!("../../datasets/google_message1_proto3.pb"),
);
// Bytes-heavy control: KB-scale `put_slice` calls dominate, so the
// out-of-line call is amortised (1.7x rather than 3-4x) and the
// encode-then-copy framing row buys nothing here.
sinks::<MediaFrame>(
c,
"media_frame",
include_bytes!("../../datasets/media_frame.pb"),
);
view_sinks(c);
}

criterion_group!(grp, run);
criterion_main!(grp);
20 changes: 2 additions & 18 deletions buffa-codegen/src/lazy_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -521,17 +521,7 @@ pub(crate) fn generate_lazy_view_with_nesting(
#[inline]
#[must_use]
pub fn encode_to_bytes(&self) -> ::buffa::bytes::Bytes {
let mut __cache = ::buffa::SizeCache::new();
let __size = match ::buffa::checked_encode_size(
self.compute_size(&mut __cache),
) {
::core::result::Result::Ok(__size) => __size as usize,
::core::result::Result::Err(_) => ::buffa::encode_size_overflow(),
};
let mut __buf = ::buffa::bytes::BytesMut::with_capacity(__size);
self.write_to(&mut __cache, &mut __buf);
::buffa::debug_assert_two_pass(__buf.len(), __size);
__buf.freeze()
::buffa::bytes::Bytes::from(self.encode_to_vec())
}

/// Encode to a new [`::buffa::bytes::Bytes`], returning an
Expand All @@ -550,13 +540,7 @@ pub(crate) fn generate_lazy_view_with_nesting(
pub fn try_encode_to_bytes(
&self,
) -> ::core::result::Result<::buffa::bytes::Bytes, ::buffa::EncodeError> {
let mut __cache = ::buffa::SizeCache::new();
let __size =
::buffa::checked_encode_size(self.compute_size(&mut __cache))? as usize;
let mut __buf = ::buffa::bytes::BytesMut::with_capacity(__size);
self.write_to(&mut __cache, &mut __buf);
::buffa::debug_assert_two_pass(__buf.len(), __size);
::core::result::Result::Ok(__buf.freeze())
self.try_encode_to_vec().map(::buffa::bytes::Bytes::from)
}
}

Expand Down
37 changes: 37 additions & 0 deletions buffa-test/src/tests/basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,43 @@ fn test_all_scalars_round_trip() {
assert_eq!(round_trip(&msg), msg);
}

/// The library's own `*_to_bytes` entry points and `Rope` no longer write
/// through `BytesMut`'s `BufMut` impl, so this is the one place the blanket
/// `EncodeSink for BufMut` instantiation for `BytesMut` — what a caller who
/// frames into their own `BytesMut` uses — is exercised: varint, zigzag,
/// fixed32, fixed64 and length-delimited writes must match the `Vec<u8>` sink
/// byte for byte.
#[test]
fn encode_into_bytesmut_matches_encode_to_vec() {
use buffa::bytes::BytesMut;

let scalars = AllScalars {
f_int32: -1,
f_uint64: u64::MAX,
f_sint64: -200,
f_fixed32: 0xDEAD_BEEF,
f_fixed64: 0xDEAD_BEEF_CAFE_BABE,
f_double: std::f64::consts::PI,
f_bool: true,
..Default::default()
};
let person = Person {
id: 7,
name: "framed".into(),
..Default::default()
};

let mut buf = BytesMut::new();
scalars.encode(&mut buf);
assert_eq!(&buf[..], &scalars.encode_to_vec()[..]);
buf.clear();
person.encode_length_delimited(&mut buf);
let mut expected: Vec<u8> = Vec::new();
person.encode_length_delimited(&mut expected);
assert_eq!(&buf[..], &expected[..]);
assert_eq!(scalars.encode_to_bytes(), scalars.encode_to_vec());
}

#[test]
fn test_person_scalar_fields() {
let mut msg = Person::default();
Expand Down
Loading
Loading