Rust Performance
Performance work in Rust follows a simple method. Measure the right build, find the real slow spot, and fix only that.
Measure the release build
A debug build can be 10 to 100 times slower than this one. Every measurement in this lesson means the release build:
cargo build --releaseReserve capacity up front
A String that knows its size never stops mid-build to grow and copy itself:
let mut buf = String::with_capacity(16);
buf.push_str("rust");
println!("len = {}, capacity = {}", buf.len(), buf.capacity());Borrow, do not clone
format! borrows name, so the String is still yours afterwards — no clone was needed:
let name = String::from("Ferris");
let shout = format!("{name}!");
println!("{shout} — and {name} is still mine");Skip the middle Vec
This chain sums without ever building a collection:
let total: usize = words.iter().map(|w| w.len()).sum();Release knobs live in Cargo.toml
Three settings worth trying on a release profile — measure before and after:
// Cargo.toml
[profile.release]
lto = true
codegen-units = 1A tiny function, called a million times
One line of real work — the call wrapped around it can cost as much as the work:
fn add_tax(cents: u32) -> u32 { cents + cents / 5 }Suggest inlining
One attribute says this body is worth pasting into callers, even in other crates:
#[inline]
fn add_tax(cents: u32) -> u32 { cents + cents / 5 }Forbid inlining
The opposite hint keeps a function separate — handy for cold error paths, and for measuring what a call costs:
#[inline(never)]
fn report_failure(code: u32) { eprintln!("failed with {code}"); }Mark code as cold
Code that almost never runs can be labelled cold, so the compiler keeps the hot path tight:
#[cold]
fn rare_error() -> &'static str { "this almost never runs" }Inline or not, the sum is the same
In this example, two identical one-line functions do the same 1,000 steps. step_slow is marked #[inline(never)], so every call really jumps. step_fast has no mark, so the compiler is free to paste its body into the loop — and it does. Both sums agree (6,994), because inlining never changes the answer, only the cost. This loop also ran in a 50-million-call version for this course. The never-inline version was the slower one in every measured run.
#[inline(never)]
fn step_slow(x: u64) -> u64 { x * 2 + 1 }
fn step_fast(x: u64) -> u64 { x * 2 + 1 }
fn main() {
let mut a = 0u64;
let mut b = 0u64;
for i in 0..1_000u64 {
a += step_slow(i % 7);
b += step_fast(i % 7);
}
println!("slow sum = {a}");
println!("fast sum = {b}");
println!("same answer = {}", a == b);
}Example
fn total_len(words: &[String]) -> usize { words.iter().map(|w| w.len()).sum() }
fn main() {
let mut buf = String::with_capacity(16);
for w in ["rust", "is", "fast"] { buf.push_str(w); buf.push(' '); }
let words: Vec<String> = vec!["ab".to_string(), "cde".to_string()];
println!("buf = {:?} total_len = {}", buf.trim(), total_len(&words));
}Key points
- Profile before you change anything — tools like perf or samply show where time truly goes
- Keep small, honest benchmarks with Criterion so a "win" stays a win
- Iterators and for loops carry no abstraction tax — use them freely, even in hot code
- An alternative allocator can be a real win for allocation-heavy programs
- The jemalloc and mimalloc crates plug in with one #[global_allocator] static
- Benchmark before and after
- The win depends on the workload
- Keep hot types small
- Values that fit a cache line copy and compare faster
- Most slow Rust programs are slow for one boring reason, too much allocating and copying.
- Rust is fast by default, with no garbage collector pauses.
- Clear code compiles to lean machine code.
- Measure first, and fix only the place the measurement names.
- Inlining: pasting a function's body into its caller, so the call and the jump disappear
- The compiler already inlines small functions on its own in release builds — most functions need no attribute at all
- Use #[inline] on small public functions in a library
- Without it, the compiler usually cannot inline a call that crosses into another crate
- #[inline(always)] and #[inline(never)] are strong hints, not orders — the compiler keeps the last word
- Inlining is not transitive: if f calls g and you want both pasted at f's call sites, mark both
- Bigger is not always faster: inlining everywhere bloats the binary and the compile time, so measure after marking
In this example, two everyday wins are shown. buf is a String created with capacity 16 reserved in advance. So pushing three words never forces it to grow and copy mid-way. The trimmed contents print as "rust is fast". total_len then borrows the Vec of words as a slice. It adds up the lengths — 2 + 3 = 5. It does this without cloning a single String. Borrow instead of copy, reserve instead of regrow: those two habits cover most real-world speed work. Rust Type Sizes, a few lessons later, shows how to measure and shrink hot types.
Note
Never judge Rust's speed from a debug build
That single mistake explains most "Rust feels slow" reports
#[inline] rarely changes anything inside one crate
It earns its keep on library functions that other crates call
Try it Yourself
Edit the code, then press Run.
Running is simulated · Rust Playground
Exercise
Your Rust program feels slow in development. What is the first check?