Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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 every benchmark shape, with or without LTO, and is now within noise of it. 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
11 changes: 11 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,17 @@ 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 LTO bench profile and the no-LTO profile a
downstream `cargo build --release` gets. Extra args go to criterion,
e.g. task bench-encode-sink -- --save-baseline main
dir: benchmarks/buffa
cmds:
- cargo bench --bench encode_sink -- {{.CLI_ARGS}}
- cargo bench --bench encode_sink --profile bench-nolto -- {{.CLI_ARGS}}

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.

15 changes: 15 additions & 0 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 Down Expand Up @@ -102,7 +103,21 @@ required-features = ["iso", "mesh"]
lto = true
codegen-units = 1

# What a downstream crate gets from a plain `cargo build --release` (no
# `[profile.release] lto` 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 the `BytesMut` sink cost that LTO otherwise hides.
[profile.bench-nolto]
inherits = "bench"
lto = false
codegen-units = 16

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

[[bench]]
name = "encode_sink"
harness = false
required-features = ["api_response", "log_record", "google_message1"]
152 changes: 152 additions & 0 deletions benchmarks/buffa/benches/encode_sink.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Encode-sink comparison: the same messages written through `Vec<u8>` vs
// `BytesMut`, and through the `encode_to_vec` / `encode_to_bytes` entry
// points, for three payload shapes.
//
// Why this exists: `BufMut for BytesMut` does not mark `put_slice` (and hence
// the default `put_u8`) `#[inline]`, so without whole-program LTO every tag and
// varint byte written through a `BytesMut` is an out-of-line call. `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.
//
// Run it twice: once at the default bench profile (fat LTO — the gap mostly
// closes, which is why the other benches never showed it) and once the way a
// downstream crate without `[profile.release] lto` builds:
//
// cargo bench --bench encode_sink
// cargo bench --bench encode_sink --profile bench-nolto
//
// (or `task bench-encode-sink`, which runs both).
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};
use bench_buffa::benchmarks::BenchmarkDataset;
use bench_buffa::proto3::GoogleMessage1;

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

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 total: u64 = sizes.iter().map(|&n| n as u64).sum();

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

// The two library entry points.
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());
}
})
});

// The same write pass into a pre-sized sink of each type: isolates the
// sink's per-`put` cost from allocation and size computation.
g.bench_function("encode_into_vec_presized", |b| {
b.iter(|| {
for (m, &n) in msgs.iter().zip(&sizes) {
let mut buf: Vec<u8> = Vec::with_capacity(n);
m.encode(&mut buf);
black_box(buf);
}
})
});
g.bench_function("encode_into_bytesmut_presized", |b| {
b.iter(|| {
for (m, &n) in msgs.iter().zip(&sizes) {
let mut buf = BytesMut::with_capacity(n);
m.encode(&mut buf);
black_box(buf);
}
})
});
// A caller that frames into its own `BytesMut` (e.g. a 5-byte envelope
// header followed by the message) keeps the slow path even after the
// `encode_to_bytes` change; this row shows what such callers still pay
// and what encoding to a `Vec` first and copying once would cost instead.
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 total: u64 = ds.payload.iter().map(|p| p.len() as u64).sum();
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));
g.bench_function("encode_to_vec", |b| {
b.iter(|| {
for v in &views {
black_box(v.encode_to_vec());
}
})
});
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) {
// String-heavy (many short length-delimited fields), nested/mixed, and
// dense small scalars respectively.
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",
include_bytes!("../../datasets/google_message1_proto3.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
21 changes: 12 additions & 9 deletions buffa/src/encode_sink.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,12 +262,12 @@ impl Rope {
/// is a non-consuming full copy.)
#[must_use]
pub fn to_contiguous_bytes(&self) -> Bytes {
let mut out = BytesMut::with_capacity(self.len());
let mut out = Vec::with_capacity(self.len());
for segment in &self.segments {
BufMut::put_slice(&mut out, segment);
out.extend_from_slice(segment);
}
BufMut::put_slice(&mut out, &self.tail);
out.freeze()
out.extend_from_slice(&self.tail);
Bytes::from(out)
}

/// Move the accumulated tail into the segment list.
Expand All @@ -281,9 +281,12 @@ impl Rope {
impl EncodeSink for Rope {
const IS_SEGMENTED: bool = true;

// The tail is written through `BytesMut::extend_from_slice` (inherent,
// `#[inline]`) rather than its `BufMut::put_*` impls, which are
// out-of-line calls per tag/varint byte without LTO.
#[inline]
fn put_u8(&mut self, value: u8) {
BufMut::put_u8(&mut self.tail, value);
self.tail.extend_from_slice(&[value]);
}

#[inline]
Expand All @@ -301,17 +304,17 @@ impl EncodeSink for Rope {
return;
}
}
BufMut::put_slice(&mut self.tail, src);
self.tail.extend_from_slice(src);
}

#[inline]
fn put_u32_le(&mut self, value: u32) {
BufMut::put_u32_le(&mut self.tail, value);
self.tail.extend_from_slice(&value.to_le_bytes());
}

#[inline]
fn put_u64_le(&mut self, value: u64) {
BufMut::put_u64_le(&mut self.tail, value);
self.tail.extend_from_slice(&value.to_le_bytes());
}

#[inline]
Expand All @@ -320,7 +323,7 @@ impl EncodeSink for Rope {
self.flush_tail();
self.segments.push(bytes);
} else {
BufMut::put_slice(&mut self.tail, &bytes);
self.tail.extend_from_slice(&bytes);
}
}
}
Expand Down
24 changes: 7 additions & 17 deletions buffa/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -714,20 +714,15 @@ pub trait Message: DefaultInstance + Clone + PartialEq + Send + Sync {
/// error-returning variant. In debug builds, also panics if a manual
/// implementation's `write_to` produces a different byte count than
/// its `compute_size` declared.
// Direct body — see encode_to_vec for why the fat-payload entry points
// do not delegate to their try_ twins.
// Encodes into a `Vec<u8>` and converts: `From<Vec<u8>> for Bytes` is
// zero-copy and allocation-free for an exactly-sized vec, and writing
// through `Vec<u8>` inlines each `put_u8`/`put_slice` to a plain store,
// where `BytesMut`'s `BufMut::put_slice` is an out-of-line call per
// tag and varint byte.
#[inline]
#[must_use]
fn encode_to_bytes(&self) -> bytes::Bytes {
let mut cache = crate::SizeCache::new();
let size = match checked_encode_size(self.compute_size(&mut cache)) {
Ok(size) => size as usize,
Err(_) => encode_size_overflow(),
};
let mut buf = bytes::BytesMut::with_capacity(size);
self.write_to(&mut cache, &mut buf);
debug_assert_two_pass(buf.len(), size);
buf.freeze()
bytes::Bytes::from(self.encode_to_vec())
}

/// Encode to a new [`bytes::Bytes`], returning an error instead of
Expand All @@ -744,12 +739,7 @@ pub trait Message: DefaultInstance + Clone + PartialEq + Send + Sync {
/// In debug builds, panics if a manual implementation's `write_to`
/// produces a different byte count than its `compute_size` declared.
fn try_encode_to_bytes(&self) -> Result<bytes::Bytes, EncodeError> {
let mut cache = crate::SizeCache::new();
let size = checked_encode_size(self.compute_size(&mut cache))? as usize;
let mut buf = bytes::BytesMut::with_capacity(size);
self.write_to(&mut cache, &mut buf);
debug_assert_two_pass(buf.len(), size);
Ok(buf.freeze())
self.try_encode_to_vec().map(bytes::Bytes::from)
}

/// Decode a message from a buffer.
Expand Down
Loading
Loading