Performance measurement, profiling, and optimization techniques for Rust...
This guide covers performance measurement, profiling, and optimization techniques for Rust applications. It provides practical tools and frameworks for identifying bottlenecks, benchmarking changes, and optimizing critical code paths.
Performance optimization should follow measurement, not intuition. Donald Knuth's famous quote applies directly to Rust:
"Premature optimization is the root of all evil (or at least most of it) in programming."
Do not optimize until:
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā "My code is slow" ā
āāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāā
ā
v
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā Profile to find hotspot ā
āāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāā
ā
āāāāāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāāāā
ā ā ā
v v v
āāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāāā
ā CPU bound ā ā Memory bound ā ā I/O bound ā
ā (flamegraph) ā ā (DHAT) ā ā (async/par) ā
āāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāāā
Hot paths are code sections executed frequently or in performance-critical contexts:
Identify hot paths by:
// Example: Identifying hot path candidates
fn process_request(req: Request) -> Response {
let parsed = parse_json(&req.body); // Called every request - HOT
let validated = validate(&parsed); // Called every request - HOT
let result = business_logic(&validated); // Called every request - HOT
serialize_response(&result) // Called every request - HOT
}
fn initialize_config() -> Config {
// Called once at startup - NOT hot, don't optimize prematurely
load_from_file("config.toml")
}
Criterion is the de facto standard for Rust benchmarking. It provides statistical analysis, regression detection, and HTML reports.
Cargo.toml:
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "my_benchmark"
harness = false
Benchmark file structure (benches/my_benchmark.rs):
use criterion::{criterion_group, criterion_main, Criterion};
fn my_benchmarks(c: &mut Criterion) {
// Benchmarks go here
}
criterion_group!(benches, my_benchmarks);
criterion_main!(benches);
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn fibonacci(n: u64) -> u64 {
match n {
0 | 1 => n,
_ => fibonacci(n - 1) + fibonacci(n - 2),
}
}
fn bench_fibonacci(c: &mut Criterion) {
// Simple benchmark with black_box
c.bench_function("fib 20", |b| {
b.iter(|| fibonacci(black_box(20)))
});
}
criterion_group!(benches, bench_fibonacci);
criterion_main!(benches);
Why black_box is essential:
black_box() prevents the compiler from optimizing away computations. Without it, the compiler might:
// BAD: Compiler may precompute result
b.iter(|| fibonacci(20));
// GOOD: Input hidden from optimizer
b.iter(|| fibonacci(black_box(20)));
// GOOD: Output hidden from optimizer
b.iter(|| black_box(fibonacci(20)));
Use BenchmarkGroup and BenchmarkId for parameterized benchmarks:
use criterion::{
black_box, criterion_group, criterion_main,
BenchmarkId, Criterion, Throughput,
};
fn bench_fibonacci_sizes(c: &mut Criterion) {
let mut group = c.benchmark_group("Fibonacci");
for n in [10, 15, 20, 25] {
group.bench_with_input(
BenchmarkId::from_parameter(n),
&n,
|b, &n| {
b.iter(|| fibonacci(black_box(n)))
},
);
}
group.finish();
}
fn bench_sorting_throughput(c: &mut Criterion) {
let mut group = c.benchmark_group("Sorting");
for size in [100, 1000, 10000] {
// Set throughput for bytes/sec calculation
group.throughput(Throughput::Elements(size as u64));
group.bench_with_input(
BenchmarkId::new("vec_sort", size),
&size,
|b, &size| {
b.iter_batched(
|| (0..size).rev().collect::<Vec<_>>(),
|mut v| v.sort(),
criterion::BatchSize::SmallInput,
);
},
);
}
group.finish();
}
criterion_group!(benches, bench_fibonacci_sizes, bench_sorting_throughput);
criterion_main!(benches);
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn linear_search(haystack: &[i32], needle: i32) -> Option<usize> {
haystack.iter().position(|&x| x == needle)
}
fn binary_search(haystack: &[i32], needle: i32) -> Option<usize> {
haystack.binary_search(&needle).ok()
}
fn bench_search_comparison(c: &mut Criterion) {
let data: Vec<i32> = (0..10000).collect();
let needle = 5000;
let mut group = c.benchmark_group("Search");
group.bench_function("linear", |b| {
b.iter(|| linear_search(black_box(&data), black_box(needle)))
});
group.bench_function("binary", |b| {
b.iter(|| binary_search(black_box(&data), black_box(needle)))
});
group.finish();
}
criterion_group!(benches, bench_search_comparison);
criterion_main!(benches);
Save and compare baselines:
# Save baseline on main branch
cargo bench -- --save-baseline main
# Switch to feature branch and compare
git checkout feature-branch
cargo bench -- --baseline main
Criterion output explained:
Fibonacci/20 time: [24.112 us 24.234 us 24.369 us]
change: [-1.2345% +0.1234% +1.5678%] (p = 0.12 > 0.05)
No change in performance detected.
Found 2 outliers among 100 measurements (2.00%)
1 (1.00%) high mild
1 (1.00%) high severe
After running cargo bench, find reports at:
target/criterion/report/index.html
Reports include:
Divan offers simpler ergonomics with attribute-based benchmarks and better module organization.
Cargo.toml:
[dev-dependencies]
divan = "0.1"
[[bench]]
name = "my_benchmark"
harness = false
Basic benchmark (benches/my_benchmark.rs):
fn main() {
divan::main();
}
fn fibonacci(n: u64) -> u64 {
match n {
0 | 1 => n,
_ => fibonacci(n - 1) + fibonacci(n - 2),
}
}
// Simple benchmark - returning value prevents dead code elimination
#[divan::bench]
fn fibonacci_20() -> u64 {
fibonacci(20)
}
// Divan automatically handles black_box for return values
#[divan::bench]
fn fibonacci_25() -> u64 {
fibonacci(25)
}
fn main() {
divan::main();
}
fn fibonacci(n: u64) -> u64 {
match n {
0 | 1 => n,
_ => fibonacci(n - 1) + fibonacci(n - 2),
}
}
// Parameterized with args attribute
#[divan::bench(args = [10, 15, 20, 25])]
fn fibonacci_n(n: u64) -> u64 {
fibonacci(n)
}
// Multiple parameters
#[divan::bench(args = [100, 1000, 10000])]
fn sort_vec(len: usize) {
let mut v: Vec<i32> = (0..len as i32).rev().collect();
v.sort();
}
For benchmarks requiring setup that shouldn't be measured:
fn main() {
divan::main();
}
// Using Bencher for setup
#[divan::bench]
fn sort_vec(bencher: divan::Bencher) {
bencher
.with_inputs(|| (0..1000).rev().collect::<Vec<i32>>())
.bench_local_values(|mut v| v.sort());
}
// bench_local_values consumes input, creates fresh each iteration
// bench_local_refs borrows input, reuses across iterations
#[divan::bench]
fn search_vec(bencher: divan::Bencher) {
bencher
.with_inputs(|| {
let v: Vec<i32> = (0..10000).collect();
(v, 5000)
})
.bench_local_refs(|(v, needle)| v.binary_search(needle));
}
Divan reflects module structure in output:
fn main() {
divan::main();
}
mod sorting {
#[divan::bench(args = [100, 1000, 10000])]
fn quicksort(len: usize) {
let mut v: Vec<i32> = (0..len as i32).rev().collect();
v.sort_unstable();
}
#[divan::bench(args = [100, 1000, 10000])]
fn stable_sort(len: usize) {
let mut v: Vec<i32> = (0..len as i32).rev().collect();
v.sort();
}
}
mod searching {
#[divan::bench]
fn binary_search() -> Result<usize, usize> {
let v: Vec<i32> = (0..10000).collect();
v.binary_search(&5000)
}
#[divan::bench]
fn linear_search() -> Option<usize> {
let v: Vec<i32> = (0..10000).collect();
v.iter().position(|&x| x == 5000)
}
}
Output groups benchmarks by module:
sorting::quicksort
sorting::stable_sort
searching::binary_search
searching::linear_search
| Feature | Criterion | Divan |
|---|---|---|
| API Style | Builder pattern | Attribute macros |
| Statistical Analysis | Comprehensive | Basic |
| HTML Reports | Yes | No |
| Regression Detection | Yes | Manual |
| Async Support | Yes | Limited |
| Allocation Counting | Via iai-callgrind | Built-in |
| CI Suitability | Good | Excellent |
| Learning Curve | Moderate | Low |
| Measurement Overhead | Higher | Lower |
| Module Organization | Manual | Automatic |
When to use Criterion:
When to use Divan:
Problem: Compiler precomputes results at compile time.
// BAD: Compiler may compute result at compile time
fn bench_bad(c: &mut Criterion) {
c.bench_function("fib", |b| {
b.iter(|| fibonacci(20)) // Result might be precomputed!
});
}
Fix: Use black_box to hide values from the optimizer.
// GOOD: Input hidden from optimizer
fn bench_good(c: &mut Criterion) {
c.bench_function("fib", |b| {
b.iter(|| fibonacci(black_box(20)))
});
}
Problem: Same key every iteration gives 100% cache hit rate.
// BAD: Same key every iteration (always cached)
fn bench_bad(c: &mut Criterion) {
let map: HashMap<u64, String> = (0..1000)
.map(|i| (i, format!("value_{}", i)))
.collect();
c.bench_function("lookup", |b| {
b.iter(|| map.get(&500)) // 100% cache hit rate
});
}
Fix: Use realistic access patterns.
// GOOD: Realistic access pattern
fn bench_good(c: &mut Criterion) {
let map: HashMap<u64, String> = (0..1000)
.map(|i| (i, format!("value_{}", i)))
.collect();
let keys: Vec<u64> = (0..1000).collect();
c.bench_function("lookup", |b| {
let mut i = 0;
b.iter(|| {
let key = keys[i % keys.len()];
i += 1;
map.get(&black_box(key))
})
});
}
Problem: Branch predictor achieves 100% accuracy with uniform data.
// BAD: Branch predictor has 100% accuracy
fn bench_bad(c: &mut Criterion) {
let data: Vec<bool> = vec![true; 10000]; // All same!
c.bench_function("filter", |b| {
b.iter(|| data.iter().filter(|&&x| x).count())
});
}
Fix: Use realistic branch distribution.
// GOOD: Realistic branch distribution
use rand::Rng;
fn bench_good(c: &mut Criterion) {
let data: Vec<bool> = (0..10000)
.map(|_| rand::thread_rng().gen())
.collect();
c.bench_function("filter", |b| {
b.iter(|| black_box(&data).iter().filter(|&&x| x).count())
});
}
Problem: Allocation cost included in benchmark.
// BAD: Measures allocation + sort
fn bench_bad(c: &mut Criterion) {
c.bench_function("sort", |b| {
b.iter(|| {
let mut data: Vec<i32> = (0..10000).rev().collect(); // Setup
data.sort(); // Actual work
})
});
}
Fix: Use iter_batched to separate setup.
// GOOD: Setup not measured
use criterion::BatchSize;
fn bench_good(c: &mut Criterion) {
c.bench_function("sort", |b| {
b.iter_batched(
|| (0i32..10000).rev().collect::<Vec<_>>(), // Setup (not measured)
|mut data| data.sort(), // Only this measured
BatchSize::SmallInput,
)
});
}
Preventing dead code elimination:
// Use black_box for inputs
b.iter(|| process(black_box(&input)));
// Use black_box for outputs
b.iter(|| black_box(compute()));
// Divan: return value (handled automatically)
#[divan::bench]
fn bench() -> u64 {
compute()
}
Warm up caches:
// Criterion warms up automatically (default: 3 seconds)
// For manual benchmarks:
for _ in 0..100 {
let _ = my_function();
}
let start = Instant::now();
// ... measure ...
Isolate what you're measuring:
// BAD: Measures allocation + computation
b.iter(|| {
let data = generate_data(); // Allocation noise
process(data)
});
// GOOD: Setup outside iteration
let data = generate_data();
b.iter(|| process(black_box(&data)));
// GOOD: Use iter_batched for consumed inputs
b.iter_batched(
|| generate_data(),
|data| process(data),
BatchSize::SmallInput,
);
Use realistic data:
// Generate representative test data
let test_cases: Vec<_> = (0..1000)
.map(|_| generate_realistic_input())
.collect();
b.iter(|| {
for case in &test_cases {
process(black_box(case));
}
});
Benchmark both hot and cold paths:
// Cache-hot benchmark
let data = load_data();
for _ in 0..10 {
process(&data);
} // Warm cache
b.iter(|| process(&data));
// Cache-cold benchmark
b.iter(|| {
std::hint::black_box(&data); // Invalidate predictions
process(&data)
});
Flamegraphs visualize where CPU time is spent.
Installation:
cargo install flamegraph
Enable debug symbols for release builds (Cargo.toml):
[profile.release]
debug = true # Enable symbols for profiling
Generate flamegraph:
# Profile binary
cargo flamegraph --bin myapp -- --my-args
# Profile specific benchmark
cargo flamegraph --bench my_benchmark -- --bench "fib 20"
# Output: flamegraph.svg
Reading flamegraphs:
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā main (100%) ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā¤
ā process_data (60%) ā send_response (40%) ā
āāāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāāāā¼āāāāāāāāāāāāāāāāāā¬āāāāāāāāāāāāāāāā¤
ā parse_json (30%)ā validate (30%) ā serialize (25%) ā network (15%) ā
āāāāāāāāāāāāāāāāāāā“āāāāāāāāāāāāāāāāāā“āāāāāāāāāāāāāāāāāā“āāāāāāāāāāāāāāāā¤
Optimization targets:
malloc so wide?)samply provides excellent profiling with Firefox Profiler integration.
cargo install samply
# Profile your application
samply record ./target/release/myapp
# Opens Firefox Profiler UI automatically
# Features:
# - Full symbol resolution
# - Call tree view
# - Timeline view
# - Stack chart
Advantages over flamegraph:
Native Linux profiling with excellent accuracy:
# Record profile
perf record --call-graph dwarf ./target/release/myapp
# View report
perf report
# Generate flamegraph from perf data
perf script | stackcollapse-perf.pl | flamegraph.pl > perf.svg
Profile configuration for profiling release builds (Cargo.toml):
# Release with debug info for profiling
[profile.release]
debug = true
# Or create a dedicated profile
[profile.profiling]
inherits = "release"
debug = true
# Use profiling profile
cargo build --profile profiling
DHAT tracks every allocation to find memory hotspots.
Setup (Cargo.toml):
[dependencies]
dhat = { version = "0.3", optional = true }
[features]
dhat-heap = ["dhat"]
Integration (src/main.rs):
#[cfg(feature = "dhat-heap")]
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
fn main() {
#[cfg(feature = "dhat-heap")]
let _profiler = dhat::Profiler::new_heap();
// Your code here
actual_main();
} // Prints allocation stats on drop
Run with profiling:
cargo run --features dhat-heap
Output shows:
dhat: Total: 1,000 bytes in 100 blocks
dhat: At t-gmax: 500 bytes in 50 blocks
dhat: At t-end: 0 bytes in 0 blocks
dhat: The data has been written to dhat-heap.json
Key metrics:
Analyze with DHAT viewer:
# Open in Firefox
firefox https://nnethercote.github.io/dh_view/dh_view.html
# Load dhat-heap.json
Track heap usage over time:
valgrind --tool=massif ./target/release/myapp
ms_print massif.out.*
# Output shows heap usage timeline:
# KB
# 1024^ #
# | @@@#
# | @@@ #
# | @@@@ #
# | @@@ #
# | @@@@ #
# | @@@@ #
# | @@@@ #
# | @@@@ #
# | @@@@@ #
# | @@@@ #
# | @@@@ #
# 0 +----------------------------------------------->time
Excellent visualization for heap profiling:
# Record
heaptrack ./target/release/myapp
# Analyze with GUI
heaptrack_gui heaptrack.myapp.*.gz
# Features:
# - Flame graph of allocations
# - Timeline of memory usage
# - Allocation hotspots
# - Temporary vs persistent allocations
Quick allocation site identification:
# Using DHAT
cargo run --features dhat-heap 2>&1 | grep "total:"
# Using custom allocator for counting
// Simple allocation counter
use std::alloc::{GlobalAlloc, Layout, System};
use std::sync::atomic::{AtomicUsize, Ordering};
static ALLOC_COUNT: AtomicUsize = AtomicUsize::new(0);
struct CountingAllocator;
unsafe impl GlobalAlloc for CountingAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
ALLOC_COUNT.fetch_add(1, Ordering::Relaxed);
System.alloc(layout)
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
System.dealloc(ptr, layout)
}
}
#[global_allocator]
static GLOBAL: CountingAllocator = CountingAllocator;
fn main() {
// Your code
println!("Total allocations: {}", ALLOC_COUNT.load(Ordering::Relaxed));
}
For reproducible benchmarks unaffected by system load:
Setup (Cargo.toml):
[dev-dependencies]
iai-callgrind = "0.14"
[[bench]]
name = "iai_bench"
harness = false
Benchmark (benches/iai_bench.rs):
use iai_callgrind::{library_benchmark, library_benchmark_group, main};
fn fibonacci(n: u64) -> u64 {
match n {
0 | 1 => n,
_ => fibonacci(n - 1) + fibonacci(n - 2),
}
}
#[library_benchmark]
fn bench_fibonacci() -> u64 {
fibonacci(20)
}
#[library_benchmark]
#[bench::small(10)]
#[bench::medium(20)]
#[bench::large(30)]
fn bench_fib_sizes(n: u64) -> u64 {
fibonacci(n)
}
library_benchmark_group!(
name = fibonacci_group;
benchmarks = bench_fibonacci, bench_fib_sizes
);
main!(library_benchmark_groups = fibonacci_group);
Run:
cargo bench --bench iai_bench
Output:
bench_fibonacci
Instructions: 12,345 (+0.00%)
L1 Hits: 10,000 (+0.00%)
L2 Hits: 500 (+0.00%)
RAM Hits: 100 (+0.00%)
Estimated Cycles: 15,000 (+0.00%)
Advantages:
Detailed cache behavior analysis:
valgrind --tool=cachegrind ./target/release/myapp
# Output summary:
# I refs: 1,234,567
# I1 misses: 1,234
# LLi misses: 123
# D refs: 567,890
# D1 misses: 12,345
# LLd misses: 1,234
# Annotate source with cache info
cg_annotate cachegrind.out.*
The highest-impact optimizations are usually algorithmic:
| Before | After | Improvement |
|---|---|---|
| O(n^2) | O(n log n) | 100x for n=1000 |
| O(n) | O(log n) | 10x for n=1000 |
| O(n) | O(1) | 1000x for n=1000 |
// O(n^2) - nested loop lookup
fn find_pairs_slow(data: &[i32], target: i32) -> Vec<(i32, i32)> {
let mut result = Vec::new();
for &a in data {
for &b in data {
if a + b == target {
result.push((a, b));
}
}
}
result
}
// O(n) - hash set lookup
use std::collections::HashSet;
fn find_pairs_fast(data: &[i32], target: i32) -> Vec<(i32, i32)> {
let set: HashSet<_> = data.iter().copied().collect();
let mut result = Vec::new();
for &a in data {
let b = target - a;
if set.contains(&b) {
result.push((a, b));
}
}
result
}
See rust-implementation-patterns.md for detailed data structure guidance.
Quick reference:
See rust-implementation-patterns.md for memory layout optimization.
Quick wins:
#[repr(C)] only when needed for FFIPre-allocation:
// BAD: Multiple reallocations
let mut v = Vec::new();
for i in 0..10000 {
v.push(i);
}
// GOOD: Single allocation
let mut v = Vec::with_capacity(10000);
for i in 0..10000 {
v.push(i);
}
Buffer reuse:
// BAD: New allocation each iteration
for item in &items {
let mut buffer = Vec::new();
process_into(item, &mut buffer);
send(buffer);
}
// GOOD: Reuse allocation
let mut buffer = Vec::new();
for item in &items {
buffer.clear(); // Keeps capacity
process_into(item, &mut buffer);
send(&buffer);
}
String building:
// BAD: Multiple allocations
let s = format!("{} {} {}", a, b, c);
// GOOD: Pre-sized when building large strings
let mut s = String::with_capacity(estimated_size);
write!(s, "{} {} {}", a, b, c).unwrap();
Help the compiler auto-vectorize:
// Likely to vectorize
fn sum_slice(data: &[f32]) -> f32 {
data.iter().sum()
}
// Explicit SIMD (nightly or with packed_simd)
#[cfg(target_arch = "x86_64")]
use std::arch::x86_64::*;
// Iterator patterns vectorize well
let squared: Vec<_> = data.iter().map(|x| x * x).collect();
// Suggest inlining (compiler may ignore)
#[inline]
fn small_hot_function(x: i32) -> i32 {
x + 1
}
// Force inlining (use sparingly)
#[inline(always)]
fn critical_hot_path(x: i32) -> i32 {
x + 1
}
// Prevent inlining (for code size or debugging)
#[inline(never)]
fn cold_error_path() {
panic!("error");
}
When to use #[inline]:
When to use #[inline(always)]:
PGO uses runtime profiling data to optimize hot paths. Typically provides 10-20% improvement.
Step 1: Build instrumented binary
RUSTFLAGS="-Cprofile-generate=/tmp/pgo-data" cargo build --release
Step 2: Run representative workload
# Run with typical inputs - multiple runs build better profiles
./target/release/my_binary --typical-args
./target/release/my_binary --other-typical-args
./target/release/my_binary --edge-case-args
# For servers: run typical request patterns
# For CLI tools: run typical commands
Step 3: Merge and build optimized binary
# Merge profile data
llvm-profdata merge -o /tmp/pgo-data/merged.profdata /tmp/pgo-data
# Build optimized binary
RUSTFLAGS="-Cprofile-use=/tmp/pgo-data/merged.profdata" cargo build --release
Good candidates:
Less benefit:
BOLT (Binary Optimization and Layout Tool) can provide additional gains after PGO:
# Requires LLVM BOLT
llvm-bolt ./target/release/myapp \
-o ./target/release/myapp.bolt \
-data=perf.fdata \
-reorder-blocks=cache+ \
-reorder-functions=hfsort
Use all CPU features available on the build machine:
# Build optimized for current CPU
RUSTFLAGS="-C target-cpu=native" cargo build --release
# Check what features are enabled
rustc --print cfg -C target-cpu=native | grep target_feature
Runtime feature detection for portable binaries:
#[cfg(target_arch = "x86_64")]
fn process_data(data: &[f32]) -> f32 {
if is_x86_feature_detected!("avx2") {
unsafe { process_avx2(data) }
} else if is_x86_feature_detected!("sse4.1") {
unsafe { process_sse41(data) }
} else {
process_fallback(data)
}
}
For distributing binaries, target reasonable baselines:
# x86-64-v2: ~2008 CPUs (SSE4.2, POPCNT)
RUSTFLAGS="-C target-cpu=x86-64-v2" cargo build --release
# x86-64-v3: ~2015 CPUs (AVX, AVX2, BMI1/2)
RUSTFLAGS="-C target-cpu=x86-64-v3" cargo build --release
# x86-64-v4: ~2017 CPUs (AVX-512)
RUSTFLAGS="-C target-cpu=x86-64-v4" cargo build --release
Recommendation: x86-64-v2 for broad compatibility, x86-64-v3 for performance-focused deployments.
# .github/workflows/bench.yml
name: Benchmarks
on:
push:
branches: [main]
pull_request:
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Run benchmarks
run: cargo bench -- --noplot
- name: Compare with main
if: github.event_name == 'pull_request'
run: |
git fetch origin main
git checkout origin/main
cargo bench -- --save-baseline main --noplot
git checkout -
cargo bench -- --baseline main --noplot
Bencher.dev:
# .github/workflows/bench.yml
name: Continuous Benchmarking
on: push
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: bencherdev/bencher@main
with:
bencher-api-token: ${{ secrets.BENCHER_API_TOKEN }}
project: my-project
testbed: ubuntu-latest
adapter: rust_criterion
Benefits:
Need key-value lookup?
ā
āāā No āā> Vec, array, or struct
ā
āāā Yes āā> How many entries?
ā
āāā < 20 āā> Vec<(K, V)> with linear search
ā - Better cache locality
ā - Lower overhead
ā
āāā 20-100 āā> Consider BTreeMap
ā - Cache friendly
ā - No hash computation
ā - Sorted iteration
ā
āāā > 100 āā> HashMap
ā
āāā Need sorted keys? āā> BTreeMap
ā
āāā Untrusted input? āā> HashMap (SipHash default)
ā - DoS resistant
ā
āāā Trusted input? āā> FxHashMap / AHashMap
- 5-10x faster hashing
Is it a recursive type?
ā
āāā Yes āā> Box required
ā enum List { Cons(T, Box<List<T>>), Nil }
ā
āāā No āā> Is it a trait object (dyn Trait)?
ā
āāā Yes āā> Box<dyn Trait> required
ā fn process(handler: Box<dyn Handler>)
ā
āāā No āā> Is it > 1KB and moved frequently?
ā
āāā Yes āā> Consider Box
ā - Reduces stack usage
ā - Makes moves cheap (pointer copy)
ā
āāā No āā> Don't Box
- Unnecessary indirection
- Extra allocation
- Worse cache behavior
Do multiple owners exist simultaneously?
ā
āāā No āā> Use ownership or borrowing
ā - Move semantics (default)
ā - References (&T, &mut T)
ā
āāā Yes āā> Single-threaded?
ā
āāā Yes āā> Rc<T>
ā - No atomic overhead
ā - Clone increments counter
ā
āāā No āā> Arc<T>
ā
āāā Need interior mutation?
ā
āāā No āā> Arc<T>
ā - Immutable shared data
ā
āāā Write-heavy āā> Arc<Mutex<T>>
ā - Exclusive lock
ā - Less reader contention
ā
āāā Read-heavy āā> Arc<RwLock<T>>
- Multiple readers
- Single writer
Data Structures:
with_capacity() when size knownMemory:
size_of::<T>()format!() / to_string() in hot pathsIteration:
Strings:
&str in function parameters, not &Stringwith_capacity() for buildingCow<str> for conditional modificationwrite! macro instead of format concatenationValidation:
--release and LTO# === CPU Profiling ===
# Flamegraph (cross-platform)
cargo install flamegraph
cargo flamegraph --bin myapp
# Output: flamegraph.svg
# samply (macOS/Linux, modern)
cargo install samply
samply record ./target/release/myapp
# Opens Firefox Profiler
# Linux perf
perf record --call-graph dwarf ./target/release/myapp
perf report
# === Memory Profiling ===
# DHAT (Rust-native)
cargo run --features dhat-heap
# Output: dhat-heap.json
# Valgrind massif
valgrind --tool=massif ./target/release/myapp
ms_print massif.out.*
# heaptrack (Linux)
heaptrack ./target/release/myapp
heaptrack_gui heaptrack.myapp.*.gz
# === Cache Profiling ===
# Cachegrind
valgrind --tool=cachegrind ./target/release/myapp
cg_annotate cachegrind.out.*
# === Allocation Counting ===
cargo run --features dhat-heap 2>&1 | grep "total:"
1. MEASURE FIRST
āāā Profile before optimizing
2. ALGORITHM
āāā O(n^2) -> O(n log n) -> O(n) -> O(1)
3. DATA STRUCTURES
āāā Vec is usually right
āāā HashMap for > 100 elements
āāā Avoid unnecessary indirection
4. ALLOCATIONS
āāā Pre-allocate (with_capacity)
āāā Reuse buffers
āāā Avoid allocations in loops
5. COMPILER HELP
āāā --release
āāā LTO
āāā target-cpu=native (if not distributing)
6. VALIDATE
āāā Benchmark changes
āāā Profile again
āāā Test with realistic data
Cargo.toml:
[profile.release]
lto = true # Link-time optimization
codegen-units = 1 # Better optimization, slower compile
panic = "abort" # Smaller binary, no unwinding
[profile.release-with-debug]
inherits = "release"
debug = true # For profiling
[profile.bench]
inherits = "release"
debug = true # Symbols in benchmarks