Skip to content
Draft
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
18 changes: 18 additions & 0 deletions .changes/unreleased/Fixed-20260825-203239.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
kind: Fixed
body: |-
**A view response whose fields are all small no longer pays for a rope
that captures nothing** ([#278]). The segmented encode was gated on the
size of the whole message, but a rope only captures an individual `bytes`
or `string` field at or above the 16 KiB framing threshold, so a message
made of many small fields — a chunk of log records, a metrics batch — took
the rope, captured nothing, and copied itself into a doubling tail before
collapsing back to one contiguous buffer, once per item on a stream. The
gate now also walks the view's fields without copying a byte and takes the
rope only when at least one field would be captured; everything else goes
straight to a single sized buffer. The walk costs a pass over the fields
and a second size computation, proportional to the field count rather
than the payload, and a message below the threshold pays neither. The
encoded bytes are unchanged.

[#278]: https://github.com/connectrpc/connect-rust/issues/278
time: 2026-08-25T20:32:39.333194520+00:00
2 changes: 1 addition & 1 deletion benches/rpc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Benchmark crate for `connectrpc`. `publish = false`; nothing here ships.
| `rpc_bench` | Full-stack unary/stream RPC over loopback HTTP, Connect/gRPC/gRPC-Web × proto/JSON. Needs a running server (`cargo run --bin echo_server` etc.). | `cargo bench --bench rpc_bench` |
| `cross_impl_bench` | Cross-implementation comparison vs tonic. | `cargo bench --bench cross_impl_bench` |
| `echo_bloat` | Codec-layer (no HTTP) `{owned,view}×{decode,encode}` sweep across five payload shapes + a 1→N fanout sweep. Motivates the future view-response handler API. | `cargo bench --bench echo_bloat` |
| `view_rope_encode` | Encode cost of a response view, contiguous vs a rope backed by the view's own buffer, swept either side of the segment threshold. Shows what the segmented response path buys and what it costs below the threshold. | `cargo bench --bench view_rope_encode` |
| `view_rope_encode` | Encode cost of a response view, contiguous vs a rope backed by the view's own buffer, swept either side of the segment threshold, and across field shapes the size alone cannot tell apart (many small fields, one large field among them). Shows what the segmented response path buys, what it costs below the threshold, and what its field probe costs. | `cargo bench --bench view_rope_encode` |

Filter by criterion regex: `cargo bench --bench echo_bloat -- fanout` or `-- map_dominated`.

Expand Down
114 changes: 108 additions & 6 deletions benches/rpc/benches/view_rope_encode.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! What encoding a response view through a rope costs, across payload sizes.
//! What encoding a response view through a rope costs, across payload sizes
//! and across field shapes the payload size cannot tell apart.
//!
//! Encoding a message into one contiguous buffer copies every field into it.
//! A view's fields are slices into the buffer the view was decoded from, so a
Expand All @@ -9,23 +10,29 @@
//!
//! - `contiguous` — the copy-everything baseline.
//! - `rope_backed` — the rope with the view's own buffer, so captures engage.
//! - `encode_view_segments` — the production entry point, including its size
//! gate, which is what makes the small sizes match the baseline.
//! - `encode_view_segments` — the production entry point, including its
//! gates: the size rule, which is what makes the small sizes match the
//! baseline, and the field probe that `view_encode_shapes` exercises.
//! - `owned_contiguous` — an owned message, whose `String` fields cannot be
//! captured; this is why owned bodies are left on the contiguous path.
//! - `rope_unbacked` — a rope with no buffer to capture from. Separates the
//! rope's own cost from the saving the capture produces.
//!
//! Above the segment threshold the encode is payload-independent: only the
//! framing is still being written.
//!
//! `view_encode_shapes` holds the payload size roughly fixed and varies how it
//! divides into fields, because the production gate has to decide from the
//! fields — a message of many small ones must not pay for a rope, one large
//! field among them must — and the probe that decides it walks every field.

use buffa::view::MessageView;
use buffa::{Rope, ViewEncode};
use bytes::Bytes;
use connectrpc::{CodecFormat, Encodable};
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use rpc_bench::proto::bench::v1::__buffa::view::FewLargeStringsView;
use rpc_bench::proto::bench::v1::FewLargeStrings;
use rpc_bench::proto::bench::v1::__buffa::view::{BloatEchoView, FewLargeStringsView};
use rpc_bench::proto::bench::v1::{BloatEcho, FewLargeStrings};

/// Build a `FewLargeStrings` whose four string fields are `each` bytes, then
/// return its encoded wire bytes. The view decoded from these borrows each
Expand Down Expand Up @@ -125,5 +132,100 @@ fn bench_view_encode(c: &mut Criterion) {
group.finish();
}

criterion_group!(benches, bench_view_encode);
/// Wire bytes for a `BloatEcho` carrying `count` tags of `each` bytes and a
/// `user_agent` of `blob` bytes: with `blob = 0` a message that can clear the
/// segment threshold as a whole while no single field comes near it, and with
/// a large `blob` the same small fields around one capturable one.
fn encoded_many_small(count: usize, each: usize, blob: usize) -> Bytes {
let msg = BloatEcho {
tags: vec!["t".repeat(each); count],
user_agent: "u".repeat(blob),
..Default::default()
};
Bytes::from(buffa::Message::encode_to_vec(&msg))
}

/// Wire bytes for a `FewLargeStrings` with one `large` field and three
/// `small` ones: the shape where exactly one field earns its own segment.
fn encoded_one_large(large: usize, small: usize) -> Bytes {
let msg = FewLargeStrings {
body_a: "x".repeat(large),
body_b: "x".repeat(small),
body_c: "x".repeat(small),
body_d: "x".repeat(small),
ts: 1,
seq: 2,
..Default::default()
};
Bytes::from(buffa::Message::encode_to_vec(&msg))
}

/// The production entry point against field shapes the size gate alone
/// cannot tell apart. A ~20 KiB message of small fields must take the
/// contiguous path, whether it is 20 fields of 1 KiB or 500 of 40 B; one
/// capturable field among them must take the rope. `contiguous` is the floor
/// for the first kind and `rope_backed` for the second, and the gap between
/// `encode_view_segments` and its floor is the probe's cost, which grows with
/// the field count — the 500-field shapes are there to show it.
fn bench_view_encode_shapes(c: &mut Criterion) {
let mut group = c.benchmark_group("view_encode_shapes");

let many_small = encoded_many_small(20, 1024, 0);
let one_large = encoded_one_large(64 * 1024, 1024);
let dense_small = encoded_many_small(500, 40, 0);
let dense_one_large = encoded_many_small(500, 40, 64 * 1024);

macro_rules! shape {
($name:literal, $buffer:expr, $view_ty:ty) => {{
let buffer: &Bytes = $buffer;
let view = <$view_ty>::decode_view(buffer).expect("decode view");
group.throughput(Throughput::Bytes(buffer.len() as u64));

group.bench_with_input(BenchmarkId::new("contiguous", $name), &view, |b, view| {
b.iter(|| std::hint::black_box(view.encode_to_bytes()))
});

// Pinned to the framing threshold rather than buffa's 4 KiB
// default, so this floor is the rope the production path builds.
group.bench_with_input(BenchmarkId::new("rope_backed", $name), &view, |b, view| {
b.iter(|| {
let mut rope = Rope::with_min_segment(16 * 1024).with_backing(buffer.clone());
ViewEncode::encode(view, &mut rope);
std::hint::black_box(rope.into_segments())
});
});

group.bench_with_input(
BenchmarkId::new("encode_view_segments", $name),
&view,
|b, view| {
b.iter(|| {
std::hint::black_box(
connectrpc::__codegen::encode_view_body_with_min_segment(
view,
buffer,
CodecFormat::Proto,
16 * 1024,
)
.expect("encode"),
)
});
},
);
}};
}

shape!("many_small_20x1KiB", &many_small, BloatEchoView);
shape!("one_large_64KiB+3x1KiB", &one_large, FewLargeStringsView);
shape!("dense_small_500x40B", &dense_small, BloatEchoView);
shape!(
"dense_one_large_500x40B+64KiB",
&dense_one_large,
BloatEchoView
);

group.finish();
}

criterion_group!(benches, bench_view_encode, bench_view_encode_shapes);
criterion_main!(benches);
Loading
Loading