Learn Compilers & Programming Languages
How source code becomes executable behavior, and how language and compiler design choices affect correctness, performance, safety, tooling and developer experience.
Before the compiler
What the language is for, and what that decides.
The Pipeline
8 lessonsSource code to behavior as a sequence of representations, each existing because the previous one could not answer the next question — and what is lost at every handover.
A source file is a byte sequence with no meaning of its own. What turns it into behavior is a language definition plus an implementation that honours it — and knowing which of the two you are arguing with is most of the skill.
Thirteen stages from characters to execution, each justified by a question the previous representation could not express — and the reason "phase" and "pass" are not the same word.
The three-way split exists for one reason worth stating in arithmetic: it turns `m` languages times `n` targets into `m` plus `n` implementations. What it costs is everything that does not survive the crossing into the IR.
Three implementation shapes, not two categories — and none of them is a property of a language. The sentence to stop saying is named, dismantled and replaced with a question that has an answer.
C++, Python, JavaScript, TypeScript, Java, Rust and Go, each traced through the same three questions — with the version numbers attached, because every one of these answers has changed at least once.
Do all the work before the program starts, and inherit four consequences: the fastest possible start, an unlimited optimization budget, one artifact per target, and permanent ignorance of everything only the run knows.
Every handover in the pipeline destroys something, and every tool you rely on afterwards — diagnostics, debuggers, profilers, stack traces, source maps — is an attempt to buy back one specific casualty at a specific price.
Follow `x = a + b` from characters to retired machine instructions, and watch each stage answer a question the previous representation could not even express.
Designing a Programming Language
9 lessonsWho the language is for decides the type system, the memory model, the concurrency model and the execution strategy. Every other answer in the domain follows from this one.
Every downstream decision — type system, memory model, concurrency model, execution strategy, error handling — is decided by who is going to write the code and what happens when they get it wrong. Answer this badly and nothing after it can be defended.
A checklist that is not a checklist: nine questions every language answers whether or not its designers noticed, each with a compiler consequence, and each capable of contradicting the answer to another.
Syntax decides which strings are programs; semantics decides what they mean. The reason to keep them apart is that a compiler phase can only enforce one of them, and almost every argument about a language is an argument about the wrong one.
Defaults, diagnostics, orthogonality and the cost of the common case are design decisions with implementation consequences, not polish applied afterwards. The languages people call pleasant paid specific, identifiable prices for it.
Implementation strategy is not a property of a language, but a language definition can make some strategies expensive and others impossible. This is the lesson about which features write cheques the execution model has to cash.
Five answers — manual, tracing collection, ownership, reference counting and regions — and for each one, the code the compiler has to emit that the programmer never wrote.
Threads and locks, async/await, actors, CSP channels and data parallelism are five language design decisions, and each one hands the compiler a different job: a memory model to obey, a state machine to build, an isolation rule to check, a scheduler to emit calls into, or a loop to prove independent.
A language that must call existing code has already had its data representation, its error model and its threading model partly decided for it. The C ABI is the lingua franca not because it is good but because everything already speaks it.
Seven requirements, seven answers, and no row where the answer is a language. Every decision in this module buys one property by paying for it somewhere specific, and the discipline is naming the payment out loud.
Domain-Specific Languages
6 lessonsWhen a new language is cheaper than a library, when it is much more expensive, and what the tooling bill actually looks like once people depend on it.
A language restricted to one problem domain, which is what lets it say more with less and refuse to express things the domain considers nonsense. The restriction is the feature; every DSL that grows out of it becomes a general-purpose language with worse tools.
An internal DSL is written in the host language and inherits its entire toolchain for free. An external one has its own syntax and its own parser, and must build every tool from scratch. The choice is almost entirely about who pays for the tooling.
Almost always no. Four questions decide it — is the domain stable, do the readers genuinely need non-host syntax, is the tooling budgeted, and could a library do it — and a yes needs all four. This lesson is the decision, stated as a decision.
Five ways to make a domain language actually run: interpret the tree, compile to the host language, compile to bytecode, generate code at build time, or embed it as schema-validated data. They differ in performance, in debuggability and in who sees the error.
JSON, TOML, YAML, HCL, Jsonnet, Starlark, CUE, Dhall — a ladder from pure data to real computation. Every rung was reached by a format that started as data and was asked for one more feature, and the two that stopped deliberately are the interesting ones.
The parser is a weekend. What people expect the moment they depend on your language — positioned diagnostics, error recovery, a formatter, editor support, a debugging story, documentation, versioning and a migration path — is the project, and it never finishes.
Frontend
Source to a checked, structured program.
Grammar & Syntax
8 lessonsWriting down what a valid program looks like: productions, derivations, EBNF, and the ambiguity that precedence and associativity exist to resolve.
A grammar is a finite set of rules that decides an infinite set of token sequences. Writing one down separates "what is a legal program" from "how do I recognise one", and that separation is the reason a language can have more than one implementation.
A derivation is the proof that a token sequence is in the language. Leftmost and rightmost derivations are the two canonical orders, and they are exactly the orders that top-down and bottom-up parsers reconstruct.
Two notations for the same grammars. EBNF adds repetition, option and grouping operators that remove the recursion boilerplate — and in doing so, quietly stops telling you which way a list associates.
One nonterminal on the left-hand side, and no ability to look at the surroundings. That single restriction is what makes efficient parsing possible — and what makes "declared before use" someone else's problem.
A grammar is ambiguous when one token sequence has two parse trees. `1 + 2 * 3` reading as both 9 and 7 is the toy case; the dangling `else` is the one that shipped in C, and both are fixed the same three ways.
Precedence is the answer to "which operator gets to be the parent". `1 + 2 * 3` builds as `+(1, *(2, 3))`, and every mechanism for arranging that — grammar layers, declaration tables, binding powers — is producing the same tree by a different route.
Precedence handles two different operators; associativity handles two of the same. `a - b - c` is `(a - b) - c` in every language you use, and `2 ** 3 ** 2` is 512 in Python and 64 in MATLAB — the same operator, associating opposite ways.
The rule that makes `-` group correctly is the same rule that makes a recursive-descent parser call itself forever. LR parsers prefer it, top-down parsers cannot survive it, and the standard fix trades a grammar rewrite for a loop that folds left by hand.
Lexical Analysis
8 lessonsCharacters to tokens, why regular languages are enough for this job, and the hazards — maximal munch, keywords that are also identifiers, numbers that run into letters.
The first transformation in the pipeline: fifteen characters of `let x = 42 + y;` become seven tokens, each with a kind, its text and the byte range it occupied. Everything downstream is written against that list rather than against the text.
The kind is the terminal symbol the grammar will match on, so choosing the kinds is designing the interface between the lexer and the parser. Too coarse and the grammar does the lexer's work; too fine and the grammar has a rule per operator.
Kind, value and position. The first two are obvious; the third is the one that pays for itself, because every diagnostic, hover, rename, source map and debugger line table in the entire toolchain is derived from byte ranges recorded once, here, and never recoverable afterwards.
A hand-written scanner is a while loop, a switch on the first character, and one character of lookahead. A generated one is a DFA table. Both implement maximal munch; they differ in who writes the automaton and in how good the error messages are.
The formal reason the lexer is cheap: token structure needs no memory beyond a bounded state, so a finite automaton suffices. The reason the parser exists: nesting does need memory, and nothing regular can count.
The machine a lexer actually is: states, transitions on characters, and a set of accepting states. Drawing the identifier and number recognisers as automata makes the whole scanner mechanical, and makes the `123abc` problem visible before you write it.
Thompson's construction turns a pattern into an NFA in linear space; subset construction turns the NFA into a DFA that runs in linear time. The bill for that speed is table size, and in the worst case it is exponential.
Everywhere the clean phase separation leaks: maximal munch producing programs nobody wrote, `123abc`, contextual keywords, `>>` closing two generic brackets, escapes, Python emitting INDENT tokens, and C needing a symbol table to lex.
Parsing
9 lessonsTokens to structure. Recursive descent and Pratt parsing by hand, LL and LR as families, and what a parser generator buys and costs.
A parser turns a flat token list into a tree whose shape is dictated entirely by the grammar. It decides what is applied to what — and it is not allowed to decide whether any of it means anything.
Two trees for the same input `1 + 2 * 3`: one with a node for every grammar rule and every comma, one with five nodes. The difference is not tidiness — it decides which tools you can build.
One function per grammar rule, the call stack as the parse stack. It is the technique most production compilers actually use — and the one that loops forever if you hand it a left-recursive grammar.
Replace the tower of precedence functions with one loop and a table of binding powers. Prefix handlers, infix handlers, and a single comparison that decides whether to keep going — this is the parsing technique most worth actually knowing.
Left-to-right scan, leftmost derivation, k tokens of lookahead. The FIRST and FOLLOW sets are the mechanical answer to "which production do I pick", and the reason some grammars simply cannot be parsed top-down.
Bottom-up: never choose a production until the whole right-hand side is on the stack. It handles left recursion natively, accepts a strictly larger class of grammars than LL — and reports its problems as "conflict in state 143".
Two actions, one stack. Walk `1 + 2` through a bottom-up parser one move at a time and watch the tree assemble itself from the leaves upward — then see exactly what a conflict is.
LR accepts a strictly larger class of grammars. Clang, rustc, Roslyn, V8 and Go all hand-write recursive descent anyway. The reason is not ignorance or inertia — it is error messages, incremental reparse, and what an IDE needs.
ANTLR, Bison, tree-sitter and LALRPOP turn a grammar file into a parser. What you buy is a machine-checked grammar and cheap change; what you pay is diagnostics, debuggability and a build step.
Diagnostics & Error Recovery
6 lessonsA compiler that stops at the first error is a bad tool. Spans, recovery, synchronization, and diagnostics that name what was expected instead of saying "syntax error".
File, line, column, offset — four numbers that sound trivial and are not. Byte offsets versus (line, column), and the UTF-16 code unit that LSP counts by and your compiler almost certainly does not.
A span is two offsets, half-open, threaded through every stage of the compiler. This is the lesson where the discipline of carrying them finally pays: underlines, secondary labels, jump-to-definition, rename, quick fixes and source maps are all one data structure.
Detect, report, synchronize, continue. A compiler that stops at the first error costs a round trip per typo — and one that recovers badly turns a single missing brace into forty messages, which is worse.
The judgement call inside error recovery: which token to resume at. Too eager and a whole function is skipped in silence; too timid and one missing brace produces forty messages that are all the same mistake.
`syntax error` versus `Expected ')' after function arguments. Found '{' instead.` The difference is not politeness — it is a span, a named expectation, and a note about the thing that caused it, each of which the compiler had to be built to keep.
A diagnostic that carries an edit an editor can apply, and the discipline that keeps it honest: "did you mean" by edit distance, a confidence label on every suggestion, and a budget past which no suggestion is better than a confident wrong one.
The Abstract Syntax Tree
6 lessonsThe representation every later phase is written against: node design, traversal, the visitor as the standard shape of a pass, and why the AST outlives the parser.
The representation every later phase is written against: `let x = 1 + 2;` stops being ten tokens and becomes a declaration holding an addition holding two literals. The punctuation is gone; the containment is everything.
Tagged union or class hierarchy, parent pointers or not, pointers or arena indices, forty node kinds or four hundred, and whether `a += 1` is its own node or sugar the parser desugars on the spot. Five decisions, each of which shapes every pass written afterwards.
Every frontend pass is a depth-first search over a tree, and which analyses are correct depends on *when* the node is processed: scopes open on the way down, types are computed on the way up, and getting that backwards produces a compiler that is confidently wrong.
A pass is an object with one method per node kind — `visitBinaryExpression`, `visitFunctionDeclaration`, `visitCallExpression` — and the tree calls it. It is the standard shape of a compiler pass because it makes new passes free, and it is the standard complaint about compiler frontends because it makes new node kinds expensive.
Two things a pass can do to an AST: annotate it, or rewrite it. Annotation is cheap and reversible; rewriting has a legality condition and destroys what was there. Whether the rewrite happens in place or produces a new tree decides whether the frontend can ever serve an editor.
The compiler is no longer the only thing that parses your code. The formatter, the linter, the language server, the refactoring engine and the documentation generator all need the same tree — which is why a modern frontend is built as a library and why "just parse it again" is the wrong answer six times over.
Semantic Analysis
8 lessonsNames, scopes and everything the grammar could not express. Symbol tables, shadowing, resolution, and the annotated tree the type checker needs.
The phase between "this parses" and "this means something". It resolves names, enforces scopes, checks types, and asks the control-flow questions the grammar could not express — every variable assigned before use, every path returning a value, no statement after a `return`.
Name to declaration to type to scope. `x` is a local variable of type `int` in the block starting at line 12; `foo` is a function of type `(int) -> bool` at file scope. It is a hash map with a scope discipline, and every name-related question a compiler or an editor answers is a query against it.
A name means whatever the enclosing text says it means. Global contains the function, the function contains the block, the block contains another block, and a lookup walks outward until it finds a binding — which is why you can read a program's meaning off the page without running it.
`let x = 1; { let x = 2; }` — the inner `x` hides the outer one for the length of its scope. It is not a special rule; it is what "walk outward and take the first hit" does. Whether it is a feature or a warning depends entirely on which language you are in.
Identifier, find the declaration, attach the symbol. Straightforward for a local variable, and genuinely hard for an overload set, a re-exported import, two glob imports that both provide the name, or a method on a receiver whose type has not been inferred yet.
Under lexical scope a name means what the enclosing text says; under dynamic scope it means whatever the most recent caller bound it to. Nearly every modern general-purpose language chose lexical — and then reintroduced dynamic scoping deliberately, as `this`, thread-locals and context variables.
Can a function call one defined later in the file? C says no without a forward declaration; Java, Rust and Go say yes anywhere; JavaScript says yes for functions and throws for `let`. The compiler achieves order-independence with one extra pass, and the language decides whether to make you do it by hand.
Same tree, new fields. Every identifier now points at a declaration and every expression carries a type: `BinaryExpression{type: int, lhs: int, rhs: int}`. This is the artifact semantic analysis produces and the thing lowering consumes, and its defining property is that the shape did not change.
Type Systems
12 lessonsWhat the language can prove before it runs. Checking, typing rules, environments, inference, unification, polymorphism, subtyping and variance.
A type system is a lightweight proof system running on a decidability budget. What it proves is a theorem you can state; what it declines to prove is a design decision, not an oversight.
Not “safe versus unsafe”. Two placements of the same check, differing on seven axes that each cut both ways — and orthogonal to the strong/weak axis that gets confused with it constantly.
One expression — `1 + "hello"` — asked of eight languages, with eight answers and four of them from statically checked languages that disagree with each other. The answer is a design decision, not a fact about types.
Premises above the line, conclusion below, and a name in brackets. Once you can read one aloud you can read a language specification, and the shape of the rule set tells you what the checker’s algorithm has to be.
Γ = { x: int, name: string }, and `Γ ⊢ x + 1 : int` says “under those assumptions, this holds”. In a real compiler Γ is not a new structure — it is the symbol table, read by the type checker instead of by the resolver.
`let x = 42` gives `x : int` in every language that has inference at all. The differences start at the second line, and the reason most mainstream languages infer locally rather than globally is error messages, not difficulty.
Fresh type variables, constraints, unification, and one clever step — generalization at `let` — buy whole-module inference with a principal type. Then subtyping, overloading and mutable references each break it in a different way.
Three rules solve every type equation: decompose matching constructors, bind a variable, or fail. The fourth thing the algorithm must do is refuse to bind a variable to a term containing itself — skip that and the type is infinite and the compiler does not terminate.
A genuinely parametric `identity<T>(x: T): T` can only return its argument. That is not a convention or a code review rule — it is a theorem about the type, provable because the function is forbidden from knowing anything about T.
Overloading, operator overloading, type classes, traits, concepts and protocols are one idea: different code per type behind one name. The interesting question is not the syntax but what each does to compilation — resolution, monomorphization or a dictionary.
One rule — if `S <: T` then an `S` may appear wherever a `T` was demanded — and it applies to every expression, which is why adding it to a checker is a redesign rather than an addition. The compiler checks the signature; Liskov’s behavioural obligations are checked by nobody.
A function is contravariant in its argument and covariant in its result; a mutable container must be invariant in its element. Java made arrays covariant anyway, and pays for it with a runtime check on every array store — `ArrayStoreException` is that decision, visible.
Type System Design
7 lessonsComposing types and representing absence: unions, intersections, algebraic data types, exhaustive pattern matching, nullability and gradual typing.
A union says a value is one of several types. That is only useful if the checker can find out *which* one — so the real subject of this lesson is narrowing, and the discriminant that makes narrowing possible.
`A & B` is a value that satisfies both constraints at once. It is the right tool for mixins and for refining an over-broad type, and it will cheerfully let you write a type that no value can ever have.
Products hold several things at once; sums hold exactly one of several things. The word "algebraic" is literal — cardinalities multiply for products and add for sums — and that arithmetic is the fastest way to tell whether a data model can represent states that must never exist.
Matching is the elimination form for a sum type: it inspects the tag and binds the payload in one construct. Destructuring, guards, nested patterns and bindings are the surface; the decision tree the compiler builds from it is a separate subject.
The compiler proves that every variant is handled, and reports a concrete value if one is not. This is the payoff that makes sum types worth having — and the reason adding a variant is a breaking change.
Two ways to represent absence: a type that silently includes an extra value and a flow analysis to exclude it, or an ordinary sum type with no special status at all. They differ in what they cost you at the boundary, in the signature, and in bytes.
Static and dynamic typing in one program, with a dynamic type that is compatible with everything. The honest version of the story includes what `any` costs, why TypeScript checks nothing at runtime, and why the sound alternative has a performance problem nobody has fully solved.
Types at the Implementation Boundary
7 lessonsWhat survives to runtime and what proves memory safety: erasure versus reification, monomorphization, ownership, lifetimes and effects.
Two answers to "is this type compatible with that one": compare the shapes, or compare the declared identities. The choice decides what a type name means, how cheap the check is, and whether a `UserId` can be handed to something expecting an `OrderId`.
A type system is sound relative to its formal model if every accepted program preserves the typing guarantees that model defines. That is a much narrower claim than "no bugs", and several widely used type systems break it on purpose.
Generic type arguments can be thrown away after checking, kept as runtime metadata, or compiled into separate specialised bodies. The choice decides what reflection can see, what casts cost, and which perfectly reasonable programs the language has to forbid.
One generic body becomes a separate compiled function per type it is used with. The type is then concrete, which is what makes inlining, known layouts and devirtualization possible — and the bill arrives as code size and compile time.
A type system can encode a resource protocol: who is responsible for a value, who may read it, who may write it, and when it must be released. The invariant that makes the proof work is aliasing XOR mutability — and it buys thread safety as a side effect.
To check a borrow, the compiler needs a region: the set of program points over which a reference must stay valid. Annotations exist because a signature is a contract and the checker will not look inside the caller — and non-lexical lifetimes were the change that made the rules match what programmers meant.
A type that says what a function does, not just what it returns. You already use several partial effect systems — checked exceptions, `async`, `const`, `unsafe` — and the complaints about function colouring are the honest cost of the idea.
Middle-end
A representation you can analyse, and the transformations that are legal on it.
Intermediate Representation
8 lessonsThe representation the middle-end is written against. Why an IR exists at all, how many levels of it there are, and what lowering means at each step.
Between the type checker and the code generator sits a third representation that belongs to neither: a flat sequence of simple instructions over an unlimited supply of virtual registers, grouped into blocks. It is not source, it is not machine code, and almost every interesting thing a compiler does happens there.
Six languages and five targets is thirty compilers if every frontend talks to every backend directly. Put one representation in the middle and it is eleven components. That arithmetic is the entire argument, and it is why the middle of a compiler is a public interface.
Rust has HIR, THIR, MIR and then LLVM IR. That is not indecision. Each level answers a question the level below it can no longer phrase, and each lowering discards something on purpose — which is precisely why the earlier level had to exist.
`x = a + b * c` becomes `t1 = b * c; t2 = a + t1; x = t2`. The rewrite looks like busywork until you notice that `t1` is a *name* — and that every analysis in the middle-end is a statement about names.
Lowering is the verb the whole middle of a compiler runs on: replace a construct with a simpler one that has the same defined behavior, and repeat until nothing is left but jumps, arithmetic and memory. A `for` loop, a closure, a `match` and an `await` are all the same kind of problem.
SSA or not, typed or untyped, how much target detail to admit, and linear or graph. LLVM IR, Cranelift CLIF, GCC GIMPLE and V8 TurboFan answer those four differently and all four are correct — because they were built to be fast at different things.
A verifier is a function that rejects malformed IR. Its value is not that it finds bugs — it is that it finds them at the pass that caused them, instead of three passes later in a code generator that had every right to assume otherwise.
Clang, rustc, swiftc, flang, Julia and Zig do not share a parser, a type system or an opinion about memory. They share an optimizer and a set of code generators, because all six agreed to emit the same instruction set — and that agreement is what LLVM actually sells.
Control Flow
7 lessonsTurning statements into a graph you can reason about: basic blocks, edges, natural loops, dominators and the dominance frontier that SSA construction needs.
Once the statements are instructions, the `if` is gone. What remains is a directed graph: blocks of straight-line code as nodes, the ways control can pass between them as edges. Every question about "when does this run" becomes a question about paths.
A maximal run of instructions with one way in and one way out. If the first instruction executes, all of them do — and that single guarantee is what makes the block, rather than the instruction, the unit every analysis is written against.
An `if` becomes two blocks and a join. A `while` becomes three blocks and an edge that points backwards. Then there is the edge nobody expects: the one from a two-way branch straight into a merge, which has no safe place to put anything — and which AtlasLang reports rather than guesses at.
The `while` was destroyed by lowering, so the optimizer has to find the loop again in the graph. A back edge `n -> h` where `h` dominates `n` is a loop; the body is `h` plus everything that reaches `n` without going through `h`. That is a definition, not a heuristic.
A dominates B if every path from the entry to B goes through A — so if B runs, A has already run. Loops make the graph cyclic, so this cannot be computed in one traversal: the algorithm iterates until a full pass changes nothing, and the second pass is not optional.
Every block has exactly one immediate dominator, so the relation is a tree — and it is a different tree from the CFG, drawn on the same nodes. Draw it separately, because the edges mean something the CFG edges do not, and half the confusion about dominance comes from overlaying them.
The frontier of A is the set of blocks where A stops being guaranteed — the first blocks reachable from A that A does not dominate. That is exactly the set of places where a definition in A might not be the one that arrives, which is exactly where a phi node goes.
Static Single Assignment
6 lessonsOne definition per name makes data dependencies explicit. Phi functions, construction, why so many analyses get simpler, and how you leave SSA again.
One rule, applied to a whole function: every value has exactly one defining instruction. `x = 1; x = x + 2` becomes `x1 = 1; x2 = x1 + 2`, and from that moment "which definition does this use read?" is answered by reading the operand name instead of by analysing the graph.
At a merge point no single definition reaches the use, so SSA writes `x3 = phi(x1, x2)` — "the value depends on which edge you arrived by". It is a notation, not an instruction, and nothing ever executes one. That is precisely why `[[out-of-ssa]]` has to exist.
The real algorithm, in two halves: place a phi for each variable at the iterated dominance frontier of its definitions, then rename by walking the dominator tree with a stack per variable. That is Cytron et al., that is LLVM's mem2reg, and that is exactly what `toSSA` does.
Every use has exactly one reaching definition. Cash that one fact in four places: constant propagation needs no analysis, dead code is a use count, def-use chains are the IR itself, and copy propagation cannot be wrong because nothing is ever reassigned.
Phis become copies at the end of their predecessors — and that sentence hides three classic miscompilations: the swap problem, the lost copy, and critical edges with nowhere to put the copies. AtlasLang breaks copy cycles by rescuing the value about to be *clobbered*, and the sim test proves it by simulating the moves.
Minimal, semi-pruned and pruned SSA differ only in how many phis they place and how much analysis they pay for it. Loop-closed SSA and gated SSA are different in kind, and much rarer — one is a normalization LLVM actually uses, the other is mostly a research form.
Data-Flow Analysis
7 lessonsOne framework — facts, transfer functions, a meet operator, iterate to a fixed point — and the four classic analyses that are all instances of it.
Four slots — a lattice of facts, a transfer function per instruction, a meet operator at joins, and iteration to a fixed point. Fill them in four different ways and you get reaching definitions, liveness, available expressions and constant propagation. There is only one algorithm here.
Apply the equations until nothing changes. It terminates because the transfer functions are monotone over a lattice of finite height, so a fact can only move one way and only so far. Worklist order changes how many rounds it takes and never what it converges to.
Liveness runs backward because "is this value needed?" is a question about the future. Reaching definitions runs forward because "where did this value come from?" is a question about the past. The direction is dictated by the question, and choosing it is not a design decision.
Which assignments may have produced the value I am reading here? A forward, may analysis with union at merges — and the analysis SSA was invented to make unnecessary, because in SSA the answer is the operand name.
Is this value needed in the future? A backward, may analysis whose answer is the direct input to `[[register-allocation]]` — and the reason it must iterate is the back edge, where a loop-carried value has to stay live around a body that never mentions it.
Has this expression already been computed on *every* path to here, with no operand changed since? A forward, must analysis with intersection at merges — and the precondition without which `[[common-subexpression-elimination]]` is a miscompilation.
`x = 5; y = x + 3` becomes `y = 8`. A forward analysis over a three-level lattice — unknown, one specific constant, not constant — and its SSA-based descendant SCCP does something the dense version cannot: it kills unreachable branches while it propagates.
Scalar Optimization
8 lessonsFolding, elimination, propagation, inlining and devirtualization — each with the precondition that makes it legal and the budget that makes it wise.
Evaluate at build time what would otherwise be evaluated at run time — but only when the operands are literals, the operation cannot fault, and the compiler computes exactly the value the machine would have computed.
Delete an instruction only when it has no side effect AND no user. Both halves are required, and removing an effectful instruction because its value happens to be unused is a miscompilation rather than an optimization.
Compute `a * b` once and reuse it — but only when the earlier computation dominates the later one, so the value is guaranteed available on every path that reaches the reuse. Over registers this is easy; over memory it needs alias analysis, which is why the two are different problems.
If `a` is a copy of `b`, use `b` directly and let the copy die. In SSA this is legal by construction; outside SSA it needs a reaching-definitions analysis, and that difference is one of the clearest arguments for SSA there is.
Replace an operation with a cheaper one that computes the identical value. The real content is not that shifts beat multiplies on some 1990s CPU — it is that `x + 0` is unconditionally `x` for integers and is not valid for IEEE-754 floats, which is why `-ffast-math` exists and why it changes what a program means.
Replace a call with the callee's body. The direct saving — a call and a return — is the least interesting part; the value is that every other optimization can now see across a boundary it could not cross. The cost is code size, compile time and instruction-cache pressure, and it is a budget rather than a rule.
Turn an indirect call through a dispatch table into a direct call to a known function — and then, because the target is known, inline it. The whole value is in that second step; a direct call on its own is barely cheaper than an indirect one.
When some inputs are known and others are not, a program can be specialized with respect to the known ones — producing a smaller, faster program that takes only the remaining inputs. It is the idea behind constant folding, template instantiation, JIT specialization and monomorphization, and it explains why they behave alike.
Loops & Memory Optimization
7 lessonsWhere the time actually goes: hoisting, unrolling, interchange, vectorization — and the aliasing and escape questions that decide whether any of it is allowed.
Move a computation whose result never changes out of the loop — but only if it is invariant AND either cannot trap or is guaranteed to execute at least once. That second condition is the one that turns a hoist into a fault the original program never had.
Duplicate the body so one iteration of the new loop does the work of several. It removes branches and exposes instruction-level parallelism, and it pays for both in code size and instruction-cache pressure — a trade whose sign depends on the trip count and the machine.
Four restructurings that leave the computation identical and change the order in which memory is touched. Each buys a specific thing — fewer traversals, better vectorizability, unit-stride access, a working set that fits in cache — and each is legal only when it preserves every dependence in the original.
Turn a loop over scalars into a loop over vectors, doing several elements per instruction. It is legal only when no dependence is violated by processing elements together, and profitable only when the memory access pattern suits it — and the list of things that make a vectorizer give up is longer than the list of things that make it succeed.
Can these two references point to the same memory? Almost every optimization over memory is gated on that question, the honest answer is usually "maybe", and "maybe" means no. Aliasing is the single biggest limiter on what a compiler is allowed to do.
Does this object outlive the scope that created it? If the compiler can prove it does not, the object can live on the stack, or be broken into registers and not exist at all — and the aliasing questions about it disappear with it.
A memory-safe language checks every array index. Removing the checks it can prove redundant is what makes safe languages fast — and it is why a loop written over a whole array is faster than the same loop written with index arithmetic the compiler cannot follow.
What a Compiler Is Allowed to Do
9 lessonsObservable behavior, the as-if rule, and undefined behavior as a licence to assume rather than a promise to crash. Semantics decide what is legal, not cleverness.
An optimization is valid only if it preserves the language's defined observable behavior. Every pass, every flag and every argument about undefined behavior in this domain is a consequence of that one sentence.
The list the whole domain depends on: input and output, volatile accesses, whether the program terminates, and the order the language sequences those in. Elapsed time, memory used, chosen registers and instruction counts are not on it — which is exactly why the compiler may change them.
The compiler may transform the program however it likes, provided the observable behavior of the result follows the rules of the language's abstract machine. It is not a loophole — it is the clause that makes any optimization at all legal.
Undefined behavior is not a run-time error and not a promise of a crash. It is a licence for the compiler to assume the program never does it — which turns a source-level mistake into a premise the optimizer reasons from.
The canonical case, worked properly: a null check placed after a dereference is deleted, because dereferencing already implied the pointer was non-null. Not a compiler being malicious — ordinary branch simplification applied to a fact the language supplied.
Can integer overflow occur? Can two references alias? Can a function have hidden side effects? The answers are properties of the language, and they decide what its compiler may do — which is why the same transformation is routine in C, forbidden in Java and unnecessary in Rust.
What `-O0` through `-O3`, `-Os` and `-Oz` actually select, why a higher number is not automatically faster, and why the only way to choose between two of them for your program is to measure both.
The middle-end is a sequence: IR in, pass, IR out, repeat. Passes come in three kinds — analyses that compute facts, transformations that rewrite, and cleanups that make the next pass's job possible — and the pipeline is how a compiler is actually organised.
The same passes in a different order produce different code, and no order is best for every program. Constant propagation, branch simplification and dead-code elimination are the canonical cascade — and running the pipeline to a fixed point is what a compiler does instead of solving the problem.
Lowering Language Features
10 lessonsClosures, coroutines, async, exceptions and match expressions are all ordinary control flow after the compiler is done with them. This module does the transformation.
Sugar changes how a program is written without changing what the language can express. That makes it cheap to add and easy to underrate — the cost is not in the semantics, it is in the grammar, the diagnostics and the number of ways to say the same thing.
The pass that rewrites high-level syntax into a core language every later phase can assume. Doing it early makes every later phase simpler; doing it early is also how compilers end up reporting errors about code nobody wrote.
A function value that refers to a variable from an enclosing scope keeps that variable alive after the enclosing frame is gone. The language question is what the closure captures; the compiler question is where the captured variable now lives.
The transformation that turns a closure into an explicit pair of code and environment record. The whole design rests on one question — does the environment hold the values or the bindings — and the classic JavaScript loop bug is what that question looks like when you get it wrong.
The other way to remove a nested function: turn its free variables into extra parameters and lift it to the top level. No environment, no allocation — and it only works when the function does not escape.
A function that can pause and resume cannot keep its locals in a stack frame, because the frame does not survive the pause. The compiler splits the function at every suspension point and moves the surviving locals into a heap object, turning the body into a resumable state machine.
An async function is a coroutine whose resumptions are driven by completing operations rather than by a consumer asking for the next value. The transformation is the same state machine, plus a continuation: something has to know what to call when the awaited thing finishes.
At the source level, a non-local jump out of an arbitrary depth of calls. At the implementation level, a choice between paying nothing until a throw and looking the answer up in a table, or paying a little on every entry and jumping straight there.
The mechanism underneath exceptions: walk the physical stack, and for each frame use compiler-emitted tables to restore the caller's registers, run that frame's cleanups, and decide whether it handles the exception. This is where "zero-cost" is paid for.
An ordered list of match arms is semantics, not implementation. The compiler turns it into a decision tree that tests each discriminant once — which is why a match is not a chain of comparisons, and why the naive reading of it is quadratic in the wrong place.
Backend
One specific machine, and where every value physically lives.
Code Generation
8 lessonsIR to instructions for one specific machine: selection by pattern matching, scheduling for a pipeline the compiler cannot observe, and the bytes that come out.
The backend takes an IR that assumed unlimited registers and no particular machine, and produces instructions for one machine with sixteen of them. Four decisions do it: select, allocate, schedule, emit — and each one makes the next one harder.
Mapping IR operations onto instructions the machine actually has. Our backend selects `lea rax, [rbx+rbx]` for `x * 2` rather than `imul` — not because it is fewer bytes, but because it is three-operand and does not touch the flags.
Instruction selection implemented properly: tile the IR tree with instruction-shaped patterns. Maximal munch is greedy and fast; dynamic programming is optimal for the cost model; BURG-style generators write the matcher for you from a declarative table.
Reordering instructions so a pipeline has something to do while a long-latency operation completes — subject to every data dependence. On a big out-of-order core the hardware reorders anyway; static scheduling earns its keep on in-order cores and in what it does to register pressure.
A small window slid over the finished instruction stream, rewriting local patterns. Our backend's real peephole deletes `mov X, X` — an instruction that exists only because the register allocator happened to give a copy the same source and destination.
How to actually read `clang -S -o -`. The same two-line `add(a, b)` is `lea eax, [rdi+rsi]` on x86-64 System V and `add w0, w0, w1` on AArch64 AAPCS — and neither listing means anything without knowing which ABI produced it.
The last translation: `add rax, rbx` becomes the three bytes 48 01 D8. A REX prefix says the operands are 64-bit, one opcode byte says "add", and a ModR/M byte names both registers.
Four things, and none of them optional: the instruction set, the register file, the calling convention and the memory model. x86-64, ARM64, RISC-V and WebAssembly answer all four differently — and one of them has no registers at all.
Register Allocation
7 lessonsMany live values, few registers. Live ranges, interference, graph colouring, linear scan, and the spill that turns a register access into a memory access.
The IR assumed an unlimited supply of names. x86-64 has sixteen general-purpose registers and AArch64 has thirty-one. Deciding which values get one, and which go to memory, is the last decision that meaningfully changes how fast the code runs.
A value is live from its definition to its last use, and two values can share a register exactly when their ranges do not overlap. Our engine models ranges without holes — a real simplification, and this lesson says what it costs.
A node per value, an edge whenever two values are live at the same point. Once the program is in this form, register allocation is graph colouring — which is how an NP-complete problem ended up in the middle of every compiler.
Chaitin-Briggs: repeatedly remove any node with fewer than k neighbours and push it on a stack, because such a node is always colourable later. When everything has k or more, push the cheapest optimistically. Then pop and assign.
Sort the intervals by start point, sweep once, hand a register back whenever an interval ends, and when nothing is free spill whichever active interval ends last. Much faster than colouring, worse code — which is exactly the trade a JIT wants.
When there is no register left, a value goes to a stack slot and every use becomes a memory access. The interesting question is never whether to spill but which value — and our engine reports the reason rather than just the outcome.
Two ways to avoid paying. Coalescing merges a copy's source and destination into one register when they do not interfere, deleting the copy. Rematerialization recomputes a cheap value at each use instead of spilling and reloading it.
Calling Conventions & ABI
7 lessonsThe contract between separately compiled code: argument passing, stack frames, saved registers, mangled names, and what breaking it costs.
Where arguments go, where the result comes back, who is obliged to preserve what, and how the stack must be aligned at the instruction before a call. The part engineers get wrong: moving values into the argument registers is a parallel copy, and emitting the moves in source order destroys an argument.
What a prologue actually builds: a saved frame pointer, space for spills and locals, and a return address it did not put there. Omitting the frame pointer buys one register and costs a profiler its stack walk.
A calling convention plus object layout plus symbol naming plus everything else two separately compiled binaries must agree on. Breaking an ABI does not produce a link error — it produces a field read from the wrong offset, and an answer that is quietly wrong.
A linker symbol table maps names to addresses and knows nothing about types, so `foo(int)` and `foo(double)` must arrive as different names. The Itanium ABI spells them `_Z3fooi` and `_Z3food`. C mangles nothing, which is the entire reason `extern "C"` exists.
Why adding one private field to a class in a shared library breaks every program already compiled against it, what pimpl and reserved padding actually buy, and why Rust deliberately refuses to have a stable ABI at all.
Building on one machine for a different one. The compiler is the easy part: what makes it work is a sysroot containing the target's headers and libraries, because a compiler that reads the host's headers produces a binary for a machine that does not exist.
The string that names a platform: `x86_64-unknown-linux-gnu`, `aarch64-apple-darwin`, `wasm32-unknown-unknown`. Four fields — architecture, vendor, OS, ABI — and each one changes a different part of the compiler.
Linking & trust
Composing binaries, and what the toolchain is trusted with.
Linking & Loading
9 lessonsComposing object files into something runnable, resolving what the compiler could not know, and handing the result to an operating system loader.
Object files and libraries in, one runnable image out. Four jobs: combine sections, resolve symbols, lay out an address space, and patch every reference that could not be resolved until the layout existed.
What is actually in a `.o`: sections holding code and data, a symbol table saying what is defined and what is needed, relocation records saying which bytes to patch, and debug metadata. `.bss` occupies no bytes in the file at all, and understanding why explains the whole format.
Defined, undefined, global, local, weak — five categories that decide every link outcome. And how to actually read `undefined reference to 'foo'`, which has four common causes and names none of them.
The compiler emits a zero and a note saying "this is an address, fix it later". The linker patches it once layout exists. Absolute versus PC-relative decides whether the code can be loaded anywhere — which is what position-independent code, the GOT and the PLT are all about.
Copy the library into the binary. One file to deploy, no runtime dependency, no version skew — paid for in binary size and in having to relink and redeploy for every library fix, including a security fix.
Leave the library out and bind to it at load time. One copy in memory serves every process, and a security fix ships as one file — paid for in load-time resolution, version skew, and `GLIBC_2.34 not found`.
`.so`, `.dll`, `.dylib` — one artifact, three platforms, three different policies. The soname is the compatibility promise, and exporting everything by default is the mistake that makes a library slow to load and impossible to change.
When several objects define the same name, the loader picks one, and the rule is positional rather than semantic. `LD_PRELOAD` weaponises that deliberately, which makes the search path and the scope order a real security surface.
From `exec` to the first instruction of `main`: the kernel maps the image, hands control to the dynamic loader, which maps libraries, applies relocations and runs initializers. `main` is not the first code to run, and a program can fail before it.
Bootstrapping & Toolchain Trust
5 lessonsWhere the first compiler came from, how a compiler comes to compile itself, and why source code alone does not capture every trust assumption in a toolchain.
If the compiler for X is written in X, what compiled the first one? Write a minimal version in another language, use it to compile the real one, then use the result to compile itself — and throw the first one away.
A self-hosted compiler compiles itself, and that gives you a genuine test for free: stage 2 and stage 3 are built from identical source by compilers that should behave identically, so their binaries must be byte-identical. When they are not, the compiler miscompiled itself.
Ken Thompson's 1984 Turing Award lecture: source code alone does not capture every trust assumption in a toolchain, because the compiler that builds the compiler can carry behavior that appears in no source anywhere. Diverse double-compiling is the known countermeasure.
Not just the compiler. The preprocessor, assembler, linker, libc, startup objects, build system, every plugin and every downloaded dependency all execute during or inside your build, and a compromise in any of them is a compromise of the output.
Identical inputs, byte-identical output. What breaks it is mundane — timestamps, absolute paths, hash-map iteration order inside the compiler, parallelism-dependent naming, embedded build IDs — and fixing it is what makes independent verification possible at all.
Execution
Interpreters, virtual machines, and compiling while the program runs.
Bytecode & Virtual Machines
9 lessonsAn instruction set you get to design. Stack versus register machines, tree-walking versus bytecode, and the dispatch loop at the centre of both.
An intermediate executable representation: a flat array of instructions over an instruction set you designed, sitting between the syntax tree the frontend produced and the machine code you decided not to emit.
Operands live on a stack, so instructions do not need to say where their inputs are. `PUSH 1; PUSH 2; ADD` leaves `3` where the next instruction will look for it, and the whole encoding shrinks because of it.
Give the virtual machine numbered registers instead of an operand stack and `ADD r3, r1, r2` replaces three instructions with one — at the cost of a bigger instruction and a code generator that now has to decide which register everything lives in.
Register VMs execute fewer instructions; each instruction is larger and costs more to decode. The win is real, modest and workload-dependent, and the decision usually turns on who writes the code generator rather than on throughput.
One recursive function, `evaluate(node)`, switching on the node kind and calling itself on the children. It is the simplest correct implementation of a language, it is the right first one to write, and it pays a pointer chase and a dispatch for every node it visits.
AST to IR to bytecode to VM. The translation rule fits in one line — a three-address `%d = a op b` becomes push a, push b, op — and the interesting parts are what the operand stack replaces and why we emit from pre-SSA IR.
Fetch, decode, execute, repeat. Three lines of structure hold an entire language implementation, and the branch at the centre of them is one of the least predictable in ordinary software — which is why so much interpreter engineering is really branch engineering.
An interpreter runs the same algorithm as native code and takes roughly an order of magnitude longer to do it. The gap is a constant factor made of dispatch, type tests, boxing and memory traffic — and knowing which of the four is yours is the difference between a real speedup and a week spent on the wrong one.
The complete state of a running VM is six things: an instruction pointer, an operand stack, a stack of frames, per-frame locals, globals and a heap. Everything a VM can do is a function of that tuple, and everything a VM must decide — pausing, resuming, tracing, giving up — is a decision about where in the tuple to put the answer.
JIT Compilation
10 lessonsCompiling with information a static compiler cannot have. Tiers, profiling, speculation, guards, and the deoptimization that catches a wrong guess.
Start the program immediately by interpreting it, watch which code actually runs, and compile that code to native instructions while the program is still running — using facts about this execution that no ahead-of-time compiler could have had.
A static compiler must be correct for every type that could occur, every branch that could be taken and every target a call could reach. A JIT sees which ones actually occur, and specializing to the actual case is worth far more than any amount of extra analysis on the general one.
Not one compiler but several, arranged from instant-and-slow to expensive-and-fast, with code promoted upward as it proves hot and demoted back down when a speculation fails. Startup and steady state stop competing for the same knob.
Deciding what to compile is a measurement problem with a cost on both sides: compile too eagerly and you spend time on code that never repays it, compile too late and the program runs slowly through the window where it mattered most.
A function that was entered once and has been looping for a minute cannot benefit from being compiled, because nothing will call it again. On-stack replacement swaps the running activation itself over to optimized code mid-loop, which means translating a live frame from one code version's layout into another's.
"This value has been a small integer every time, so compile an integer fast path." The profile is evidence, not proof — which is exactly why the fast path is preceded by a check, and why the whole apparatus of guards and deoptimization exists behind it.
The cheap runtime check that turns an assumption into a sound one. A guard is a comparison, a branch and a piece of metadata — and its cost is the bar every speculation has to clear before it is worth making.
A guard fails, and execution must continue correctly in code that assumed nothing — which means reconstructing an interpreter frame from an optimized one. Keeping that reconstruction possible is a standing obligation, and the obligation, not the mechanism, is what this lesson is about.
Cache the resolved answer at the call site itself, guarded by a check of the key that produced it. One target is monomorphic and nearly free; a few is polymorphic and still cheap; many is megamorphic, and the right response is to stop caching rather than to cache harder.
Compilation on the user's critical path, a warmup period where the program is measurably slower than itself, memory for code and profiles, benchmark numbers that will not sit still, and a writable-then-executable memory region that some platforms refuse to allow at all.
Around all of it
Real pipelines, correctness, tooling, builds — and building your own.
Real Pipelines
11 lessonsPython, JavaScript, TypeScript, C++, Rust and Go: four genuinely different routes from source to behavior, compared without pretending they are the same.
Running a `.py` file compiles it. CPython tokenizes, parses, builds a symbol table and emits bytecode into a code object before a single statement executes — and then a stack machine written in C runs that bytecode.
A modern JavaScript engine parses lazily, executes bytecode immediately, watches what actually happens, and recompiles the hot parts into native code with the observed types baked in — then unbakes them when the observation turns out to have been wrong.
TypeScript parses, type-checks and emits — and the type information is generally erased from the emitted JavaScript. Checking and emitting are separate concerns, which is why tools that skip checking entirely can still produce correct output.
Preprocessor, compiler, assembler, linker: four programs, not one. The translation unit is the compilation boundary, headers are copied into every unit that includes them, and the linker is the only stage that sees the whole program.
A separate language that runs before the compiler and understands nothing about C++. It copies text, substitutes text and deletes text — and every problem it causes traces back to that one property.
C++ templates are compile-time generic programming by code generation: one template, one concrete function or class per type used. Checking happens at instantiation, which is why an error in your call site is reported inside the library.
One template becomes one concrete function per set of arguments used, in every translation unit that used it — and then the linker throws nearly all of those copies away. The bill arrives as compile time, object-file size and link work, in that order.
`constexpr`, `consteval`, `constinit`, Rust's `const fn` and Zig's `comptime` are all one idea: the compiler contains an interpreter for its own language, and work moved into it disappears from the running program and reappears in the build.
Rust runs a program through more distinct representations than any other mainstream compiler, and each one exists to make a specific check possible: traits need types, borrow checking needs a control-flow graph, and monomorphization needs both. LLVM only sees the last of it.
Go's compiler is fast because the language was designed to let it be: no headers, no textual inclusion, a strictly acyclic import graph, a compact export summary per package, and a deliberately small feature set. It has its own backend, and it emits one self-contained binary.
One trivial program — add two numbers, print the result — in C++, JavaScript, TypeScript and Python. Four genuinely different routes to the same six characters of output, and the differences decide what is checked, what survives to run time, and what has to be installed on the machine.
Compiler Infrastructure
9 lessonsLLVM as reusable middle-end and code generator rather than "a compiler", GCC as the other one, and WebAssembly as a portable sandboxed target.
LLVM is compiler infrastructure: a collection of reusable libraries built around well-specified intermediate representations, with analyses, optimizations and code generators you link into your own program. Clang is one of its clients, not the thing itself.
Language frontend, shared middle-end, target backend — with one intermediate representation at each seam. That factoring turns M languages times N targets into M frontends plus N backends, and it is the reason a new language gets twelve architectures on its first release.
LLVM IR is a typed, SSA-form instruction set that is readable by humans and has three isomorphic forms — text, bitcode and in-memory. Learning to read it turns "the optimizer did something" into a diff you can point at.
Clang is a C, C++ and Objective-C frontend that lowers to LLVM IR — and, unusually, a library whose AST is a supported product in its own right. That second decision is why clang-format, clang-tidy and clangd exist and why they agree with the compiler.
The other mature toolchain, and a genuinely different architecture: GENERIC, then GIMPLE, then RTL, then a target. Three successive intermediate representations where LLVM has one, an extension model based on plugins rather than libraries, and a different licence history.
Compiler, assembler, linker, loader, debugger and build system are six related but distinct programs with different inputs, different outputs and different failure messages. Knowing which one spoke is most of diagnosing a build.
WebAssembly is a target a compiler aims at instead of a machine: source language, compiler, a `.wasm` module, and a runtime that validates it and then executes it — by interpreting, by compiling it on load, or by compiling it ahead of time.
A stack machine with structured control flow, one linear memory, no ambient authority and a validation pass that succeeds or fails in one sweep. Every one of those choices exists so a host can prove things about code it did not write.
One artifact everywhere, a sandbox by construction and microsecond startup, against a measurable performance gap with structural causes: bounds-checked linear memory, no direct system calls, and a feature surface that depends on which proposals the host implements.
Compiler Correctness & Security
8 lessonsThe one program whose bugs are everyone else’s bugs: miscompilation, differential testing, fuzzing, translation validation and formal verification.
The compiler turns a valid program into behavior the language does not allow it to have. It is the only bug class where reading your own source cannot find it, and it silently invalidates every test you have — including the ones that pass.
Seven layers, from a unit test on one pass to a fuzzer generating programs nobody wrote. The highest-value test in any compiler is the property that optimization never changes what a program prints — and it is the one most compilers add last.
Record the emitted IR, assembly or diagnostics in a file and diff against it on every change. Excellent at catching what you did not mean to do, useless at telling you whether what you meant was right — and completely dependent on the compiler being deterministic.
Compile and run the same program through two compilers, two versions or two optimization levels, and compare. It needs no oracle — the implementations are each other’s oracle — but it needs programs whose behavior the language actually pins down, which is the entire difficulty.
Generate programs nobody wrote to find crashes and, far more valuably, wrong code. Csmith and YARPGen construct programs that are well-defined by design; EMI takes the opposite route and mutates code that provably never executes, so the output must not change.
Do not prove the optimizer correct — prove that *this* compilation preserved semantics. A checker runs alongside the compiler, compares the IR before and after each transformation, and reports the ones it cannot justify. Alive2 does this for LLVM, and it found bugs that had been shipping for years.
CompCert’s middle-end and backend carry a machine-checked proof that the compiled code refines the source semantics. The honest evidence is the Yang et al. fuzzing result: every other compiler tested had wrong-code bugs found, and the verified part of CompCert had none. What is *not* proven matters just as much.
The compiler is a trusted component that can delete your security code, exploit your undefined behavior into a vulnerability, or be malicious itself. The canonical case is a `memset` that zeroes a password buffer being removed as a dead store — which is why `explicit_bzero` and `SecureZeroMemory` exist.
Static Analysis & Language Tooling
10 lessonsThe frontend is the IDE. Abstract interpretation, interprocedural analysis, linters, formatters, concrete syntax trees and the language server that serves them all.
Answering questions about every possible execution without running any of them. A compiler frontend is already a static analyser; the interesting part is not the machinery but the two ways it fails — noise you turn off, and silence you trust.
Execute the program over a deliberately impoverished set of values — signs, nullability, intervals — so that the analysis terminates and covers every input at once. Widening is the part that makes loops finish, and it is where the precision goes.
Which statements can run in which order, and — the genuinely hard case — which functions a call site can actually reach when the callee is a value. In a higher-order language you cannot build the call graph without the analysis, and cannot run the analysis without the call graph.
Facts that cross a function boundary. The dial is context sensitivity — whether two call sites of the same function get one answer or two — and every notch of precision is paid for in compile time. Summaries are the compromise everything real is built on.
A compiler error means the code violates the language rules. A lint means the code is suspicious, unidiomatic, or probably-wrong-but-legal. The boundary between them is not fixed — it moves by ecosystem, and knowing where yours put it explains most of your tooling.
Parse the source, throw the layout away, and print it again from the tree. It only works if the tree kept the comments and blank lines the AST discards — which is why a formatter is the first tool that forces you to build a concrete syntax tree.
A tree in which every byte of the source appears exactly once — whitespace, comments and the literal text of every token included. It is what you need the moment a tool has to write code back out rather than only read it.
A compiler frontend rebuilt under three constraints a batch compiler never has: it must be incremental, it must produce answers about code that does not compile, and it may never throw information away. That is a different engineering problem, not the same one with a socket attached.
JSON-RPC over a pipe, and one decision that mattered more than any of its message types: standardizing the interface turned M editors times N languages into M plus N. The gotcha worth knowing is that its positions are UTF-16 code units.
A rename is not a text replacement. It is a query against the symbol table for every reference bound to one declaration, plus a check that the new name does not collide anywhere those references live — and the difference between those two operations is a class of silent bug.
Debug Information
6 lessonsMapping optimized machine code back to what you wrote — line tables, variable locations, source maps, and why a variable reads as "optimized out".
The compiler emits a second artifact alongside the code: a map from addresses to source positions, a description of where every variable lives at every point, the shape of every type, and how to walk back up the stack. None of it is recoverable from the instructions.
The same problem as a DWARF line table, solved in JSON for pipelines that emit source rather than machine code. The two things worth understanding are the VLQ encoding that makes the mappings small, and the composition rule that makes a chain of four tools still point at your original file.
Two default configurations that bundle several independent decisions together, and the bundling is the problem. Optimization, debug information and assertions are three separate dials, and the right shipped build is very often optimized *with* full debug information, split out of the artifact.
Why a variable reads "optimized out", why the instruction pointer jumps backwards between two functions, and why a breakpoint on a line you can see never fires. Three symptoms, three specific transformations, and none of them is a bug.
Turning `0x00007f8a3c0012ef` back into `parse_header at http.c:184`, using symbols and debug information you deliberately stripped out of the shipped binary. The whole thing works or fails on one detail: whether the build ID ties the two artifacts together.
The most directly useful skill in the domain. Every question of the form "did it inline that", "did that vectorize", "is this bounds check still there" is answerable in under a minute with a flag you can memorise — and optimization remarks will tell you *why* the answer was no.
Compilation at Scale
9 lessonsCompilation units, modules, interface files and the dependency analysis that keeps a rebuild proportional to the change rather than to the codebase.
The unit the compiler processes at once decides everything about build cost. In C and C++ that unit is a translation unit — one source file plus every header it transitively includes — which is why editing one line of a header can rebuild half the project.
Compile each unit independently, link the results together. It buys parallelism and incremental rebuilds, and pays with an optimizer that cannot see past the boundary — which is precisely the gap LTO exists to fill.
A language-level module gives namespacing, explicit dependencies, encapsulation and separate compilation without textual inclusion — a dependent reads a compiled interface rather than re-parsing your source.
A compiler can consume a dependency's exported signatures without reparsing its implementation. `.hi`, `.mli`, `.d.ts`, C++ BMIs and Go export data are all the same idea, and it is what makes incremental compilation work at scale.
Recompile what the change actually affected, not what it touched. The modern form is not file timestamps but a memoized graph of queries, where a change invalidates exactly the results that depended on it.
A build is a directed acyclic graph of artifacts. A change to a node may or may not require rebuilding its dependents, and which one it is depends on *what* changed — a body or a signature.
The compiler turns one set of sources into one artifact. The build system decides which of those invocations must run at all. Getting that division wrong — most often by trusting timestamps — produces both missed and spurious rebuilds.
A build is hermetic when its result depends only on its declared inputs — not on which `cc` happens to be first in `PATH`, not on a header that exists on one laptop, not on anything fetched from the network while it runs.
Every optimization is a purchase: build seconds now for execution seconds later. The exchange rate is set by how often the program runs against how often it is built — and there are whole classes of program where the purchase buys nothing at all.
Whole-Program & Feedback-Directed Optimization
6 lessonsSeeing across module boundaries with LTO, and measuring before optimizing with PGO — including what an unrepresentative profile does to the result.
Seeing every function at once turns three transformations from impossible to routine — cross-module inlining, devirtualization and dead-function elimination — by supplying the one thing separate compilation deliberately withheld: the rest of the program.
LTO is a scheduling trick, not a new optimization: the compiler writes IR into object files instead of machine code, and the linker — the first component that has all of them — hands them back to the optimizer before generating any.
Compile once with counters, run a realistic workload, feed the counts back, compile again. The optimizer stops guessing which branch is taken and which function is hot — and the largest real win is usually not what people expect.
A profile is not neutral evidence. An unrepresentative one does not fail to help — it actively points the optimizer at the wrong code, and it decays quietly as the source moves underneath it.
PGO and a JIT are the same idea run at different times. Both optimize from measured behavior; the only two things that differ are when the evidence is collected and whether a guard is needed to act on it.
A compiler is judged on four axes that trade against each other — compile time, memory, incremental turnaround and generated code quality — and the first one changes how engineers work, not merely how long they wait.
Compilers for Agent Systems
5 lessonsA model-generated plan is a program in an untrusted language. Parse it, type it, validate it and check its permissions before any of it executes.
A model-generated plan is source code in an untrusted language written by an unreliable author. That single reframing hands you a whole compiler frontend of techniques — a grammar, a parser, name resolution, a type checker and an authorization pass — and tells you the order to run them in.
Give the agent a small language with a grammar, and its plans become trees you can print, diff, refuse, rewrite and replay. `SEARCH(...) |> FILTER(...) |> SUMMARIZE()` as an AST before any tool runs is worth more than the same three calls made one at a time, and the reasons are the ordinary reasons an IR exists.
A tool call is a function call whose arguments came from an untrusted source, so the boundary needs a type checker. JSON Schema is that type system, constrained decoding is the technique that makes malformed calls unsamplable rather than merely detectable, and neither of them says anything about whether the call should happen.
Four gates, each rejecting a class of problem the others structurally cannot, in an order that is not arbitrary. The reason the ordering matters is the same reason `[[phase-ordering]]` matters in a compiler: a later phase depends on facts an earlier one established, and running them out of order either weakens the check or leaks information.
The practical lesson: how to get a reliable data structure out of text a model wrote. A strict parser with real error recovery beats a pile of regular expressions for the same reasons it does in a compiler, repair-and-retry is a legitimate strategy with a cost worth naming, and no parser will ever tell you whether the output meant what the user wanted.
AtlasLang
9 lessonsBuild the whole thing, one stage at a time, from `print(1 + 2)` to a typed language with a bytecode VM, an SSA optimizer and a language server.
From `print(1 + 2);` to a typed language with a bytecode VM, an SSA optimizer and a register allocator — twelve representations, all of them produced by a compiler in this repository that you can type into.
Characters to tokens by maximal munch, with a half-open byte range on every token — and one hazard, `123abc`, that our lexer reports instead of silently splitting into two tokens and producing a parse error three lines away.
Recursive descent for statements and Pratt parsing for expressions, in one file, so you can read the two techniques next to each other — plus panic-mode recovery that synchronizes on `;` and statement keywords instead of stopping at the first error.
The shortest path from a parsed program to a running one is to walk the tree and evaluate as you go. It is where most languages start, it is the version whose correctness is easiest to argue, and it is the version AtlasLang deliberately did not ship — for reasons worth knowing.
An inner `let x` must not disturb an outer one. Our lowering keys storage slots by the resolved symbol rather than by the source name — because an earlier version keyed them by name, and the outer `x` was silently overwritten.
`int`, `bool`, `str`; annotations optional on `let` and inferred from the initializer, required on parameters and returns. And a definite-return analysis so conservative it rejects `while (true) { return 1; }` — which is the cleanest example of soundness without completeness you will find.
Twenty-three opcodes and an operand stack. A three-address instruction `%d = a op b` becomes "push a, push b, op", and every virtual register becomes a numbered local slot — which is the whole translation, and the whole argument for having had an IR first.
Eight transformations over SSA, run to a fixed point, each carrying its legality precondition as data rather than as a comment. Two predicates do all the safety work: a `print` is never removed, and `x / 0` is never folded.
A working compiler is the smaller half. Once people write programs in your language they need diagnostics that point at the mistake, a formatter that ends the argument, highlighting that is right about their code, and eventually a language server — because everything they use is going to want one.