🎓 A1Learner
🔍

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 --release

Reserve 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 = 1

A 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?