| name | rust-performance |
| description | Guides advanced Rust performance optimization: flamegraph profiling, criterion benchmarks, allocation profiling, SIMD patterns, and zero-copy design.
Use when the user asks about Rust performance, flamegraph, criterion, allocation profiling, SIMD, zero-copy, perf optimization.
Do NOT use when the user asks about Rust ownership (use `rust-ownership-patterns`), Rust async (use `rust-async-patterns`), general performance testing (use `performance-testing`).
|
| license | Apache-2.0 |
| metadata | {"author":"foundry-skills","version":"1.0.0","tags":"rust optimization debugging","category":"software-engineering","subcategory":"languages-runtimes","depends":"","disclaimer":"none","difficulty":"advanced"} |
Rust Performance
When to Use
Use this skill when:
- The user asks how to profile a Rust program to find hotspots (flamegraphs,
perf, cargo-flamegraph, samply)
- The user wants to set up or interpret
criterion benchmarks, understand statistical output, or compare benchmark runs
- The user is debugging unexpectedly high heap allocation rates (using
dhat, heaptrack, cargo-instruments, or jemalloc profiling)
- The user wants to apply SIMD intrinsics or auto-vectorization hints (
std::simd, packed_simd, explicit target_feature attributes)
- The user is designing zero-copy data pipelines using
bytes::Bytes, memory-mapped files, or arena allocators
- The user asks about
release profile tuning (opt-level, lto, codegen-units, panic = "abort")
- The user wants to reduce monomorphization bloat, binary size, or compile-time regressions caused by generics
- The user asks about cache-line alignment, false sharing, or struct layout optimization with
repr(C), repr(packed), or manual field reordering
- The user is hitting performance regressions and needs a systematic bisect-and-benchmark workflow
- The user asks about
#[inline], #[cold], #[likely]/#[unlikely] branch hints, or intrinsic usage
Do NOT use this skill when:
- The user asks about ownership, borrowing, or lifetime errors -- use
rust-ownership-patterns
- The user asks about
async/await, tokio runtime tuning, or async executor performance -- use rust-async-patterns
- The user wants a general performance testing strategy (load testing, SLA definition, percentile goals) -- use
performance-testing
- The user is debugging correctness bugs, panics, or undefined behavior (use
rust-debugging or rust-unsafe-patterns)
- The user is asking about Rust compile times rather than runtime performance -- use
rust-compile-time-optimization
- The user wants to profile a web service end-to-end including HTTP overhead -- combine this skill with
rust-async-patterns
Process
Step 1 -- Establish a Reproducible Baseline
Before touching a single line of code, lock down the measurement environment so results are trustworthy.
- Set
RUSTFLAGS="-C target-cpu=native" for local profiling to get realistic SIMD and scheduling behavior, but do NOT ship binaries with this flag unless the deployment target is homogeneous.
- Build in release mode with the exact
Cargo.toml profile you will ship: cargo build --release. Never profile debug builds -- the optimizer is disabled and results are meaningless.
- Disable frequency scaling on Linux:
sudo cpupower frequency-set -g performance. On macOS, connect AC power and use caffeinate -s.
- Pin the process to a CPU core during benchmarking:
taskset -c 2 ./my_binary on Linux. This eliminates scheduler jitter.
- Record the baseline: binary size (
ls -lh target/release/binary), wall-clock time (hyperfine --warmup 5 './binary'), and peak RSS (/usr/bin/time -v ./binary or heaptrack ./binary).
- Commit this baseline to a file (
perf-baseline.txt) so regressions are immediately visible.
Step 2 -- Profile to Find the Actual Bottleneck
Never guess. The bottleneck is almost never where you think it is.
- CPU flamegraph (Linux): Install
cargo install flamegraph. Run cargo flamegraph --bin my_binary -- [args]. This calls perf record under the hood and produces an SVG. Look for wide, flat plateaus near the top of the flame -- those are the hot functions consuming the most samples.
- CPU flamegraph (macOS): Use
cargo install samply, then samply record ./target/release/binary. This uses the macOS dtrace-compatible sampler and opens results in a browser-based profiler UI.
- Instruction-level profiling: For tight loops, use
perf stat -e cache-misses,cache-references,instructions,cycles ./binary to get cache miss rates. A cache miss rate above 5% on the hot path is a red flag.
- Allocation profiling: Add
dhat as a dev-dependency (dhat = "0.3"), wrap main with #[global_allocator] static ALLOC: dhat::Alloc = dhat::Alloc; and let _profiler = dhat::Profiler::new_heap();, then open the resulting dhat-heap.json in the DHAT viewer. Look for allocation sites with high "total bytes" that are not in arena or pool patterns.
- Heaptrack (Linux):
heaptrack ./target/release/binary gives a full allocation trace with call stacks and a flamegraph of allocation sources -- more ergonomic than DHAT for exploratory work.
- Classify the bottleneck: After profiling, classify as: CPU-bound (high cycles/instruction, hot function in flamegraph), memory-bandwidth-bound (high cache misses, low IPC), allocation-bound (DHAT shows millions of small heap allocations), or branch-misprediction-bound (
perf stat shows high branch-misses).
Step 3 -- Benchmark the Hot Path with Criterion
Set up statistical benchmarks around the confirmed bottleneck before changing anything.
- Add to
Cargo.toml:
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "hot_path"
harness = false
- In
benches/hot_path.rs, use black_box religiously to prevent the compiler from optimizing away benchmark work:
use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};
fn bench_parse(c: &mut Criterion) {
let data: Vec<u8> = generate_realistic_payload(65536);
c.bench_with_input(BenchmarkId::new("parse_records", "64kb"), &data, |b, d| {
b.iter(|| parse_records(black_box(d)))
});
}
criterion_group!(benches, bench_parse);
criterion_main!(benches);
- Use
--bench flag with -- --save-baseline before to snapshot before changes, and -- --baseline before to compare after. Criterion reports mean, standard deviation, and a confidence interval -- changes below the noise threshold (typically ±2%) are not statistically significant.
- Keep input sizes realistic: if production input is typically 10-100 KB, benchmark at 10 KB, 64 KB, and 512 KB. Microbenchmarks on 16-byte inputs will give misleading results due to branch prediction warming.
- Use
BenchmarkGroup::throughput(Throughput::Bytes(n)) to get MB/s output, which is more meaningful than raw nanoseconds for data-processing code.
Step 4 -- Apply CPU and Algorithmic Optimizations
Work top-down: algorithmic complexity first, then instruction-level.
- Algorithmic first: Replacing an O(n²) inner loop with a hash-based O(n) approach will dwarf any SIMD gain. Check: is there a lookup table, early exit, or sorted-data assumption that eliminates work entirely?
- Branch elimination: Replace
if x > 0 { a } else { b } with [b, a][(x > 0) as usize] in hot loops where the branch is unpredictable. This converts a conditional jump to an array index, allowing the CPU to avoid a pipeline flush.
- Loop unrolling and auto-vectorization: Ensure the compiler can vectorize inner loops by: (1) avoiding data-dependent early exits inside the loop body, (2) iterating over slices not raw pointers, (3) adding
#[target_feature(enable = "avx2")] on the function if targeting AVX2. Verify auto-vectorization happened by checking the assembly: cargo rustc --release -- --emit=asm and looking for ymm register usage.
- Explicit SIMD with
std::simd (nightly) or wide crate (stable): For hot numeric kernels, use wide::f32x8 to process 8 floats per instruction. The wide crate is stable-compatible and wraps platform intrinsics safely.
#[inline(always)] on small, hot functions: Functions called millions of times per second in tight loops benefit from inlining even if the compiler heuristic would not inline them. Use #[inline(always)] sparingly -- only on functions where cross-crate inlining is needed or the function is tiny (under 10 instructions).
- Iterator chaining vs. manual loops: Rust iterators typically compile to the same machine code as manual loops due to LLVM optimization. However,
collect() on a chain with unknown length triggers reallocation. Use with_capacity or collect into a pre-allocated Vec when the output size is known.
Step 5 -- Reduce and Optimize Allocations
Allocation is expensive: a malloc/free pair costs 50--200 ns and fragments the heap, increasing cache pressure.
Step 6 -- Optimize Memory Layout and Cache Usage
Cache miss latency (50--200 cycles for L2/L3 misses, 300+ cycles for DRAM) is the dominant cost in many real programs.
- Field reordering for hot/cold split: Put fields that are accessed together in the hot path at the top of the struct. Fields that are rarely accessed (error details, debug info) belong at the end or in a separate
Box<ColdData>. This reduces the number of cache lines touched per object.
- Measure struct size with
std::mem::size_of::<T>() and verify it is what you expect. A struct that is 72 bytes when you expected 64 may have padding -- reorder fields from largest to smallest alignment to eliminate padding.
#[repr(C)] for predictable layout: Use when interoperating with C FFI or when you need guaranteed field order for SIMD load patterns.
#[repr(packed)] only with extreme caution: Eliminates padding but causes unaligned loads, which are slow on x86 and undefined behavior traps on ARM. Only use if the struct is written to disk or network and you control deserialization.
- Array of Structs (AoS) vs. Struct of Arrays (SoA): When iterating over a large collection and only accessing one or two fields, SoA layout dramatically improves cache utilization. Transform
Vec<Particle { x, y, z, vx, vy, vz, mass }> into separate Vec<f32> arrays for x, y, z, etc. when your hot loop only needs x and y.
- False sharing: When multiple threads write to adjacent fields in a shared struct, they thrash the same cache line (64 bytes on x86). Add
#[repr(align(64))] to pad struct fields that are written by different threads.
- Prefetching: For pointer-chasing workloads (linked lists, trees), use
core::arch::x86_64::_mm_prefetch to issue prefetch hints 8--16 iterations ahead of the current node.
Step 7 -- Tune the Release Profile
The Cargo.toml profile settings can yield 10--40% performance improvements with no code changes.
Step 8 -- Validate, Document, and Prevent Regression
- Run the full test suite:
cargo test --release (run tests in release mode to catch optimizations that expose latent bugs).
- Re-run criterion benchmarks with
-- --baseline before to confirm the expected improvement and no unexpected regressions in adjacent benchmarks.
- Run Miri (
cargo +nightly miri test) to catch undefined behavior introduced by unsafe blocks added during optimization.
- Add the benchmark to CI using
cargo criterion --message-format=json and fail the build if a key benchmark regresses more than 5% compared to the main branch baseline.
- Document every non-obvious optimization with a comment linking to the profiling evidence:
- Update
perf-baseline.txt with the new measurements.
Output Format
When responding to a Rust performance question, structure the response as follows:
## Rust Performance Analysis: [Feature/Function Name]
### Bottleneck Classification
| Dimension | Evidence | Severity |
|-------------------|-----------------------------------|-----------|
| CPU utilization | [flamegraph plateau / idle] | High/Med/Low |
| Allocation rate | [DHAT callsite / clean] | High/Med/Low |
| Cache pressure | [perf cache-misses % / clean] | High/Med/Low |
| Branch mispredict | [perf branch-misses % / clean] | High/Med/Low |
### Baseline Metrics
- Binary: [size in MB]
- Wall time (p50): [ms or µs]
- Wall time (p99): [ms or µs]
- Peak RSS: [MB]
- Throughput: [MB/s or ops/s if applicable]
### Recommended Optimizations (Priority Order)
1. [Highest ROI optimization] -- Expected gain: [X%], Complexity: [Low/Med/High]
2. [Second optimization] -- Expected gain: [X%], Complexity: [Low/Med/High]
3. [Third optimization] -- Expected gain: [X%], Complexity: [Low/Med/High]
### Criterion Benchmark Setup
[Complete bench file for the hot path]
### Implementation
[Complete, production-quality Rust code for the optimization]
### Validation Checklist
- [ ] Criterion baseline saved before changes
- [ ] Criterion comparison run after changes
- [ ] `cargo test --release` passes
- [ ] Miri clean (if unsafe added)
- [ ] CI benchmark gate configured
- [ ] Optimization rationale documented in code comment
Rules
-
Never profile debug builds. The compiler inserts bounds checks, disables inlining, and leaves stack variables un-optimized in debug mode. Any profiling result from a debug build is noise. Always use cargo build --release or cargo flamegraph (which builds release automatically).
-
black_box is mandatory in criterion benchmarks. Without criterion::black_box, LLVM will constant-fold or dead-code-eliminate the computation being benchmarked, producing a benchmark that measures nothing. Every input and output in a benchmark iter closure must pass through black_box.
-
Never use #[inline(always)] on large functions. Inlining a 200-instruction function at 50 call sites bloats the instruction cache and causes icache misses that cost more than the call overhead saved. Use #[inline(always)] only on functions whose body compiles to fewer than ~10 instructions.
-
#[repr(packed)] is almost always wrong. Unaligned loads are a 10--50% slowdown on x86 and cause SIGBUS on ARM. The only legitimate use is for wire-format structs where you control serialization and deserialization. Never use it as a general size optimization.
-
LTO and codegen-units = 1 must be disabled in test profiles. Setting these in [profile.release] and then running cargo test (which uses the test profile) causes extremely slow incremental test builds. Always override in [profile.test] or use a separate [profile.dist] for shipping.
-
panic = "abort" breaks catch_unwind. If your crate uses catch_unwind for error isolation (common in plugin systems and FFI boundaries), setting panic = "abort" will cause those panics to abort the process instead of being caught. Audit all catch_unwind sites before enabling this flag.
-
Criterion's reported improvement is relative to the specific input size. A 5x speedup on a 1 KB input may vanish at 1 MB due to cache effects. Always benchmark at the realistic size range of your production data -- minimum, typical, and maximum.
-
SIMD code must be guarded with is_x86_feature_detected! or safety contracts. Calling AVX2 intrinsics on a machine that does not support AVX2 causes SIGILL. Either use runtime detection or compile separate code paths with and an unsafe contract.
Edge Cases
1. Benchmark Results Are Noisy and Irreproducible
This happens when CPU frequency scaling is active, other processes are competing, or the benchmark input is too small. Fix: (1) set CPU governor to performance mode, (2) run benchmarks with --bench -- --sample-size 200 to increase statistical power, (3) use hyperfine --warmup 10 for wall-clock measurements to allow CPU caches and branch predictors to warm up. If criterion reports > 10% noise on a tight loop benchmark, the system environment is the problem, not the code.
2. Flamegraph Shows Time in memcpy or malloc
When a significant fraction of the flamegraph (more than 10%) is in allocator or memcpy functions, the bottleneck is allocation throughput, not compute. Switching to a faster allocator is a quick win: add tikv-jemallocator = "0.5" and set it as the global allocator. jemalloc has lower contention under multi-threaded allocation and better cache locality for size classes between 32 and 4096 bytes. If memcpy dominates, find the clone() and to_vec() call sites in the flamegraph and replace them with borrowed references or bytes::Bytes.
3. Auto-Vectorization Is Not Happening
Check the assembly (cargo rustc --release -- --emit=asm -C llvm-args=-x86-asm-syntax=intel) for the hot function. If you see scalar movss/addss instead of vmovups/vaddps, the compiler is not vectorizing. Common blockers: (1) loop bounds are not statically known -- add a let len = slice.len(); variable before the loop so LLVM can prove bounds, (2) the loop has a conditional break -- split into two loops (one for the main body, one for the tail), (3) aliasing -- if the function takes raw pointers, add noalias via unsafe contract or switch to slice references.
4. Optimization Causes Correctness Regression Under Miri or in Tests
When an optimization introduces unsafe code (e.g., unsafe { slice.get_unchecked(i) } to remove bounds checks), always: (1) run cargo +nightly miri test to catch undefined behavior, (2) document the precondition as a // SAFETY: comment, (3) add a debug-mode assertion that verifies the precondition:
debug_assert!(i < slice.len(), "index {} out of bounds for len {}", i, slice.len());
unsafe { *slice.get_unchecked(i) }
The debug_assert! is compiled out in release mode but catches violations during testing.
5. Performance Regression After Enabling LTO
LTO occasionally causes performance regressions when it inlines a function that was previously not inlined, causing instruction cache pressure. Diagnose by comparing perf stat instructions and L1-icache-load-misses with and without LTO. If LTO causes a regression, use #[inline(never)] on the specific function that is being inlined by LTO to restore the previous behavior while keeping LTO for the rest of the codebase.
6. Multi-Threaded Code Has Lower Throughput Than Single-Threaded
This is almost always false sharing or lock contention. Detect false sharing: add perf stat -e LLC-load-misses,LLC-store-misses and compare single-threaded vs. multi-threaded runs. If LLC misses scale linearly with thread count, suspect false sharing. Fix: pad shared structs with #[repr(align(64))]. Detect lock contention: use perf lock record / perf lock report or add std::sync::Mutex::try_lock monitoring. If contention is high, switch from Mutex<Vec<T>> to crossbeam_queue::SegQueue or shard the data structure.
7. WASM or Embedded Target with No perf or Flamegraph
For WebAssembly, use wasm-pack build --release and measure with the browser's built-in profiler (Chrome DevTools Performance tab or Firefox Profiler). std::simd is supported in WASM via wasm32-simd128 but requires RUSTFLAGS="-C target-feature=+simd128". Allocation profiling in WASM is done with custom allocator wrappers that count bytes since DHAT and heaptrack are Linux-specific. For embedded targets (no-std), use hardware cycle counters via the cortex-m or riscv crates' DWT/MCYCLE registers to measure cycle counts for specific functions.
8. Optimization Removes a Useful Abstraction
Sometimes the right optimization (e.g., inlining a small function, switching from HashMap to a sorted Vec with binary search for small N) makes the code harder to maintain. In these cases: (1) keep the clean abstraction in a non-hot-path fallback, (2) write the optimized version as a separate private function called only from the hot path, (3) add a benchmark that would catch if future refactoring accidentally reverts to the slow path. Do not delete the abstraction -- link to it in a comment as the reference implementation.
Example
Input: "My Rust CSV parser is too slow. It processes 200 MB CSV files and takes 8 seconds. I need to get it under 2 seconds."
Output:
Rust Performance Analysis: CSV Parser
Bottleneck Classification
Before optimizing anything, I need you to run this profiling sequence:
cargo build --release
hyperfine --warmup 3 './target/release/csv_parser large_file.csv'
cargo install flamegraph
cargo flamegraph --bin csv_parser -- large_file.csv
perf stat -e cache-misses,cache-references,instructions,cycles,branch-misses \
./target/release/csv_parser large_file.csv
Based on the 200 MB in 8 seconds = 25 MB/s throughput, this is almost certainly either:
- Allocation-bound (a fresh
String allocated per field per row), or
- Branch-misprediction-bound (per-character dispatch loop), or
- Both.
A well-optimized CSV parser should reach 500--2000 MB/s on modern hardware. 25 MB/s is a 20--80x gap, which means algorithmic and allocation problems, not SIMD tuning.
| Dimension | Most Likely Evidence | Severity |
|---|
| CPU utilization | Hot loop in field parser | Medium |
| Allocation rate | String per field -- DHAT will show millions of allocs | High |
| Cache pressure | Secondary effect of allocations | Medium |
| Branch mispredict | Per-byte dispatch in inner loop | Medium |
Criterion Benchmark Setup
Add to Cargo.toml:
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "csv_parse"
harness = false
Create benches/csv_parse.rs:
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use std::hint::black_box;
fn generate_csv(rows: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(rows * 60);
out.extend_from_slice(b"id,name,value,timestamp\n");
for i in 0..rows {
use std::io::Write;
write!(out, "{},user_{},{},2024-01-{:02}\n", i, i % 10000, i * 3, (i % 28) + 1).unwrap();
}
out
}
fn bench_csv(c: &mut Criterion) {
let mut group = c.benchmark_group("csv_parse");
for row_count in [10_000usize, 100_000, 1_000_000] {
let data = generate_csv(row_count);
let bytes = data.len() as u64;
group.(Throughput::(bytes));
group.(
BenchmarkId::(, row_count),
&data,
|b, d| b.(|| ((d))),
);
}
group.();
}
criterion_group!(benches, bench_csv);
criterion_main!(benches);
Save the baseline before any changes:
cargo bench -- --save-baseline before
Implementation: Zero-Allocation CSV Parser
The core optimization is to parse directly from the input &[u8] buffer, yielding borrowed &str slices instead of allocating String values. This eliminates the dominant allocation cost.
#[derive(Debug)]
pub struct Record<'a> {
pub fields: Vec<&'a str>,
}
pub fn parse_csv(input: &[u8]) -> Vec<Record<'_>> {
let estimated_rows = input.len() / 60;
let mut records = Vec::with_capacity(estimated_rows);
let mut lines = input.split(|&b| b == );
= lines.() {
(h) => h,
=> records,
};
= header.().(|&&b| b == ).() + ;
lines {
line.() || line == {
;
}
= ::(field_count);
(line, & fields);
records.(Record { fields });
}
records
}
<>(line: & [], fields: & <& >) {
= ;
= ;
i < line.() {
line[i] {
=> {
(std::::(&line[start..i]).());
fields.( { std::::(&line[start..i]) });
start = i + ;
i += ;
}
=> {
i += ;
i < line.() {
line[i] == {
i + < line.() && line[i + ] == {
i += ;
} {
i += ;
;
}
} {
i += ;
}
}
}
=> {
i += ;
}
_ => {
i += ;
}
}
}
start <= line.() {
= &line[start..];
= tail.() == (&) { &tail[..tail.()-] } { tail };
(std::::(tail).());
fields.( { std::::(tail) });
}
}
Profile Tuning for this Use Case
Add to Cargo.toml:
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
panic = "abort"
[profile.bench]
inherits = "release"
debug = true
Benchmark After Optimization
cargo bench -- --baseline before
Expected output:
csv_parse/parse_rows/1000000
time: [387.4 ms 391.2 ms 395.8 ms]
thrpt: [451.7 MiB/s 456.2 MiB/s 461.0 MiB/s]
change: [-95.2% -94.9% -94.6%] (p = 0.00 < 0.05)
Performance has improved.
200 MB at 456 MB/s = 0.44 seconds -- well under the 2-second target.
Further Optimization If Still Not Fast Enough
If the parser is still too slow after the zero-copy change (e.g., for 10+ GB files), consider:
-
Memory-mapped input: Use memmap2::Mmap to avoid the read() syscall overhead entirely. The OS will page in only the pages that are touched, which is ideal for files accessed linearly.
let file = std::fs::File::open(path)?;
let mmap = unsafe { memmap2::Mmap::map(&file)? };
let records = parse_csv(&mmap[..]);
-
Parallel chunk processing with Rayon: Split the file into N chunks at newline boundaries and process each chunk on a separate Rayon thread. Joining at \n boundaries requires scanning at most 256 bytes per chunk boundary.
use rayon::prelude::*;
let chunks = split_at_newlines(&mmap, rayon::current_num_threads());
let records: Vec<_> = chunks.par_iter().flat_map(|chunk| parse_csv(chunk)).collect();
-
SIMD newline search: Replace the per-byte split with an AVX2 newline scanner that processes 32 bytes per iteration, finding newline positions ~16x faster:
use memchr::memchr_iter;
for newline_pos in memchr_iter(b'\n', input) { ... }
Validation Checklist