RustArena

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:

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:

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

Common mistakes

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 

Practice it