Skip to content

The two-stage front end of a compiler: lexing splits text into tokens, parsing builds a tree from those tokens.

A finite state machine recognizes tokens. A recursive descent or generated parser consumes them, using a stack to track nested structure.

Character-level patterns are regular; nesting requires a stack. Separating the jobs lets a fast machine handle the first and a more powerful one handle the second.

When source reaches a compiler, the first problem is not what it means but where its pieces begin and end. A stream of characters has to become identifiers, numbers, operators, and nested constructs before any later stage can reason about it. Lexing and parsing split that work: the lexer turns characters into tokens, and the parser organizes those tokens into structure. The consequence is a front end that uses simple machinery for character patterns and stronger machinery only where nesting demands it.

The split between lexing and parsing exists because the two jobs sit at different levels of the language hierarchy.

StageWhat it doesMachineryKey limitation
LexerRecognizes tokens (identifiers, numbers, operators)Finite state machine, one passCannot count nested delimiters
ParserRecognizes nested structureStack (explicit or via recursion), context-free grammarCannot express regular patterns efficiently

Separating the two keeps the fast, character-level work in a simple machine and leaves the parser dealing with a short stream of typed tokens rather than raw text.

Recursive descent versus generated parsers

Section titled “Recursive descent versus generated parsers”

Most production compilers hand-write a recursive descent parser despite the existence of parser generators. A generated parser is compact and provably faithful to a grammar, but when it rejects input it can report little beyond the position and the legal tokens.

A recursive descent parser is ordinary code (one function per grammar construct), allowing:

  • Context-specific error messages
  • Targeted lookahead for ambiguities
  • Stepping through in a debugger

Several patterns force the front end to break its clean two-stage split. A contextual keyword is an ordinary identifier in one position and a keyword in another, so the lexer cannot label it without knowing where the parser is. C is the sharp case: whether a * b; declares a pointer or multiplies two values depends on whether a was already introduced as a type name, a fact only the symbol table holds, so the classic C front end feeds that information back into the lexer. Python takes block structure from indentation, which a character-level scanner cannot express, so its tokenizer keeps an indentation stack and emits explicit INDENT and DEDENT tokens for the parser to read. Older C++ compilers parsed the >> that closes two nested template arguments as the right-shift operator, and the standard had to add a rule to disambiguate it.

Error recovery separates a usable compiler from a merely correct one. A parser that stops at the first error forces a compile-fix-compile cycle per mistake.

TechniqueHow it works
Panic modeDiscards tokens until reaching a synchronizing token (semicolon, closing brace)
Targeted insertionAssumes a specific missing token and proceeds as if it were present

Cascading errors occur when one real mistake generates a page of consequences. Good recovery suppresses diagnostics that are likely downstream of an earlier one.

The same machinery parses configuration formats, query languages, log formats, template syntaxes, and structured output from a model.

Parsing structured model output has exactly the failure modes a compiler engineer would predict: output that is nearly valid but truncated, or valid syntax carrying semantically wrong content. Constrained decoding makes invalid output unrepresentable rather than merely unlikely.

A tree encoding what a program means rather than how it was punctuated.

The parser produces it by discarding syntactic noise such as parentheses and separators; equivalent source forms yield the same tree.

Later stages need meaning, not typography. Encoding precedence in tree shape removes it from every subsequent pass.

After parsing, the compiler still has evidence of how the grammar recognized the program: separators, parentheses, and intermediate rules that helped parse it. Later stages need a cleaner object, one that says what operation contains what operands without preserving every bit of punctuation. An abstract syntax tree is that object. It gives optimizers, linters, formatters, and codemods a stable structure to traverse, so they work on the program rather than on its surface spelling.

A parse tree records how the input matched the grammar. An abstract syntax tree records what the program means.

Parse treeAbstract tree
Contains nodes for every grammar rule traversedKeeps only semantic structure
Includes precedence rules, parentheses, separatorsPrecedence encoded by tree shape
Two equivalent source forms produce different treesEquivalent source forms produce the same tree

The compiler stops caring how the programmer wrote it as soon as this tree exists.

ToolWhat it doesWhy trees matter
LinterWalks tree looking for probable mistakesUnaffected by spacing or spelling
FormatterDiscards original whitespace, prints tree to fixed layoutOutput stable regardless of input
CodemodMatches subtree, replaces with new subtreeMechanical migration across API changes

Source locations carried through every stage are what make diagnostics possible. Each node records where in the original file it came from (usually as a byte range), and every transformation must propagate that information.

Without source locations, a compiler can prove a program wrong and be unable to say where. This gets harder as the program is transformed, since a node in an optimized representation may correspond to several source locations or to code inlined from elsewhere.

A regular expression cannot match balanced delimiters, so it cannot reliably find the end of a function, a nested call, or a block.

Problems with regex transformations:

  • No notion of scope (renaming a variable renames the string inside unrelated scopes, string literals, and comments)
  • No notion of type (cannot distinguish two methods with the same name on different objects)
  • Failures are silent and produce code that still compiles
  • They cluster in large or unusual files where manual review is least likely

A tree-based transformation gets all of this correct once the tree carries binding information. A parse alone does not: the shape of the syntax says nothing about which declaration a name refers to, so a rename driven by the bare tree still walks into shadowing and imports. What makes the transformation safe is resolved names and known types hanging off the nodes.

A transformation that runs on the tree works in terms of binding rather than characters. Renaming a declaration reaches only the uses that resolve to it and leaves the same name alone where it sits in a string literal or an unrelated scope. Matching on subtree shape lets it rewrite a call to one method without touching another method of the same name on a different type, and delimiter balance is guaranteed because a subtree is balanced by construction. The price is that the tree has to be printed back to source, and a minimal syntax tree drops the comments and spacing a person wants kept, so tools that rewrite source in place carry a fuller tree that preserves them. A rename in an IDE and a mechanical migration across an API change are both tree rewrites underneath.

A language between source and machine code used as the shared representation inside a compiler.

Front ends lower source to the IR; optimizers transform it; back ends lower it to machine code.

Writing one optimizer against the IR covers every source language and every target, turning a multiplication problem into an addition problem.

A toolchain that accepts several source languages and emits code for several processors can easily turn into a grid of one-off translators. Each new language would need every target, and each new target would need every language. Intermediate representations put a shared language in the middle. Front ends lower into it, optimizers rewrite it, and back ends lower it to machines. The consequence is that support grows by adding edges to the middle instead of filling the whole grid.

The common middle solves a multiplication problem. Supporting many source languages on many target architectures by writing a direct translator for each pair costs the product of the two counts.

ApproachCostWhat optimizations can use
Direct translationlanguages × targetsMust be duplicated for each pair
Common IRlanguages + targetsWritten once against the IR

A new language gets every existing target for the price of one front end, and a new processor gets every existing language for the price of one back end.

Real compilers use several levels rather than one:

LevelWhat it retainsWhat uses it
High-levelLoops, array accesses, type informationLoop transformations, vectorization
MiddleBasic blocks connected by branchesData-flow analysis
Low-levelExplicit registers, addressing modesInstruction selection, scheduling

Each lowering step discards information deliberately. A transformation that needs loop structure has to rebuild it once the loops are branches, recovering natural loops from the control-flow graph by way of dominators, and what does not survive that round trip is what the source said rather than what the graph implies.

LLVM IR is a typed, low-level, single-assignment representation resembling a portable assembly language. Its uniformity is its strength for scalar and pointer-heavy code and its limitation for anything with domain structure, since a tensor operation lowered into loops and pointer arithmetic has lost the information a tensor optimizer would use.

MLIR responds by making the representation extensible: instead of one instruction set it provides a framework in which many coexist, organized into dialects. A program can begin in a dialect that knows about tensors and convolutions, and be progressively lowered through dialects that know about loops, then vectors, then hardware intrinsics.

Deep learning compilers adopted this structure because they face the same multiplication problem. Lowering a model graph through progressively more specific representations lets the high stages fuse operations while the tensor structure is still present, and lets the low stages handle tiling and vendor intrinsics.

The gain that justifies the machinery is fusion: a chain of elementwise operations executed separately writes each intermediate result to memory and reads it back, while a fused kernel keeps the value in registers. On hardware where memory bandwidth is the binding constraint, that difference sets the achievable throughput.

An IR discipline where every variable is assigned exactly once and phi nodes select among values at control-flow joins.

Give each assignment a distinct name, so every use identifies its defining assignment directly.

Finding which definition reaches a use becomes a lookup instead of a search, so every data-flow analysis gets cheaper.

Once a program has branches and loops, even a plain question can get expensive: which assignment produced the value being used here? In a usual representation, the answer may require walking back through possible paths. SSA form makes each assignment create a new versioned name, with special join names only where control paths meet. That turns many data-flow questions into direct name relationships, so optimization passes can use facts without recomputing them from the control-flow graph.

Single assignment turns a search problem into a lookup. In an ordinary representation a variable can be assigned in many places, so answering which value reaches a given use requires walking backward through the control flow graph.

Under single assignment:

  • Each name is written exactly once
  • A use names its definition directly
  • Reassignment in the source becomes a new version of the name
  • Def-use chains are present in the representation rather than computed by each pass

Phi nodes handle the one case where a value genuinely depends on which path was taken. If two branches each assign a variable and control rejoins, a phi node sits at the top of the merge block and defines a new name whose value is selected according to which predecessor block control arrived from.

It is a notational device rather than a machine instruction, removed during register allocation by inserting copies at the end of each predecessor.

OptimizationUnder SSAWithout SSA
Constant propagationOne lookup (is the single definition a constant?)Path analysis
Dead code eliminationReference count (any uses?)Data-flow analysis over CFG
Common subexpression eliminationSyntactic comparison of operator and operand namesFull data-flow analysis

The adoption is explained by the compounding effect rather than any single optimization. The representation makes each analysis cheaper, and cheaper analyses mean a pipeline can afford more passes.

Nearly every serious compiler built since the early 1990s uses SSA: production compilers for systems languages, managed-runtime just-in-time compilers, and JavaScript engines all converged on it independently.

Limitations:

  • Cost of construction and destruction around phi nodes
  • Memory analyses do not benefit (memory locations are not names, and aliasing reintroduces the search problem)

Individual transformations applied in sequence to an IR, each preserving the meaning of the program.

Each pass rewrites the IR according to a local rule. The pipeline runs many passes, often repeating some at different points.

Earlier passes expose opportunities for later ones: inlining reveals constants, constants enable dead code elimination.

Compiled speed usually comes from many small rewrites compounding. One rewrite exposes a constant, another removes the branch made pointless by that constant, and a later one cleans up the now-unused work. Optimization passes are those meaning-preserving rewrites, run over the intermediate representation in a deliberate order. The consequence is that generated and inlined code, which often contains opportunities a person would never write by hand, can collapse into much simpler machine work.

A pass pipeline is an ordered sequence, and the order changes the result because passes are not commutative. Inlining a function exposes constants that constant propagation can then fold, which may make a branch condition constant, which lets dead code elimination delete a whole block.

Run in the other order, none of that happens. Production pipelines contain the same pass several times at different points, tuned empirically against benchmark suites.

PassWhat it does
Constant foldingEvaluates at compile time any expression whose operands are known
Dead code eliminationRemoves computations whose results are never used
Loop-invariant code motionHoists computations that do not change across iterations out of the loop, when the expression cannot trap or the loop is known to run at least once
Strength reductionReplaces expensive operations with cheaper equivalents (multiply by power of two → shift)

Individually these look almost too simple to matter. Their value comes from running on code that earlier passes generated, since human-written source rarely contains an obviously foldable constant while inlined and specialized code contains them everywhere.

No pass order is best for all programs, and finding the best order for a given program is intractable. The pipeline is a fixed sequence chosen to work well on average.

Approaches that attack it:

  • Iterative compilation: searches a restricted space for a specific program (used when compile budget is large relative to run budget)
  • Learned heuristics: predict a good ordering from program features

Neither yields a general solution. Iterative compilation searches the space for one program and pays for it in compile time, so its result does not transfer to the next program and it is affordable only when the same binary will run far more often than it is built. A learned heuristic predicts an ordering from program features but generalizes only over code that resembles its training set, and it degrades on inputs outside that distribution. Each improves on a fixed pipeline for the cases it targets while leaving the core problem untouched, so a production compiler ships a default order such as -O2, chosen empirically to work well on average and to behave predictably. The phase-ordering problem is open in general, and a good default is the working answer rather than a solution to it.

Semantic preservation is the contract every pass must honor, defined against the language specification rather than observed behavior. A miscompilation is a transformation that violates this contract.

Miscompilations are rare in mature compilers and cluster at:

  • Undefined behavior
  • Floating point under relaxed modes
  • Concurrency (reordering safe for one thread is visible to another)

Replacing a function call with the body of the callee at the call site.

The compiler copies the callee into the caller, renaming variables to avoid collision, and deletes the call.

The call boundary hides information: the compiler must assume the callee could do anything. Removing it exposes constants and enables further optimization.

A function call is a boundary where the compiler often has to stop assuming what it knows. Arguments go in, a result comes back, and the body may hide constants or branches that would be obvious if it were nearby. Inlining replaces the call with the callee body at the call site. Saving the call mechanics helps, but the larger consequence is that later optimizations can see through the former boundary.

The direct saving from inlining is the call overhead (setting up arguments, saving registers, jumping, returning), which amounts to a few cycles on a modern processor with a well-predicted branch.

The larger effect is that inlining removes an optimization barrier:

Before inliningAfter inlining
Compiler must assume call could do anythingCallee’s body is ordinary code in caller’s context
Cannot track constants across boundaryConstant argument becomes constant inside body
Must treat return value as unknownBranch conditions may become determinate

Code size against speed is the trade, and instruction cache pressure rather than binary size is the real limit. Each inlined copy is duplicated code, and beyond a point the working set no longer fits in the instruction cache.

Aggressive inlining sometimes regresses performance in ways invisible in a microbenchmark, where the entire loop fits in cache regardless. This is why larger programs often see different inlining effects than small tests.

The inliner estimates the size of the callee after inlining (a function full of constant-foldable code may shrink) and weighs it against an estimate of call frequency.

A source-level inline hint is advisory in most languages, and the compiler is usually right. The reliable way to influence the decision is a profile, since profile-guided compilation replaces the static frequency guess with a measurement.

The same idea appears on tensors under the name kernel fusion. In scalar code the barrier is the function call. In tensor code the barrier is the boundary between two operations, each of which reads from memory and writes back.

Fusing them into one kernel keeps the intermediate value in registers or on-chip memory. The costs rhyme: a fused kernel uses more registers, and past a point the pressure forces spilling or reduces concurrent threads.

Assigning an unbounded number of program values to a fixed set of hardware registers.

Build an interference graph where edges connect values live at the same time, then color the graph so no edge has matching endpoints.

A value in a register is accessed in a cycle; a spilled value costs a load. Graph-coloring heuristics look for an assignment with few spills; finding the minimum is NP-hard.

Near the end of compilation, the program may have more live values than the processor has fast storage for. Values whose lifetimes do not overlap can share a register; values needed at the same time cannot. Register allocation is the assignment of those program values to the fixed hardware register file. The consequence is immediate at runtime: values kept in registers are cheap to use, while spilled values have to be loaded from memory when needed.

A value is live from its definition until its last use. Two values interfere if their live ranges overlap (both must exist simultaneously and cannot share a register).

Building a graph with one vertex per value and an edge for each interference turns allocation into graph coloring: assign a color to every vertex such that adjacent vertices differ, where the colors are the physical registers.

AlgorithmHow it worksUse case
Classic (Chaitin-Briggs)Repeatedly remove vertices with fewer neighbors than registers, push to stack, pop, and colorAhead-of-time compilers
Linear scanTreats live ranges as simple intervals over a linearized orderJIT compilers (faster, less optimal)

The chosen spill candidate matters more than the coloring order. Chaitin’s classic metric divides an estimate of access cost (weighted heavily by loop nesting depth) by the value’s degree in the interference graph; some allocators divide by the size of the live range instead.

Spilling means keeping a value in memory and loading it when needed.

A spill inside an inner loop is expensive in a way that is easy to underestimate. Each access becomes a load; if modified, a store as well. What looks like one extra instruction is millions of extra memory operations in a numerical kernel.

A spilled value’s stack slot will usually be in L1 cache (a few cycles, not a memory round trip), but it consumes load and store issue slots. Allocators try hard to place spills outside loops.

Register pressure limits how aggressively earlier passes may transform code:

  • Unrolling a loop by 4× quadruples simultaneously live temporaries
  • Inlining makes callee’s values compete with caller’s
  • Vectorization increases pressure on the vector register file

This is why optimizers model register pressure while making decisions rather than optimizing greedily and discovering the problem afterward.

On accelerators the constraint is sharper: the register file is divided among concurrently resident threads, so a kernel using more registers per thread reduces how many threads can be in flight.

A GPU hides memory latency by keeping many threads resident and switching among them whenever one stalls, so the count of resident threads (its occupancy) sets how much latency the hardware can cover. Registers are frequently the binding resource: the file on each streaming multiprocessor is fixed, and registers per thread multiplied by resident threads cannot exceed it, so a register-hungry kernel runs at low occupancy and gives up the latency hiding that makes the device fast. Spilling is worse here than on a CPU. A spilled value goes to memory that, on current hardware, sits in the same off-chip DRAM as the rest of the working set, cached but far costlier to reach than the L1 stack slot a CPU spill usually lands in. The allocator and the passes above it are therefore balancing register use against occupancy rather than against a few extra loads.

A compact instruction set executed by software rather than by hardware, plus the loop that dispatches it.

Source compiles to bytecode once. An interpreter loop reads each instruction, decodes it, and calls a handler.

Bytecode is portable across machines and can be inspected or instrumented at runtime, at the cost of dispatch overhead per instruction.

A runtime that wants the same compiled program to run on different machines can avoid targeting each processor directly. It can instead compile to a small instruction set for a software machine and execute that with a loop. Bytecode and interpreters are that arrangement: bytecode is the portable instruction stream, and the interpreter dispatches each operation. The consequence is easier portability and runtime inspection, paid for by dispatch overhead in tight loops.

DesignHow it worksTrade-off
Stack machineOperands from implicit stack, single-byte instructionsCompact, easy to generate; more instructions and stack traffic
Register machineOperands named from virtual register fileFewer dispatches; larger instructions, needs allocation

Register designs generally win on interpretation speed because dispatch dominates.

WebAssembly (Wasm) uses a stack machine for its instructions. A module’s linear memory is a contiguous byte array that can grow in pages of 64 KiB, the unit fixed by the WebAssembly specification. A load or store outside the current memory size causes a trap, which stops execution and reports the failure to the host environment.

Bounds checks isolate linear memory from the engine’s memory. They do not check the boundaries of objects stored inside it: a bad pointer can overwrite a neighboring object without trapping. Haas and colleagues make this distinction explicit in their 2017 WebAssembly design paper. Memory-unsafe code compiled to Wasm can retain its memory-corruption bugs inside the sandbox.

Dispatch overhead is the dominant cost in a simple interpreter.

Dispatch styleMechanismBranch prediction behavior
Switch loopRead opcode, enter switch, return to topOne branch site with varying targets; predicts poorly
ThreadedEach opcode jumps directly to next handlerOne branch site per opcode; much better prediction

A mispredicted indirect branch costs 10-20 cycles on a deeply pipelined processor, often more than the work the instruction performed.

The global interpreter lock is a mutex ensuring only one thread executes bytecode at a time. Threads do not give parallel speedup for computation in pure Python.

Alternatives for parallelism:

  • Separate processes (pay for inter-process data transfer)
  • Extension code that releases the lock while running compiled work
  • Free-threaded build (extensions with exclusive-execution assumptions must be revised)

Interpreted versus compiled numerical work

Section titled “Interpreted versus compiled numerical work”
OperationInterpreted loopCompiled vectorized kernel
Cost per element~100 nanoseconds (dispatch + unboxing + allocation)~few hundred picoseconds
Difference2-3 orders of magnitude

The idiom in array libraries is to express computation as whole-array operations rather than loops: the loop then runs inside compiled code and the interpreter executes a handful of instructions to launch it.

Per-element callbacks in the interpreted language are the standard performance trap.

Compiling code at runtime using information gathered while the program runs.

Counters identify hot methods. The JIT compiles them with speculative assumptions (observed types, call targets), guarded by cheap checks that deoptimize on violation.

Runtime profiles reveal which paths are hot and what types appear; the compiler specializes for those rather than for all possibilities.

An interpreter sees facts that a build-time compiler cannot: which functions run often, which branches are common, and which types actually appear. Leaving all of that information unused keeps hot code paying interpreter overhead forever. JIT compilation uses runtime profiles to compile the paths that matter, guarded by checks for the assumptions it made. The consequence is fast steady-state execution when behavior is stable enough to specialize.

Code starts in an interpreter or baseline compiler, and counters track how often each function and loop executes. When a counter crosses a threshold, the method is queued for an optimizing compiler.

The optimizing compiler speculates on facts observed so far (e.g., a call site always reaching one implementation) and emits a cheap guard. When a guard fails, the runtime deoptimizes: reconstructs the interpreter state and resumes in the slower tier.

What JIT learnsWhat it enables
A site has only ever seen one typeSpecialized code with a guard confirming the type
An object always has a certain shapeProperty access at fixed offset instead of hash probe

A well-warmed dynamic language can approach statically compiled performance on type-stable code paths. A site that sees many types stays slow.

A program spends its early execution interpreting, profiling, and compiling.

Measurement approachWhen honest
Steady-state numbers onlyLong-running service
Warm-up curve reportedCommand-line tool or short-lived function

Deployment shapes that penalize warm-up (per-request isolation, frequent restarts) are why ahead-of-time compilation regained ground.

Tracing compilers optimize hot paths instead of hot methods: record the actual sequence of operations through a hot loop, produce a straight-line trace with guards at each divergence point.

Works well when the loop follows the same path most times. Works badly when control flow varies (each divergence adds a guard whose failure exits the trace).

In tensor languages, the traced sequence is a chain of tensor operations with stable shapes, and the trace becomes a graph that can be fused and compiled for an accelerator. Data-dependent control flow forces a break in the trace.

Compiling a program to native code before it runs, producing a directly executable artifact.

The compiler sees the whole program at build time, optimizes across modules, and emits machine code for the target.

No warm-up, no compilation spikes, and the artifact can ship without a runtime. The cost is that runtime values are unavailable for specialization.

Some deployments cannot afford a period of interpreting, profiling, and compiling before performance settles. Others need to ship a binary that runs without generating code at runtime. AOT compilation moves code generation to build time and produces native code before the program starts. The consequence is predictable startup and no compilation spikes during service, while specialization based on actual runtime values is no longer available.

An ahead-of-time compiled binary executes optimized code from its first instruction. The latency distribution has no compilation spikes, which is a narrower claim than a flat first request: cold page faults, lazy relocation and whatever the program initializes on first use are all still there to be paid.

SituationBetter approach
Service measured at high percentileAOT
Short-lived instances (steady state never reached)AOT
Generating code at run time is prohibitedAOT

Recovering run-time information statically

Section titled “Recovering run-time information statically”

A build can gather information that improves its output without observing the program in production.

TechniqueWhat it doesLimitation
Link-time optimizationDefers code generation until whole program available; inlining works across modulesStill cannot specialize on runtime values
Profile-guided compilationRuns instrumented build, feeds back branch frequencies and call countsProfile may not match deployment workload

A tensor compiler produces much better code when it knows exact dimensions (tiling, unrolling, vector widths, memory layout all depend on them).

Each distinct shape may trigger a fresh compilation, which takes hundreds of milliseconds to tens of seconds. A serving system that receives varied input lengths spends much of its time compiling.

StrategyHow it worksTrade-off
PaddingRound every input up to a fixed sizeWasted compute on padding
BucketingDefine a small set of sizes, pad to nearestBounded compilations; some waste
Dynamic shapesLeave dimensions symbolicAvoids recompilation; gives up some optimization

Warming the cache at start-up by running one request per bucket before accepting traffic is standard practice.

A static discipline that classifies expressions by the kind of value they produce and rejects programs that violate those classifications.

The compiler assigns a type to each expression and checks that every use is consistent with the type at the definition site.

A type error caught at compile time cannot occur at runtime. The discipline rules out a class of bugs by construction.

In ordinary programming, many bugs are mismatches between what a value is and what a piece of code assumes it can do. Tests catch only the paths they execute. Type systems make the compiler classify expressions and reject uses inconsistent with those classifications before the program runs. The consequence is that a broad family of mistakes becomes impossible in accepted programs, though the checker must reject some programs that would have happened to run correctly.

PropertyMeaningPractical choice
SoundNever accepts a program that will commit the errors it claims to preventNearly all systems keep this
CompleteNever rejects a program that would have run correctlyAll practical systems give this up

Rice’s theorem rules out deciding any non-trivial semantic property of general programs, so no checker can be both sound and complete.

Some widely used systems deliberately introduce unsoundness at specific points (covariant array assignment, escape hatches that suppress checking).

CheckingCoverageCost
StaticEvery path, including those tests never reachAnnotation effort, compile time, cannot express runtime-dependent properties
DynamicExamines actual values; handles anything the program can representFinds errors only on paths that execute

Gradual typing allows typed and untyped regions to coexist so an existing codebase can be annotated incrementally.

What happens at the boundary matters. Systems that erase types (the widely deployed ones) give no run-time guarantee at all: an untyped caller can pass a value that the typed code assumed impossible.

What types enableWhy it matters
Known size and layoutStructure laid out contiguously with fields at known offsets (better cache behavior)
Concrete type at call siteDirect call or inlined body instead of dynamic dispatch
Known numeric typeArithmetic on raw value rather than heap-allocated tagged object

This is the same information a JIT discovers through profiling, obtained for free from the declaration.

A comment describing what a function accepts becomes wrong the moment someone changes the function and forgets it. A signature is checked on every build, so it is either accurate or the build fails.

This is what makes a large codebase navigable by someone who did not write it, and what makes editor tooling (completion, jump-to-definition, safe renaming) possible.

Deducing types the programmer did not write from the structure of the program.

Hindley-Milner assigns a fresh type variable to each expression, collects constraints from how they combine, and solves by unification.

The type system remains static and sound while the programmer writes almost no annotations, so the language feels dynamic.

Static checking is useful, but writing every type can make code feel heavier than the information in the program requires. Often the surrounding expressions already determine the missing classifications. Type inference is the compiler’s reconstruction of those omitted types from constraints in the code. The consequence is a language that keeps compile-time checking while letting the programmer omit annotations where the structure already pins the answer down.

The algorithm infers types by generating constraints and solving them with unification:

  • Assign each expression a fresh type variable
  • Collect equations among those variables from the program’s structure
  • Solve the system by unification, matching type expressions and binding variables

The result is the most general type that works. A function that could be used at many types is given a polymorphic type covering all of them; the programmer writes no annotations at all.

FeatureWhy it breaks inference
SubtypingReplaces equalities with inequalities; many solutions with no clear best one
OverloadingType of expression depends on which function is selected; selection depends on types (circular)

Mainstream object-oriented languages have both features, and none of them offers whole-program inference. Type inference for the full polymorphic lambda calculus is undecidable.

Local inference and bidirectional checking

Section titled “Local inference and bidirectional checking”

The practical answer that explains the annotation style of modern languages:

  • Local inference: Deduces types within a limited scope (function body), requires annotations at boundaries (parameters, return types)
  • Bidirectional checking: Splits into synthesis (type computed from expression) and checking (expected type pushed down)

A lambda passed to a function whose parameter type is known can be checked against that type, so its own parameters need no annotation. The same lambda in isolation would need them.

Error messages pointing at the wrong line are a real cost of inference rather than an implementation defect. Because unification propagates constraints, an inconsistency is detected wherever two conflicting constraints finally meet, which may be far from the mistake.

The problem is worse with more inference and better with more annotations, which is a concrete argument for annotating boundaries even where the compiler does not require it. Compilers mitigate it by tracking the provenance of each constraint so a message can name the origin as well as the point of conflict, and by special-casing common patterns. The mitigation is partial in every implementation.

The binary-level contract for passing arguments, returning results, preserving registers, and laying out data.

The calling convention assigns arguments to registers or stack slots. The ABI extends this to structure layout, exception propagation, and symbol naming.

A binary boundary requires both sides to agree. The ABI provides that agreement, so code from different compilers can link and call each other.

A call across a binary boundary cannot rely on source-level intentions. Both sides must agree on where arguments are placed, where results appear, which registers survive, and how data is laid out. Calling conventions and the ABI are that agreement. They let separately compiled units, even from different languages, link and call each other, but they also make compatibility brittle: changing the agreement breaks binaries built for the old one.

A calling convention specifies where arguments go, where the return value comes back, and who preserves what.

ComponentHow it works
Integer argumentsFirst several in designated registers, rest on stack
Floating pointSeparate register set
Caller-saved registersCaller must save before call if it wants the value
Callee-saved registersFunction must restore before returning

The exact registers and counts differ between platforms.

The application binary interface is the full contract, and it outlives any single compiler version. Beyond the calling convention it fixes:

  • Structure layout and padding
  • Size and alignment of every primitive type
  • Representation of virtual dispatch tables
  • How exceptions propagate
  • Symbol naming scheme

Breaking the ABI is disruptive enough that ecosystems carry known suboptimal decisions indefinitely rather than change them. Mixing components built against different versions produces crashes rather than a clean error.

A language with overloading needs distinct linker symbols for functions sharing a source name, so it encodes scopes and parameter types into the symbol. Two compilers with different schemes cannot link against each other’s libraries.

C has no overloading, so its symbol names are the source names. This is why every language that wants to talk to another speaks C at the boundary.

The FFI is where an interpreted process reaches compiled code. It converts values between interpreter representation and machine representation, calls the function, and converts the result back.

Cost/hazardIssue
Per-call overheadCalling a tiny function in a loop is slower than staying in interpreter
Memory ownershipTwo sides have different allocators and lifetime notions
ErrorsCompiled function signaling failure through return code needs explicit checking

Resolving symbol references across compiled units and producing an executable or loading one into memory.

The linker matches undefined symbols to definitions, patches addresses, and emits a final binary. The loader maps it and fixes up position-independent references.

Separate compilation requires a later stage to stitch modules together. Position-independent code requires fixup because ASLR varies placement.

A source file can compile cleanly while still referring to functions or data that live somewhere else. Those promises are settled only when compiled units are stitched together and placed into memory. Linking and loading name that work: resolving symbols, patching addresses, and mapping the resulting binary so it can run. The consequence is that missing libraries, version mismatches, and layout disagreements show up after the compiler has accepted the source.

TypeWhen resolvedAdvantagesDisadvantages
StaticBuild timeSelf-contained artifact, can optimize across boundarySize; fixing vulnerability requires rebuilding everything
DynamicLoad timeOne copy serves all processes, fixes picked up immediatelyProgram depends on environment; environment can be wrong

Resolution, relocation, position-independence

Section titled “Resolution, relocation, position-independence”

Producing a runnable image takes several stages, and each handles references the compiler could not resolve on its own.

StageWhat happens
ResolutionMatches each undefined reference to a definition
RelocationPatches addresses into code once final layout is known
Position-independent codeRefers to data through a table filled at load time (needed because ASLR varies placement)

The indirection costs an extra load on each access to a global.

If two dependencies require incompatible versions of the same shared library under the same name, the loader typically maps only one, and the other dependency binds to the wrong version.

Containers sidestep rather than solve version conflicts: each image ships its own complete set of libraries. That converts the conflict problem into a patching problem, since a vulnerability in a common library must be fixed in every image that contains it.

SymptomCause
Undefined symbol at build timeMissing library on link line; order issue; mangled name mismatch
Symbol lookup failure at run timeLibrary at run time differs from build time
Symbol found but behaving wronglyName matched but layout or semantics changed; produces corruption not diagnostic

Automatic reclamation of memory that is no longer reachable from program roots.

A tracing collector walks references from roots, marks live objects, and reclaims unmarked memory. Generational variants collect the young region often and the old region rarely.

Reachable objects cannot be freed without breaking the program; unreachable objects can. Automating the distinction removes use-after-free and double-free bugs.

Manual memory management asks programmers to decide exactly when an object can never be reached again. Free too early and a later use is broken; never free and the process grows. Garbage collection gives that decision to the runtime by tracing reachable objects from roots and reclaiming what is left. The consequence is fewer lifetime bugs, paid for with collection work that can affect pause time, throughput, and heap sizing.

An object is live if a chain of references leads to it from the roots (registers, thread stacks, global variables). Everything else is unreachable and can be reclaimed.

The gap between reachable and useful is where memory leaks in managed languages live: a cache never evicted, a listener never removed, a collection that accumulates entries. The program’s memory grows without a single missing deallocation.

Finding the cause requires a heap dump showing which reference chain retains the objects.

A heap dump is a snapshot of every live object and the references between them, taken from a running process. An analyzer builds the dominator tree over that graph, which gives each object a retained size (the memory that would come back if it became unreachable), and it finds the shortest path from a suspect object to a garbage-collection root. The retaining chain usually names the bug once it is laid out: a static registry that is only ever inserted into, or an observer that outlives the object it was watching. Comparing two dumps taken minutes apart isolates the class whose instance count keeps climbing, and that points at the allocation site faster than reading the code does. Tools such as the Eclipse Memory Analyzer for the JVM and the heap-snapshot panel in a browser’s developer tools present these views directly.

StrategyAdvantageWeakness
TracingHandles cycles; pays nothing on pointer operationsCollection pass timing program does not control
Reference countingPrompt reclamation; predictable timingArithmetic on every pointer (expensive if atomic); cycles never freed

Systems built on reference counting either add a cycle collector or require the programmer to break cycles with weak references.

Most objects die young. A generational collector allocates into a small young region and collects it frequently (cheap because almost everything is dead). Objects surviving several young collections are promoted to an older region collected rarely.

The bookkeeping requires a write barrier: a small piece of code on every pointer store that records references from old objects to young ones.

GoalBest choiceWhy
Batch jobThroughput (stop-the-world)Long pause costs nothing when nothing is waiting
Service measured at p99Short pauses (concurrent/incremental)Pauses appear directly in tail latency

The tuning knob that matters most is heap headroom: collection frequency falls as the heap grows relative to the live set. A heap sized too close to the live set produces continuous collection regardless of algorithm.

Program states the language specification declines to define, leaving the compiler free to assume they never happen.

The optimizer treats undefined operations as unreachable. Code that would execute only after such an operation may be deleted, including checks meant to prevent it.

Mandating one behavior would require extra instructions on hardware that handles the case differently. Leaving it undefined lets the compiler generate faster code.

Some source operations are outside the behavior the language promises to define, even though the processor would still execute some instruction sequence. Undefined behavior is that gap between machine action and language meaning. The optimizer may assume such states never happen and simplify the program under that assumption. The consequence is that defensive-looking checks can vanish if they occur after an operation the specification already treated as impossible.

ReasonMechanism
PortabilityDifferent processors handle signed overflow, misaligned access, shift amounts differently; defining one behavior forces extra instructions on hardware that disagrees
OptimizationDeclaring a case undefined lets the optimizer assume it never happens, which enables loop transformations, vectorization, and constant propagation

Consider code that dereferences a pointer and then tests it against null:

  • The dereference is undefined if the pointer is null
  • The compiler may assume the pointer was not null
  • The subsequent test is always false under that assumption
  • Both the test and the error handling are deleted

The programmer’s defensive check is gone from the binary. Nothing here is a compiler bug: each step follows from the specification. The surprise comes from reading source as machine operations rather than as a program in a language with a formal contract.

A large share of memory-safety exploits begin at undefined behavior. A buffer overflow, use-after-free, or read past allocation end is undefined, so what follows is determined by memory layout an attacker can often influence.

Microsoft reported in 2019 that about 70 percent of the vulnerabilities it assigns a CVE each year are memory-safety issues, and the Chromium project reports the same share, around 70 percent, of its high-severity security bugs.

ApproachCostCoverage
Sanitizers2-3× slowdown (ThreadSanitizer 5-15×); testing onlyDetects violations on paths that execute
Memory-safe languageRewrite plus runtime/expressiveness penaltyRemoves class by construction

Government and industry guidance since 2022 pushes toward memory-safe languages for new development while accepting that existing systems will be hardened incrementally.

Primary literature

  1. A Theory of Type Polymorphism in ProgrammingRobin Milner, 1978, Journal of Computer and System Sciences

    Gave the theory behind polymorphic type inference, showing that the principal type of an expression in a polymorphic language can be computed without annotations.

  2. Efficiently Computing Static Single Assignment Form and the Control Dependence GraphCytron et al., 1991, ACM TOPLAS

    Gave an efficient algorithm, linear in practice, for converting a program to SSA form, making def-use analysis trivial and enabling most modern compiler optimizations.

  3. LLVM: A Compilation Framework for Lifelong Program Analysis and TransformationLattner and Adve, 2004, CGO

    Introduced a typed, low-level intermediate representation and modular compiler infrastructure that decouples front ends from back ends and optimization passes.

  4. Bringing the Web up to Speed with WebAssemblyHaas et al., 2017, PLDI

    Presented a portable, validated stack-machine format with a bounds-checked linear memory for running low-level code in a sandbox.

  5. MLIR: A Compiler Infrastructure for the End of Moore's LawLattner et al., 2021, CGO

    Introduced a multi-level intermediate representation with user-defined dialects, allowing domain-specific optimizations at the abstraction level where the relevant structure is visible.

Sources

  1. WebAssembly Specification: OverviewWebAssembly Community Group

  2. WebAssembly Specification: Memory instancesWebAssembly Community Group

  3. Software Memory Safety (Cybersecurity Information Sheet)National Security Agency

  4. A proactive approach to more secure codeMicrosoft Security Response Center, July 16, 2019

  5. Memory safetyThe Chromium Projects

  6. AddressSanitizerClang documentation

  7. MemorySanitizerClang documentation

  8. ThreadSanitizerClang documentation