Rust performance tips: release builds, allocations and iterators
Practical ways to make Rust code faster: build with --release, measure first, avoid needless clones and allocations, pre-size collections, buffer output.
Rust is fast by default, but a few habits make a big difference, and a few mistakes are common enough to check first.
1. Build in release mode
cargo run and cargo build produce debug builds: no optimizations, overflow checks on. They can be 10 to 100 times slower than release builds. Before measuring or comparing anything:
cargo run --release
cargo build --release # binary in target/release/
For a little more speed, [profile.release] in Cargo.toml can set lto = true (link-time optimization across crates) and codegen-units = 1, at the cost of compile time.
2. Measure before optimizing
Guessing where time goes is usually wrong. Use real tools:
- criterion (a crate) for reliable micro-benchmarks with statistics, run with
cargo bench(the built-in#[bench]attribute is nightly-only). - perf (Linux), Instruments (macOS), or cargo flamegraph to see which functions use the CPU.
std::time::Instantfor quick, rough timings of whole phases.
3. Fix the algorithm first
Replacing an O(n²) nested loop with a HashMap lookup or a sort plus binary search beats any micro-optimization. Check that membership tests in big collections use a HashSet, not Vec::contains.
4. Avoid needless allocations
Each String, Vec, Box or .clone() allocates on the heap. In hot code:
- Borrow instead of cloning. Take
&strand&[T]parameters; return references when the data already exists. - Pre-size collections:
Vec::with_capacity(n),String::with_capacity(n),HashMap::with_capacity(n). - Reuse buffers:
buf.clear()keeps the capacity, so a loop that reads lines into oneStringallocates once. - Don't
collectin the middle of a chain just to iterate again. Cow<str>returns borrowed data when nothing changed and allocates only when it must.
5. Use iterators
Iterator chains (iter().filter().map().sum()) compile to tight loops, frequently without bounds checks, and the optimizer can vectorize them. They're at least as fast as index loops, and clearer.
6. Buffer I/O
println! locks stdout and may flush on each line. For lots of output, lock once and wrap it in a BufWriter; use BufReader (or read_to_string) for input. This alone can speed up a text-processing program many times over.
7. Choose the right collection
| Need | Use |
|---|---|
| push/pop at the end, index | Vec |
| push/pop at both ends (queue) | VecDeque |
| key lookup | HashMap (or BTreeMap if you need ordering or ranges) |
| always take the smallest/largest | BinaryHeap |
| membership tests | HashSet |
8. Smaller wins
sort_unstableis faster thansortwhen equal elements' order doesn't matter.- Prefer generics (static dispatch) to
Box<dyn Trait>in very hot loops. - Parallelize CPU-bound work over large data with
rayon(par_iter()). #[inline]rarely helps inside one crate; the optimizer already decides well.
Common mistakes
- Benchmarking a debug build.
- Cloning to satisfy the borrow checker inside a loop.
- Calling
to_string()orformat!just to compare or look up keys (compare&strdirectly). v.contains(&x)on a bigVecinside a loop: O(n²) overall.
Example
use std::collections::HashSet;
use std::io::{self, BufWriter, Write};
// Borrows its input and returns a count: no allocation per call.
fn count_errors(lines: &[&str]) -> usize {
lines.iter().filter(|l| l.starts_with("ERROR")).count()
}
fn main() -> io::Result<()> {
let log: Vec<String> = (0..1000).map(|i| if i % 7 == 0 { format!("ERROR job {i}") } else { format!("INFO job {i}") }).collect();
let views: Vec<&str> = log.iter().map(|s| s.as_str()).collect();
println!("errors: {}", count_errors(&views));
// Reuse one buffer instead of allocating a new String per line.
let mut buf = String::with_capacity(64);
let mut longest = 0;
for line in &log {
buf.clear();
buf.push_str(line);
buf.make_ascii_uppercase();
longest = longest.max(buf.len());
}
println!("longest line {longest} bytes, buffer capacity still {}", buf.capacity() >= 64);
// HashSet membership instead of Vec::contains in a loop.
let banned: HashSet<u32> = [7, 49, 343].into_iter().collect();
let allowed = (1..=1000).filter(|n| !banned.contains(n)).count();
println!("allowed ids: {allowed}");
// Pre-sized Vec plus an iterator chain.
let mut squares = Vec::with_capacity(10);
squares.extend((1..=10u64).map(|n| n * n));
println!("sum of squares {}", squares.iter().sum::<u64>());
// Lock stdout once and buffer many writes.
let stdout = io::stdout();
let mut out = BufWriter::new(stdout.lock());
for n in squares.iter().take(5) {
write!(out, "{n} ")?;
}
writeln!(out)?;
out.flush()?;
Ok(())
}
Output:
errors: 143
longest line 13 bytes, buffer capacity still true
allowed ids: 997
sum of squares 385
1 4 9 16 25