notebook № 058 plates · 30 min to read, longer to play
The architecture of an ahead-of-time compiler
On turning words into motion
A compiler is a pipeline of translations, each one a little closer to the machine. This notebook has a real one inside it, small enough to read and complete enough to produce x86-64 machine code. Write a program once at the top; every plate shows the same program at the next stage down.
plate I · timing
Before you press run
There are three ways to make a computer follow a program. An interpreter reads it and does what it says, line by line, every time. A just-in-time compiler starts out interpreting, notices which parts run hot, and translates those to machine code while the program runs. An ahead-of-time compiler does all the translating first, once, and hands you a binary that is nothing but machine code.
AOT is how C, C++, Rust, Go and Swift ship. You pay for compilation at build time, on your machine, and every user after that gets full speed from the first instruction. No warm-up and no compiler shipped inside the program.
It isn’t free, though. Slide the amount of work down to a few milliseconds and switch on count the build: for a script you run once, the interpreter is long finished before the compiler is.
plate II · the lexer
Words
Here is the program. Edit it. Everything below this plate, all the way down to the machine code, recompiles as you type.
The language is deliberately tiny: let, assignment, integer arithmetic, comparisons, if/else, while, print(…) and return. Small enough to compile in a few hundred lines, big enough to need everything a real compiler does.
The first stage, the lexer, sees none of that structure. It sees characters, and groups them into tokens: a keyword, a name, a number, an operator. It is greedy: <= is one token, never < followed by =. It throws away what the machine doesn’t care about, like spaces and comments. The colours in the editor are the lexer’s own output.
Type let 9lives = 1; and watch which stage complains, and where.
plate III · the parser
Grammar
A flat list of tokens has no shape. The parser gives it one: an abstract syntax tree, where every statement and every expression is a node and its parts hang beneath it. This one is a recursive-descent parser, one small function per rule of the grammar. Clang, rustc and Go’s compiler all parse this way, by hand.
Precedence is where the grammar shows. In sum + i * 2, the multiplication sits deeper in the tree than the addition, so it happens first. Nobody wrote a rule saying “multiply first”: the function for + calls the function for * to get its operands, and depth does the rest. Parentheses don’t survive into the tree at all; they only changed its shape.
plate IV · the checker
Meaning
A program can be perfectly grammatical and still mean nothing. return totl; parses fine. Only the next stage, semantic analysis, notices that nothing called totl exists. It walks the tree with a stack of scopes, binds every name to the declaration it refers to, and builds the symbol table on the right.
This is where a language decides how kind it is. Rust and Elm made their reputations here, on error messages that point at the exact characters and guess what you meant. A real checker would also infer and check types; this language has only one type, so it settles for scopes, plus a few warnings about code that can never run and values nobody reads.
Pick broken. Then fix it one error at a time, and watch the later plates come back to life.
plate V · the middle
One name, one value
Trees are good for checking and bad for optimising. So the compiler lowers the tree into an intermediate representation: short instructions, each doing one thing, grouped into basic blocks that run straight through and end in a jump. Draw the jumps as arrows and you get the control-flow graph below. The while loop is the arrow that goes back up.
The IR is in SSA form (static single assignment): every value gets exactly one name, assigned exactly once. The variable sum becomes %sum.0, %sum.1, %sum.2, one per assignment. Where two paths meet and each brings a different version, a phi picks the right one depending on which arrow you came in on. In exchange for that bookkeeping, every optimisation in the next plate becomes simple, because a name can never change behind your back.
Cranelift, the code generator Miri uses, builds its SSA with the same algorithm, and skips the phi notation altogether: its blocks take parameters, and a jump passes the values in, like a function call. It’s the same idea in different clothes.
plate VI · the optimiser
The optimiser
An optimiser is a set of small, paranoid passes. Each one looks for a pattern it can prove safe to rewrite, rewrites it, and hands the IR to the next. None is clever on its own; run them in a loop until nothing changes and they add up.
Constant propagation replaces a name with its value when the value is known. Constant folding does arithmetic on known numbers at compile time, and turns a branch on a known condition into a plain jump. Common-subexpression elimination reuses a result instead of computing it twice. Strength reduction swaps an expensive operation for a cheaper one: i * 2 becomes i shl 1. Dead-code elimination deletes whatever no longer affects the output.
Pick arithmetic and switch the passes on one at a time. Propagation alone changes almost nothing visible; add folding and the whole program collapses into ret 42. The passes need each other. That is also why the order of passes is one of the most argued-about settings in any real compiler.
Then look at what this optimiser can’t do. The sum loop survives, because every pass here looks at one value at a time. LLVM can recognise that loop as an arithmetic series and replace it with the closed-form formula. That isn’t magic either, just a smarter pass than mine.
plate VII · the back end
Sixteen registers
The IR assumes an endless supply of names. An x86-64 core has sixteen general-purpose registers, and some are spoken for: the stack pointer, the frame pointer, the return value, the division unit’s pair. The back end has to make the endless fit into the few.
First, out of SSA: each phi becomes a copy at the end of the block that jumps in. Then register allocation. This one is a linear scan: it works out how long every value stays alive (the bars), walks them from left to right and hands each a free register. When none is free, the value that will live longest goes to the stack, a spill, and every use of it becomes a trip to memory.
Drag the register count down and watch the spills arrive, and the assembly grow its [rbp-…] loads and stores. Plate V of the CPU notebook tells you what each of those costs.
plate VIII · the linker
Bytes, symbols, a binary
The assembler turns every instruction into bytes. These are real x86-64 encodings, checked byte for byte against Clang’s assembler, and the same code, linked and run natively, prints exactly what the interpreter in plate VI predicts. c3 is ret; 55 is push rbp; every jump is resolved to a distance in bytes.
One thing the assembler can’t resolve is print. It lives in another file, so its address is unknown. The assembler writes four zero bytes and a note, a relocation: “patch this with the distance to print, minus 4”. The result is an object file: code, a list of the symbols it defines, a list of the ones it needs.
The linker takes object files and makes one program: it lays the code out in memory, learns where every symbol landed, and goes back to patch each relocation. Step through it.
That’s the whole journey: characters, tokens, a tree, a checked tree, SSA, better SSA, assembly, bytes, a binary. Real compilers add types, generics, inlining, vectorisation and a hundred passes, but the skeleton is this one. Miri, the GPU-first language I’m building, walks the same road: lexing, parsing, type checking, a mid-level IR, then Cranelift for the machine code. And at the very end, every compiler meets the same thing, the fetch–decode–execute loop of the CPU notebook, reading these bytes one by one.
Questions people ask
- What is the difference between AOT and JIT compilation?
An AOT compiler does all its work before the program runs and ships a native binary. A JIT compiler translates code while the program runs, so it can use what it sees at runtime, but it pays for compiling during execution. C, C++, Rust and Go are usually compiled ahead of time; Java and JavaScript engines use JITs.
- What is SSA form?
Static single-assignment form is an intermediate representation in which every variable is assigned exactly once. Where control flow merges, a phi node picks the right value. It makes optimisations such as constant propagation and dead-code elimination simple and fast, which is why LLVM, GCC and Cranelift all use it.
- What does a linker do?
It takes the object files the compiler produced, joins their code and data into one executable, and patches every reference to a symbol defined somewhere else with that symbol’s final address.
Related material
elsewhere in the notebooks
further reading
- Crafting Interpreters Robert Nystrom The friendliest book on the front half of this pipeline: scanning, parsing, resolving. Free to read online.
- Compiler Explorer Matt Godbolt Plates VI–VIII for real compilers: paste C, Rust or Go and read the assembly they produce, flag by flag.
- Cranelift A fast, readable code generator written in Rust. Its SSA builder uses the same algorithm as plate V; Miri compiles through it.
- LLVM documentation The industrial version of everything here: the IR reference, the pass list, the backends.
- Static single-assignment form Wikipedia A compact history of SSA and the algorithms that build it.
- How Miri’s compiler works The same pipeline, at full size: lexing, parsing, type checking, MIR lowering and Cranelift code generation in a real language.
- Why I’m building a GPU-first programming language How I ended up writing a compiler in the first place.