The compiler does not stop at an executable. Between source and silicon lies a whole execution spectrum — interpreters, just-in-time compilers, tiered pipelines that re-compile hot code, speculation guarded by deoptimisation, and the runtime services (garbage collection, exceptions, dynamic linking) the compiled code leans on. We close with the shared infrastructure shaping the field: LLVM, Cranelift, GraalVM, MLIR and WebAssembly — the “new UNCOL”.
Decks 03–08 took source text to machine code. This part asks the next question: how does that code actually run, and what does the runtime do for it? We trace the execution spectrum, then the engines and services that make modern dynamic languages fast.
There is no single way to “run” a program. The same source can be compiled ahead-of-time to native code, interpreted directly, or compiled just-in-time while it runs. Most real systems are hybrids.
Work moved earlier (AOT) gives fast startup and predictable peak but cannot see actual inputs. Work moved later (JIT/interp) can specialise to observed behaviour, at the cost of warmup time and runtime footprint.
CPython compiles to bytecode then interprets. The JVM interprets then JITs. V8 has no interpreter-free mode. Even C uses a runtime (libc, the dynamic loader). The lines are practical, not absolute.
Each strategy optimises a different point on the startup–peak–footprint–portability surface. A serverless function and a long-running server want opposite ends of it.
| Property | AOT native | Bytecode interpreter | Tiered JIT | AOT + PGO |
|---|---|---|---|---|
| Startup latency | instant | instant | warmup needed | instant |
| Peak throughput | high | low | highest | high |
| Memory footprint | small | small | large (code + profiles + GC) | small |
| Uses runtime feedback | no | no | yes (online) | offline only |
| Portability of artefact | one ISA/ABI | bytecode is portable | bytecode is portable | one ISA/ABI |
| Ships a compiler? | no | no | yes, at runtime | no |
| Sweet spot | CLIs, systems, FaaS | scripting, glue | servers, browsers | predictable hot paths |
A JIT pays a warmup tax before reaching peak. For short-lived processes (a Lambda invocation, a CLI run) that tax may never amortise — which is exactly why GraalVM native-image and CRaC-style snapshots exist.
Profile-guided optimisation runs the program on representative input, records branch/edge profiles, then re-compiles AOT using them — offline feedback without a runtime compiler.
The simplest interpreter walks the AST directly. It is easy to write but slow: every node is a virtual dispatch, pointers chase all over the heap, and there is no locality. Real engines first lower the AST to a linear bytecode and interpret that.
eval(node) over the AST// Stack bytecode interpreter — the classic
// fetch/decode/execute switch dispatch loop
for (;;) {
Op op = *ip++; // fetch
switch (op) { // decode + dispatch
case OP_CONST: push(*ip++); break;
case OP_ADD: { int b=pop(),a=pop();
push(a+b); } break;
case OP_JMP: ip += (int8_t)*ip; break;
case OP_RET: return pop();
/* ... one case per opcode ... */
}
}
Each iteration ends in one indirect branch (the switch jump). The CPU's branch predictor sees a single site whose target is effectively random — near-constant mispredicts. That is the cost we attack next.
In a bytecode interpreter the useful work of an opcode (an add, a load) is tiny; the dispatch — fetch, decode, branch to the handler — dominates. Faster interpreters are mostly about cheaper dispatch and better branch prediction.
| Technique | Mechanism | Note |
|---|---|---|
| Switch dispatch | One switch; compiler emits a jump table | Simplest; one mispredicting branch site |
| Indirect threading | Bytecode → table of handler addresses; jump via table | Used where computed-goto is unavailable |
| Direct threading | Each handler ends in goto *next_handler (GCC labels-as-values) | Distinct branch per opcode → better prediction |
| Token / subroutine threading | Bytecode is a list of calls to handlers | Forth heritage; trades CALL/RET overhead |
| Superinstructions | Fuse common opcode sequences into one handler | Amortises dispatch over several ops |
The standard speedup: a GNU C labels-as-values table maps each opcode to its handler's address, and every handler ends by jumping directly to the next op's handler — no central switch. CPython adopted this; it gave a 15–20% interpreter win.
// Direct-threaded dispatch (GCC/Clang extension)
static void *table[] = {
[OP_CONST]=&&do_const, [OP_ADD]=&&do_add,
[OP_JMP]=&&do_jmp, [OP_RET]=&&do_ret,
};
#define NEXT() goto *table[*ip++]
NEXT(); // start dispatch
do_const: push(*ip++); NEXT();
do_add: { int b=pop(),a=pop();
push(a+b); } NEXT();
do_jmp: ip += (int8_t)*ip; NEXT();
do_ret: return pop();
Each NEXT() is its own indirect branch. The hardware predictor keys on the site, so it learns correlations like “after a LOAD usually comes an ADD” — impossible with one shared switch site.
Profile the bytecode, find hot opcode pairs or triples (e.g. LOAD_FAST; LOAD_FAST; BINARY_ADD), and synthesise a single fused handler. One dispatch now does three ops' work. Forth called these “compiled words”.
Even a perfect interpreter still dispatches once per op. To go faster you must remove dispatch entirely — that means compiling to native code. Enter the JIT.
A JIT compiles bytecode (or a trace) to native machine code at runtime, then jumps into it. Because it runs during execution, it can specialise to the program's actual behaviour — the inputs, the types, the hot paths it has observed. The idea dates to McCarthy's LISP (1960) and Hansen's adaptive FORTRAN.
Into an executable code cache — pages mapped RWX (or W^X with flips). The runtime patches call sites to point at compiled entries, manages the cache, and can throw code away when it deoptimises.
Traces handle tight loops superbly but struggle with branchy, megamorphic code (trace explosion). Most mainstream VMs settled on method JITs with inlining; PyPy and LuaJIT remain the great trace-based survivors.
No single compiler wins on both startup and peak, so modern VMs run a tiered pipeline: start interpreting for instant launch, then promote hot code through progressively more optimising compilers as profiles accumulate. Counters (invocation + back-edge) trigger each promotion.
V8's modern shape is Ignition → Sparkplug → Maglev → TurboFan. HotSpot uses interpreter → C1 → C2; .NET uses the interpreter-free Quick JIT (Tier-0) → optimising RyuJIT (Tier-1), plus ReadyToRun AOT.
Tiering trades a brief slow start for a far higher ceiling. Plotting throughput over time shows the classic warmup curve: each tier steps performance up as hot code is recompiled — with a temporary dip whenever a deopt throws optimised code away.
Microbenchmarks and short jobs may finish before reaching peak, so they measure mostly warmup. Benchmark harnesses (JMH, the SunSpider/JetStream successors) explicitly discard warmup iterations.
AOT-compile a baseline (.NET ReadyToRun, OpenJDK AOT/Leyden), snapshot a warmed heap (CRaC), or skip the JIT entirely (GraalVM native-image) — trading peak ceiling for fast, predictable start.
In a dynamic language a.x or a + b could mean anything — the lookup is potentially a full hash search every time. The inline cache (Deutsch & Schiffman, Smalltalk-80; refined in Self) caches the result of the last lookup at the call site and reuses it while the type stays the same.
An IC degrades as it sees more types: monomorphic (1) → polymorphic (2–4) → megamorphic (many). The optimising JIT reads these states as type feedback: a monomorphic site can be inlined to a single guarded field load; a megamorphic one cannot.
Inline caches need a cheap, stable answer to “what type is this object?” — but dynamic objects have no declared class. The trick from Self: synthesise one. Objects with the same set of properties in the same order share a hidden class (V8 calls it a Map; JSC a Structure; SpiderMonkey a Shape).
function Point(x, y) {
this.x = x; // transition: {} -> M1 (has x)
this.y = y; // transition: M1 -> M2 (has x,y)
}
let a = new Point(1, 2); // hidden class M2
let b = new Point(3, 4); // SAME hidden class M2 ✓
// M2 records: x @ offset 0, y @ offset 1
// IC at "p.x" specialises to: load slot 0 if map==M2
b.z = 9; // b transitions M2 -> M3; a stays M2
// now the .x site is polymorphic — slower
Adding properties in different orders, or adding them after construction, forks the transition tree into different hidden classes — turning monomorphic sites polymorphic. Hence the perennial advice: initialise all fields, in the same order, in the constructor.
Hidden classes form a tree of transitions keyed by “added property X”. Constructing objects the same way walks the same path — reusing maps and keeping caches monomorphic.
Maps + inline caches are the same two ideas from Self (Chambers, Ungar, Hölzle, 1989–91) that made dynamically-typed code competitive with statically-typed code — later carried into HotSpot and V8.
The optimising JIT bets: “x is always a small integer”, “this call is always Array.map”. It compiles fast code that assumes the bet, protected by a cheap guard. If a guard ever fails, it must deoptimise — bail out of the optimised code mid-execution, without losing the running computation.
The dual of deopt. A long-running loop is already on the stack in the interpreter when it goes hot. OSR swaps that live frame for an optimised one — entering compiled code in the middle of the loop, mid-execution. (“OSR exit” = the deopt direction.)
// What the optimiser speculates on:
function sum(arr) {
let s = 0;
for (let i = 0; i < arr.length; i++)
s += arr[i]; // guard: arr is a packed
// Smi (small-int) array
return s;
}
// Hot, monomorphic -> TurboFan inlines a
// tight int loop, no bounds re-check, no boxing.
//
// Later: sum([1, 2, "x"]) -> guard FAILS
// -> DEOPT: rebuild interpreter frame,
// resume at the "+=" bytecode, mark
// the array element type as polymorphic.
Every speculation is guarded and every optimised point has a recorded mapping back to bytecode state (locals, stack, PC). Speculation is therefore always recoverable — the program's observable semantics never change.
Compiled code rarely manages memory alone. The runtime provides allocation and reclamation — either manual (malloc/free, RAII, ownership) or automatic (a garbage collector). The compiler must cooperate: it emits the metadata the collector needs.
malloc/free, new/deleteFor a precise tracing GC, the compiler must tell the collector, at each safepoint, which registers and stack slots hold live pointers (a stack map), and emit write barriers on pointer stores. Without this, the GC can't move or even find objects safely.
Two objects referencing each other never reach count zero. CPython runs a separate generational cycle detector; Swift relies on the programmer marking weak/unowned references to break cycles.
All tracing collectors find the live set the same way; they differ in how they reclaim and compact — trading throughput, pause time, space overhead and fragmentation.
| Algorithm | How it works | Pros | Cons |
|---|---|---|---|
| Mark–sweep | Mark live, sweep dead onto free lists | No copying; simple | Fragmentation; sweep cost |
| Mark–compact | Mark, then slide live objects together | No fragmentation; bump allocation | Extra compaction pass; moves objects |
| Copying / semispace | Copy live from “from”-space to “to”-space (Cheney) | Fast bump alloc; compacts for free | Halves usable heap |
| Generational | Separate young/old; collect young often | Cheap, high yield on young gen | Needs write barriers + remembered set |
“Most objects die young.” Empirically, the vast majority of allocations become garbage almost immediately. So collect the young generation frequently and cheaply (a small copying collection), and promote the rare survivors to an old generation collected rarely.
To collect the young gen alone, the GC must know about old→young pointers. A write barrier — a few instructions the compiler emits on every pointer store — records such cross-generation (or cross-region) references into a remembered set / card table.
A typical generational layout: a small young generation (an Eden plus two survivor spaces for copying), and a large old generation for tenured survivors. New objects bump-allocate into Eden; minor GCs copy survivors and age them; long-lived objects are promoted.
A naive collector is stop-the-world: it pauses every thread while it traces. For interactive and server workloads those pauses are unacceptable, so modern collectors do most of their work concurrently with the running program — the headline goal is bounded, sub-millisecond pauses.
While the collector traces, the program keeps mutating pointers. A barrier must catch any store that would hide a live object behind already-scanned (black) memory — otherwise the GC frees something still in use.
| Collector | Style | Target |
|---|---|---|
| Java G1 | Generational, region-based, mostly-concurrent | Balanced, bounded pauses |
| Java ZGC / Shenandoah | Concurrent, load/colour-pointer barriers | Sub-ms pauses, huge heaps |
| Go GC | Concurrent mark-sweep, non-generational | Low latency, simple |
| V8 Orinoco | Generational, parallel + concurrent | Short main-thread pauses |
| .NET | Generational (0/1/2), bg concurrent | Server & workstation modes |
Throughput, latency (pause time) and footprint form a trilemma — you tune two at the third's expense. ZGC buys tiny pauses with barrier overhead and memory; a simple STW copying GC maximises throughput but pauses.
Exceptions need the runtime to abandon the current computation and transfer control to a handler further up the stack — unwinding intervening frames and running their cleanup. How that is implemented determines whether the non-throwing path costs anything.
.eh_frame / DWARF CFI, LSDA)throw: a personality routine walks the tables to find a matching handler & runs destructorstry saves machine state with setjmpthrow = longjmp back to the saved pointWalking frames from the throw point up to the handler, running each frame's cleanup (C++ destructors, Rust Drop, finally blocks). The unwinder reads the same CFI tables a debugger uses to reconstruct frames.
The very same precise-frame metadata serves the collector. At a safepoint, a stack map tells the GC which slots are live pointers — so it can find and move objects. Threads are brought to safepoints to be paused consistently.
Core Wasm long lacked exceptions; the exception-handling proposal (try/catch/throw with tags) now standardises them for compiled C++/Swift targets.
Deck 08 emitted an object file with relocations. At load time the dynamic linker (ld.so) finishes the job: it maps shared libraries, resolves cross-module symbols, and patches addresses — ideally lazily, only on first use.
; A call to an external function via the PLT
call printf@PLT
; printf@PLT (the stub):
printf_plt:
jmp *printf@GOT(%rip) ; 1st call: GOT
; still points back
; into the resolver
push $reloc_index ; which symbol
jmp _dl_runtime_resolve ; ld.so resolves,
; rewrites the GOT,
; then tail-calls printf
; 2nd call onward: jmp *GOT goes straight to printf
The PLT (Procedure Linkage Table) holds call stubs; the GOT (Global Offset Table) holds resolved addresses. First call routes through ld.so which fills the GOT slot; every later call is a single indirect jump.
.so/.dylib sharing across processesEntries telling the loader “patch the address here once you know where the symbol landed”. Resolved at link time (static), load time (e.g. R_X86_64_RELATIVE), or first use (PLT/GOT, lazy).
Static linking bakes everything into one binary — fast start, larger, no shared updates. Dynamic linking shares libraries across processes and allows security patching, at the cost of load-time resolution. RELRO + eager binding hardens the GOT.
Writing a production backend (instruction selection, scheduling, register allocation, every ISA) is enormous. The field's answer: reusable backend infrastructure. A new language emits a shared IR and inherits decades of optimisation and dozens of targets — the N×M argument from deck 06, realised.
libgccjitBefore LLVM, every language paid the backend tax alone. Sharing the optimiser and code generator let small teams ship serious languages (Rust, Swift, Julia, Zig) and let researchers prototype passes against a real, multi-target backend.
LLVM optimises hard but compiles slowly. A wave of newer infrastructure targets different points: fast codegen, language-agnostic JITs, and extensible, multi-level IRs.
rustc_codegen_cranelift)linalg → affine → llvm)Specialising an interpreter to a fixed program yields a compiled program (1st projection). Specialising the specialiser to the interpreter yields a compiler (2nd). GraalVM/Truffle makes the 1st projection a practical, language-agnostic JIT — one optimiser, many guest languages.
All four — LLVM, Cranelift, Graal, MLIR — are bets that compiler infrastructure should be shared. The frontier is no longer one compiler per language but reusable, composable layers that many languages and accelerators plug into.
WebAssembly (Wasm, 2017) is a compact, portable bytecode for a stack machine — designed as a compile target, not a language to write by hand. It is fast to validate, fast to compile, sandboxed by construction, and deterministic. C/C++/Rust/Go/Swift all emit it.
;; A Wasm function (text format) — stack machine
(func $sum (param $n i32) (result i32)
(local $acc i32) (local $i i32)
(loop $L
(local.set $acc
(i32.add (local.get $acc) (local.get $i)))
(local.set $i
(i32.add (local.get $i) (i32.const 1)))
(br_if $L ;; structured
(i32.lt_s (local.get $i) ;; control flow:
(local.get $n)))) ;; no raw jumps
(local.get $acc))
No arbitrary goto — only nested block / loop / if with labelled breaks (br, br_if). This guarantees a reducible CFG, makes single-pass validation trivial, and lets baseline engines compile in one fast linear sweep.
A Wasm module is itself executed by an engine that re-uses everything in this deck: an interpreter, a baseline JIT, an optimising JIT — or an AOT compile. Many source languages converge on Wasm, and many engines run it: Wasm is increasingly the portable waypoint McCarthy & Strachey called UNCOL in 1958.
The runtime story keeps moving. A few directions visible from here — the common thread is co-design: compilers, runtimes, and hardware increasingly evolve together rather than over a fixed interface.
From A-0 and FORTRAN to TurboFan and MLIR, the job has not changed: bridge the gap between how humans want to express computation and how machines actually execute it — and keep moving work to whichever moment (compile, link, load, run) makes the program fastest, smallest or safest.
.watBuilding a Mini-Compiler — tying the whole series together: lexer, parser, types, IR, optimisation and codegen in one small, working compiler.