Programming Language Development: Intermediate Representations, Optimization, and Code Generation¶
Part 2 of 5 of the Programming Language Development reference (plugin
programming-language-development), covering §5–§7. Sibling skills:language-design-parsing-and-types(§0–§4),language-runtimes-interpreters-and-jits(§8–§9),language-diagnostics-tooling-and-evolution(§10–§14),language-development-reference(§15–§20). Section numbers are shared across the set; a reference written as §N →skillpoints into that sibling skill.Currency: Verified August 2026. See §17 →
language-development-referencefor the currency snapshot and what goes stale first.How to read this. Reference, not tutorial. Sections are independent. Three markers: - [DURABLE] — theory, algorithms, or a lesson every language implementation has independently learned. Does not expire. - [VERSIONED] — depends on a specific toolchain, standard, or project's current state. Verify against its docs. - [CONTESTED] — competent language designers disagree, publicly and permanently. Both cases given.
⚠️ GOTCHA boxes mark the design mistakes that are cheap to make on day one and impossible to undo on day one thousand.
The framing that organizes everything below: a language is a set of promises you can never take back. Syntax you can deprecate. Semantics you cannot. Every hard decision in this document is really the question "what am I willing to be permanently responsible for?" — and the languages people love and the languages people resent are distinguished mostly by how carefully their designers answered it early.
§5. Intermediate Representations¶
5.1 Why more than one¶
[DURABLE] Every serious compiler has at least three IR levels, because the transformations you want at each level need different information:
AST source-shaped. Names, sugar, full spans. For type checking & diagnostics.
↓ desugar, resolve
HIGH IR typed, desugared, still source-ish. For borrow checking, exhaustiveness,
source-level optimizations. (rustc: HIR → THIR)
↓ lower
MID IR explicit control flow (CFG), explicit memory ops, SSA. THE optimization IR.
(rustc: MIR · Swift: SIL · Go: SSA · LLVM IR)
↓ lower
LOW IR machine-shaped, target types, still virtual registers. (LLVM MachineIR)
↓
MACHINE CODE
5.2 SSA — Static Single Assignment¶
[DURABLE] SSA is the dominant mid-level IR form and has been since the early 1990s. Every variable is assigned exactly once; control-flow merges use φ (phi) functions.
entry:
x = 1 x₁ = 1
if c: x = 2 br c, then, join
y = x + 1 then:
x₂ = 2
br join
join:
x₃ = φ(x₁ from entry, x₂ from then)
y₁ = x₃ + 1
Construction: the classic algorithm is Cytron et al. (1991) using dominance frontiers to place φ-nodes. The modern practical alternative is Braun et al. (2013), "Simple and Efficient Construction of Static Single Assignment Form" — builds SSA directly during AST-to-IR translation without a separate dominance computation. If you're writing a compiler today, use Braun; it is far simpler and produces comparable results.
Destruction: φ-nodes aren't real instructions. Converting out of SSA means inserting copies on incoming edges, and doing it naively causes the lost-copy and swap problems. Sreedhar et al.'s method is the standard correct approach. This is where subtle miscompiles live.
5.3 The other IR forms¶
- CPS (continuation-passing style) — every call is a tail call; control flow is explicit as data. Elegant for functional languages, first-class control (call/cc), and compiler correctness proofs. Used by SML/NJ; the classic reference is Appel's Compiling with Continuations.
- ANF (A-normal form) — all intermediate results named, arguments are atomic. Roughly "CPS's benefits without the plumbing," and easier to read. Common in functional compilers.
- Sea of nodes — a graph where control and data dependencies are unified, allowing aggressive reordering. HotSpot C2 and V8's TurboFan use it. Powerful, and notoriously hard to debug — V8 has been moving away from it in parts of the pipeline, which is worth knowing before you adopt it.
- Stack-based bytecode — JVM, CPython, WebAssembly. Compact, trivial to generate, slower to interpret than register-based.
- Register-based bytecode — Lua, Dalvik, LuaJIT. Fewer instructions dispatched; measurably faster interpretation.
[DURABLE] CPS/ANF/SSA are the same thing viewed differently. Appel's "SSA is Functional Programming" (1998) is the paper that makes this click: an SSA φ-node is a function parameter of a basic block, and a basic block is a continuation.
5.4 MLIR — multi-level IR as infrastructure¶
[VERSIONED] MLIR generalizes "have several IRs" into a framework: instead of one fixed IR, you define dialects — extensible sets of operations, types, and attributes — and write progressive lowering passes between them. A single module can hold operations from several dialects at once.
Why it matters: it's the substrate for the ML-compiler ecosystem (TensorFlow, IREE, Triton), for Mojo (Chris Lattner's language, explicitly built on MLIR), for Flang's OpenMP lowering, and increasingly for hardware-adjacent domains. It ships inside the LLVM monorepo, so it moves with LLVM's release train.
When MLIR is right: you have multiple abstraction levels, domain-specific optimizations, heterogeneous targets (CPU/GPU/accelerator), or you want to reuse a large pass and infrastructure ecosystem. When it isn't: a straightforward language targeting CPUs — MLIR's conceptual overhead and build weight are substantial, and plain LLVM IR is a much shorter path.
5.5 Practical IR design rules¶
- Make it verifiable and write the verifier first. Run it after every pass in debug builds. This catches more miscompiles than any other single practice.
- Make it printable and parseable. A textual round-trippable form makes every bug
report, every test, and every debugging session tractable. LLVM's
.llformat is the reason LLVM is debuggable at all. - Explicit is better than implicit. Make types, effects, and control flow explicit in the IR even when it's verbose.
- Preserve source locations through every transformation or debug info and diagnostics
degrade silently (§10.4 →
language-diagnostics-tooling-and-evolution). - Design for testability: passes should be individually runnable on IR files.
§6. Optimization¶
6.1 The passes, by category¶
| Category | Passes |
|---|---|
| Local / peephole | Constant folding, algebraic simplification, strength reduction, instruction combining |
| Data-flow | Constant propagation (SCCP), dead code elimination, common subexpression elimination, GVN, copy propagation |
| Control-flow | Branch folding, jump threading, block merging, tail duplication, loop rotation |
| Loop | LICM (loop-invariant code motion), unrolling, fusion/fission, interchange, strength reduction of induction variables, vectorization |
| Interprocedural | Inlining (the most important one), specialization, IPO/LTO, devirtualization, escape analysis |
| Memory | SROA/mem2reg (scalar replacement of aggregates — promoting memory to SSA registers), alias analysis, load/store forwarding |
| Layout | Basic-block placement, PGO-driven hot/cold splitting |
[DURABLE] The 80/20 of optimization is inlining plus mem2reg/SROA plus constant folding plus DCE. Inlining exposes opportunities for everything else — it's a meta-optimization. mem2reg turns naive stack-slot-per-variable codegen into real SSA, which is why a front end can emit dumb, obviously-correct code and still get fast output.
6.2 The rules¶
- Correctness always beats speed. A miscompile costs more than any optimization saves, and it destroys trust in the whole toolchain. Compiler bugs are the bugs users trust least and debug worst.
- Measure. Optimizations interact non-obviously; your intuition about which passes matter will be wrong. Build the benchmark suite before the pass.
- Pass ordering matters and has no optimal solution — the "phase-ordering problem." LLVM's pipeline is a hand-tuned sequence with several passes run more than once. Accept this; don't look for elegance here.
- Undefined behaviour is an optimization contract, and it is dangerous. UB lets the optimizer assume things (no signed overflow, no null dereference, no strict-aliasing violation) and is the source of the most surprising and hostile compiler behaviour in C and C++. If you're designing a new language, define the behaviour — even "wraps" or "traps" or "unspecified but not undefined" is enormously better. Rust's decision to make overflow panic in debug and wrap in release is a defensible model.
- Debug builds must be fast to produce and debuggable.
-O0should be a genuinely different, minimal pipeline, not the same pipeline with fewer passes.
6.3 Where optimization happens¶
Modern systems do it at several levels, and it matters which:
- Front end / high IR: language-specific optimizations you can't express later
(devirtualizing based on trait resolution, eliminating bounds checks using type
information, monomorphization).
- Mid-level (LLVM IR): the general-purpose bulk.
- Link time (LTO/ThinLTO): cross-module inlining and devirtualization. ThinLTO is
the scalable variant, using summaries rather than merging everything into one module.
- Runtime (JIT): speculative optimization using real profile data (§9.3 → language-runtimes-interpreters-and-jits).
- PGO/BOLT: profile-guided layout, applied to already-linked binaries.
§7. Code Generation and Backends¶
7.1 Choosing a backend¶
| Backend | Best for | Trade-offs |
|---|---|---|
| LLVM | Production compilers wanting best-in-class optimization and broad targets | Excellent codegen; slow compilation; a very large C++ dependency; API churn across versions |
| Cranelift | Fast compilation, JIT, WebAssembly, debug builds | Written in Rust; fast; less optimization than LLVM; used by Wasmtime and as rustc's alternative backend |
| GCC (libgccjit / gccrs-style frontend) | Targets LLVM doesn't support; GPL ecosystem | Fewer frontends use it; different integration model |
| Custom | Full control, no dependency, fast debug builds, unusual targets | You now own instruction selection, register allocation, and every target |
| WebAssembly | Portability, sandboxing, plugins | §7.5 |
| Transpile to C | Maximum portability, bootstrap | You inherit C's UB and lose debuggability |
| Bytecode + interpreter | Fastest path to a working language | Slow; see §9 → language-runtimes-interpreters-and-jits |
[VERSIONED — a live and instructive case study.] The LLVM-dependency question is being
tested in public right now by two projects:
- Zig is deliberately reducing its LLVM dependency. Its self-hosted x86_64 backend
became the default for Debug mode, with dramatic reported compile-time wins (a hello
world going from ~22.8 s to ~275 ms; the Zig compiler itself from ~75 s to ~20 s). By
2026, Zig's own release notes state that its x86 backend is more robust than its LLVM
backend in terms of implementing the Zig language, and a tracking issue exists to
remove LLVM, LLD, and Clang libraries from the compiler entirely. The stated payoff:
"all our bugs are belong to us," trivial bootstrapping, and in-place incremental binary
patching. Zig 0.16 (April 2026) added architectures — Alpha, KVX, MicroBlaze, OpenRISC,
PA-RISC, SuperH — while removing Solaris, AIX, and z/OS support.
- rustc keeps LLVM as the production backend but ships Cranelift as an alternative
for debug builds. Measured on large real projects (Zed, Tauri, hickory-dns), it delivers
roughly a 20% reduction in code generation time, translating to about a 5% speedup
in total clean-build time. Note the ratio: codegen is only ~25% of the wall clock,
which is the honest counterargument to "replace LLVM to get fast builds." rustc also
maintains a GCC backend, and abstracts over all three via rustc_codegen_ssa.
[DURABLE] The generalizable lesson: LLVM gives you world-class output slowly. If your
users' dominant pain is edit-compile-test latency rather than runtime performance, a
second, fast, dumb backend is a better investment than optimizing your LLVM usage — and
architecting for two backends from the start (as rustc did with rustc_codegen_ssa) is
much cheaper than retrofitting.
7.2 Instruction selection¶
Map IR operations to target instructions. Approaches: macro expansion (one IR op → a fixed instruction sequence; simple, poor code), tree pattern matching with dynamic programming (Aho–Johnson / BURS / iburg; optimal per-tree), DAG-based (LLVM SelectionDAG — handles shared subexpressions), and GlobalISel (LLVM's newer IR-to-machine-IR framework, designed to be faster and more incremental than SelectionDAG).
7.3 Register allocation¶
[DURABLE] Optimal register allocation is NP-complete (it's graph colouring — Chaitin's 1981 reduction). The practical approaches:
| Algorithm | Notes |
|---|---|
| Linear scan | Fast, decent output. The right choice for a JIT or a debug-build backend. Poletto & Sarkar |
| Graph colouring (Chaitin–Briggs) | Better output, slower. Traditional AOT choice |
| SSA-based | Interference graphs of SSA programs are chordal, so colouring is polynomial. An elegant and increasingly common result worth knowing |
| PBQP | Partitioned boolean quadratic programming; handles irregular architectures |
The hard parts are always: spilling (choosing what to evict — usually by loop depth and next-use distance), coalescing (removing redundant moves without making the graph uncolourable), live-range splitting, and calling-convention and register-class constraints (which are far more of the real work than the colouring algorithm).
7.4 The ABI¶
[DURABLE] The ABI is the hardest under-appreciated part of a back end, and getting it wrong produces bugs that only appear when crossing a language boundary. You must specify: calling convention (argument registers, stack layout, who cleans up), struct passing rules (by value in registers? by hidden pointer? the System V x86-64 classification algorithm is genuinely intricate), return values (including small aggregates), name mangling, stack alignment (16 bytes on x86-64 SysV — violate it and SSE code faults), varargs, exception unwinding tables, and TLS.
⚠️ GOTCHA — you cannot invent your own ABI and also interoperate. If you want C FFI (you do — §8.5 →
language-runtimes-interpreters-and-jits), you must implement the platform ABI exactly, including its ugly corners. Most new languages have at least one embarrassing "we passed a small struct wrong on Windows ARM64" bug.
7.5 WebAssembly as a target¶
[VERSIONED — this changed materially in 2026.] Wasm is a genuinely good compilation target: a stack machine with structured control flow, a linear memory, and strong sandboxing guarantees.
WASI 0.3.0 was released on 11 June 2026, and the change is architectural rather than
incremental: native async is moved down into the Component Model's canonical ABI, with
async func, stream<T>, and future<T> as primitives. The wasi:io package —
pollables, input-streams, output-streams — is removed entirely, absorbed into the
canonical ABI. The motivating problem is worth understanding because it's a general
lesson in interface design: under WASI 0.2, a pollable was a resource scoped to a single
component instance, so in a chain A→B→host, component B could not forward the host's
wake-ups to A and had to actively poll just to relay readiness. In 0.2 every component
needed its own event loop with no way to coordinate. Most 0.2→0.3 signature changes are
described as mechanical.
What this means for a language implementer: the Component Model plus WIT (the interface definition language) plus the canonical ABI turns Wasm from "a module with ad-hoc host glue" into a typed, language-agnostic service boundary — a component declares what it needs and provides, and the host or linker wires the edges. Bindings generators can now emit idiomatic async bindings per language. Wasmtime 45 ran the RC; Wasmtime 46 ships 0.3.0. A formally specified Component Model 1.0 is the next milestone, previewed at the Bytecode Alliance Plumbers Summit and Wasm I/O 2026.
The honest caveats, which the ecosystem states openly: WASI still has no native
multi-threading, which quietly rules out whole categories of compute-heavy server
workloads; WASI 1.0 is planned but not shipped; and adoption remains concentrated in
specific niches (edge functions, plugin systems) rather than general server compute —
Fermyon's edge platform and wasmCloud deployments are real, but they are chosen niches.
Note also that reporting on WASI versions is unusually inconsistent — you will find
sources in 2026 simultaneously describing 0.3 as "released," "in preview," and "the next
milestone." Check wasi.dev directly.
Plugin: programming-language-development · View SKILL.md on GitHub