Guide contents

Lesson 85 of 94

How the Rust Compiler Works

Rust does its heavy lifting at compile time. rustc checks the program, then hands it to LLVM, which optimizes it once, ahead of time, into a native binary. There's no interpreter, no JIT, and no warm-up. The result is that the compiler, not a runtime, decides how fast your code is. Knowing what it does with your code tells you:

  • why the borrow checker accepts one version of a function and rejects another,
  • why generic code is fast but slow to compile and big,
  • why iterators and closures cost nothing at runtime,
  • when bounds checks disappear, and when they don't,
  • and what release profiles, LTO, and PGO actually change.
Topic Rust
Front end rustc front end: parsing, macros, type and borrow checking, MIR
Optimization LLVM: optimizes everything at build time
Profile guidance Profile-guided only if you ask (PGO)
Generics Monomorphized: one copy of the code per concrete type
Dispatch Static dispatch by default; dyn only when you ask
Allocations Values are inline unless you allocate
Speculation No speculation, so no deoptimization
Startup Instant start: the binary is already machine code

Part 1: The pipeline

1. From source to binary

rustc compiles one crate at a time (a crate is the compilation unit). Each crate goes through these stages:

Stage What happens What you see
Lexing and parsing text to tokens to a syntax tree (AST) syntax errors
Macro expansion, name resolution println!, #[derive], vec! expanded; paths resolved "cannot find value", macro errors
HIR (high-level IR) for, ?, async, if let desugared into simpler forms
Type checking, trait resolution inference, method lookup, trait bounds E0308 mismatched types, E0277 trait not implemented
MIR (mid-level IR) the function as a control-flow graph
Borrow checking (on MIR) moves, borrows, lifetimes E0382, E0499, E0502, E0505
Const evaluation const and static initializers computed E0080
Monomorphization one copy of each generic function per set of type arguments
LLVM IR, optimization inlining, loop optimizations, vectorization
Code generation, linking machine code, one binary linker errors

The front end (up to MIR) is what makes Rust safe. LLVM is what makes it fast, and it's the same optimizer that Clang uses for C and C++.

2. Desugaring: a for loop is an iterator and a loop

Many language features are rewritten into simpler ones early on. A for loop becomes a call to IntoIterator::into_iter and a loop that calls next until it returns None. Both versions below compile to the same code.

Rust
fn main() {
    let names = vec!["ada", "grace", "linus"];

    // What you write:
    for name in &names {
        print!("{name} ");
    }
    println!();

    // Roughly what the compiler turns it into:
    let mut iter = IntoIterator::into_iter(&names);
    loop {
        match Iterator::next(&mut iter) {
            Some(name) => print!("{name} "),
            None => break,
        }
    }
    println!();
}
Output
ada grace linus
ada grace linus

This is why anything that implements IntoIterator works in a for loop, and why for x in vec (by value) moves the Vec: into_iter takes self.

3. Desugaring: ? is a match with a conversion

The ? operator becomes a match that returns early on an error, and converts the error with From::from. The conversion is why ? can turn an io::Error into your own error type.

Rust
#[derive(Debug)]
struct AppError(String);

impl From<std::num::ParseIntError> for AppError {
    fn from(e: std::num::ParseIntError) -> Self {
        println!("  (From::from called by `?`)");
        AppError(e.to_string())
    }
}

fn parse_with_question_mark(s: &str) -> Result<i32, AppError> {
    let n = s.parse::<i32>()?;
    Ok(n * 2)
}

fn parse_desugared(s: &str) -> Result<i32, AppError> {
    let n = match s.parse::<i32>() {
        Ok(v) => v,
        Err(e) => return Err(From::from(e)),
    };
    Ok(n * 2)
}

fn main() {
    println!("{:?}", parse_with_question_mark("21"));
    println!("{:?}", parse_with_question_mark("x"));
    println!("{:?}", parse_desugared("x"));
}
Output
Ok(42)
  (From::from called by `?`)
Err(AppError("invalid digit found in string"))
  (From::from called by `?`)
Err(AppError("invalid digit found in string"))

The real desugaring goes through the Try trait, which is how ? also works on Option and in functions returning ControlFlow, but the behavior is this match.

4. Macro expansion: seeing what println! becomes

Macros are expanded before type checking, so the type checker only ever sees ordinary code. cargo expand (install it with cargo install cargo-expand) shows the result. For this program:

Rust
fn main() {
    let name = "Ada";
    println!("hello {name}");
}
Output
hello Ada

cargo expand (or rustc +nightly -Zunpretty=expanded main.rs) prints:

Text
#![feature(prelude_import)]
extern crate std;
#[prelude_import]
use std::prelude::rust_2024::*;
fn main() {
    let name = "Ada";
    { ::std::io::_print(format_args!("hello {0}\n", name)); };
}

Two things to notice. Every crate starts with an implicit use std::prelude::rust_2024::*, which is where Vec, String, Option, and println! come from. And format_args! is built into the compiler: it checks the format string against the arguments at compile time, which is why a wrong placeholder is a compile error rather than a runtime error. cargo expand is the fastest way to understand what a #[derive] or a library macro generates.

5. MIR: the function as a control-flow graph

After type checking, each function is lowered to MIR: a small language of basic blocks, temporaries, and explicit control flow. The borrow checker, const evaluation, and several optimizations all work on MIR, and it's the input to code generation. You can print it with rustc --emit=mir. For this function:

Rust
pub fn add_one(x: i32) -> i32 {
    let y = x + 1;
    y
}

fn main() {
    println!("{}", add_one(41));
}
Output
42

the debug-build MIR is:

Text
fn add_one(_1: i32) -> i32 {
    debug x => _1;
    let mut _0: i32;
    let mut _2: (i32, bool);
    scope 1 {
        debug y => _0;
    }

    bb0: {
        _2 = AddWithOverflow(copy _1, const 1_i32);
        assert(!move (_2.1: bool), "attempt to compute `{} + {}`, which would overflow", copy _1, const 1_i32) -> [success: bb1, unwind continue];
    }

    bb1: {
        _0 = move (_2.0: i32);
        return;
    }
}

_0 is the return value, _1 the argument, and bb0/bb1 are basic blocks. The overflow check you get in debug builds is visible as an explicit assert that branches to a panic. In release builds (overflow-checks = false) it isn't generated at all. MIR is the compiler's own simplified form of your program, and nothing interprets it at runtime.

6. The borrow checker works on the control-flow graph

Because borrow checking runs on MIR, a borrow lasts until its last use along each path, not until the end of the block ("non-lexical lifetimes", or NLL). This is why the following compiles: first is never used after the push.

Rust
fn main() {
    let mut scores = vec![10, 20, 30];
    let first = &scores[0];
    println!("first: {first}"); // last use of `first`: the borrow ends here
    scores.push(40); // so a mutable borrow is fine
    println!("{scores:?}");
}
Output
first: 10
[10, 20, 30, 40]

The analysis still has a known blind spot. When a function conditionally returns a borrow, the checker assumes the borrow lasts for the rest of the function on every path:

Rust✗ does not compile · E0499
use std::collections::HashMap;

fn get_or_create(map: &mut HashMap<u32, String>, id: u32) -> &mut String {
    if let Some(existing) = map.get_mut(&id) {
        return existing;
    }
    map.insert(id, String::from("new")); // rejected, although this path doesn't use `existing`
    map.get_mut(&id).unwrap()
}

fn main() {
    let mut map = HashMap::new();
    get_or_create(&mut map, 1).push('!');
}
Output
error[E0499]: cannot borrow `*map` as mutable more than once at a time
 --> src/main.rs:7:5
  |
3 | fn get_or_create(map: &mut HashMap<u32, String>, id: u32) -> &mut String {
  |                       - let's call the lifetime of this reference `'1`
4 |     if let Some(existing) = map.get_mut(&id) {
  |                             --- first mutable borrow occurs here
5 |         return existing;
  |                -------- returning this value requires that `*map` is borrowed for `'1`
6 |     }
7 |     map.insert(id, String::from("new")); // rejected, although this path doesn't use `existing`
  |     ^^^ second mutable borrow occurs here

This program is actually safe; the current checker just can't prove it. The next-generation borrow checker, Polonius, accepts it. Until then, restructure: here the entry API does it in one lookup.

Rust
use std::collections::HashMap;

fn get_or_create(map: &mut HashMap<u32, String>, id: u32) -> &mut String {
    map.entry(id).or_insert_with(|| String::from("new"))
}

fn main() {
    let mut map = HashMap::new();
    get_or_create(&mut map, 1).push('!');
    get_or_create(&mut map, 1).push('?');
    println!("{:?}", map[&1]);
}
Output
"new!?"

When the borrow checker rejects code you believe is correct, this pattern (a conditional return of a borrow) is the most common reason, and knowing it saves hours.

7. Const evaluation: code that runs inside the compiler

const items and const fn calls in constant contexts are evaluated by the compiler, using an interpreter for MIR. The result is stored in the binary as data. Here a lookup table is computed at build time, so the program does no work to build it at startup.

Rust
const fn crc_table() -> [u32; 256] {
    let mut table = [0u32; 256];
    let mut i = 0;
    while i < 256 {
        let mut c = i as u32;
        let mut k = 0;
        while k < 8 {
            c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
            k += 1;
        }
        table[i] = c;
        i += 1;
    }
    table
}

static CRC_TABLE: [u32; 256] = crc_table(); // computed by rustc, stored in the binary

fn crc32(data: &[u8]) -> u32 {
    let mut crc = !0u32;
    for &b in data {
        crc = CRC_TABLE[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8);
    }
    !crc
}

fn main() {
    println!("{:08x}", crc32(b"hello world"));
}
Output
0d4a1185

Because the compiler actually runs the code, errors in it are compile errors. An overflow in a constant doesn't wait for runtime:

Rust✗ does not compile · E0080
const BUFFER_SIZE: u32 = 10;
const HEADER_SIZE: u32 = 16;
const PAYLOAD_SIZE: u32 = BUFFER_SIZE - HEADER_SIZE;

fn main() {
    println!("{PAYLOAD_SIZE}");
}
Output
error[E0080]: attempt to compute `10_u32 - 16_u32`, which would overflow
 --> src/main.rs:3:27
  |
3 | const PAYLOAD_SIZE: u32 = BUFFER_SIZE - HEADER_SIZE;
  |                           ^^^^^^^^^^^^^^^^^^^^^^^^^ evaluation of `PAYLOAD_SIZE` failed here

Rust's const evaluation is a full interpreter with loops, match, and function calls.

8. Type inference works across the whole function

Rust infers types from how a value is used later in the function, not only from the initializer. This is why Vec::new() needs no type and why collect() and parse() take their target type from context.

Rust
use std::collections::{BTreeSet, HashMap};

fn main() {
    let mut buffer = Vec::new(); // Vec<?> for now...
    buffer.push(7u8); // ...now known to be Vec<u8>
    println!("{buffer:?}");

    let words = ["b", "a", "b"];
    let set: BTreeSet<_> = words.iter().collect(); // target chosen by the annotation
    let counts = words.iter().fold(HashMap::new(), |mut m, w| {
        *m.entry(*w).or_insert(0u32) += 1;
        m
    });
    let total: u64 = "40".parse::<u64>().unwrap() + 2; // turbofish chooses parse's target
    println!("{set:?} {} {total}", counts["b"]);
}
Output
[7]
{"a", "b"} 2 42

Inference never crosses function signatures: parameters and return types must be written out (except for closures and impl Trait). That keeps compile errors local and makes signatures the documentation.

9. Method lookup: inherent methods first, then traits, with auto-ref

value.method() is resolved at compile time. The compiler tries the receiver type, then &T, then &mut T, then dereferences and repeats (through Box, String to str, Vec to slice). At each step, inherent methods (in impl Type) win over trait methods. Knowing the order explains which method gets called when names collide.

Rust
struct Temperature(f64);

impl Temperature {
    fn describe(&self) -> String {
        format!("inherent: {:.1} C", self.0)
    }
}

trait Describe {
    fn describe(&self) -> String;
}

impl Describe for Temperature {
    fn describe(&self) -> String {
        format!("trait: {:.1} C", self.0)
    }
}

fn main() {
    let t = Temperature(21.5);
    println!("{}", t.describe()); // the inherent method wins
    println!("{}", Describe::describe(&t)); // fully qualified: the trait method
    println!("{}", <Temperature as Describe>::describe(&t));

    let boxed = Box::new(Temperature(-3.0));
    println!("{}", boxed.describe()); // auto-deref through Box
    let s = String::from("hello");
    println!("{}", s.len() + s.to_uppercase().len()); // String derefs to str methods
}
Output
inherent: 21.5 C
trait: 21.5 C
trait: 21.5 C
inherent: -3.0 C
10

All of this is decided statically. The call compiles to a direct call (or a vtable call only if the receiver is a dyn Trait).

Part 2: Generics, closures, and dyn

10. Monomorphization: one copy per type

Rust monomorphizes generics: each generic function is compiled separately for every combination of type arguments it's used with. Every copy is specialized, so there's no boxing and the optimizer sees concrete types.

Rust
use std::any::type_name;

fn largest<T: PartialOrd + Copy>(items: &[T]) -> T {
    let mut max = items[0];
    for &item in items {
        if item > max {
            max = item;
        }
    }
    max
}

fn which<T>() -> &'static str {
    type_name::<T>()
}

fn main() {
    println!("{}", largest(&[3, 9, 2]));
    println!("{}", largest(&[1.5, 0.5]));
    println!("{}", largest(&['x', 'b']));

    // Each instantiation is a separate function in the binary.
    let copies = [which::<i32> as fn() -> &'static str, which::<f64>, which::<String>];
    for f in copies {
        println!("{:<22} at a different address: {}", f(), copies.iter().filter(|g| std::ptr::fn_addr_eq(**g, f)).count() == 1);
    }
}
Output
9
1.5
x
i32                    at a different address: true
f64                    at a different address: true
alloc::string::String  at a different address: true

The trade-off: faster code, but more code. A generic function used with 20 types is compiled 20 times, which costs compile time and binary size. cargo llvm-lines (a separate tool) lists which generic functions produce the most code.

11. Controlling code bloat: the inner-function trick

A common pattern in the standard library: keep the generic part of a function tiny, and move the real work into a non-generic inner function that is compiled only once. std::fs::read is written like this.

Rust
use std::path::Path;

// Generic shell: instantiated per argument type, but only a few instructions each.
fn line_count<P: AsRef<Path>>(path: P) -> usize {
    // Non-generic body: compiled once, no matter how many types call it.
    fn inner(path: &Path) -> usize {
        // Pretend this is 200 lines of real work.
        path.to_string_lossy().split('/').filter(|s| !s.is_empty()).count()
    }
    inner(path.as_ref())
}

fn main() {
    let a = line_count("/var/log/app.log"); // &str
    let b = line_count(String::from("/etc/hosts")); // String
    let c = line_count(Path::new("/tmp")); // &Path
    let d = line_count(std::path::PathBuf::from("/usr/local/bin")); // PathBuf
    println!("{a} {b} {c} {d}");
}
Output
3 2 1 3

Four callers with four types produce four tiny shells and one copy of inner. Use this for large generic functions in libraries, or switch to &dyn Trait when the dynamic call's cost doesn't matter.

12. dyn Trait: one copy, dispatched through a vtable

A function taking &dyn Trait is compiled once. Calls go through a vtable (a table of function pointers per concrete type). Trait Objects covers the details; here are the sizes.

Rust
use std::fmt::Display;
use std::mem::size_of;

fn show_generic<T: Display>(value: T) -> String {
    format!("<{value}>") // compiled once per T
}

fn show_dyn(value: &dyn Display) -> String {
    format!("<{value}>") // compiled once, for every type
}

fn main() {
    println!("{} {} {}", show_generic(1), show_generic("a"), show_generic(2.5));
    println!("{} {} {}", show_dyn(&1), show_dyn(&"a"), show_dyn(&2.5));
    println!("&i32: {} bytes, &dyn Display: {} bytes (data pointer + vtable pointer)", size_of::<&i32>(), size_of::<&dyn Display>());
}
Output
<1> <a> <2.5>
<1> <a> <2.5>
&i32: 8 bytes, &dyn Display: 16 bytes (data pointer + vtable pointer)
Generics (impl Trait, <T: Trait>) dyn Trait
Code copies one per type one
Call direct, can be inlined indirect through the vtable, rarely inlined
Compile time and binary size grow with the number of types constant
Mixed types in one collection no yes

13. Closures are structs the compiler writes for you

Each closure becomes an anonymous struct holding its captures, plus an implementation of Fn, FnMut, or FnOnce. A closure that captures nothing is zero bytes. Calling it is an ordinary (usually inlined) function call; there's no allocation.

Rust
use std::mem::size_of_val;

fn main() {
    let add_one = |x: i32| x + 1;
    let offset = 10i64;
    let add_offset = |x: i64| x + offset; // captures &offset
    let label = String::from("id");
    let tag = move |x: u32| format!("{label}-{x}"); // captures a String by value
    let (a, b) = (1u8, 2u64);
    let pair = move || (a, b); // captures u8 + u64, padded

    println!("add_one:    {} bytes", size_of_val(&add_one));
    println!("add_offset: {} bytes (a reference)", size_of_val(&add_offset));
    println!("tag:        {} bytes (a String)", size_of_val(&tag));
    println!("pair:       {} bytes", size_of_val(&pair));
    println!("{} {} {} {:?}", add_one(1), add_offset(1), tag(7), pair());
}
Output
add_one:    0 bytes
add_offset: 8 bytes (a reference)
tag:        24 bytes (a String)
pair:       16 bytes
2 11 id-7 (1, 2)

Roughly, add_offset becomes:

Rust
struct AddOffsetClosure<'a> {
    offset: &'a i64,
}

impl AddOffsetClosure<'_> {
    fn call(&self, x: i64) -> i64 {
        x + *self.offset
    }
}

fn main() {
    let offset = 10i64;
    let closure = AddOffsetClosure { offset: &offset };
    println!("{}", closure.call(1));
}
Output
11

14. Every closure has its own type

Because each closure is its own struct, two closures never have the same type, even with identical signatures. That's why if can't return two different capturing closures directly:

Rust✗ does not compile · E0308
fn pick(rate: f64) -> impl Fn(f64) -> f64 {
    if rate > 0.0 {
        move |price| price * (1.0 - rate)
    } else {
        move |price| price + rate
    }
}

fn main() {
    println!("{}", pick(0.1)(100.0));
}
Output
error[E0308]: `if` and `else` have incompatible types
 --> src/main.rs:5:9
  |
2 | /     if rate > 0.0 {
3 | |         move |price| price * (1.0 - rate)
  | |         ---------------------------------
  | |         |
  | |         the expected closure
  | |         expected because of this
4 | |     } else {
5 | |         move |price| price + rate
  | |         ^^^^^^^^^^^^^^^^^^^^^^^^^ expected closure, found a different closure
6 | |     }
  | |_____- `if` and `else` have incompatible types
  |
  = note: expected closure `{closure@src/main.rs:3:9: 3:21}`
             found closure `{closure@src/main.rs:5:9: 5:21}`
  = note: no two closures, even if identical, have the same type
  = help: consider boxing your closure and/or using it as a trait object
help: you could change the return type to be a boxed trait object
  |
1 - fn pick(rate: f64) -> impl Fn(f64) -> f64 {
1 + fn pick(rate: f64) -> Box<dyn Fn(f64) -> f64> {
  |
help: if you change the return type to expect trait objects, box the returned expressions
  |
3 ~         Box::new(move |price| price * (1.0 - rate))
4 |     } else {
5 ~         Box::new(move |price| price + rate)
  |

The fixes: box them (Box<dyn Fn(f64) -> f64>, one allocation and dynamic dispatch), or move the condition inside one closure. Closures that capture nothing are the exception: they coerce to a plain function pointer fn(f64) -> f64, so an if choosing between two of them compiles.

Rust
fn pick(discount: bool) -> fn(f64) -> f64 {
    // Non-capturing closures coerce to function pointers.
    if discount { |price| price * 0.9 } else { |price| price }
}

fn pick_boxed(rate: f64) -> Box<dyn Fn(f64) -> f64> {
    if rate > 0.0 { Box::new(move |price| price * (1.0 - rate)) } else { Box::new(|price| price) }
}

fn main() {
    println!("{} {}", pick(true)(100.0), pick_boxed(0.25)(100.0));
}
Output
90 75

15. Zero-sized types vanish

Types with no data (unit structs, (), PhantomData, non-capturing closures) take no space and generate no code. The compiler uses this heavily: a HashSet<T> is a HashMap<T, ()>, and the () values cost nothing.

Rust
use std::collections::HashMap;
use std::marker::PhantomData;
use std::mem::size_of;

struct Meters;
#[allow(dead_code)]
struct Length<Unit> {
    value: f64,
    unit: PhantomData<Unit>, // compile-time only
}

fn main() {
    println!("Length<Meters>: {} bytes", size_of::<Length<Meters>>());
    println!("(): {}, [(); 1000]: {}", size_of::<()>(), size_of::<[(); 1000]>());

    let mut v: Vec<()> = Vec::new();
    for _ in 0..1_000_000 {
        v.push(());
    }
    println!("Vec<()> with {} elements, capacity {}", v.len(), v.capacity());

    let map: HashMap<&str, ()> = [("a", ()), ("b", ())].into();
    println!("a set in disguise: {}", map.contains_key("a"));
}
Output
Length<Meters>: 8 bytes
(): 0, [(); 1000]: 0
Vec<()> with 1000000 elements, capacity 18446744073709551615
a set in disguise: true

A Vec<()> never allocates, and reports the maximum capacity because it can hold any number of nothings.

Part 3: What the optimizer does

The examples in this part show real x86-64 assembly produced by rustc -C opt-level=3 (the release profile) with Rust 1.94. You can see the same for your own code with cargo asm (the cargo-show-asm tool), rustc --emit=asm, or the Compiler Explorer at godbolt.org. #[unsafe(no_mangle)] keeps the function names readable.

16. Iterators compile to the same loop as indexing

Rust
#[unsafe(no_mangle)]
pub fn sum_loop(v: &[u32]) -> u32 {
    let mut total = 0u32;
    let mut i = 0;
    while i < v.len() {
        total = total.wrapping_add(v[i]);
        i += 1;
    }
    total
}

#[unsafe(no_mangle)]
pub fn sum_iter(v: &[u32]) -> u32 {
    v.iter().fold(0u32, |acc, &x| acc.wrapping_add(x))
}

fn main() {
    let v: Vec<u32> = (1..=10).collect();
    println!("{} {}", sum_loop(&v), sum_iter(&v));
}
Output
55 55

Both functions compile to identical machine code. The core loop adds eight numbers per iteration with SIMD instructions (paddd adds four 32-bit integers at once, and there are two accumulators):

Text
sum_iter:
	...
.LBB3_6:
	movdqu	xmm2, xmmword ptr [rdi + rdx]
	paddd	xmm0, xmm2
	movdqu	xmm2, xmmword ptr [rdi + rdx + 16]
	paddd	xmm1, xmm2
	add	rdx, 32
	cmp	rax, rdx
	jne	.LBB3_6
	...

The iterator, the closure, and fold were all inlined away. This is what "zero-cost abstraction" means: you couldn't write the loop better by hand. Note that there is no bounds check in sum_loop either: the compiler proved i < v.len() from the loop condition.

17. Bounds checks: often removed, sometimes hoisted, never silently skipped

Every v[i] is checked, and an out-of-bounds index panics. Whether that check costs anything depends on what the optimizer can prove. Three versions of a dot product:

Rust
#[unsafe(no_mangle)]
pub fn dot_indexed(a: &[f32], b: &[f32]) -> f32 {
    let mut s = 0.0;
    for i in 0..a.len() {
        s += a[i] * b[i]; // `a[i]` is provably in bounds, `b[i]` is not
    }
    s
}

#[unsafe(no_mangle)]
pub fn dot_zip(a: &[f32], b: &[f32]) -> f32 {
    let mut s = 0.0;
    for (x, y) in a.iter().zip(b) {
        s += x * y; // zip stops at the shorter slice: nothing to check
    }
    s
}

#[unsafe(no_mangle)]
pub fn dot_asserted(a: &[f32], b: &[f32]) -> f32 {
    assert_eq!(a.len(), b.len()); // one check up front...
    let mut s = 0.0;
    for i in 0..a.len() {
        s += a[i] * b[i]; // ...lets the compiler drop the per-element checks
    }
    s
}

fn main() {
    let (a, b) = ([1.0, 2.0, 3.0], [4.0, 5.0, 6.0]);
    println!("{} {} {}", dot_indexed(&a, &b), dot_zip(&a, &b), dot_asserted(&a, &b));
}
Output
32 32 32

In the optimized assembly, dot_zip and dot_asserted contain no bounds-check call. dot_indexed contains one, but not in the loop: LLVM hoisted it, comparing b.len() with a.len() - 1 once before the loop starts:

Text
dot_indexed:
	test	rsi, rsi
	je	.LBB1_1
	lea	rax, [rsi - 1]
	cmp	rcx, rax             ; b.len() <= a.len() - 1 ?
	jbe	.LBB1_11             ; then jump to the panic
	...                          ; the loop itself has no checks
.LBB1_11:
	...
	call	qword ptr [rip + _ZN4core9panicking18panic_bounds_check...]

The lessons for hot code:

  • Prefer iterators (iter, zip, chunks, windows): they have no checks to remove.
  • If you index, a single assert! on the lengths, or reslicing (let b = &b[..a.len()];), tells the compiler what it needs to know.
  • Don't reach for get_unchecked until the assembly or a benchmark shows the check matters. Usually it doesn't.

Look at the loop again and you'll notice something else: it uses mulss/addss, which handle one float at a time, while the integer sum used SIMD. That's the next example.

18. The optimizer respects floating-point semantics

Integer addition is associative, so LLVM may reorder a sum into several SIMD lanes. Floating-point addition is not associative: (a + b) + c can differ from a + (b + c). Rust (unlike C with -ffast-math) never lets the optimizer change a float result, so a float reduction stays sequential.

Rust
fn main() {
    let (a, b, c) = (0.1f64, 0.2, 0.3);
    println!("(a + b) + c = {}", (a + b) + c);
    println!("a + (b + c) = {}", a + (b + c));

    // Summing in a different order gives a different answer.
    let values: Vec<f32> = (1..=100_000).map(|i| 1.0 / i as f32).collect();
    let forward: f32 = values.iter().sum();
    let backward: f32 = values.iter().rev().sum();
    println!("forward {forward}, backward {backward}, equal: {}", forward == backward);
}
Output
(a + b) + c = 0.6000000000000001
a + (b + c) = 0.6
forward 12.090851, backward 12.090153, equal: false

If you want SIMD speed for float reductions, write the reordering yourself (for example, sum into [f32; 8] accumulators with chunks_exact(8)), and accept the slightly different rounding explicitly.

19. Constant folding: loops can disappear

LLVM recognizes common loop shapes and replaces them with closed-form arithmetic. This loop sums 0..n:

Rust
#[unsafe(no_mangle)]
pub fn triangle(n: u64) -> u64 {
    let mut total = 0u64;
    for i in 0..n {
        total = total.wrapping_add(i);
    }
    total
}

fn main() {
    println!("{}", triangle(1_000_000));
}
Output
499999500000

The optimized function has no loop at all. It computes n * (n - 1) / 2 with a multiply and a shift:

Text
triangle:
	test	rdi, rdi
	je	.LBB5_1
	lea	rax, [rdi - 1]
	lea	rcx, [rdi - 2]
	mul	rcx
	shld	rdx, rax, 63
	lea	rax, [rdi + rdx]
	dec	rax
	ret

This is why naive benchmarks lie: if the input is a constant and the result is unused, the compiler may compute it at build time or delete the work entirely. Use std::hint::black_box, shown in example 21.

20. Newtypes are free

Wrapping a value in a struct for type safety costs nothing at runtime. These two functions compile to the same machine code, so LLVM merged them into one symbol:

Rust
#[derive(Clone, Copy, Debug)]
pub struct Meters(pub f64);

#[unsafe(no_mangle)]
pub fn add_raw(a: f64, b: f64) -> f64 {
    a + b
}

#[unsafe(no_mangle)]
pub fn add_meters(a: Meters, b: Meters) -> Meters {
    Meters(a.0 + b.0)
}

fn main() {
    println!("{} {:?}", add_raw(1.5, 2.0), add_meters(Meters(1.5), Meters(2.0)));
}
Output
3.5 Meters(3.5)
Text
add_meters:
	addsd	xmm0, xmm1
	ret
...
add_raw = add_meters

In Rust, a newtype wrapper costs nothing, so there's no reason to skip it.

21. black_box: stopping the optimizer from cheating

std::hint::black_box returns its argument unchanged, but the optimizer must assume it could have done anything with it. Wrap inputs so they aren't treated as constants, and outputs so the work isn't deleted.

Rust
use std::hint::black_box;
use std::time::Instant;

fn triangle(n: u64) -> u64 {
    (0..n).fold(0u64, |acc, i| acc.wrapping_add(i))
}

fn main() {
    let start = Instant::now();
    let mut result = 0;
    for _ in 0..1000 {
        // Without black_box, the optimizer may compute this once, at compile time.
        result = black_box(triangle(black_box(1_000_000)));
    }
    let _elapsed = start.elapsed(); // print or record this in a real benchmark
    println!("result {result}");
}
Output
result 499999500000

black_box is a best-effort hint, not a guarantee, which is one reason to use a benchmarking harness such as criterion (covered in Testing, Fuzzing & Benchmarking) rather than timing loops by hand.

Part 4: Build settings that change the output

22. Profiles: what --release changes

cargo build and cargo build --release use two built-in profiles. The differences explain most "Rust is slow" reports: someone measured a debug build.

Setting dev (default) release What it does
opt-level 0 3 LLVM optimization level
debug full none debug info for debuggers and backtraces
debug-assertions on off debug_assert!, some std checks
overflow-checks on off panic on integer overflow (wraps in release)
incremental on off reuse work between builds
codegen-units 256 16 parallel LLVM units: faster builds, fewer cross-unit optimizations
lto off off (only within the crate) link-time optimization across crates
panic unwind unwind abort removes unwinding code

Profiles are configured in Cargo.toml. A common production profile:

TOMLCargo.toml
[profile.release]
lto = "fat"          # optimize across all crates at link time
codegen-units = 1    # one LLVM unit: slowest build, best code
debug = "line-tables-only" # readable backtraces and profiles, small size cost
# panic = "abort"    # smaller and slightly faster, if nothing catches panics

[profile.dev.package."*"]
opt-level = 2        # optimize dependencies even in debug builds

The last section is a practical trick: your own code stays fast to compile and easy to debug, while heavy dependencies (image codecs, crypto, parsers) run at release speed in debug builds.

23. LTO, PGO, and target CPUs: optimizing ahead of time

Rust can use knowledge of the whole program and of which paths are hot, all at build time:

Goal Rust technique How
Inlines across libraries Link-time optimization lto = "thin" (fast) or "fat" (best)
Optimizes for measured hot paths Profile-guided optimization build instrumented, run a workload, rebuild with the profile (cargo pgo)
Uses the CPU's newest instructions Target CPU flags RUSTFLAGS="-C target-cpu=native" (only for the machine you build on), or x86-64-v3 for modern servers
Picks code paths per CPU at runtime Runtime feature detection is_x86_feature_detected!("avx2") with a fallback
Rust
fn sum(v: &[u32]) -> u32 {
    #[cfg(target_arch = "x86_64")]
    {
        if is_x86_feature_detected!("avx2") {
            // Safety: we just checked that the CPU supports AVX2.
            return unsafe { sum_avx2(v) };
        }
    }
    v.iter().sum()
}

#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx2")]
unsafe fn sum_avx2(v: &[u32]) -> u32 {
    v.iter().sum() // the same code, compiled with AVX2 allowed
}

fn main() {
    println!("{}", sum(&[1, 2, 3]));
}

PGO often gives around 10–20% on branchy code such as parsers, interpreters, and compilers (rustc itself is built with PGO). LTO helps most when hot paths cross crate boundaries. Measure both on your own workload; neither is free in build time.

24. Inlining across crates

Within a crate, LLVM inlines whatever it thinks is worth it. Across crates, it can only inline a function if its body is available when the caller is compiled. Generic functions always are (they're instantiated in the caller's crate). Non-generic functions are only if they're marked #[inline], or if LTO is on.

Rust
pub mod geometry {
    /// Small and called in hot loops by other crates: worth `#[inline]`.
    #[inline]
    pub fn squared_distance(a: (f64, f64), b: (f64, f64)) -> f64 {
        let (dx, dy) = (a.0 - b.0, a.1 - b.1);
        dx * dx + dy * dy
    }

    /// Large and called rarely: let the compiler decide.
    pub fn describe(points: &[(f64, f64)]) -> String {
        format!("{} points", points.len())
    }
}

fn main() {
    let pts = [(0.0, 0.0), (3.0, 4.0)];
    println!("{} {}", geometry::squared_distance(pts[0], pts[1]), geometry::describe(&pts));
}
Output
25 2 points

Guidelines: put #[inline] on small, non-generic public functions of a library that will be called in hot loops. Avoid #[inline(always)] unless a measurement demands it; it grows code everywhere it's used. Inside an application, prefer LTO over sprinkling attributes.

25. Editions: the compiler supports every version of the language

A Rust edition (2015, 2018, 2021, 2024) is chosen per crate in Cargo.toml, and the compiler supports all of them at once. Crates of different editions link together freely, so your 2024-edition service can depend on a 2018-edition library.

TOMLCargo.toml
[package]
name = "billing"
edition = "2024"
rust-version = "1.85" # the minimum compiler version this crate supports

Editions only change the surface language (new keywords, prelude additions, changed defaults such as the 2024 rules for unsafe extern and impl Trait captures). The compiler, the standard library, and the generated code are the same for all editions. cargo fix --edition automates most of an upgrade.

Summary

  • rustc parses, expands macros, desugars (for, ?, async), type-checks, and lowers each function to MIR. Borrow checking and const evaluation run on MIR; LLVM does the optimization.
  • The borrow checker reasons about control flow (a borrow ends at its last use). Conditionally returning a borrow is its best-known blind spot; restructure or use entry.
  • Const evaluation runs real code inside the compiler, so lookup tables cost nothing at runtime and errors in constants are compile errors.
  • Generics are monomorphized: fast, specialized code at the cost of compile time and size. Keep generic shells small, or use dyn where dispatch cost doesn't matter.
  • Closures are compiler-generated structs; each has a unique type. Zero-sized types disappear.
  • The optimizer turns iterators into vectorized loops, hoists or removes bounds checks, folds loops into formulas, and makes newtypes free. It never reorders float arithmetic.
  • Release profiles, LTO, PGO, and target CPU flags optimize the program ahead of time. Always measure release builds.

Next: The Runtime & Async Executors: what runs before main, threads and stacks, the panic runtime, and how Tokio schedules your tasks.