Chapter 24 · The Complete Pebble Compiler — and Beyond¶
Part 6 · Capstone · about 3 weeks · Previous: Ch 23 · Next: Where to go from here
The problem¶
Twenty-three chapters produced the parts: a front end that turns Pebble source into PIR (Ch 1–Ch 11), a code generator from PIR to LLVM IR, and a shelf of passes, pebble-mem2reg, pebble-sccp, pebble-gvn, pebble-licm, pebble-inline and the rest (Ch 12–Ch 20), with LLVM's own back end behind them (Ch 21–Ch 23). This chapter asks the questions that only arise when the parts meet. Given the set of passes \(\Sigma\) you wrote, each with the facts it needs and the facts it creates, in which order, and how many times, should pebblec -O1 run them, and how do you know the result is right and worth it? That is the first half: pipeline design, then the compile-time versus run-time decisions a language implementer makes (which checks to prove away statically, which code to specialize at run time), the JIT designs that move compilation to run time altogether, and the debug information that lets a debugger map machine state back to Pebble variables. The second half surveys what a Pebble successor would need next: memory management (reference counting, tracing collection with stack maps, ownership, regions), exception handling beyond Lesson 11.7, and the two directions modern compiler work points to: multi-level IRs (MLIR) and proofs (CompCert, Alive2). The inputs are your compiler and the course's 74 end-to-end programs; the outputs are a -O1 that is your optimizer end to end, a differential fuzzer that compares it with pir-run, -O0 and LLVM's -O2 on thousands of generated programs, a benchmark report, and (★) a JIT with a REPL, debug info that gdb understands, and a second front end in Rust held to the same conformance suite.
What you will be able to do¶
- Design a multi-stage optimization pipeline from the enabling relations of your passes: canonicalize first, iterate the simplification block to a fixpoint, run loop passes on canonical loops, clean up last; and prove that the fixpoint loop terminates (drill
pass-order). - Measure the pipeline: instructions, object size, compile time and run time at
-O0, your-O1and LLVM's-O2on eight benchmarks, and explain each ratio with a pass. - Fuzz your compiler differentially with a generator that emits only accepted, terminating Pebble programs, and turn a disagreement into a reduced witness.
- Decide, for a check or a specialization, whether it belongs at compile time (proof), at run time (dispatch, guard) or both (deoptimization), and read the stack map a deoptimization point leaves behind.
- Build an ORC
LLJITsession that runs Pebble against the runtime, switch it to lazy compilation withLLLazyJIT, and explain what a re-optimizing tier adds. - Emit debug information with
DIBuilderand debug records, read the DWARF thatllcproduces (llvm-dwarfdump), evaluate a location expression by hand, and derive a location list (drilldwarf-location). - Compare the four memory-management designs on the same allocation trace: count retains and releases (drill
refcount-trace), compute the roots a stack map must hold at a safepoint (drillgc-roots), explain what the borrow checker rejects, and what a region frees. - Place MLIR's dialect conversion, CompCert's simulation proofs and Alive2's translation validation on one map of "how do we know the compiler is right", and use
alive-tvon your own pipeline's output.
Prerequisites: everything. Specifically Ch 12 (pass manager, pipelines, phase ordering, fuzzing, translation validation), Ch 13–Ch 20 (the passes you will order), Ch 11 (the driver, the runtime ABI, Lesson 11.7's exception handling), Ch 14 (liveness, for stack maps), Ch 9 (metadata and debug records), Ch 0 (JIT architectures, tiering). The fast track (0 → 8 → … → 24) builds the front end from the solutions: -DPEBBLE_USE_SOLUTION=lexer,parser,names,types,lower,codegen.
Notation¶
Shared notation follows the house notation: §1 (sets, functions), §3 (graphs), §7 (dataflow, liveness), §8 (complexity). In this chapter:
| Symbol | Meaning |
|---|---|
| \(\Sigma\), \(s \in \Sigma\), \(s(P)\) | the set of passes; a pass as a function on programs (Ch 12, Definition 12.2.6) |
| \(\mathcal{F}\), \(F(P) \subseteq \mathcal{F}\) | the finite set of facts (opportunities) and the facts a program holds (Definition 24.1.1) |
| \(\mathrm{needs}(s)\), \(\mathrm{rem}(s)\), \(\mathrm{adds}(s)\) | the enabling relation of pass \(s\): facts that must be absent, facts it removes, facts it creates (Definition 24.1.2) |
| \(w = s_1 \cdots s_\ell\), \(w^{\omega}(P)\) | a pipeline word and its fixpoint (Ch 12, Definition 12.2.8) |
| \(\mathcal{C}_{\mathrm{ssa}}, \mathcal{C}_{\mathrm{loop}}\) | canonical forms: promotable allocas gone; loop-simplify + LCSSA (Definition 24.1.3) |
| \(t_c\), \(t_r\), \(n\) | compile-time cost, per-execution run-time cost, number of executions (Definition 24.2.1) |
| \(A\), \(g_A\), \(\mathrm{deopt}\) | a speculative assumption, its guard, the transfer to unoptimized code (Definition 24.2.6) |
| \(\mathrm{MU}\), \(\mathrm{JD}\), \(\mathrm{stub}(f)\) | ORC materialization unit, JITDylib, the lazy reexport stub of \(f\) (Definition 24.3.1) |
| \(\mathrm{DIE}\), \(\mathrm{loc}(v, a)\), \(\mathrm{LL}(v)\) | a DWARF debugging information entry; the location of variable \(v\) at address \(a\); its location list (Definitions 24.4.1–24.4.4) |
| \(\mathrm{rc}(o)\), \(\mathrm{live}(p)\), \(\mathrm{roots}(p)\) | reference count of object \(o\); values live at point \(p\); the pointers among them (Definitions 24.5.1, 24.5.5) |
| \(\rho\), \(\mathrm{owner}(v)\), \(\mathrm{borrows}(v)\) | a region; the owner of a value; its live borrows (Definitions 24.5.7, 24.5.9) |
| \(\mathrm{funclet}(pad)\), \(\mathrm{color}(B)\) | the outlined handler of an EH pad; the pad a block belongs to (Definition 24.6.1) |
| \(L_1 \sqsupseteq L_2\), \(\sim\) | refinement of semantics; a simulation relation (Definitions 24.7.3, 24.7.4) |
Numbered statements are N.k.m (chapter, lesson, counter), as in NOTATION.md §9.
Technique map¶
| Family | Techniques (origin) | Lesson |
|---|---|---|
| Pipeline design | staged pipelines ordered by enabling relations (Whitfield & Soffa 1997; LLVM PassBuilderPipelines); canonicalization gates: SSA, loop-simplify, LCSSA as preconditions (Cytron et al. 1991; LLVM LoopSimplify, Lesson 15.7); fixpoint iteration with change detection (Kildall 1973's fixpoint view; LLVM devirt<4>, GCC may_iterate) |
24.1 |
| Compile-time vs run-time trade-offs | static check elimination (bounds-check elimination: Gupta 1990, Bodík, Gupta & Sarkar 2000; Ch 18's pebble-bce); multiversioning and run-time dispatch (GCC/Clang target_clones, ifuncs; LLVM LoopVersioning); speculation with guards and deoptimization (Hölzle, Chambers & Ungar 1992; LLVM llvm.experimental.deoptimize) |
24.2 |
| JIT designs | ORC LLJIT (Lang Hames, 2018–; LLVM LLJIT); lazy compilation by reexport stubs (Deutsch & Schiffman 1984; LLVM LLLazyJIT, LazyReexports); tiered re-optimization (Hölzle & Ungar 1994; HotSpot C1/C2; LLVM ReOptimizeLayer) |
24.3 |
| Debug information | DWARF (DWARF 5, 2017; DIEs, line tables, location expressions and lists); DIBuilder metadata (DICompileUnit, DISubprogram, DILocalVariable, DILocation); debug records #dbg_declare/#dbg_value (LLVM 19, 2024) |
24.4 |
| Memory-management survey | reference counting, Swift ARC (Collins 1960; Swift's ARCOptimization); tracing GC with stack maps and statepoints (McCarthy 1960; Diwan, Moss & Hudson 1992; LLVM gc.statepoint); ownership and borrowing, Rust (Clarke, Potter & Noble 1998; rustc borrowck); regions (Tofte & Talpin 1994; glibc obstack) |
24.5 |
| Exception-handling survey (after Lesson 11.7) | funclet-based EH (Windows: MSVC personalities; LLVM catchswitch/catchpad/cleanuppad, WinEHPrepare); WebAssembly EH (Wasm exception-handling proposal, 2023; LLVM WasmEHPrepare); exception-aware optimization: nounwind inference, invoke to call, EH in the inliner (LLVM FunctionAttrs, SimplifyCFG, InlineFunction) |
24.6 |
| What's next | MLIR: dialects and progressive lowering by dialect conversion (Lattner et al. 2021); verified compilation: CompCert's simulation proofs (Leroy 2009); translation validation at scale: Alive2 (Lopes et al. 2021), on your pipeline | 24.7 |
flowchart LR
PM[Pass manager<br/>Ch 12] --> ST[Staged pipeline<br/>enabling relations]
ST --> CG[Canonicalization gates<br/>SSA, loop-simplify]
ST --> FX[Fixpoint iteration<br/>change detection]
ST -->|"what to prove statically"| SE[Static check elimination<br/>Ch 18 BCE]
SE -->|"cannot prove: choose at run time"| MV[Multiversioning<br/>target_clones, ifunc]
MV -->|"assume and guard"| SP[Speculation + deopt]
SP -->|"compile at run time"| JIT[ORC LLJIT]
JIT --> LZ[Lazy stubs<br/>LLLazyJIT]
LZ --> TR[Tiered re-optimization]
SP -.->|"stack maps"| GC[Tracing GC<br/>statepoints]
DW[DWARF] --> DI[DIBuilder metadata] --> DR[Debug records]
RC[Reference counting<br/>ARC] --- GC --- OW[Ownership<br/>Rust] --- RG[Regions]
EH11[Lesson 11.7 tables] --> FN[Funclets] --> WA[Wasm EH]
EH11 --> EO[EH-aware optimization]
TV[Ch 12 translation validation] --> A2[Alive2 at scale]
A2 --- CC[CompCert proofs]
ST --> ML[MLIR dialect conversion]
Who uses what¶
| System | Technique | Notes |
|---|---|---|
| LLVM 23 / Clang | staged default<O1..O3> pipelines with devirt<4> iteration (24.1); target_clones ifunc dispatch, llvm.experimental.deoptimize (24.2); ORC LLJIT/LLLazyJIT/ReOptimizeLayer (24.3); DIBuilder, debug records, DWARF 5 emission (24.4); gc.statepoint stack maps (24.5); funclets, Wasm EH, nounwind inference (24.6) |
the substrate of everything Pebble does |
| GCC 15 | passes.def staged pipeline, ipa-pure-const nothrow inference, target_clones, DWARF 5 |
24.1, 24.6 |
| Swift 6 | ARC with owned/guaranteed conventions and the SIL ARC optimizer | 24.5 |
| rustc 1.94 | ownership and borrowing (rustc_borrowck, NLL), drop elaboration, DWARF through DIBuilder |
24.4, 24.5 |
| HotSpot, V8 | tiered compilation with deoptimization and OSR (Lesson 0.3); precise tracing GCs with stack maps | 24.2, 24.3, 24.5 |
| Wasmtime / Cranelift | one-pass "e-graph" pipeline (Lesson 17.8), stack maps for host GCs, Wasm EH | 24.5, 24.6 |
| CompCert 3.15 | a verified staged pipeline (driver/Compiler.v) proved by forward simulations |
24.7 |
| MLIR (Flang, IREE, CIRCT) | dialects, progressive lowering by dialect conversion | 24.7 |
| Pebble | pebble-o1 runs your staged design; ★ pebble-jit, ★ pebble-debugify, ★ pebble-frontend-rust |
exercises, labs |
Comparison¶
The fixed columns follow docs/authoring/DEPTH_CONTRACT.md §3 item 8. Each lesson repeats its rows.
Pipeline design (lesson 24.1)
| Technique | Power / precision | Speed (asymptotic · practical) | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| Staged pipeline by enabling relations | every enabled opportunity is taken once (Theorem 24.1.10); order effects visible (57 vs 49 instructions on the witness) | one run of each pass · pebble-o1 6 stages, 10–20 ms on the benchmarks |
predictable, explainable by the stage list | low: a table | every production optimizer (LLVM, GCC, CompCert) |
| Canonicalization gates | passes see one shape; a pass written for \(\mathcal{C}_{\mathrm{ssa}}\) is correct on every program (Proposition 24.1.8) | mem2reg, loop-simplify, lcssa are near-linear · negligible | fewer special cases in every later pass | low, but the discipline must be kept: re-canonicalize after passes that break the form | LLVM (mem2reg, loop-simplify, lcssa, instcombine's canonical forms) |
| Fixpoint iteration | reaches every opportunity the passes can expose to each other (Theorem 24.1.12); bounded by the round limit | \(O(k \cdot \lvert w \rvert)\) pass runs for \(k\) rounds · rounds 1–3 in practice (the trace box) | diminishing returns; a change detector is needed | moderate: hashing or change flags | LLVM devirt<4>, InstCombine's worklist, GCC may_iterate, pebble-o1's MaxRounds |
Compile-time vs run-time trade-offs (lesson 24.2)
| Technique | Power / precision | Speed | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| Static check elimination | removes a check iff a proof exists (sound; Theorem 24.2.8): 14 → 12 → 3 traps on bench-checks at -O0/-O1/-O2 |
compile time only · SCEV queries per check | zero run-time cost for proved checks; the rest stay | moderate (SCEV, dominating conditions) | Java, Swift, Rust, Pebble bounds checks |
| Multiversioning, run-time dispatch | \(k\) variants, one selected per call or per loop entry | code size \(\times k\); one indirect call or test · cheap | best variant per machine or per input; no correctness risk | low (attributes) to moderate (loop versioning) | target_clones, glibc ifuncs, LoopVersioning |
| Speculation with deoptimization | any assumption, with a guard; exact recovery (Theorem 24.2.10) | a test per guard; deopt is slow but rare | fastest when assumptions hold; cliffs when they fail | high: side tables, frame reconstruction | HotSpot, V8, llvm.experimental.deoptimize |
JIT designs (lesson 24.3)
| Technique | Power / precision | Speed | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| ORC LLJIT (eager) | whole module compiled on first lookup; symbols resolved by JITDylib search order | compile everything up front (4 of 4 functions on the running example) | simplest; startup pays for unused code | low (LLJITBuilder) |
lli, pebble-jit, Julia's base JIT |
| Lazy compilation (stubs) | one materialization unit per function; a function compiles at its first call (2 of 4) | startup proportional to what runs | one indirection per not-yet-compiled call, then patched | low with LLLazyJIT; moderate by hand |
lli -jit-kind=orc-lazy, Kaleidoscope |
| Tiered re-optimization | recompile hot functions at higher -O with profile facts (Algorithm 24.3.6) |
pays compile time only where it matters (nbody: 100 ms at -O0 vs 62 ms at -O2 in the JIT) | best steady-state; needs counters and redirection | high (ReOptimizeLayer, RedirectableSymbolManager) |
HotSpot, V8, .NET, ORC's ReOptimizeLayer |
Debug information (lesson 24.4)
| Technique | Power / precision | Speed | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| DWARF | describes any variable location (expression language, Theorem 24.4.6) | tables read lazily by the debugger · object size grows 2–5× | gdb prints Pebble variables (the box) |
high to write by hand; LLVM emits it | every Unix toolchain, LLDB, GDB |
DIBuilder metadata |
scopes, types, variables, locations as IR metadata | linear in the IR | the source of every DWARF DIE | low: builder calls | Clang, rustc, Swift, pebble-debugify |
| Debug records | variable values per program point, surviving mem2reg; passes unaffected (Lesson 9.6) |
no instruction-count effect | location lists at -O2 (the box); "optimized out" where values die | low to emit; passes must preserve them | LLVM 19+ IR; -g everywhere |
Memory management (lesson 24.5)
| Technique | Power / precision | Speed | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| Reference counting (ARC) | frees at the last release (Theorem 24.5.11); cycles leak | a retain/release per reference copy; ARC removes pairs | deterministic destruction; no pauses | moderate (compiler) + low (runtime) | Swift, Objective-C, Python's first line of defense |
| Tracing GC with stack maps | frees everything unreachable; needs exact roots (Theorem 24.5.12) | zero cost on copies; pauses or barriers | moving collectors need relocation of every live pointer (the stack-map box) | high: runtime + compiler support | Java, Go, V8, OCaml, Wasm hosts |
| Ownership (Rust) | no run-time work at all; rejects some correct programs | compile-time only | errors at compile time (the rustc box) |
high in the type system; drop elaboration in the compiler | Rust, Cyclone's unique pointers |
| Regions | frees a whole region in \(O(1)\) (Proposition 24.5.13) | bump allocation; no per-object free | long-lived regions retain garbage | low (a library) to high (inference) | glibc obstacks, Apache pools, arenas in compilers, Cyclone, MLKit |
Exception-handling survey (lesson 24.6)
| Technique | Power / precision | Speed | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| Funclet EH | handlers as outlined functions with their own frames; exact nesting (Theorem 24.6.10) | zero cost on the happy path; a funclet call per handler | Windows SEH interop (__CxxFrameHandler3) |
high: WinEHPrepare coloring and outlining |
MSVC ABI, Clang on Windows |
| WebAssembly EH | structured try/catch instructions; no unwinder tables |
zero cost; the engine unwinds | portable across engines | moderate: WasmEHPrepare |
Emscripten, wasm32 targets |
| EH-aware optimization | nounwind inference removes unwind edges (Proposition 24.6.6) |
cheap analyses | smaller code, more inlining | low | LLVM FunctionAttrs, GCC ipa-pure-const |
What's next (lesson 24.7)
| Technique | Power / precision | Speed | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| MLIR dialect conversion | legality-driven rewriting to a target dialect (Theorem 24.7.7) | pattern-driven; worklist | mixed-dialect IR at every stage | a framework: high once, low per lowering | Flang, IREE, CIRCT, Triton |
| CompCert (verified compilation) | a machine-checked proof for every compilation (Theorem 24.7.8) | the compiler runs normally; proofs are static | no miscompilation, by construction | very high (proof engineering) | safety-critical C |
| Alive2 (translation validation) | per-run refinement check, bounded loops | SMT time, seconds to minutes per function | counterexamples with inputs (the box) | none for the user; the tool exists | LLVM's own test suite, your pebble-o1 output |
Measured (reproduce: uv run python labs/ch24-capstone/provided/bench.py --pebblec build/<preset>/bin/pebblec --markdown labs/ch24-capstone/inputs/bench/*.pbl; full table in benchmark.md). On this container (x86-64, LLVM 23.1.2, minimum of 5 runs): the geometric-mean speedup of your -O1 over -O0 on the eight benchmarks is 3.6×, LLVM's -O2 over -O0 is 4.1×, so -O2 runs in 89 % of -O1's time on the geometric mean (from 1.42× vs 1.61× on bench-fib to 8.2× vs 9.5× on bench-matmul); -O1 leaves 1.3× the LLVM instructions of -O2 on the mean (0.7× on bench-matmul, where -O2 unrolls, to 1.9× on bench-fib); compile time is 1.2–2.2× that of -O0 for -O1 and 1.3–2.9× for -O2.
Route through this chapter¶
| Step | What | Techniques | How it is exercised |
|---|---|---|---|
| 1 | Lesson 24.1 | staged pipelines, canonicalization gates, fixpoint iteration | drill pass-order; E1: designCoursePipeline; ch24.Pipeline.*, ch24.e2e-ch11 |
| 2 | Lesson 24.2 | static elimination, multiversioning, speculation | quiz; the fuzz and benchmark lab E2 |
| 3 | Lesson 24.3 | LLJIT, lazy stubs, tiering | ★ J1 pebble-jit (labs/ch24-jit) |
| 4 | Lesson 24.4 | DWARF, DIBuilder, debug records | drill dwarf-location; ★ E3 pebble-debugify |
| 5 | Lesson 24.5 | ARC, tracing GC, ownership, regions | drills refcount-trace, gc-roots; quiz |
| 6 | Lesson 24.6 | funclets, Wasm EH, EH-aware optimization | quiz ("find it in LLVM") |
| 7 | Lesson 24.7 | MLIR, CompCert, Alive2 | quiz; alive-tv on your IR |
| 8 | Lab labs/ch24-capstone/ (exercises) |
the generator L1; fuzzing and benchmarking your -O1 vs -O0 vs -O2 |
./course test 24; fuzz.py, bench.py |
| 9 | ★ labs/ch24-rust-frontend/ |
a second front end held to the conformance suite | ch24.rust-e2e-* (needs cargo) |
| 10 | Final exam | all chapters | ./course quiz 24 (cumulative; ≥ 80 % to finish the course) |
Practice and check¶
./course drill pass-order --difficulty easy # warm up; --solution shows every step
./course drill gc-roots # liveness at safepoints
./course drill dwarf-location # the DWARF stack machine, location lists
./course drill refcount-trace # naive ARC by hand
./course flash 24 # daily, a few minutes
./course quiz 24 # the cumulative final exam
./course test 24 # E1, L1 and the end-to-end suites; ★ parts labelled star
./course status # done = quiz ≥ 80 % and tests pass
Where to go from here¶
You have written a compiler: a lexer, two parsers' worth of theory, a type checker, a lowering to a mid-level IR, a code generator, and an optimizer whose passes you can now order, iterate, fuzz, benchmark and validate. Three directions continue from here.
- Go deeper into LLVM. The pieces you used as black boxes are open: read
PassBuilderPipelines.cppwith Lesson 24.1's vocabulary and try to improvedefault<O2>on your benchmarks; write aMachineFunctionPassfor the back end of Chapters 21–23; contribute a fix to a pass you fuzzed. The LLVM Developer Policy and thellvm/docs/pages cited in this chapter's references are the path in. - Go up: MLIR. Lesson 24.7 shows that Pebble's front end and PIR already have the shape of a dialect. Rebuilding PIR as an MLIR dialect, with
pebble-loweras a dialect conversion, is a two-week project that teaches the modern way to build domain compilers (the MLIR tutorial "Toy" is the starting point). - Go formal. Run
alive-tvover every-O1output of the fuzzer (Lesson 24.7); then read the first three chapters of Leroy's Formal verification of a realistic compiler and CompCert'sCompiler.v. The gap between "tested on 10 000 programs" and "proved for all programs" is where compiler research stands today.
Two small things remain in the course tree for you: the ★ labs you skipped, and tests/ch11/e2e/, which welcomes the two programs of your own that Chapter 11's E4 asked for. Thank you for building Pebble.
References¶
The chapter's annotated bibliography (papers, textbook sections, pinned LLVM, GCC, Swift, rustc and CompCert source files, documentation) is in references.md. Start with: [WS97] (enabling relations), [LLVM-PBP] (the pipeline you are improving on), [LLVM-ORC] and [LLVM-Statepoints] (the JIT and GC substrate), [DWARF5] (the format debuggers read), [HCU92] (deoptimization), [Ler09] (CompCert) and [LLH+21] (Alive2).