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.
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!();
}ada grace linus
ada grace linusThis 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.
#[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"));
}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:
fn main() {
let name = "Ada";
println!("hello {name}");
}hello Adacargo expand (or rustc +nightly -Zunpretty=expanded main.rs) prints:
#![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:
pub fn add_one(x: i32) -> i32 {
let y = x + 1;
y
}
fn main() {
println!("{}", add_one(41));
}42the debug-build MIR is:
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.
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:?}");
}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:
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('!');
}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 hereThis 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.
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]);
}"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.
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"));
}0d4a1185Because the compiler actually runs the code, errors in it are compile errors. An overflow in a constant doesn't wait for runtime:
const BUFFER_SIZE: u32 = 10;
const HEADER_SIZE: u32 = 16;
const PAYLOAD_SIZE: u32 = BUFFER_SIZE - HEADER_SIZE;
fn main() {
println!("{PAYLOAD_SIZE}");
}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 hereRust'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.
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"]);
}[7]
{"a", "b"} 2 42Inference 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.
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
}inherent: 21.5 C
trait: 21.5 C
trait: 21.5 C
inherent: -3.0 C
10All 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.
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);
}
}9
1.5
x
i32 at a different address: true
f64 at a different address: true
alloc::string::String at a different address: trueThe 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.
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}");
}3 2 1 3Four 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.
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>());
}<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.
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());
}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:
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));
}1114. 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:
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));
}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.
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));
}90 7515. 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.
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"));
}Length<Meters>: 8 bytes
(): 0, [(); 1000]: 0
Vec<()> with 1000000 elements, capacity 18446744073709551615
a set in disguise: trueA 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
#[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));
}55 55Both 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):
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:
#[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));
}32 32 32In 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:
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_uncheckeduntil 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.
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);
}(a + b) + c = 0.6000000000000001
a + (b + c) = 0.6
forward 12.090851, backward 12.090153, equal: falseIf 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:
#[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));
}499999500000The optimized function has no loop at all. It computes n * (n - 1) / 2 with a multiply and a shift:
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
retThis 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:
#[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)));
}3.5 Meters(3.5)add_meters:
addsd xmm0, xmm1
ret
...
add_raw = add_metersIn 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.
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}");
}result 499999500000black_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:
[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 buildsThe 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 |
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.
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));
}25 2 pointsGuidelines: 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.
[package]
name = "billing"
edition = "2024"
rust-version = "1.85" # the minimum compiler version this crate supportsEditions 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
rustcparses, 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
dynwhere 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.
