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.

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute

Before the compiler

What the language is for, and what that decides.

The Pipeline

8 lessons

Source 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.

From Source to Behavior
▶ lab

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.

Q · What has to be true before the text in my editor does anything at all?
The Phases, and Why Each One Exists
▶ lab

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.

Q · What are the phases of a compiler, and why is each one a separate phase instead of one big function?
Frontend, Middle-End, Backend

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.

Q · Why do compilers split into a frontend, a middle-end and a backend, and what does each part know?
Compiler versus Interpreter Is Not a Binary
▶ lab

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.

Q · Is my language compiled or interpreted, and why does nobody give me a straight answer?
What Seven Real Languages Actually Do
▶ lab

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.

Q · For the languages I actually use, what happens at build time and what happens at run time?
Ahead-of-Time Compilation

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.

Q · What do I actually get by compiling ahead of time, and what am I giving up?
What Dies at Each Stage
▶ lab

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.

Q · Why can the debugger not show me that variable, and why does the error message point at the wrong thing?
One Line, All the Way Down
▶ lab

Follow `x = a + b` from characters to retired machine instructions, and watch each stage answer a question the previous representation could not even express.

Q · What actually happens to a single line of source code between my editor and the CPU?

Designing a Programming Language

9 lessons

Who 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.

Who Is the Language For?

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.

Q · Before I decide anything about syntax or types, what question am I supposed to answer first?
The Questions a Language Definition Must Answer

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.

Q · What do I actually have to decide to have a language, rather than a syntax?
Syntax versus Semantics

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.

Q · What is actually the difference between a syntax error and a type error, and why do languages draw the line differently?
Ergonomics Is a Compiler Feature

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.

Q · Why do some languages feel good to write and others feel like an argument, and is that anything more than taste?
Choosing an Execution Model

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.

Q · How does what I put in the language decide what implementations of it are possible?
Choosing How Memory Is Managed

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.

Q · Which memory management model should my language have, and what does each one make the compiler responsible for?
Choosing a Concurrency Model

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.

Q · Which concurrency model should the language have, and what does each one force the compiler to do?
Interoperability Is a Language Design Decision

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.

Q · What does it cost my language to be able to call, and be called by, code written in something else?
The Trade Nobody Escapes

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.

Q · Given what I actually need, which set of language design decisions follows — and what am I paying for it?

Domain-Specific Languages

6 lessons

When 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.

Domain-Specific Languages

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.

Q · What makes something a domain-specific language rather than just a library with a lot of functions?
Internal versus External DSLs

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.

Q · Should my domain language be embedded in the host language or have its own syntax?
Should I Build a DSL?

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.

Q · We keep saying this would be cleaner as a small language. Should we actually build one?
Implementing a DSL

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.

Q · I have a grammar and an AST for my domain language. What do I do with it?
Configuration Languages

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.

Q · Which configuration format should I use, and why do they all seem to turn into programming languages?
The Tooling Cost of a DSL

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.

Q · We have a working parser and interpreter. Why is nobody using our language?

Frontend

Source to a checked, structured program.

Grammar & Syntax

8 lessons

Writing down what a valid program looks like: productions, derivations, EBNF, and the ambiguity that precedence and associativity exist to resolve.

Formal Grammars
▶ lab

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.

Q · Why write a grammar down at all instead of just writing the parser?
Productions & Derivations
▶ lab

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.

Q · What does it actually mean to say a parser "derives" a program, and why does the order of the rewrites matter?
BNF & EBNF
▶ lab

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.

Q · What does EBNF actually buy me over plain BNF, and what does the shorter notation stop telling me?
Context-Free Grammars

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.

Q · What can a context-free grammar express that a regular expression cannot, and where does it run out?
Ambiguous Grammars
▶ lab

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.

Q · What exactly makes a grammar ambiguous, and what are my options once it is?
Operator Precedence
▶ lab

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.

Q · Where does operator precedence actually live — the grammar, the parser, or a table — and how do I check what my language does?
Associativity
▶ lab

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.

Q · Why does `a - b - c` group to the left, and which operators do not?
Left Recursion
▶ lab

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.

Q · Why does `expr → expr "+" term` hang a recursive-descent parser but not an LR parser, and how do I get rid of it?

Lexical Analysis

8 lessons

Characters 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.

Lexical Analysis
▶ lab

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.

Q · What does a lexer actually produce, and why is that a separate phase from parsing at all?
Token Kinds
▶ lab

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.

Q · How do I decide what counts as one token kind, and where does the keyword-versus-identifier decision belong?
Token Metadata
▶ lab

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.

Q · Why does every token need to remember exactly where it came from, when the parser only looks at the kind?
Implementing a Lexer
▶ lab

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.

Q · How do I actually write a lexer, and when is a generator worth the dependency?
Regular Languages
▶ lab

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.

Q · Why are regular expressions enough for tokens but hopeless for programs?
Finite Automata
▶ lab

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.

Q · What does the state machine behind a lexer look like, and how do I derive one for a token family?
NFA vs DFA
▶ lab

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.

Q · Why build an NFA and then convert it, instead of building the DFA directly?
Lexer Hazards
▶ lab

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.

Q · Where does the tidy lexer/parser split actually break down in real languages?

Parsing

9 lessons

Tokens to structure. Recursive descent and Pratt parsing by hand, LL and LR as families, and what a parser generator buys and costs.

What a Parser Actually Does
▶ lab

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.

Q · What is a parser responsible for, and what is it not allowed to decide?
Parse Tree vs Abstract Syntax Tree
▶ lab

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.

Q · What is the difference between a parse tree and an AST, and why do compilers build one and IDEs the other?
Recursive Descent
▶ lab

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.

Q · How do I write a parser by hand, and why does my expression rule recurse forever?
Pratt Parsing
▶ lab

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.

Q · How do I parse expressions with fifteen precedence levels without writing fifteen functions?
LL Parsing, FIRST and FOLLOW
▶ lab

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.

Q · What does the "LL" in LL(1) actually mean, and how does a parser decide which production to use?
LR Parsing
▶ lab

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".

Q · What makes LR parsing more powerful than LL, and what is a shift/reduce conflict actually telling me?
Shift and Reduce, Step by Step
▶ lab

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.

Q · What do "shift" and "reduce" actually do to the stack, and when does the parser not know which to pick?
LL vs LR: Why Production Compilers Chose the Weaker One
▶ lab

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.

Q · If LR is more powerful, why does almost every compiler I use hand-write a recursive-descent parser?
Parser Generators
▶ lab

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.

Q · Should I write a grammar file and generate a parser, or write the parser myself?

Diagnostics & Error Recovery

6 lessons

A 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".

Source Locations
▶ lab

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.

Q · How does a compiler know which line and column an error is on, and why do the numbers sometimes disagree between tools?
Spans and Ranges
▶ lab

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.

Q · Why do compilers store a range rather than a position, and what does carrying it through every phase actually buy?
Error Recovery
▶ lab

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.

Q · How does a compiler keep going after a syntax error, and why does one missing brace produce forty messages?
Parser Synchronization
▶ lab

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.

Q · After a parse error, how does the parser decide where to start parsing again?
Diagnostic Quality
▶ lab

`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.

Q · What separates an error message that helps from one that is technically correct?
Suggested Fixes
▶ lab

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.

Q · When should a compiler propose a fix, and when is proposing one actively harmful?

The Abstract Syntax Tree

6 lessons

The 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 Abstract Syntax Tree
▶ lab

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.

Q · What is an AST actually, and what does it know that the token stream did not?
Designing AST Nodes
▶ lab

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.

Q · How should I actually represent AST nodes — classes, tagged unions, or indices into an array?
Walking the Tree
▶ lab

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.

Q · How does a compiler pass actually visit every node, and does the order matter?
The Visitor as the Shape of a Pass
▶ lab

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.

Q · Why is every compiler pass written as a visitor, and what does that shape cost me?
Changing the Tree
▶ lab

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.

Q · When a pass changes the AST, does it mutate the tree or build a new one — and why does anyone care?
One Tree, Six Consumers
▶ lab

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.

Q · Why do modern compilers ship their frontend as a library instead of just a binary?

Semantic Analysis

8 lessons

Names, scopes and everything the grammar could not express. Symbol tables, shadowing, resolution, and the annotated tree the type checker needs.

Semantic Analysis
▶ lab

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`.

Q · What does a compiler check after parsing succeeds, and why could the parser not check it?
The Symbol Table
▶ lab

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.

Q · Where does a compiler keep what it knows about every name, and how does it find the right one?
Lexical Scope
▶ lab

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.

Q · How does a compiler decide which declaration an identifier refers to?
Shadowing
▶ lab

`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.

Q · What happens when an inner scope declares a name that already exists outside it?
Name Resolution
▶ lab

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.

Q · How does an identifier get connected to the thing it names, and where does that get hard?
Static and Dynamic Scoping
▶ lab

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.

Q · What is dynamic scoping, and why does my lexically-scoped language still have some?
Declaration Order
▶ lab

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.

Q · Can a function call another one that is defined further down the file, and what does the compiler have to do to allow it?
The Annotated AST
▶ lab

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.

Q · What exactly does the type checker hand to the next phase, and how is it different from what the parser produced?

Type Systems

12 lessons

What the language can prove before it runs. Checking, typing rules, environments, inference, unification, polymorphism, subtyping and variance.

What a Type System Actually Proves

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.

Q · What properties can a type system actually prove about my program before it runs, and what is it structurally unable to prove?
Static and Dynamic Typing, Compared Honestly

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.

Q · Should I reach for a statically typed language or a dynamically typed one, and what does each choice actually make hard?
Type Checking: Is This Operation Defined for These Operands?

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.

Q · What is a type checker actually doing when it decides whether `1 + "hello"` is allowed?
Typing Rules: Reading the Notation With the Line Through It

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.

Q · How do I read the fraction-looking notation in a language specification or a types paper?
The Type Environment: What Γ Is, and Where the Compiler Keeps It

Γ = { 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.

Q · What is the Γ in `Γ ⊢ x + 1 : int`, and what data structure is it in an actual compiler?
Type Inference: Leaving the Type Off
▶ lab

`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.

Q · When can I leave the type off, and why do most mainstream languages only let me do it locally?
Hindley–Milner: Inference Without a Single Annotation
▶ lab

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.

Q · How does an ML compiler type a whole module with no annotations at all, and why did my language not do that?
Unification, and Why `T = List<T>` Must Fail
▶ lab

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.

Q · How does a type checker actually solve `T = List<U>`, `U = int`, and why must `T = List<T>` be rejected?
Parametric Polymorphism and the Theorems You Get Free

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.

Q · What does `<T>` actually guarantee, beyond saving me from writing the function twice?
Ad-Hoc Polymorphism: One Name, Different Code

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.

Q · When one name means different code for different types, who decides which code runs, and when?
Subtyping: What `Dog <: Animal` Licenses

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.

Q · What does `Dog <: Animal` actually license the compiler to do, and what does it not check?
Variance: Why `List<Dog>` Is Not a `List<Animal>`

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.

Q · If `Dog <: Animal`, is `List<Dog> <: List<Animal>` — and why is the answer usually no?

Type System Design

7 lessons

Composing types and representing absence: unions, intersections, algebraic data types, exhaustive pattern matching, nullability and gradual typing.

Union Types

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.

Q · When is `string | number` actually useful, and why does my code still not compile after I have checked the type?
Intersection Types

`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.

Q · What does `A & B` actually give me, and why does `string & number` type-check when nothing can ever be one?
Algebraic Data Types

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.

Q · Why are enums with payloads called "algebraic", and when should I reach for one instead of a struct with a few optional fields?
Pattern Matching

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.

Q · What does `match` give me that a chain of `if` statements and field accesses does not?
Exhaustiveness Checking

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.

Q · How do I make the compiler tell me every place I need to update when I add a variant?
Nullability and Optional Types

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.

Q · Should absence be a nullable type or an `Option`, and what does each actually cost at runtime?
Gradual Typing

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.

Q · If TypeScript checks my types, why does a value of the wrong type still reach production?

Types at the Implementation Boundary

7 lessons

What survives to runtime and what proves memory safety: erasure versus reification, monomorphization, ownership, lifetimes and effects.

Structural vs Nominal Typing

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`.

Q · Why does TypeScript accept a completely different type that happens to have the same fields, and how do I stop it?
Type Soundness

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.

Q · What does it actually mean for a type system to be "sound", and why is TypeScript deliberately not?
Type Erasure and Reification

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.

Q · Where did my generic type go at runtime, and why can I not write `new T[]`?
Monomorphization

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.

Q · Why does my Rust binary grow every time I add a generic call, and what do I get for it?
Ownership Types

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.

Q · How can a compiler prove memory safety with no garbage collector and no runtime check?
Lifetime Analysis

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.

Q · Why does the compiler need a lifetime annotation when it can obviously see where the reference is used?
Effect Systems

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.

Q · Can the type of a function say what it *does*, not just what it returns?

Middle-end

A representation you can analyse, and the transformations that are legal on it.

Intermediate Representation

8 lessons

The 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.

What an Intermediate Representation Is
▶ lab

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.

Q · What is an IR, and why is there a whole extra representation between my typed program and the machine code?
Why an IR Exists: M x N Becomes M + N
▶ lab

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.

Q · Why not just generate machine code straight from the typed tree, and skip a whole representation?
Levels of IR: High, Mid and Low
▶ lab

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.

Q · Why do real compilers have three or four IRs instead of one, and how do I know which one a pass should run on?
Three-Address Code
▶ lab

`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.

Q · Why does the compiler invent temporary names for values I never named, and what would break without them?
Lowering
▶ lab

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.

Q · What does "lowering" actually mean, and how does a high-level feature like `match` or `async` become ordinary jumps?
Designing an IR: The Four Decisions
▶ lab

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.

Q · If I were designing an IR, what are the decisions, and what does each one actually cost me?
IR Verification
▶ lab

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.

Q · Why does a compiler check its own intermediate representation, and what exactly is it checking?
Many Frontends, One Backend
▶ lab

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.

Q · How do six languages with nothing in common end up sharing an optimizer, and what does each of them give up to get it?

Control Flow

7 lessons

Turning statements into a graph you can reason about: basic blocks, edges, natural loops, dominators and the dominance frontier that SSA construction needs.

The Control-Flow Graph
▶ lab

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.

Q · What is a control-flow graph, and what can I ask of it that I could not ask of the source?
Basic Blocks
▶ lab

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.

Q · What exactly makes a group of instructions a basic block, and why is that the unit compilers analyse?
Building the CFG
▶ lab

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.

Q · How does a compiler turn nested `if` and `while` statements into blocks and edges, and what goes wrong?
Natural Loops
▶ lab

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.

Q · How does a compiler find the loops in a graph after the `while` and `for` have been lowered away?
Dominators
▶ lab

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.

Q · What does it mean for one block to dominate another, and why does computing it require iteration?
The Dominator Tree
▶ lab

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.

Q · What does the dominator tree look like, how is it different from the CFG, and what walks it?
The Dominance Frontier
▶ lab

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.

Q · Where does a definition stop being guaranteed, and why is that the same set as "where the phi nodes go"?

Static Single Assignment

6 lessons

One definition per name makes data dependencies explicit. Phi functions, construction, why so many analyses get simpler, and how you leave SSA again.

Static Single Assignment
▶ lab

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.

Q · What does SSA form actually change about an IR, and why is a rule about naming worth an entire pass?
Phi Functions
▶ lab

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.

Q · What is a phi node actually doing, and how can an instruction that cannot be executed be part of the IR?
Constructing SSA
▶ lab

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.

Q · How does a compiler decide where phi nodes go, without checking every block for every variable?
Why SSA Helps
▶ lab

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.

Q · Which specific analyses get cheaper under SSA, and by how much — or is "it makes optimization easier" all there is to it?
Leaving SSA
▶ lab

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.

Q · How do phi nodes turn into real instructions, and why does doing the obvious thing produce wrong code?
SSA Variants
▶ lab

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.

Q · People talk about pruned SSA and loop-closed SSA — are these different representations, or just different phi-placement policies?

Data-Flow Analysis

7 lessons

One framework — facts, transfer functions, a meet operator, iterate to a fixed point — and the four classic analyses that are all instances of it.

The Data-Flow Framework
▶ lab

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.

Q · Is there one thing called "data-flow analysis", or is every analysis its own algorithm?
Iterating to a Fixed Point
▶ lab

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.

Q · Why does "keep applying the equations until they stop changing" terminate, and why does the order I visit blocks in not change the answer?
Forward and Backward Analysis
▶ lab

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.

Q · How do I know whether an analysis should run forwards or backwards through the control-flow graph?
Reaching Definitions
▶ lab

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.

Q · Which assignments could have produced the value at this program point, and why does SSA make the question trivial?
Liveness Analysis
▶ lab

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.

Q · How does a compiler know which values still matter at a given point, and why can it not work that out in a single pass?
Available Expressions
▶ lab

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.

Q · When is it safe to reuse a value the program already computed, instead of computing it again?
Constant Propagation
▶ lab

`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.

Q · How does a compiler know a variable holds a specific value, and how far can it carry that knowledge?

Scalar Optimization

8 lessons

Folding, elimination, propagation, inlining and devirtualization — each with the precondition that makes it legal and the budget that makes it wise.

Constant Folding
▶ lab

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.

Q · If the compiler can already see that an expression is `2 * 3`, why would it ever emit a multiply?
Dead Code Elimination
▶ lab

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.

Q · The compiler deleted code I wrote. What did it have to prove first, and when does it get that wrong?
Common Subexpression Elimination
▶ lab

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.

Q · The same expression appears twice. Why does the compiler sometimes reuse the first result and sometimes not?
Copy Propagation
▶ lab

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.

Q · Why does the compiler bother emitting `a = b` at all, and what lets it get rid of the copy afterwards?
Strength Reduction and Algebraic Identities
▶ lab

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.

Q · Should I write `x << 1` instead of `x * 2`, and why does the compiler refuse to simplify some of my floating-point arithmetic?
Inlining
▶ lab

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.

Q · When does inlining a function actually make the program faster, and when does it make it slower?
Devirtualization
▶ lab

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.

Q · My hot loop calls a virtual method. Can the compiler turn that into a direct call, and what does it have to know first?
Partial Evaluation and Specialization
▶ lab

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.

Q · Half of my function's inputs are fixed at startup. Can the compiler produce a version of it that only takes the rest?

Loops & Memory Optimization

7 lessons

Where the time actually goes: hoisting, unrolling, interchange, vectorization — and the aliasing and escape questions that decide whether any of it is allowed.

Loop-Invariant Code Motion
▶ lab

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.

Q · The expression inside my loop clearly does not change. Why did the compiler leave it there?
Loop Unrolling
▶ lab

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.

Q · Does unrolling a loop still help on a processor that predicts branches almost perfectly?
Fusion, Fission, Interchange and Tiling
▶ lab

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.

Q · The loop does the same arithmetic either way. Why does the order of the loops change the running time by an order of magnitude?
Automatic Vectorization
▶ lab

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.

Q · Why did my loop not vectorize, when the one next to it did?
Alias Analysis
▶ lab

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.

Q · Why does the compiler keep reloading a value from memory when nothing visibly writes to it?
Escape Analysis
▶ lab

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.

Q · I allocated an object inside a function and never returned it. Does it still cost a heap allocation?
Bounds Check Elimination
▶ lab

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.

Q · If every array access in Rust and Java is bounds-checked, why is the generated code not slower than C?

What a Compiler Is Allowed to Do

9 lessons

Observable 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.

Optimization Legality
▶ lab

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.

Q · What makes a transformation an optimization rather than a bug?
Observable Behavior
▶ lab

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.

Q · Which effects of my program is the compiler obliged to preserve, and which is it free to change?
The As-If Rule
▶ lab

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.

Q · Where does a compiler actually get permission to rewrite my program?
Undefined Behavior
▶ lab

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.

Q · What does "undefined behavior" actually license a compiler to do?
How Undefined Behavior Becomes Faster Code
▶ lab

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.

Q · How exactly does an undefined construct in my source turn into a missing check in my binary?
Semantics Decide, Not Cleverness
▶ lab

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.

Q · Why does the same optimization happen in one language and not another, for identical-looking source?
Optimization Levels
▶ lab

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.

Q · What do the optimization levels really change, and how do I pick one?
Pass Pipelines
▶ lab

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.

Q · How is an optimizer actually structured, and what is a "pass"?
Phase Ordering
▶ lab

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.

Q · Does the order of optimization passes matter, and if so, what is the right order?

Lowering Language Features

10 lessons

Closures, coroutines, async, exceptions and match expressions are all ordinary control flow after the compiler is done with them. This module does the transformation.

Syntactic Sugar
▶ lab

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.

Q · What actually makes a feature "just syntactic sugar", and does calling it that mean it does not matter?
Desugaring
▶ lab

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.

Q · When in the pipeline should high-level syntax be rewritten into the core language, and what does the choice cost?
Closures

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.

Q · If a function returns a lambda that uses a local variable, where does that variable live after the function returns?
Closure Conversion
▶ lab

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.

Q · What does the compiler actually build when it sees a lambda that captures a variable?
Lambda Lifting

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.

Q · If I can pass captured variables as extra arguments, why does anyone build an environment record?
Lowering Coroutines
▶ lab

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.

Q · What does the compiler do to a function containing `yield` so that it can stop in the middle and continue later?
Lowering Async and Await
▶ lab

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.

Q · What does `async` actually do to a function, and why can I only `await` inside one?
Exception Handling

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.

Q · What does `try`/`catch` compile to, and is it true that exceptions cost nothing until one is thrown?
Stack Unwinding

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.

Q · How does the runtime know what to destroy and where to jump when an exception propagates through a frame that never mentions exceptions?
Compiling Pattern Matching
▶ lab

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.

Q · Does a `match` really test every arm in order until one fits, and if not, what does it actually generate?

Backend

One specific machine, and where every value physically lives.

Code Generation

8 lessons

IR to instructions for one specific machine: selection by pattern matching, scheduling for a pipeline the compiler cannot observe, and the bytes that come out.

Code Generation
▶ lab

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.

Q · What does a compiler backend actually do after the optimizer has finished?
Instruction Selection
▶ lab

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.

Q · How does a compiler decide which machine instruction implements an IR operation?
Tree Pattern Matching
▶ lab

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.

Q · How is instruction selection actually implemented, rather than described?
Instruction Scheduling
▶ lab

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.

Q · Why would a compiler emit instructions in a different order than the IR, and does it still matter on modern hardware?
Peephole Optimization
▶ lab

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.

Q · What can a compiler still fix by looking at two or three adjacent instructions?
Reading Assembly Output
▶ lab

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.

Q · How do I read the assembly my compiler produces, and what do I need to know before the register names mean anything?
Machine Code Encoding
▶ lab

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.

Q · What bytes does an instruction actually turn into, and why is x86 variable-length when ARM is not?
What a Backend Must Know About Its Target
▶ lab

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.

Q · What does a compiler backend actually need to know about the machine it is generating code for?

Register Allocation

7 lessons

Many live values, few registers. Live ranges, interference, graph colouring, linear scan, and the spill that turns a register access into a memory access.

Register Allocation
▶ lab

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.

Q · How does a compiler fit an unlimited number of IR values into sixteen registers?
Live Ranges
▶ lab

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.

Q · How does a compiler know when a value stops mattering?
The Interference Graph
▶ lab

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.

Q · How does a compiler represent "these two values cannot share a register"?
Graph Colouring Allocation
▶ lab

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.

Q · How does an allocator colour a graph when colouring is NP-complete?
Linear Scan Allocation
▶ lab

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.

Q · What does an allocator look like when compile time is the thing the user is waiting for?
Spilling
▶ lab

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.

Q · What happens when the allocator runs out of registers, and how does it choose the loser?
Coalescing and Rematerialization
▶ lab

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.

Q · How does an allocator get rid of the register-to-register moves, and when is recomputing cheaper than remembering?

Calling Conventions & ABI

7 lessons

The contract between separately compiled code: argument passing, stack frames, saved registers, mangled names, and what breaking it costs.

Calling Conventions
▶ lab

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.

Q · How do a caller and a callee that were compiled separately agree on where the arguments are?
Stack Frame Layout
▶ lab

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.

Q · What is in a stack frame, who puts it there, and what does `-fomit-frame-pointer` actually cost me?
What an ABI Actually Is

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.

Q · What is an ABI, and why does breaking one corrupt data instead of failing to build?
Name Mangling

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.

Q · Why is my symbol called `_ZN3foo3barEid`, and why does `extern "C"` fix my link error?
ABI Stability

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.

Q · Why can I not add a field to a class in my shared library without rebuilding everything that uses it?
Cross-Compilation

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.

Q · Why does building for another platform need more than a compiler that can emit its instructions?
Target Triples

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.

Q · What does each field of `x86_64-unknown-linux-gnu` actually change in the compiler?

Linking & trust

Composing binaries, and what the toolchain is trusted with.

Linking & Loading

9 lessons

Composing object files into something runnable, resolving what the compiler could not know, and handing the result to an operating system loader.

What a Linker Does
▶ lab

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.

Q · What happens between my `.o` files and an executable I can actually run?
Object Files
▶ lab

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.

Q · What is inside a `.o` file, and why is `.bss` free?
Symbols and References
▶ lab

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.

Q · What do the letters in `nm` output mean, and why does `undefined reference` appear for a function I definitely wrote?
Relocations
▶ lab

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.

Q · How does a `call` with four zero bytes in it turn into a working call, and what are the GOT and the PLT for?
Static Linking
▶ lab

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.

Q · What do I actually get by linking statically, and what am I giving up?
Dynamic Linking
▶ lab

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`.

Q · What actually happens at load time when my program uses a shared library, and why do I get `GLIBC_2.34 not found`?
Shared Libraries
▶ lab

`.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.

Q · What is in a `.so`, what does the soname mean, and why is `-fvisibility=hidden` recommended?
Symbol Resolution Order
▶ lab

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.

Q · When two loaded libraries define the same symbol, which one wins — and who decides?
The Loader
▶ lab

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.

Q · What actually runs between `exec` and the first line of `main`?

Bootstrapping & Toolchain Trust

5 lessons

Where 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.

Bootstrapping a Compiler

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.

Q · If the Rust compiler is written in Rust, what compiled the first Rust compiler?
Self-Hosting and the Three-Stage Build

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.

Q · What does a three-stage build actually prove, and why must stage 2 and stage 3 be identical?
Reflections on Trusting Trust

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.

Q · If I read all the source, do I know what the binary does?
The Toolchain Is Your Trusted Computing Base

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.

Q · What am I actually trusting when I run a build?
Reproducible Compilation

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.

Q · Why do two builds of the same source produce different binaries, and does it matter?

Execution

Interpreters, virtual machines, and compiling while the program runs.

Bytecode & Virtual Machines

9 lessons

An instruction set you get to design. Stack versus register machines, tree-walking versus bytecode, and the dispatch loop at the centre of both.

Bytecode
▶ lab

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.

Q · Why compile to bytecode instead of just running the syntax tree, or going all the way to machine code?
Stack-Based Virtual Machines
▶ lab

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.

Q · How does a virtual machine execute `1 + 2` when the `ADD` instruction has no operands?
Register-Based Virtual Machines
▶ lab

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.

Q · What changes if my virtual machine has registers instead of an operand stack?
Stack VM vs Register VM
▶ lab

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.

Q · Which should I build — a stack VM or a register VM — and what actually differs?
Tree-Walking Interpreters
▶ lab

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.

Q · Can I just walk the syntax tree and execute it, and what does that cost me?
Compiling to Bytecode
▶ lab

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.

Q · How do I turn a syntax tree into bytecode my VM can execute?
The Dispatch Loop
▶ lab

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.

Q · What is actually at the centre of an interpreter, and why does the way it branches matter so much?
Where an Interpreter's Time Actually Goes
▶ lab

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.

Q · My bytecode interpreter is about ten times slower than the same algorithm in C. Where is that time going, and how much of it can I actually get back?
What a Virtual Machine Has to Hold
▶ lab

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.

Q · What is the complete state of a running virtual machine, and what would I have to save to suspend a program and resume it later?

JIT Compilation

10 lessons

Compiling with information a static compiler cannot have. Tiers, profiling, speculation, guards, and the deoptimization that catches a wrong guess.

Just-in-Time Compilation
▶ lab

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.

Q · What does a JIT actually do, and at what moment does it do it?
Why Runtime Information Helps
▶ lab

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.

Q · What can a compiler know at run time that it genuinely cannot know at build time, and why is that worth so much?
Tiered Compilation
▶ lab

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.

Q · Why do engines run several compilers instead of one good one, and what decides which tier a piece of code is in?
Profiling and Hotness
▶ lab

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.

Q · How does a runtime decide that a piece of code is worth compiling, and what does getting the threshold wrong actually cost?
On-Stack Replacement
▶ lab

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.

Q · A function has been running one loop for thirty seconds. Compiling it will not help, because it will never be called again — so how does it ever get faster?
Speculative Optimization
▶ lab

"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.

Q · How can a compiler emit code that assumes something it has not proved, and still be correct?
Guards
▶ lab

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.

Q · What does the check in front of a speculative fast path actually cost, and what does it have to do besides compare two values?
Deoptimization
▶ lab

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.

Q · When an optimized function's assumption turns out to be wrong, how does the program carry on correctly — and what did the compiler have to give up to make that possible?
Inline Caches
▶ lab

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.

Q · A dynamic call has to look up its target every time. How does caching that lookup at the call site work, and why does it stop working when the site sees too many things?
What a JIT Costs
▶ lab

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.

Q · What am I actually paying for a JIT, and when is the bill larger than the benefit?

Around all of it

Real pipelines, correctness, tooling, builds — and building your own.

Real Pipelines

11 lessons

Python, JavaScript, TypeScript, C++, Rust and Go: four genuinely different routes from source to behavior, compared without pretending they are the same.

The CPython Pipeline
▶ lab

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.

Q · What actually happens between `python script.py` and my first line of output?
The JavaScript Pipeline
▶ lab

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.

Q · Why is my JavaScript slow for the first few hundred iterations and then suddenly fast?
The TypeScript Pipeline
▶ lab

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.

Q · If TypeScript has types, why does none of my type-checking happen at runtime?
The C++ Pipeline
▶ lab

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.

Q · What are all these steps between my `.cpp` file and the executable, and why does the error come from a different program each time?
The Preprocessor
▶ lab

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.

Q · Why does `#define` cause such strange bugs, and why does including a header in a different order change what compiles?
Templates
▶ lab

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.

Q · Why does a one-line mistake with a template produce four hundred lines of errors from inside a header I never opened?
Template Instantiation
▶ lab

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.

Q · Where does the compile time and the binary size actually go in a template-heavy project?
Compile-Time Evaluation
▶ lab

`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.

Q · How much of my program can the compiler just run before it ships, and what do I pay for that?
The Rust Pipeline
▶ lab

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.

Q · Why does rustc have so many intermediate representations, and why is my Rust build slow?
The Go Pipeline
▶ lab

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.

Q · Why does Go compile so fast, and what did the language give up to make that true?
Four Languages, One Program
▶ lab

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.

Q · The same five-line program in four languages produces the same output — so what is actually different underneath?

Compiler Infrastructure

9 lessons

LLVM as reusable middle-end and code generator rather than "a compiler", GCC as the other one, and WebAssembly as a portable sandboxed target.

What LLVM Actually Is

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.

Q · People keep saying "LLVM" as though it were one program — what is it actually?
The Three-Phase Architecture

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.

Q · Why is a shared intermediate representation such a big deal architecturally?
Reading LLVM IR
▶ lab

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.

Q · What does LLVM IR actually look like, and what do I need to know to read it?
Clang

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.

Q · What does Clang do that LLVM does not, and why is so much C++ tooling built on it?
GCC

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.

Q · How does GCC differ from LLVM architecturally, without either of us picking a side?
What a Toolchain Actually Contains

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.

Q · When my build breaks, which program actually produced this error?
WebAssembly as a Compilation Target

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.

Q · What actually happens between my C or Rust source and code running as WebAssembly?
The WebAssembly Execution Model

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.

Q · What does the WebAssembly machine actually look like, and why is it shaped like that?
WebAssembly Versus Native

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.

Q · Should I ship this as WebAssembly or as a native binary, and what am I actually giving up?

Compiler Correctness & Security

8 lessons

The one program whose bugs are everyone else’s bugs: miscompilation, differential testing, fuzzing, translation validation and formal verification.

Miscompilation
▶ lab

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.

Q · How would I ever know whether the wrong answer came from my code or from the compiler?
Testing a Compiler
▶ lab

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.

Q · What does a serious test suite for a compiler actually contain, and which test earns the most?
Golden Tests

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.

Q · When is a recorded-output test worth its maintenance cost, and what can it never tell me?
Differential Testing
▶ lab

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.

Q · How can I test a compiler when I have no way of knowing what the right answer is?
Compiler Fuzzing
▶ lab

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.

Q · How do people actually find compiler bugs, given that nobody is writing the programs that trigger them?
Translation Validation
▶ lab

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.

Q · Can I get some of the assurance of a verified compiler without rewriting the compiler in a proof assistant?
Verified Compilers

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.

Q · What does it actually mean for a compiler to be proven correct, and what is still not proven?
Compilers and Security

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.

Q · In what ways is the compiler part of my threat model rather than part of my toolbox?

Static Analysis & Language Tooling

10 lessons

The frontend is the IDE. Abstract interpretation, interprocedural analysis, linters, formatters, concrete syntax trees and the language server that serves them all.

Static Analysis
▶ lab

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.

Q · What can a tool tell me about my program before it runs, and how much should I believe it?
Abstract Interpretation
▶ lab

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.

Q · How can a tool reason about every possible input without enumerating any of them?
Control-Flow Analysis
▶ lab

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.

Q · When the thing being called is a variable, how does any tool know what runs?
Interprocedural Analysis
▶ lab

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.

Q · How does a tool know anything about a value that was computed in a different function?
Linters
▶ lab

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.

Q · Why is one problem a compiler error, an almost identical one a warning, and a third one something I have to install a separate tool to find?
Formatters
▶ lab

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.

Q · Why does writing a code formatter require a different parser than writing a compiler?
The Concrete Syntax Tree
▶ lab

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.

Q · Why do IDEs and refactoring tools build a second, bigger tree when the compiler already has one?
The Language Server
▶ lab

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.

Q · Why can I not just run the compiler in a loop and send its output to my editor?
The Language Server Protocol

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.

Q · Why did every editor suddenly get good support for every language at roughly the same time?
Semantic Refactoring
▶ lab

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.

Q · Why does a find-and-replace rename break code in ways an IDE rename does not?

Debug Information

6 lessons

Mapping optimized machine code back to what you wrote — line tables, variable locations, source maps, and why a variable reads as "optimized out".

Debug Information
▶ lab

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.

Q · How does a debugger know that this machine instruction came from line 47 and that `count` is in `rbx` right now?
Source Maps
▶ lab

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.

Q · How does the browser show me my TypeScript when what is running is a minified bundle?
Debug and Release Builds
▶ lab

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.

Q · Should I really ship a build with no debug information just because it is the release default?
Debugging Optimized Code
▶ lab

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.

Q · The debugger says my variable is optimized out and the cursor is jumping around. What is actually happening?
Symbolication
▶ lab

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.

Q · My crash report is a list of hex addresses. How do I get function names and line numbers back?
Reading What the Compiler Produced
▶ lab

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.

Q · How do I actually check what the compiler did, instead of guessing?

Compilation at Scale

9 lessons

Compilation units, modules, interface files and the dependency analysis that keeps a rebuild proportional to the change rather than to the codebase.

Compilation Units
▶ lab

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.

Q · What does the compiler actually process in one invocation, and why does one header change rebuild so much?
Separate Compilation
▶ lab

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.

Q · Why do we compile files separately and link afterwards instead of compiling the whole program at once?
Modules as Units of Separate Compilation

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.

Q · What does a language-level module system give me that headers and includes do not?
Interface Files

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.

Q · How does a compiler type-check my code against a library it never parses?
Incremental Compilation

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.

Q · How does a compiler avoid redoing work when only a little changed?
The Build Dependency Graph

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.

Q · When I change one file, how does the build decide what else has to be rebuilt?
Where the Compiler Ends and the Build System Begins

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.

Q · What is the build system's job, and what is the compiler's?
Hermetic Compilation
▶ lab

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.

Q · Why does this build work on my machine and fail in CI, when the commit is identical?
Compile Time versus Runtime
▶ lab

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.

Q · Is it worth turning on more optimization, and how would I know?

Whole-Program & Feedback-Directed Optimization

6 lessons

Seeing across module boundaries with LTO, and measuring before optimizing with PGO — including what an unrepresentative profile does to the result.

Whole-Program Optimization
▶ lab

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.

Q · What can a compiler do if it can see the whole program that it cannot do one file at a time?
Link-Time Optimization
▶ lab

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.

Q · What is `-flto` actually doing, and what is the difference between full LTO and ThinLTO?
Profile-Guided Optimization
▶ lab

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.

Q · How does a compiler find out which paths are hot, and what does it do differently once it knows?
What a Profile Costs You
▶ lab

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.

Q · What can go wrong with profile-guided optimization, and when is a sampled profile the better choice?
Feedback-Directed Optimization
▶ lab

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.

Q · What do PGO and a JIT actually have in common, and what is the real difference between them?
The Compiler Is Also a Program With Performance Requirements
▶ lab

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.

Q · Why is my build slow, and what is the compiler actually trading away when it is fast?

Compilers for Agent Systems

5 lessons

A 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 Plan Is a Program

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.

Q · A model just handed my system a plan to execute. What should happen to it before anything runs?
Agent DSLs and the Plan AST

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.

Q · Why should an agent produce a plan in a small language instead of just calling tools one at a time?
Typed Tool Calls
▶ lab

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.

Q · How do I turn a model's tool call into a typed function call I can actually dispatch?
Parse, Validate, Authorize, Execute

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.

Q · In what order should I check a model-generated plan, and what does each check actually establish?
Recovering Structure From Model Output

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.

Q · The model wrapped its JSON in prose and left a trailing comma. Do I regex it, repair it, or reject it?

AtlasLang

9 lessons

Build 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.

AtlasLang: The Whole Thing
▶ lab

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.

Q · What does it actually take to build a working language, end to end?
AtlasLang: The Lexer
▶ lab

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.

Q · How does AtlasLang decide where one token ends and the next begins?
AtlasLang: The Parser
▶ lab

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.

Q · How does AtlasLang turn a flat token list into a tree, and how does it keep going after a syntax error?
AtlasLang: Evaluating the Tree Directly
▶ lab

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.

Q · What is the least machinery that will actually run a parsed program?
AtlasLang: Scopes, Shadowing and a Real Bug
▶ lab

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.

Q · What actually goes wrong if a compiler tracks variables by their names?
AtlasLang: Three Types and One Honest Limitation
▶ lab

`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.

Q · What can AtlasLang prove before running, and where does it give up on purpose?
AtlasLang: Bytecode and the Stack Machine
▶ lab

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.

Q · What does an instruction set look like when you get to design it, and how does three-address IR become one?
AtlasLang: Eight Passes and Two Guards
▶ lab

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.

Q · Which optimizations does AtlasLang actually perform, and what stops each one from being a bug?
AtlasLang: What a Language Owes Its Users
▶ lab

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.

Q · The compiler works. What else does a language need before anyone can use it?