TL;DR: I built benchfmt.rs, a zero-dependency Rust benchmark runner and Criterion converter for Go’s benchstat. It adds /key=value projections, heap tracking for allocated bytes (B/op) and objects (allocs/op) per op, and pprof profiling.
In this post, I am benchmarking varint and Prometheus Remote Write 2.0 decoding throughout Go and Rust to showcase the benefits of benchstat-like stream in Rust. I’m additionally sharing several fun learnings on the way (e.g., yes Rust is faster).
Recently, I’ve been operating on a few Rust projects throughout Google and open source. I came to Rust following years of penning performance-sensitive Go in Prometheus, Thanos, and instruction Go in the “Efficient Go” book. For me, much of Rust’s achievement narrative is really good, until…. I wanted to run A/B microbenchmarks.
Perhaps I was spoiled by the Go benchmarking ecosystem, alongside the go tool -bench and benchstat in the center. To me, Rust norm benchmarking tools are lacking:
- Standard format for case difference projections.
- Standard heap allocation measurements.
- Standard pprof support.
- Simple to comprehend reliability scoring.
DISCLAIMER: A norm current method item that volition be acquainted to ample figure of developers, and I biased on Go cognition for sure!
As a result, I asked myself: can we get benchstat-compatible output for Rust microbenchmarks? Wouldn’t that create A/B evaluation easier and unlock fun Go vs Rust comparisons?
Here we are! I built benchfmt.rs, a zero-dependency Rust benchmark runner alongside a thread-local heap allocation counter, pprof profiler integration, and the identical CLI flags as go test -bench. For those who don’t rely a new benchmarking runtime (you shouldn’t from the start!), it additionally ships a Criterion and libtest converter that emits Go’s benchfmt format directly.
In this blog post, we volition execute the identical pieces of code in Go and Rust, benchmark them throughout distinct Rust frameworks, and difference the results. We power study item new alongside the way (I did whenever construction this!). Enjoy!
All benchmark code and raw outputs live in bwplotka/benchmarks/benchmarks/rust-go-decode.
Benchmarking two workloads
Let’s choice two workloads to execute and benchmark in the two Go and Rust. I am particularly inquisitive concerning protobuf decoding performance, which, comic enough, is frequently among the chief expenses of operating distributed systems these days. Thus, let’s decode our Prometheus Remote Write 2.0 protocol. Because protobuf code generators have distinct maturity and trade-offs throughout languages, let’s additionally measure item simpler alongside it, a standalone varint decoder.
Workload details:
- DecodeVarints. A 36-line unsigned LEB128 varint stream decoder. Varints are the entire figure encoding underneath Protocol Buffers. Our fixture varints_2000.bin is 4,798 bytes and packs 2,000 uint64 values mixing 1-byte sign indices, 2-byte bucket offsets, 4-byte deltas, and 6-byte millisecond Unix timestamps. The function writes into a preallocated 2,000-element piece alongside zero heap allocations.
- DecodeRemoteWrite2. A complete HTTP petition build decoder for Snappy-compressed Remote Write 2.0 (io.prometheus.write.v2.Request) payloads. We test batches of 200 and 2,000 series, anywhere 2,000 matches Prometheus’s default max_samples_per_send. The 2,000-series fixture mixes counters, gauges, traditional and native histograms, 1,000 trace exemplars, and metric metadata. Remote Write 2.0 interns all strings into a top-level symbols array and stores packed varint labels_refs per series. That shrinks the 2,000-series lot to 244.9 KiB uncompressed and 57.0 KiB Snappy-compressed.
Implementing and benchmarking in Go
Let’s commencement alongside Go. First, DecodeVarints writes direct into a caller-provided slice, alongside a accelerated way for single-byte values below 0x80:
func DecodeVarints(src []byte, dst []uint64) (int, error) { n := 0 i := 0 for i < len(src) { if n >= len(dst) { return n, ErrDstTooSmall } b := src[i] i++ if b < 0x80 { dst[n] = uint64(b) n++ continue } x := uint64(b & 0x7f) shift := uint(7) for { if i >= len(src) { return n, ErrUnexpectedEOF } b = src[i] i++ x |= uint64(b&0x7f) << shift if b < 0x80 { break } shift += 7 if shift >= 64 { return n, ErrOverflow } } dst[n] = x n++ } return n, nil }
Second, for DecodeRemoteWrite2, we tune the Go implementation the way Prometheus runs in manufacturing fairly than benchmarking dilatory reflection-based unmarshaling. We brace github.com/golang/snappy, which uses x86-64 gathering on linux/amd64, alongside vtprotobuf and its generated, optimized UnmarshalVT decoder:
func DecodeRemoteWrite2(compressed []byte) (*writev2.Request, error) { raw, err := snappy.Decode(nil, compressed) if err != nil { return nil, err } req := &writev2.Request{} if err := req.UnmarshalVT(raw); err != nil { return nil, err } return req, nil }
Now let’s measure the effectiveness of our Go code!
Notice Go’s /key=value sub-benchmark names (samples=2000/lang=go), which let Go’s authoritative benchstat tool pivot throughout /lang or /samples later, alongside alongside b.SetBytes, b.ReportAllocs, and for b.Loop():
func BenchmarkDecodeVarints(b *testing.B) { manifest := ensureFixtures(b) raw, err := os.ReadFile(filepath.Join("testdata", "varints_2000.bin")) if err != nil { b.Fatalf("read varints: %v", err) } dst := make([]uint64, manifest.VarintsCount) // Encode dimensions as /key=value so benchstat can pivot throughout /samples or /lang. b.Run("samples=2000/lang=go", func(b *testing.B) { b.SetBytes(int64(len(raw))) b.ReportAllocs() // NOTE: b.Loop() resets the timer on its archetypal call, stops it on exit, and keeps // call arguments and results living without package-level descend variables. for b.Loop() { n, err := DecodeVarints(raw, dst) if err != nil || n != manifest.VarintsCount { b.Fatalf("unexpected decode result: n=%d err=%v", n, err) } } }) } func BenchmarkDecodeRemoteWrite2(b *testing.B) { ensureFixtures(b) for _, samples := range []int{200, 2000} { file := fmt.Sprintf("rw2_%d.snappy", samples) compressed, err := os.ReadFile(filepath.Join("testdata", file)) if err != nil { b.Fatalf("read %s: %v", file, err) } b.Run(fmt.Sprintf("samples=%d/lang=go", samples), func(b *testing.B) { b.SetBytes(int64(len(compressed))) b.ReportAllocs() for b.Loop() { req, err := DecodeRemoteWrite2(compressed) if err != nil || len(req.Timeseries) != samples { b.Fatalf("unexpected v2 decode: err=%v", err) } } }) } }
Finally, let’s run 6 benchmark runs (“epochs”) for 2s all at -cpu 2 on a 24-core Intel Xeon 2.20GHz Linux workstation (Go 1.26.8, Rust 1.99.1):
go test -run '^$' -bench '^BenchmarkDecode' \ -benchtime 2s -count 6 -cpu 2 -timeout 999m -benchmem \ | tee results/go.txt
One era looks as follows:
goos: linux goarch: amd64 pkg: github.com/bwplotka/benchmarks/benchmarks/rust-go-decode cpu: Intel(R) Xeon(R) CPU @ 2.20GHz BenchmarkDecodeVarints/samples=2000/lang=go-2 279861 8514 ns/op 563.52 MB/s 0 B/op 0 allocs/op BenchmarkDecodeRemoteWrite2/samples=200/lang=go-2 8467 264321 ns/op 31.43 MB/s 195296 B/op 2189 allocs/op BenchmarkDecodeRemoteWrite2/samples=2000/lang=go-2 976 2448633 ns/op 23.84 MB/s 1643970 B/op 19297 allocs/op
Implementing and benchmarking in Rust alongside Criterion
Now let’s harbor the two decoders and benchmarks to Rust. First, decode_varints mirrors the Go iteration row for line:
pub fn decode_varints(src: &[u8], dst: &mut [u64]) -> Result<usize, VarintError> { let mut n = 0usize; let mut i = 0usize; while i < src.len() { if n >= dst.len() { return Err(VarintError::DstTooSmall); } let mut b = src[i]; i += 1; if b < 0x80 { dst[n] = u64::from(b); n += 1; continue; } let mut x = u64::from(b & 0x7f); let mut shift = 7u32; loop { if i >= src.len() { return Err(VarintError::UnexpectedEof); } b = src[i]; i += 1; x |= u64::from(b & 0x7f) << shift; if b < 0x80 { break; } shift += 7; if shift >= 64 { return Err(VarintError::Overflow); } } dst[n] = x; n += 1; } Ok(n) }
Second, decode_remote_write2 pairs the snap crate alongside prost compile-time generated Message structs:
pub fn decode_remote_write2(compressed: &[u8]) -> Result<proto::v2::Request, String> { let raw = snap::raw::Decoder::new() .decompress_vec(compressed) .map_err(|e| e.to_string())?; proto::v2::Request::decode(raw.as_slice()).map_err(|e| e.to_string()) }
Now, let’s compose the two Rust benchmarks using Criterion, the default choice for stable-Rust microbenchmarking. We keep the identical /key=value benchmark IDs and configure Throughput::BytesDecimal to equivalent Go’s decimal MB/s:
fn bench_varints(c: &mut Criterion) { // Resolve testdata/ from the crate directory baked in at compile time. let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata"); let raw = fs::read(root.join("varints_2000.bin")).unwrap(); // Preallocate the output buffer exterior the timed iteration so all op does 0 heap allocs. let mut dst = vec![0u64; 2000]; // Group + function ID associate into "DecodeVarints/samples=2000/lang=rust" to equivalent Go's b.Run. let mut group = c.benchmark_group("DecodeVarints"); // Report decimal MB/s (1e6 B/s) to equivalent Go's b.SetBytes; Throughput::Bytes prints MiB/s. group.throughput(Throughput::BytesDecimal(raw.len() as u64)); group.bench_function("samples=2000/lang=rust", |b| { // b.iter runs the closure throughout Criterion's warmup and 100 linear-ramp samples. b.iter(|| { // std::hint::black_box is an LLVM optimization barrier: Unlike Go's b.Loop(), Rust // would constant-fold known inputs or delete unused activity without black_box hints. let n = decode_varints(black_box(&raw), black_box(&mut dst)).unwrap(); assert_eq!(n, 2000); // Observe the written buffer so LLVM cannot optimize distant stores into dst. black_box(dst[n - 1]); }); }); group.finish(); } fn bench_remote_write2(c: &mut Criterion) { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata"); let mut group = c.benchmark_group("DecodeRemoteWrite2"); for samples in [200usize, 2000usize] { let file = format!("rw2_{samples}.snappy"); let compressed = fs::read(root.join(file)).unwrap(); group.throughput(Throughput::BytesDecimal(compressed.len() as u64)); let id = format!("samples={samples}/lang=rust"); group.bench_function(&id, |b| { b.iter(|| { let req = decode_remote_write2(black_box(&compressed)).unwrap(); assert_eq!(req.timeseries.len(), samples); // Force req to materialize before dropping at the end of the iteration. black_box(req); }); }); } group.finish(); } // Generate the benchmark collection runner and tradition main() required by harness = false in Cargo.toml. criterion_group!(benches, bench_varints, bench_remote_write2); criterion_main!(benches);
Finally, let’s run the Criterion benchmark suite:
cargo place --bench criterion_decode
DecodeVarints/samples=2000/lang=rust time: [5.2789 µs 5.3369 µs 5.4014 µs] thrpt: [888.29 MB/s 899.02 MB/s 908.90 MB/s] DecodeRemoteWrite2/samples=200/lang=rust time: [237.09 µs 238.48 µs 239.95 µs] thrpt: [34.620 MB/s 34.833 MB/s 35.037 MB/s] DecodeRemoteWrite2/samples=2000/lang=rust time: [2.0712 ms 2.0831 ms 2.0968 ms] thrpt: [27.835 MB/s 28.018 MB/s 28.179 MB/s]
Criterion downsides
Criterion is convenient whenever you desire confidence intervals and HTML parcels from a sole cargo place command without external tools. However, compared to go test and benchstat, four scheme choices get in the way:
-
Compile weight. Pulling in criterion alongside default-features = false motionless compiles clap, regex-automata, ciborium, oorandom, itertools, tinytemplate, and anes. That adds 42 transitive crates and ~19s to a chilly cargo place build.
-
Linear-ramp sampling vs steady-state epochs.
- Go’s testing.B calibrates a fixed repeat figure N for -benchtime 2s and runs -count 6 equal steady-state epochs, which benchstat summarizes using the outlier-resistant median and Mann-Whitney U test.
- Criterion defaults to Linear sampling: following a 3-second warmup, it collects 100 batches of [1d, 2d, ..., 100d] iterations and fits a least-squares row y = m * x through the origin. Because the row is forced through zero, it does not subtract a fixed timer-overhead intercept. Instead, it computes a k^2-weighted average anywhere lot 100 carries 10,000x the importance of lot 1 and delayed OS spikes drag the incline up.
Comparing those two models immediately introduces two measurable distortions:
- First, grouping Criterion’s 100 ramp samples into 6 benchstat epochs gives era 1 11.5x small iterations than era 6 (24,888 vs 286,212 on DecodeVarints). Mixing abbreviated bursts alongside lengthy runs inflates variance to ± 5%, compared to ± 1-2% in steady-state epochs.
- Second, timing 100 distinct closure batches adds +8.6% overhead on microsecond loops (5.36 µs vs 4.90 µs, p=0.002) and skews first allocating samples alongside chilly allocator caches (+2.3% on DecodeRemoteWrite2).
-
No heap allocation tracking. Criterion reports wall-clock period and throughput only. On DecodeVarints at 0 allocs/op, that is fine. On DecodeRemoteWrite2, we cannot inform whether prost made 500 heap allocations or 500,000, or whether a refactor doubled heap churn.
-
Pairwise baseline folders vs multi-dimensional tables. Criterion compares target/criterion/<id>/base against new. You cannot nourish a sole run document into a array that pivots -col /samples or -col /lang throughout Go and Rust.
Converting Criterion output alongside the benchfmt CLI
If a Rust codebase already uses Criterion, rewriting benchmarks fair to get a benchstat array is not ideal. That is why benchfmt.rs includes a standalone benchfmt CLI binary. It says benchmark.json and sample.json immediately from target/criterion, extracts Throughput::BytesDecimal (or Throughput::Bytes) as decimal MB/s, and groups Criterion’s 100 samples into 6 adjacent epochs via --epochs 6:
cargo instal --git https://github.com/bwplotka/benchfmt.rs benchfmt criterion target/criterion \ --pkg github.com/bwplotka/benchmarks/benchmarks/rust-go-decode \ --cpu 2 \ > results/rust_criterion.txt benchstat -col /lang results/go.txt results/rust_criterion.txt
Without touching the benchmark implementation, benchstat gives us a side-by-side Go vs Rust table:
goos: linux goarch: amd64 pkg: github.com/bwplotka/benchmarks/benchmarks/rust-go-decode cpu: Intel(R) Xeon(R) CPU @ 2.20GHz │ go │ rust │ │ sec/op │ sec/op vs basis │ DecodeVarints/samples=2000-2 8.536µ ± 1% 5.364µ ± 5% -37.16% (p=0.002 n=6) DecodeRemoteWrite2/samples=200-2 271.4µ ± 3% 235.1µ ± 4% -13.37% (p=0.002 n=6) DecodeRemoteWrite2/samples=2000-2 2.470m ± 5% 2.082m ± 1% -15.72% (p=0.002 n=6) geomean 178.9µ 138.0µ -22.87% │ go │ rust │ │ B/s │ B/s vs basis │ DecodeVarints/samples=2000-2 536.1Mi ± 1% 853.2Mi ± 5% +59.15% (p=0.002 n=6) DecodeRemoteWrite2/samples=200-2 29.19Mi ± 3% 33.70Mi ± 4% +15.44% (p=0.002 n=6) DecodeRemoteWrite2/samples=2000-2 22.53Mi ± 5% 26.74Mi ± 1% +18.67% (p=0.002 n=6) geomean 70.65Mi 91.60Mi +29.67% │ go │ │ B/op │ DecodeVarints/samples=2000-2 0.000 ± 0% DecodeRemoteWrite2/samples=200-2 190.7Ki ± 0% DecodeRemoteWrite2/samples=2000-2 1.568Mi ± 0% geomean ¹ ¹ summaries must be >0 to compute geomean │ go │ │ allocs/op │ DecodeVarints/samples=2000-2 0.000 ± 0% DecodeRemoteWrite2/samples=200-2 2.189k ± 0% DecodeRemoteWrite2/samples=2000-2 19.30k ± 0% geomean ¹ ¹ summaries must be >0 to compute geomean
Now, alongside this perspective we eventually have a readable, norm benchstat output Go developers love. It plainly shows the Go vs Rust effectiveness for our example functions. Rust is 37.16% faster on DecodeVarints (5.36 µs vs 8.54 µs) and 13.37% to 15.72% faster on DecodeRemoteWrite2.
Unfortunately, there are several gaps:
- The B/op and allocs/op tables lone have a go pillar since Criterion never recorded allocations
- Criterion’s linear-ramp epochs motionless depart ± 5% variance on DecodeVarints.
Native benchfmt runs alongside benchfmt::Suite and CountingAlloc
For new Rust benchmarks, we can skip Criterion and use benchfmt::Suite alongside harness = false in Cargo.toml. Registering CountingAlloc as #[global_allocator] tracks all alloc, alloc_zeroed, and realloc in a thread_local! Cell<(u64, u64)> alongside zero nuclear disagreement and zero external dependencies:
// Wrap Rust's System allocator globally so all heap alloc/realloc on the benchmark thread // increments thread-local (allocs, bytes) counters for B/op and allocs/op. #[global_allocator] static ALLOC: CountingAlloc = CountingAlloc::new(); fn main() { let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata"); let raw_varints = fs::read(root.join("varints_2000.bin")).unwrap(); let rw2_200 = fs::read(root.join("rw2_200.snappy")).unwrap(); let rw2_2000 = fs::read(root.join("rw2_2000.snappy")).unwrap(); let mut dst = vec![0u64; 2000]; // Parse go-test-compatible CLI flags (-bench, -benchtime, -count, -cpu, -benchmem, // -cpuprofile, -memprofile) from std::env::args() and set the default "pkg:" header. // Using the identical pkg way as Go's go.mod lets `benchstat -col /lang` associate Go and Rust rows. Suite::from_env("github.com/bwplotka/benchmarks/benchmarks/rust-go-decode") // Attach ALLOC so all benchmark snapshots thread-local allocation deltas per epoch. .with_alloc(&ALLOC) // Suite prepends "Benchmark" in benchfmt output, matching Go's BenchmarkDecodeVarints. .bench("DecodeVarints/samples=2000/lang=rust", |b| { b.set_bytes(raw_varints.len() as u64); b.report_allocs(); // Go b.Loop() equivalent: starts timing, alloc snapshots, and any pprof grasp on the // archetypal call, runs the calibrated N steady-state iterations, afterward stops all three. while b.keep_running() { let n = decode_varints(black_box(&raw_varints), black_box(&mut dst)).unwrap(); assert_eq!(n, 2000); black_box(dst[n - 1]); } }) .bench("DecodeRemoteWrite2/samples=200/lang=rust", |b| { b.set_bytes(rw2_200.len() as u64); b.report_allocs(); while b.keep_running() { let req = decode_remote_write2(black_box(&rw2_200)).unwrap(); assert_eq!(req.timeseries.len(), 200); black_box(req); } }) .bench("DecodeRemoteWrite2/samples=2000/lang=rust", |b| { b.set_bytes(rw2_2000.len() as u64); b.report_allocs(); while b.keep_running() { let req = decode_remote_write2(black_box(&rw2_2000)).unwrap(); assert_eq!(req.timeseries.len(), 2000); black_box(req); } }) .run(); }
Does CountingAlloc activity alongside Tokio and tradition allocators? Mostly yes. You can cover tradition allocators akin jemalloc or mimalloc via CountingAlloc::with_allocator(Jemalloc). Because CountingAlloc uses thread-local counters on the benchmark thread, single-threaded Tokio current_thread plant out of the box, during multi-threaded pools akin tokio::spawn or rayon need a process-wide nuclear AllocTracker passed to Suite::with_alloc.
Suite::from_env says std::env::args(), auto-detects goos, goarch, and cpu, and writes the passed cord as the pkg: metadata row (matching Go’s component way so benchstat joins Go and Rust rows into one table). Passing flags following -- mirrors go test:
cargo place --bench suite_decode -- \ -bench '^BenchmarkDecode' -benchtime 2s -count 6 -cpu 2 -benchmem \ | tee results/rust_suite.txt
First, let’s verify that benchfmt::Suite agrees alongside Criterion on timing, equal alongside CountingAlloc energetic on all allocation:
benchstat criterion=results/rust_criterion.txt suite=results/rust_suite.txt
│ criterion │ suite │ │ sec/op │ sec/op vs basis │ DecodeRemoteWrite2/samples=2000/lang=rust-2 2.082m ± 1% 2.033m ± 2% -2.33% (p=0.015 n=6) DecodeRemoteWrite2/samples=200/lang=rust-2 235.1µ ± 4% 231.5µ ± 1% -1.55% (p=0.004 n=6) DecodeVarints/samples=2000/lang=rust-2 5.364µ ± 5% 4.902µ ± 2% -8.60% (p=0.002 n=6) geomean 138.0µ 132.1µ -4.21%
On DecodeRemoteWrite2, Criterion and benchfmt::Suite concur inside 1.6% to 2.3%, during Suite’s steady-state 2s epochs tighten worst-case variance from ± 4-5% downward to ± 1-2%. On DecodeVarints, during b.keep_running() runs 490,000 back-to-back iterations in one tight iteration alternatively of 100 abbreviated ramp batches, clocking 4.90 µs vs 5.36 µs.
Comparing Go vs Rust
Now let’s run the complete Go vs Rust difference alongside results/rust_suite.txt:
benchstat -col /lang results/go.txt results/rust_suite.txt
goos: linux goarch: amd64 pkg: github.com/bwplotka/benchmarks/benchmarks/rust-go-decode cpu: Intel(R) Xeon(R) CPU @ 2.20GHz │ go │ rust │ │ sec/op │ sec/op vs basis │ DecodeVarints/samples=2000-2 8.536µ ± 1% 4.902µ ± 2% -42.56% (p=0.002 n=6) DecodeRemoteWrite2/samples=200-2 271.4µ ± 3% 231.5µ ± 1% -14.71% (p=0.002 n=6) DecodeRemoteWrite2/samples=2000-2 2.470m ± 5% 2.033m ± 2% -17.68% (p=0.002 n=6) geomean 178.9µ 132.1µ -26.12% │ go │ rust │ │ B/s │ B/s vs basis │ DecodeVarints/samples=2000-2 536.1Mi ± 1% 933.3Mi ± 2% +74.09% (p=0.002 n=6) DecodeRemoteWrite2/samples=200-2 29.19Mi ± 3% 34.22Mi ± 1% +17.23% (p=0.002 n=6) DecodeRemoteWrite2/samples=2000-2 22.53Mi ± 5% 27.37Mi ± 2% +21.48% (p=0.002 n=6) geomean 70.65Mi 95.62Mi +35.35% │ go │ rust │ │ B/op │ B/op vs basis │ DecodeVarints/samples=2000-2 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=6) ¹ DecodeRemoteWrite2/samples=200-2 190.7Ki ± 0% 271.9Ki ± 0% +42.59% (p=0.002 n=6) DecodeRemoteWrite2/samples=2000-2 1.568Mi ± 0% 2.053Mi ± 0% +30.98% (p=0.002 n=6) geomean ² +23.15% ² ¹ all samples are equal ² summaries must be >0 to compute geomean │ go │ rust │ │ allocs/op │ allocs/op vs basis │ DecodeVarints/samples=2000-2 0.000 ± 0% 0.000 ± 0% ~ (p=1.000 n=6) ¹ DecodeRemoteWrite2/samples=200-2 2.189k ± 0% 1.905k ± 0% -12.97% (p=0.002 n=6) DecodeRemoteWrite2/samples=2000-2 19.30k ± 0% 16.49k ± 0% -14.55% (p=0.002 n=6) geomean ² -9.40% ² ¹ all samples are equal ² summaries must be >0 to compute geomean
Look closely at the B/op and allocs/op tables. That is the benevolent of astonishment you young female without allocation counters:
- Rust makes 14.55% small heap allocations at 2,000 sequence (16,490 vs Go’s 19,297 allocs/op). Go’s protoc-gen-go generates pointer slices specified as []*TimeSeries and []*Sample, allocating all nested struct separately on the heap. Rust’s prost stores Vec<TimeSeries> and Vec<Sample> inline by value.
- Yet Rust requests 30.98% additional heap bytes at 2,000 sequence (2.05 MiB vs Go’s 1.57 MiB) and 42.59% more at 200 sequence (271.9 KiB vs 190.7 KiB)!
benchstat tells us what changed, during pprof tells us why. With features = ["pprof"] and [profile.bench] debug = 1 in Cargo.toml, benchfmt::Suite supports the identical -cpuprofile, -memprofile, and -memprofilerate flags as go test. Every .pb.gz document opens immediately in go tool pprof -http=:8080 or pprof.me.
CPU profiling DecodeVarints
DecodeVarints does zero heap allocations in the two languages, yet Rust finishes in 4.90 µs during Go takes 8.54 µs for the identical loop. Let’s grasp single-threaded CPU profiles from the two runners:
go test -run '^$' -bench '^BenchmarkDecodeVarints$' -benchtime 5s -count 1 -cpu 1 \ -cpuprofile /tmp/go_varints_cpu.pb.gz cargo place --bench suite_decode -- \ -bench '^BenchmarkDecodeVarints/' -benchtime 3s -count 1 -cpu 1 \ -cpuprofile /tmp/rust_varints_cpu.pb.gz
Opening the origin perspective in pprof shows anywhere all compiler spends its cycles:
Both languages expend complete 99% of CPU inner the varint loop. Go, however, burns 1.74s (32.3%) on x |= uint64(b&0x7f) << change solitary and 1.24s (23.0%) on the external iteration checks. Inspecting go tool objdump and objdump -d shows two codegen differences rearward the 42.56% gap:
- Loop unrolling and 64-bit change masking. Because change steps by 7 up to 64 (at most 9 bytes), LLVM unrolls Rust’s inner continuation loop. Every changeable << change becomes a changeless change (shl $0x7, shl $0xe, shl $0x15, …) alongside no change oppose or change >= 64 branch. Go keeps the inner iteration rolled and defines x << change as 0 whenever change >= 64, whereas x86 SHLQ CL masks modulo 64. That forces Go to emit SHLQ CL, CMPQ $64, SBBQ, and ANDQ on all continuation byte.
- Signed int vs unsigned usize indices. Go’s i and n are signed int, so i < len(src) and n >= len(dst) do not demonstrate non-negativity throughout the iteration back-edge. Go emits copy unsigned CMPQ/JBE piece limits checks before src[i] and dst[n], during LLVM removes the inner checks on Rust’s unsigned usize indices and inlines decode_varints into during b.keep_running().
CPU and heap profiling DecodeRemoteWrite2
Next, let’s collect -cpuprofile and exact -memprofile captures on the 2,000-series Remote Write 2.0 benchmark:
# CPU profiles: 3s at -cpu 1. go test -run '^$' -bench '^BenchmarkDecodeRemoteWrite2/samples=2000/' -benchtime 3s -count 1 -cpu 1 \ -cpuprofile /tmp/go_rw2_cpu.pb.gz cargo place --bench suite_decode -- \ -bench 'DecodeRemoteWrite2/samples=2000' -benchtime 3s -count 1 -cpu 1 \ -cpuprofile /tmp/rust_rw2_cpu.pb.gz # Exact heap profiles: 50 iterations alongside all allocation sampled. go test -run '^$' -bench '^BenchmarkDecodeRemoteWrite2/samples=2000/' -benchtime 50x -count 1 -cpu 1 \ -memprofile /tmp/go_rw2_mem.pb.gz -memprofilerate 1 cargo place --bench suite_decode -- \ -bench 'DecodeRemoteWrite2/samples=2000' -benchtime 50x -count 1 -cpu 1 \ -memprofile /tmp/rust_rw2_mem.pb.gz -memprofilerate 1
The two CPU flamegraphs display extremely distinct bottlenecks:
Go’s x86-assembly snappy.Decode at ~0.12 ms/op really strikes pure-Rust break at ~0.18 ms/op, and vtprotobuf’s own cable parsing takes lone ~0.95 ms/op. Go motionless loses general since runtime.mallocgc and GC assistance obtain 54.1% of all on-CPU time, or ~1.34 ms/op, allocating and scanning 19,297 heap objects.
In Rust, 71.1% of CPU period goes to prost site decoding, 8.9% to break decompression, and 18.7% to RawVecInner::grow_amortized under Vec::push and Vec::reserve. The alloc_space heap profiles display why RawVecInner::grow_amortized is so occupied and why Rust allocates 30.98% additional B/op than Go:
Go’s UnmarshalVT preallocates slices and structs near to their final size, totaling 1.57 MiB/op. Rust allocates 2.05 MiB/op, and 88.4% of those bytes arrive from RawVec capability doubling inner prost:
- prost decodes packed repeated uint32 labels_refs one u32 at a period alongside Vec::push alternatively of pre-sizing from the cable byte length. Every sequence grows its Vec<u32> through capacities 4 -> 8 -> 16 -> 32, accounting for 23.0% of all allocated bytes.
- Growing Vec<v2::TimeSeries> from 0 to 2,048 copies inline 112-byte structs at all power of two, which is 21.3% of allocated bytes. Go lone grows an 8-byte []*TimeSeries pointer slice.
- Pushing one 24-byte Sample into an bare Vec<Sample> jumps direct to capability 4 because of RawVec::MIN_NON_ZERO_CAP, adding another 8.5% of allocated bytes throughout 1,900 series.
However, even alongside that additional Vec churn, glibc thread-cache reallocations in Rust cost ~3.5x small CPU than Go’s mallocgc and GC assist, so -memprofile points direct to a Vec::with_capacity fix.
Checking our microbenchmarks alongside a standalone binary and /usr/bin/time -v
Ok, those microbenchmarks are great, but let’s verify our effectiveness numbers against the genuine program!
Microbenchmarks run inner an synthetic iteration alongside a hot education cache, a heated allocator, and a tradition #[global_allocator] wrap in suite_decode. Do those ns/op and B/op numbers clasp up in an unmodified publish binary?
To check, I wrote two standalone CLI binaries:
Each binary runs N iterations, accumulates a checksum, and optionally keeps K decoded messages living via --retain K to simulate an ingestion queue. I ran the two under GNU /usr/bin/time -v alongside GOMAXPROCS=2 on Go to equivalent -cpu 2:
cargo build --release --bin verify_decode /usr/bin/time -v ./target/release/verify_decode --case rw2_2000 --iters 1000 --retain 0 /usr/bin/time -v ./target/release/verify_decode --case rw2_2000 --iters 1000 --retain 1000
Here is what /usr/bin/time -v reported throughout all runs:
| Run | Iters | Microbench | Binary wall | Total CPU | CPU % | Max RSS |
|---|---|---|---|---|---|---|
| Rust varints | 500,000 | 4.90 µs | 5.74 µs | 2.89s (5.78 µs/op) | 99% | 2.44 MiB |
| Go varints | 500,000 | 8.54 µs | 8.64 µs | 4.34s (8.68 µs/op) | 100% | 6.11 MiB |
| Rust rw2_2000 | 1,000 | 2.03 ms | 2.22 ms | 2.22s (2.22 ms/op) | 99% | 4.21 MiB |
| Go rw2_2000 | 1,000 | 2.47 ms | 2.97 ms | 4.28s (4.28 ms/op) | 144% | 15.98 MiB |
| Rust rw2_2000 --retain 1000 | 1,000 | 2.03 ms | 2.75 ms | 3.18s (3.18 ms/op) | 99% | 1,292.4 MiB |
The process-level numbers display a few crucial details:
- CountingAlloc did not distort latency. The standalone Rust binary ran rw2_2000 at 2.22 ms/op throughout 1,000 cold-start iterations, near to 2.03 ms/op in benchfmt::Suite during counting 16,490 allocations per iteration.
- B/op measures allocator churn, not live memory. Over 1,000 iterations of rw2_2000, Rust requested 2.00 GiB from the allocator, yet highest RSS alongside retain=0 stayed at 4.21 MiB. Dropping all Request at the end of the iteration lets glibc reuse the freed thread-cache chunks on the next repeat without asking Linux for new pages. Keeping all 1,000 batches living alongside --retain 1000 raised RSS to 1,292.4 MiB (1.29 MiB per retained communication following impermanent Snappy and Vec-doubling buffers were freed) and divided CPU into 2.12s person + 1.06s kernel sys period for leaf faults.
- Wall-clock ns/op hides Go’s backdrop GC cost. In benchstat, Rust was 17.68% faster than Go on rw2_2000. Under /usr/bin/time -v at GOMAXPROCS=2, Rust used 99% of one center and 4.21 MiB RSS, during Go hit 144% CPU, 4.28 ms of CPU period per op, and 15.98 MiB RSS. Leaving GOMAXPROCS unrestricted on all 24 cores pushed Go to 220% CPU, or 7.13 ms of CPU period per op, and 18.53 MiB RSS as backdrop GC threads swept heap objects. Across the entire process, Rust used 1.9x to 3.2x small CPU and 3.8x to 4.4x small RAM per request.
Summary
In broad I think Rust has genuine possible in this AI era:
- It has extreme compile-time verification, including strict ownership and recollection safety checks, which is ideal whenever AI generates code.
- It takes ages and can be annoying to program in by hand (sorry!), but AI writes a lot of code nowadays. Code mostly have to be readable for reviews, and Rust is fine adequate at that.
- It has a powerful columnar retention ecosystem about crates akin arrow-rs.
- It has no refuse collector, gives additional authority complete recollection allocations, and is clearly faster!
In conditions of benchmarking, having the two benchfmt tools in Rust lets you equivalent the workflow to the job:
- Run benchfmt criterion target/criterion on existing Criterion suites whenever you desire benchstat A/B tables throughout commits or languages without editing benchmark code.
- Use benchfmt::Suite and CountingAlloc alongside features = ["pprof"] for new Rust microbenchmarks to get accelerated std-only compiles, steady-state epochs, B/op and allocs/op tracking, and pprof profiles.
Give benchfmt.rs a rotation and let me cognize on GitHub what breaks or what is missing!