LLVM and Modern Compiler Infrastructure

For decades every serious compiler was a monolith: a front end, optimiser and back end welded together, sharing private data structures, impossible to reuse. Writing a compiler for a new language meant rebuilding the optimiser and every code generator from scratch. LLVM — begun by Chris Lattner in the early 2000s — dissolved that monolith by insisting on one radical discipline: a single, well-specified intermediate representation that every front end targets and every back end consumes. Get your language down to LLVM IR and you inherit, for free, a world-class optimiser and code generation for x86, ARM, RISC-V, GPUs and more.

The payoff is the "hourglass" architecture. Many source languages funnel down through their front ends into the narrow waist — LLVM IR — and then fan back out to many machine targets. The waist is the whole trick: because every optimisation and every back end speaks exactly one IR, the m front ends and n back ends need m + n pieces of engineering instead of m \times n.

The IR: a typed, SSA, virtual assembly language

LLVM IR is not bytecode and not source — it is a low-level, RISC-like virtual instruction set with two defining properties that make it a superb optimisation substrate:

Here is what a small function looks like in the textual IR. Note the explicit types on every operand, the single-assignment virtual registers %…, and the phi node that reconciles the two predecessors of the loop-free branch:

; int max(int a, int b) { return a > b ? a : b; } define i32 @max(i32 %a, i32 %b) { entry: %cmp = icmp sgt i32 %a, %b ; signed a > b -> i1 br i1 %cmp, label %ret_a, label %ret_b ret_a: br label %done ret_b: br label %done done: ; phi picks the value based on which block we came from (SSA merge) %r = phi i32 [ %a, %ret_a ], [ %b, %ret_b ] ret i32 %r }

Every front end — no matter how different C, Rust and Swift are on the surface — lowers to exactly this kind of typed SSA. From here on, the optimiser neither knows nor cares which language it came from.

The pass manager and the pipeline

LLVM's optimiser is a pipeline of passes, each a self-contained transformation or analysis over the IR, sequenced and scheduled by the pass manager. The crucial distinction:

Passes have dependencies: a transform declares which analyses it needs, and the pass manager makes sure those are available and — critically — tracks which analyses a transform invalidates, so stale information is recomputed rather than trusted. A transform runs, possibly invalidating the dominator tree; the next pass that needs it triggers a fresh computation. This bookkeeping is exactly what lets dozens of passes compose safely into the pipelines behind -O2 and -O3. (LLVM's modern "new pass manager" makes this dependency and invalidation tracking explicit and fast.)

Link-time optimisation

Ordinary compilation optimises one translation unit at a time; a call across a file boundary is opaque, so no inlining or interprocedural reasoning can cross it. Link-time optimisation (LTO) breaks that wall. Each source file is compiled to LLVM bitcode rather than final machine code; at link time the bitcode modules are combined and the optimiser runs across the whole program — now able to inline across files, propagate constants globally, and prune truly-dead functions.

Full LTO merges everything into one giant module (maximally precise, but memory-hungry and serial). ThinLTO is the scalable compromise: it builds a lightweight cross-module summary index, then optimises each module in parallel while importing just the functions worth inlining from others — most of LTO's benefit at a fraction of the cost, which is why it is the production default.

MLIR: raising the IR to many levels

LLVM IR sits at one level of abstraction — roughly, typed scalar assembly. But a compiler for machine learning, or for a high-level DSL, wants to optimise at higher levels too: tensor operations, loop nests, dataflow graphs, whole domain-specific constructs that flatten badly into LLVM IR's low-level world. MLIR (Multi-Level IR), the modern evolution from the LLVM project, answers this with dialects: a common IR infrastructure in which many co-existing sub-languages — a tensor dialect, an affine dialect (loops as polyhedra), a gpu dialect, and ultimately the llvm dialect — live in one framework and are progressively lowered from high abstraction to low.

The philosophy is the same one that made LLVM win, applied one level up: don't force everything into a single fixed abstraction; provide shared infrastructure (the pass manager, the printer, the verifier, the rewrite engine) that many IRs can reuse, and lower between them in steps. MLIR is the backbone of modern ML compilers (TensorFlow, IREE, the Torch stack) for exactly this reason.

Counting the hourglass advantage

The economic argument for the shared-IR model is worth making concrete. Without a common waist, every front end needs its own back end for every target — a full m \times n matrix. With the waist, you write each front end once and each back end once.

// The hourglass economics: m front ends, n back ends. function withoutSharedIR(m: number, n: number): number { return m * n; } // one back end per (lang, target) function withSharedIR(m: number, n: number): number { return m + n; } // each written once const langs = 5; // C, C++, Rust, Swift, Julia const targets = 6; // x86, ARM, RISC-V, PowerPC, WebAssembly, GPU const naive = withoutSharedIR(langs, targets); const llvm = withSharedIR(langs, targets); console.log(`without shared IR: ${naive} language-target back ends`); console.log(`with LLVM IR: ${llvm} pieces (${langs} front ends + ${targets} back ends)`); console.log(`saved: ${naive - llvm} pieces of engineering`);

Five languages and six targets: 30 bespoke back ends collapse to 11 reusable pieces. That multiplicative-to-additive collapse is why Clang, rustc, the Swift compiler, Julia, and dozens of research languages all chose to emit LLVM IR rather than build their own back ends.

Two engineering decisions, mostly. First, library design: LLVM was built as a set of modular C++ libraries with clean APIs, not a take-it-or-leave-it monolithic tool — you could embed the optimiser in a JIT, a shader compiler, or a database query engine and use only the parts you needed. Second, a stable, well-documented, textual IR that was pleasant to read, easy to emit, and durable across versions, so front-end authors could target it confidently. Add a permissive licence (which let companies adopt it without friction) and Clang proving the whole stack could match GCC on a real C/C++ compiler, and you get a virtuous cycle: more front ends justified more back-end investment, which attracted more front ends. GCC had long had an internal IR (GIMPLE/RTL), but it was deliberately not exposed as a reusable substrate — LLVM's bet that the IR itself should be a public product is what made the ecosystem.

A tempting misconception is to treat LLVM IR (or its bitcode) like Java bytecode or WebAssembly — a universal, target-independent artifact you distribute and run anywhere. It is not. LLVM IR carries target-specific assumptions almost from the start: data-layout strings fix pointer size, endianness and alignment; the ABI, calling conventions, and even which operations are legal depend on the target the front end had in mind. As the pipeline lowers toward machine code it becomes progressively more target-committed. So a .bc file produced for 64-bit ARM is not a clean drop-in for 32-bit x86. LLVM IR is a compiler's internal waist for optimisation and code generation — brilliant at that job — not a portable shipping format. When people want genuine "compile once, run anywhere", they reach for WebAssembly or a VM bytecode, not raw LLVM IR.