Performance & Profiling 🏎️
People pick Rust for speed, then measure nothing and guess everything. This lesson is about the stopwatch: what actually costs time, what is folklore, and how to know the difference.
Rule zero: release mode 🏁
cargo run builds with no optimization so it compiles fast and
debugs nicely. cargo run --release turns the optimizer loose.
The difference is not subtle:
use std::time::Instant;
fn main() {
let start = Instant::now();
let sum: u64 = (1..=10_000_000).sum();
println!("sum = {sum}");
println!("took {:?}", start.elapsed());
}
On the machine this course was written on: 72ms in debug, 55µs in
release. Over a thousand times faster, from one flag. Every
"Rust is slow" thread on the internet contains at least one person
benchmarking a debug build. Never judge speed without
--release. (The playground's Run button uses debug mode, so
run these locally to see release numbers.)
What actually costs: allocations and clones 💸
CPUs add numbers absurdly fast. What is comparatively slow is asking the
allocator for heap memory and copying data around. That is why
.clone() deserves a raised eyebrow in a loop:
use std::time::Instant;
fn total_len_take(words: Vec<String>) -> usize {
words.iter().map(|w| w.len()).sum()
}
fn total_len_borrow(words: &[String]) -> usize {
words.iter().map(|w| w.len()).sum()
}
fn main() {
let words: Vec<String> = (0..200_000).map(|n| format!("word number {n}")).collect();
let start = Instant::now();
let a = total_len_take(words.clone()); // clones 200,000 strings first
println!("clone + take: {:?}", start.elapsed());
let start = Instant::now();
let b = total_len_borrow(&words); // just lends them
println!("borrow: {:?}", start.elapsed());
assert_eq!(a, b);
}
In release mode: 3.7ms for the cloning version, 74µs for the
borrowing one, fifty times faster. All those Level 2 borrowing
rules were never just about safety: every & you write is
also a copy you did not make. Ownership is the performance model.
Folklore vs the stopwatch ⏱️
Classic advice says: if you know a Vec's final size, create it
with Vec::with_capacity(n) so it never has to reallocate as it
grows. True! But measure what that is worth:
use std::time::Instant;
fn main() {
let n = 1_000_000;
let start = Instant::now();
let mut sized = Vec::with_capacity(n);
for i in 0..n {
sized.push(i);
}
println!("pre-sized: {:?}", start.elapsed());
let start = Instant::now();
let mut grown = Vec::new();
for i in 0..n {
grown.push(i);
}
println!("growing as we go: {:?}", start.elapsed());
assert_eq!(grown, sized);
}
On this machine, release mode: 0.9ms pre-sized, 1.1ms growing.
A real win, but a modest one, because Vec doubles its capacity
each time it grows, so a million pushes trigger only about twenty
reallocations. And a dirty secret: swap the order of those two blocks and
the numbers shift, because the first block warms up the allocator for the
second. Benchmarking is hard, folklore is often stale, and the
only trustworthy answer comes from measuring your code, both
orders, several runs.
Zero-cost abstractions, verified 🎁
"Zero-cost abstraction" is Rust's most famous promise: the pretty high-level version compiles to the same machine code as the manual loop. You do not have to take that on faith:
fn main() {
let nums: Vec<i64> = (1..=1_000).collect();
let mut total = 0;
for n in &nums {
if n % 3 == 0 {
total += n * n;
}
}
let total2: i64 = nums.iter().filter(|n| *n % 3 == 0).map(|n| n * n).sum();
assert_eq!(total, total2);
println!("both ways agree: {total}");
}
In release builds these produce essentially identical assembly; the
optimizer fuses filter and map into one loop,
and often vectorizes it. Write whichever version is clearer, which is
usually the iterator chain. In garbage-collected languages, "pretty" code
has a tax; in Rust it does not, and that is precisely why companies
rewrite hot paths in it.
The real toolbox 🧰
| Tool | What it answers |
|---|---|
std::time::Instant | "roughly how long does this take?" - built in, good enough to start |
| criterion | proper benchmarks: many runs, statistics, "did my change actually help?" |
| cargo flamegraph | "where does the time GO?" - a picture of your program's hot spots |
| hyperfine | timing whole programs against each other, warm-up handled for you |
The workflow pros actually follow: make it work, make it right, then profile before making it fast. The hot spot is nearly never where you guessed, and optimizing the wrong thing costs clarity for nothing.
The 200x data structure
Build a "library" of 5,000 book titles and a 5,000-title wishlist, then
count how many wishlist titles the library owns, twice: once with
library.contains(...) (a Vec scan per lookup),
once by first collecting the library into a
HashSet. Time both. Before you run it: write down your guess.
Reveal solution
use std::collections::HashSet;
use std::time::Instant;
fn main() {
let library: Vec<String> = (0..5_000).map(|n| format!("book-{n}")).collect();
let wishlist: Vec<String> = (0..5_000).map(|n| format!("book-{}", n * 2)).collect();
let start = Instant::now();
let slow = wishlist.iter().filter(|w| library.contains(w)).count();
println!("Vec::contains: {slow} owned, {:?}", start.elapsed());
let start = Instant::now();
let shelf: HashSet<&String> = library.iter().collect();
let fast = wishlist.iter().filter(|w| shelf.contains(w)).count();
println!("HashSet: {fast} owned, {:?}", start.elapsed());
}
Release mode on this machine: 21ms vs 95µs, about 220 times
faster, and the gap widens with size. The Vec version does 25
million string comparisons; the HashSet does 5,000 hash lookups. No
clever trick beats picking the right data structure, which is why
Lesson 10 introduced HashMap right after Vec. 📚