How buffa compares to other protobuf implementations — prost, prost with
bytes, Google's official protobuf v4 (protobuf-v4), and Go's google.golang.org/protobuf
— on the canonical benchmark messages, at a single point in time. (For buffa
against its own past, see ../history/.)
tables.md and charts/*.svg are the rendered results; measurement-spread.md
records how stable each number is.
Docker — for contributors (task bench-cross). Each implementation builds and
runs in its own container, so a contributor can reproduce the comparison anywhere
without installing five toolchains. This is the right tool for a local regression
check. It is not how the published numbers are produced: a laptop or CI host is
shared and virtualised, so its absolute throughput drifts and is not trustworthy.
Bare metal — for the published numbers. The committed tables.md is produced on
a quiesced bare-metal host with the same strategy as the per-release history, so the
numbers are stable and the implementations are compared on an equal footing.
- One quiesced machine. A dedicated bare-metal host with CPU turbo disabled and
the
performancegovernor. Each benchmark runs one at a time — a single instance pinned to one isolated physical core, with nothing else running — so there is no cross-instance contention. Each implementation is run for five sequential passes and the per-benchmark number is the median across them, with the spread recorded inmeasurement-spread.md. - Pinned toolchains, held constant across all implementations. rust 1.96.0
(the same pin as the per-release history), go 1.23, and protoc 33.1.
protoc 33.1 specifically because Google's
protobufv4 crate version-checks protoc exactly; the other implementations only need ≥ 3.15 for proto3optional. - The release profile, applied to every Rust implementation. Each Rust bench
crate builds at
lto=true, codegen-units=1plus 64-byte block alignment (-Cllvm-args=-align-all-nofallthru-blocks=6) — the same release-and-layout-normalized profile the history uses. This matters for fairness: the bench crates are excluded from the root workspace, so before this was fixed they silently built at cargo's defaultcodegen-units=16, lto=off, which understated every Rust implementation (a disassembly showed 317 vs 7 un-inlineddecode_varintcall sites in the buffa binary). The empty[workspace]+[profile.bench]table in each bench crate'sCargo.tomlmakes the profile take effect; alignment is applied at build time.
- Best-achievable layout, not as-shipped. Block alignment removes the build-time
code-layout lottery (so a rebuild reproduces the numbers), at the cost of measuring
the layout a profile-guided build would reach rather than what a plain
cargo buildships. The right frame for "which implementation is faster," the same choice the history makes — see../history/annotations.md. - Each implementation runs as its combined benchmark binary (all messages in one binary), so buffa's numbers here sit ~10% below the per-message-isolated history. That cross-message inliner coupling is constant across the comparison, so it does not bias one implementation against another; the history isolates per message because there the coupling varies across releases.
- protobuf-v4 wraps upb (C); the Rust profile flags only partly reach it. The
bench profile's
lto=true,codegen-units=1, and block-alignment apply to Rust LLVM IR;upb.cis compiled separately byccand linked without cross-language LTO (the upstreambuild.rshas a// TODO: enable ltofor this). Thecccrate inherits cargo's-O3but does not auto-defineNDEBUG, and the upstreambuild.rsdoesn't either, so the metal script and Dockerfile setCFLAGS=-DNDEBUGexplicitly — without it everyUPB_ASSERTis a liveassert()and everyUPB_ASSUMEisassert()instead of__builtin_unreachable().
The encode harness is identical across implementations (pre-decode outside the timed
loop, fresh allocation per iteration, black_box the result), and serialize() is
the only public encode API on the protobuf v4 crate, so the gap is what every Rust
consumer of that crate sees today. Two architectural costs account for it:
serialize()allocates a fresh upb arena, encodes back-to-front into it, then copies the arena buffer into aVec<u8>.upb_Encodehas no capacity hint (upb_ByteSizeexists but is implemented as a second full encode), so it starts from a small block andencode_growbufferdoubles on overflow — each growth is amemmoveof the bytes written so far. The Rust wrapper then.to_vec()s the final arena slice. For a message dominated by a largebytesfield (MediaFrame),perfshows ~55–60% of self-time in libcmemcpy/memmoveplus arena alloc/free, and the result is roughly three times the copy work of buffa's and prost'sVec::with_capacity(encoded_len())followed by a single in-place write. There is noserialize_into(&mut Vec)or arena-reuse path in the public API; the lower-levelupb::wire::encodelives underprotobuf::__internal. This boundary copy — moving bytes from C-managed arena memory into a Rust-ownedVec— is exactly the FFI tax a pure-Rust implementation avoids by construction.- upb's encoder is table-driven, where buffa's and prost's are monomorphized
generated code. For scalar-heavy messages (ApiResponse, GoogleMessage1),
perfshows ~75% of time insideupb_Encode's mini-table walk (encode_message/encode_scalar/encode_array) versus the fully-inlined per-field code that Rust codegen produces. That is a deliberate upb design choice (one encoder for all message shapes, small binary), not a benchmark artefact.
Decode is a different story: upb's decoder is competitive with the generated Rust decoders on most shapes, because the arena already holds the parsed message and there is no C→Rust output copy on the hot path.
tables.md,charts/*.svg— generated comparison tables and per-message charts.measurement-spread.md— generated per-implementation spread (stability).generate.py— renderstables.md+charts/from a results directory.cross_aggregate.py— turns a bare-metal run's output into per-impl median + spread (the inputsgenerate.pyconsumes, plusmeasurement-spread.md).cross_metal_run.sh— the bare-metal build-and-run script (below).
On a quiesced bare-metal host with the toolchains above on PATH, from the repo root:
benchmarks/charts/cross_metal_run.sh > /tmp/cross.out
python3 benchmarks/charts/cross_aggregate.py /tmp/cross.out benchmarks/results
python3 benchmarks/charts/generate.py benchmarks/resultsCloud provisioning and teardown of the metal host are intentionally kept out of the repository.