Coding by Hand
Rust home

How Rust Compiles

A Rust compiler is a restaurant kitchen with eight stations. A ticket comes in at the back door, the prep cook chops it into pieces, the sous chef arranges the pieces in the order they will cook, two inspectors check the meat is safe and the plates are not double-claimed, the grill master cooks the food, the line cook plates it, and the runner hands it to the customer. Each station does one job and slides the result down the pass to the next station. The thing the customer eats has been touched by every station in turn, and if any station rejects the ticket, nothing leaves the kitchen. That is what cargo build is — eight stations, one ticket, every plate inspected before it goes out.

The eight-station compiler line: a ticket enters at the back, a plate leaves at the pass.
The eight-station compiler line: a ticket enters at the back, a plate leaves at the pass.

The kitchen was opened in 2006 by a Mozilla engineer named Graydon Hoare, and for the first four years it cooked nothing — the kitchen itself was being built in OCaml, a different language, because you cannot use the kitchen to build itself before it exists. In 2010 Hoare flipped the switch and rewrote the kitchen in its own dishes, a trick programmers call self-hosting. The first inspector station back then was a simple one and let through too many bad plates. The serious memory bugs Rust was supposed to prevent were sneaking past in code that used loops and early returns. In 2016 the team led by Niko Matsakis added a second inspector station with a new clipboard called MIR — the Mid-level Intermediate Representation — and the bad plates stopped getting through. The grill at the far end of the kitchen has been the LLVM compiler from day one, because LLVM already knew how to cook for every CPU on Earth and Hoare did not want to rebuild that. A second grill called Cranelift is being installed now for faster debug builds where you do not care about peak speed.

Here is the ticket the kitchen will cook for the rest of this lesson — one tiny function that prints a single word.

// the program the kitchen will cook from start to finish
fn greet() {
    println!("source");
}

Two lines of code. To you it looks like a function called greet that prints the word "source." To the kitchen it is a stack of raw ingredients no station has touched yet. The first station is the prep cook, called the lexer. The lexer reads the text one character at a time and chops it into the smallest meaningful chunks the language defines — a keyword like fn, a name like greet, a parenthesis, a curly brace, a string literal in quotes. Each chunk is called a token. Whitespace and comments get swept off the counter. By the time the prep cook is done, the source code is no longer text. It is a tray of about a dozen labeled chunks waiting for the next station.

Source text chopped into tokens, tokens arranged into the AST cookbook order.
Source text chopped into tokens, tokens arranged into the AST cookbook order.

The sous chef is the parser, and the parser takes the tray of chunks and arranges them into a tree shape called the Abstract Syntax Tree. The tree is the recipe in cookbook order — at the top sits FnItem named greet, hanging off it is Block, hanging off that is MacroCall named println, and hanging off that is the string "source". The structure of the tree is the structure of the program. If the tokens cannot be arranged into a legal tree — if you forgot a closing brace or wrote fn greet( with no closing paren — the parser stops and the ticket dies right here. No plate ever leaves the kitchen on a parse error.

The two inspector stations come next, and they are the part of Rust that no other mainstream compiler has. The first inspector clipboards on a representation called HIR, short for High-level Intermediate Representation. HIR is the AST cleaned up — sugar like for loops desugared into explicit calls, every name resolved to the exact thing it points to, every expression tagged with its type. The HIR inspector's job is type-checking. Every value gets a label that says what kind of thing it is. The function greet returns (), the unit type, which is Rust's way of saying nothing. The string literal "source" is a &'static str. If you tried to add a number to a string, the HIR inspector would write the red mark and stop the ticket.

The second inspector clipboards on MIR, the representation Niko Matsakis's team added in 2016 to fix the original sin. MIR is the same program flattened into a numbered list of basic blocks — block 0 does this, block 1 does that, this edge jumps from block 0 to block 1, this one returns. The flat shape makes it easy for the inspector to walk every possible path through the function and check what the older inspector could not — that no value is used after it has been moved out, that no two pieces of code hold conflicting borrows of the same memory at the same moment. This is the borrow checker. It is the reason your first month with Rust feels like arguing with a permit office. It is also the reason memory bugs do not ship.

The MIR inspector walks every path through the function and checks every borrow.
The MIR inspector walks every path through the function and checks every borrow.

Past the two inspectors the kitchen does something that surprises first-time visitors. Generic functions — the ones you wrote with <T> so the same code can work on any type — get duplicated, one copy per type that was ever called with. A Vec<i32> and a Vec<String> look like one struct in your source but become two separate structs at this stage, each with its methods stamped out for the exact type. The trick is called monomorphization, a word borrowed from category theory that just means "make many-shaped into one-shaped." The cost is bigger binaries. The payoff is that there are no virtual function calls — each call site jumps to a function specialized for exactly the type sitting there. C++ templates work the same way. The grill cooks fast because the line cook already labeled which pan to use.

Now the grill. The grill at Rust's kitchen is LLVM, and LLVM speaks a language of its own called LLVM IR — a portable assembly-like notation that LLVM can lower into the machine code of any CPU it supports. The Rust compiler walks the MIR for each function and emits LLVM IR — define void @greet() { call @rt_println(i8* %.str) } for our two-liner. LLVM takes the IR and runs the optimization passes Chris Lattner has spent two decades polishing — inlining, loop-invariant motion, dead code elimination, register allocation, instruction scheduling — and out the other side comes machine code for the target CPU. To make this run in parallel across many cores, the Rust compiler chops the program into chunks called codegen units and hands each chunk to LLVM separately. More codegen units means faster compilation; fewer means LLVM sees more code at once and can optimize more aggressively across function boundaries.

The crate split into codegen units, each unit handed to a parallel LLVM grill.
The crate split into codegen units, each unit handed to a parallel LLVM grill.

Each codegen unit comes out of the grill as an object file — a .o file holding compiled machine code with holes punched in it where the calls to other modules go. The last station is the linker. The linker takes all the object files for your crate, the object files for every dependency, and the precompiled standard library, and stitches them together — filling in every hole with the address of the function being called, building the executable's headers, laying out the sections — and writes a single binary to disk. On Linux the linker is usually ld or lld; on macOS it is Apple's ld64; on Windows it is link.exe from MSVC or lld-link. The output is the file you can double-click.

The linker stitches every object file plus the standard library into one executable.
The linker stitches every object file plus the standard library into one executable.

Here is what every stage looks like for the two-line function above, written as a mock log so you can read the kitchen's order of operations without compiling anything yourself.

fn pipeline_log() {
    let stages = [
        ("lex     ", "5 tokens: fn  greet  ( )  { ... }"),
        ("parse   ", "AST: FnItem 'greet' -> Block -> MacroCall 'println'"),
        ("HIR     ", "type-check: greet() : () , string literal : &'static str"),
        ("MIR     ", "borrow-check: no borrows, no moves, clean"),
        ("monomorph", "println! expands to <&str as Display>::fmt for this call"),
        ("LLVM IR ", "define void @greet() { call @rt_println(i8* %.str) }"),
        ("codegen ", "x86_64 object: 312 bytes (greet.o)"),
        ("link    ", "greet.o + libstd.rlib -> a.out (executable, 421 KB)"),
    ];
    for (stage, line) in stages {
        println!("[{stage}] {line}");
    }
}

Run the program and the log prints in order — lex, parse, HIR, MIR, monomorph, LLVM IR, codegen, link — and then the last station hands you the actual compiled executable, which runs and prints its one word.

fn pretend_to_run() {
    println!("-- running the linked executable --");
    greet();
}
-- pipeline log for the one function above --
[lex     ] 5 tokens: fn  greet  ( )  { ... }
[parse   ] AST: FnItem 'greet' -> Block -> MacroCall 'println'
[HIR     ] type-check: greet() : () , string literal : &'static str
[MIR     ] borrow-check: no borrows, no moves, clean
[monomorph] println! expands to <&str as Display>::fmt for this call
[LLVM IR ] define void @greet() { call @rt_println(i8* %.str) }
[codegen ] x86_64 object: 312 bytes (greet.o)
[link    ] greet.o + libstd.rlib -> a.out (executable, 421 KB)

-- running the linked executable --
source

The log is a pretend version of what rustc -Z dump-mir and --emit=llvm-ir and --emit=obj would show you for a real program — each flag asks the compiler to write the contents of one station's pass to disk so you can read it. The numbers in the log are honest orders of magnitude. A println!-only function does come out to a few hundred bytes of object code, and the full binary does come out near 400 kilobytes because the Rust standard library gets statically linked in by default.

A separate piece of the kitchen that does not fit on the assembly line is the trait solver — the engine that decides, when you write x.clone(), which actual clone method to call out of every type that implements the Clone trait. The current solver was written years ago and has accumulated rough edges, so a rewrite called Chalk has been in progress since 2017, and a newer effort called the next-gen trait solver has been merging into the compiler piece by piece. Both are pushing the same goal — make the trait checking station as predictable as the borrow checking station already is.

The kitchen as described cooks one ticket from start to finish on every build, and that is the next bottleneck — when you change one byte of source, the compiler has to redo most of the work for the file that changed and any file that depends on it, which is why the next lesson is about the layout of memory and how Rust decides what goes on the stack, what goes on the heap, and why those choices change what the compiler can prove.