IRimplementation

Why an IR Exists: M x N Becomes M + N

Six languages and five targets is thirty compilers if every frontend talks to every backend directly. Put one representation in the middle and it is eleven components. That arithmetic is the entire argument, and it is why the middle of a compiler is a public interface.

The question

Why not just generate machine code straight from the typed tree, and skip a whole representation?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

One target-independent, source-independent instruction set that every frontend produces and every backend consumes. What it exists to answer is not a question about a program at all — it is a question about a *codebase*: how many pieces must be written, and how many must change when a language or a machine is added.

What this phase may assume or do

The arrangement only works if the IR is genuinely neutral in both directions. A frontend may emit nothing that only one backend understands, and a backend may assume nothing about which frontend produced its input. The moment a pass tests "was this Fortran", the M + N property is gone and the count silently starts drifting back toward M x N.

Key points

  • M frontends and N backends need M x N direct code generators, or M + N components with one shared IR in the middle.
  • A new backend gives every existing language a new target for free — the coordination cost, not just the code, disappears.
  • Optimizations written against the IR are written once and shared by every language and target, which is why the middle-end is where most of the engineering accumulates.
  • The shared IR is neutral by being lossy: what one language knows and its neighbours do not is encoded as an attribute, kept in a private IR, or discarded.
  • One shared middle-end means one miscompilation is everybody's miscompilation.

The arithmetic

Suppose you want to compile C, C++, Rust, Swift, Fortran and Haskell to x86-64, AArch64, RISC-V, WebAssembly and PowerPC. Wire each frontend to each backend directly and you have thirty code generators to write and maintain. Add a seventh language and you write five more. Add a sixth target and you write six more.

Now put one instruction set in the middle. Each frontend lowers to it once; each backend consumes it once. Six plus five is eleven components. Adding a language is one new component, not five. Adding a target is one new component, not six — and every existing language gets that target for free, on the day it lands.

That last clause is what makes the design famous rather than merely tidy. When a backend for a new architecture is merged into LLVM, every language whose compiler targets LLVM IR can emit code for that architecture without any of those language teams doing anything. The value is not the code sharing; it is that the *coordination* disappears.

What it costs to add one thing, under each arrangementtypical
ArrangementComponents for 6 languages x 5 targetsCost of a new languageCost of a new target
Direct: every frontend emits machine code30 code generators5 new backends, written by the language team6 new backends, one per language team
Shared IR in the middle6 frontends + 5 backends = 111 new frontend1 new backend; every language gets it

The middle-end is where the work went

implementationThis describes LLVM as of the 2020s. GCC has the same shape with GIMPLE and RTL in place of LLVM IR, but its middle-end is not a public interface and its frontends live in-tree, so the "add a language without touching the project" property does not hold there. The counting argument is general; the plug-in-a-frontend property is specific to projects that decided to support it.

The counting argument explains why the IR exists. It does not explain why the middle of a compiler is where most of the engineering ended up, and that is the second half of the story.

An optimization written against the IR is written *once* and benefits every language and every target. Inlining, constant propagation, dead-code elimination, loop-invariant motion, vectorization — none of them care whether the source was Rust or Swift, and none of them care whether the target is ARM or WebAssembly. So the middle-end accumulates: a production pass pipeline is a hundred-plus passes deep, and every one of them is shared work that nobody has to write twice.

The catch is that shared work requires shared semantics. If two source languages disagree about whether signed overflow is defined, the IR cannot simply have "add" — it needs to carry the distinction, which is what LLVM's nsw flag is for. Every such disagreement becomes either a flag on an instruction or a lie the optimizer eventually acts on. This is where [[ub-and-optimization]] starts.

The narrow waist, as LLVM actually arranges itimplementation
  1. Frontendsbuild time
    Clang for C/C++/Objective-C, rustc, swiftc, flang, Julia, Zig and others — each with its own parser, type system and diagnostics.
    Everything language-specific: syntax, type checking, borrow checking, template instantiation.
    The source language itself. Past this point nothing knows what produced the IR.
  2. LLVM IRbuild time
    A typed, SSA-form, three-address instruction set with an explicit CFG — the same for every frontend above.
    A single representation every pass and every backend agrees on.
  3. Middle-end passesbuild time
    The same IR, rewritten. Roughly a hundred and fifty passes in a default -O2 pipeline.
    Every target-independent optimization, written once for all languages.
    Correspondence with the source, unless debug metadata is maintained through each pass.
  4. Backendsbuild time
    Target instructions, selected and scheduled for one architecture.
    A commitment to x86-64, AArch64, RISC-V, WebAssembly or another target.
    Portability, and the last of the target independence.

Read it asCount the arrows rather than the boxes. Every frontend has exactly one outgoing arrow and every backend exactly one incoming arrow, and that is the whole property. [[multiple-frontends-one-backend]] is what happens when you take this seriously enough to make the IR a documented, versioned interface.

What the arrangement costs

The neutral representation is neutral by being lossy. Rust knows that two &mut references cannot alias; LLVM IR did not originally have a way to say so, and Rust spent years unable to enable the noalias attribute that would have expressed it because doing so kept exposing latent LLVM miscompilations. Swift keeps its own high-level IR — SIL — precisely because the things it wants to optimize are invisible once lowered to LLVM IR.

That is the general pattern: the shared IR is defined by what all its producers have in common, and everything a language knows that its neighbours do not is either encoded as an attribute, kept in a private earlier IR, or lost. [[ir-levels]] is the standard answer, and it is why "one IR" is nearly always at least two.

The second cost is coupling. When every language depends on one middle-end, a miscompilation in that middle-end is a bug in every language at once. That is not hypothetical — it is the reason [[compiler-fuzzing]] and [[differential-testing]] are funded activities rather than hobbies.

How it works

The steps, in the order the compiler takes them.

  • Define an instruction set with no construct that only one source language needs and no construct that only one target supports.
  • Each frontend lowers its own typed representation to that instruction set, encoding language-specific guarantees as attributes or flags rather than as new instructions where possible.
  • Target-independent passes read and rewrite the IR without ever asking which frontend produced it.
  • Each backend performs instruction selection, scheduling and register allocation from the IR to one architecture.
  • The IR gets a textual form, a parser and a verifier, so that any stage can be tested in isolation with a hand-written input.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A frontend encodes a language guarantee the IR cannot express, and an optimization that was legal for that language is silently not applied — the code is correct and slower than the language author expected, with nothing to point at.
  • A frontend asserts a guarantee the IR *can* express but the language does not actually provide, and the optimizer acts on it. The result is a miscompilation that appears only at high optimization levels and only in some builds.
  • A pass grows a special case for one language's idiom. Nothing breaks immediately; two years later the middle-end can no longer be reasoned about without knowing which frontend is upstream.
  • A new backend lands and one language's output breaks on it, because that frontend had been relying on an unstated behaviour of the existing backends.

When it helps

  • Any project that expects more than one target. The break-even is roughly at the second one, and it arrives sooner than teams expect — WebAssembly turned a great many single-target compilers into two-target compilers overnight.
  • Building a new language. Emitting LLVM IR or Cranelift IR gets a competent optimizer and every supported architecture without writing a backend at all.
  • Sharing analysis infrastructure. A dataflow framework written against the IR serves the optimizer, the static analyser and the language server equally.

When it hurts

  • One language, one target, and a hard compile-time budget. The IR is pure overhead there, and a direct code generator will be faster to run and smaller to maintain.
  • A language whose distinguishing guarantees do not survive lowering. If the interesting optimizations all need information the shared IR cannot carry, you will end up writing a private IR anyway — and then you have two.

What it costs

Every one of these is paid by something.

  • A shared IR buys M + N instead of M x N, and pays with a representation defined by the intersection of what its producers have in common — every language-specific guarantee must be encoded, kept elsewhere, or lost.
  • It buys a shared optimizer and pays with shared blast radius: one middle-end bug is a bug in every language that uses it, which is why compiler fuzzing exists.
  • It buys retargetability and pays compile time, because lowering to a neutral form and then back down is strictly more work than going straight to one target.
  • Making the IR a public interface buys external frontends and pays with a compatibility obligation: every instruction, flag and attribute becomes something that cannot be changed freely.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Transpile to another language and let its compiler do the rest. TypeScript emits JavaScript; early C++ emitted C. You inherit a mature backend for nothing, and you inherit that language's semantics and its error messages too — [[typescript-pipeline]].
  • Target a portable bytecode instead of a portable IR. The JVM and .NET both took this route: the neutral representation ships and is executed rather than being compiled away, which moves the backend to install or run time — [[bytecode]].
  • Keep a private high-level IR and lower to a shared one afterwards. Swift SIL and Rust MIR both do this, which is the honest answer when a language knows things LLVM IR cannot say.
  • Write direct code generators anyway. Go did, and gets very fast compilation and complete control of its own backend in exchange for maintaining every target itself — [[go-pipeline]].

See it for yourself

The flag, dump or tool that shows you this directly.

  • Compare two frontends into one IR: clang -S -emit-llvm -o - hello.c and rustc --emit=llvm-ir -o - hello.rs produce the same instruction set from different languages. The similarity is the lesson.
  • llc -march=aarch64 file.ll and llc -march=x86-64 file.ll compile the same IR file to two architectures — one input, two backends, no frontend involved.
  • llc -version lists every target the local LLVM was built with. That list is what a new frontend gets for free.
  • Compiler Explorer with two source panes and the LLVM IR output filter shows two languages converging on the same representation side by side.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "LLVM is a compiler." LLVM is a middle-end and a collection of backends. Clang is the C and C++ compiler that uses it, and the distinction is exactly the M + N argument — [[llvm-architecture]].
  • "A shared IR means all languages compile to the same code." It means they share the same optimizer and code generators. What each frontend *emits* into that IR differs enormously, and that is where language differences survive.
  • "The IR is neutral, so nothing is lost." The IR is neutral because things are lost. That is the mechanism, not a side effect.
  • "M + N is always better than M x N." Not when M and N are both one. The arithmetic only favours the shared IR once you actually have several of something.

Misconceptions

The claim, and what is actually true.

Any language can be compiled by writing a frontend for a shared IR.
Any language can be *lowered* to one. Whether the result is good depends on whether the guarantees that make the language fast survive the lowering — which is why Swift and Rust both keep an IR of their own above LLVM's.
The middle-end is small compared with the frontend and backend.
In LLVM it is the largest part by pass count and by far the largest by accumulated engineering, because it is the only part every user shares.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

If six languages each generated code for five machines directly, somebody would have to write and maintain thirty code generators. Agree on one representation in the middle and it is eleven pieces instead — and a new machine benefits all six languages the day it lands. That is why the IR is worth an entire extra phase.

practical

If you are building a language, this is the decision that determines your first two years. Emitting LLVM IR or Cranelift IR gives you a real optimizer and every target immediately, at the cost of a large dependency, slow compile times at high optimization levels, and error messages from a middle-end that does not know your language exists. Writing your own backend gives you fast builds and control, and means you own instruction selection and register allocation for every architecture you support.

advanced

The narrow-waist argument has a failure mode that only shows up at scale: the waist is defined by the intersection of its producers, so every new frontend either fits the existing intersection or widens it. Widening it is how IRs accumulate attributes — noalias, nonnull, dereferenceable, nsw — each one a guarantee some language could make and the IR previously could not express. Each is also a new obligation on every pass, and a new way for a frontend to assert something false. The interesting engineering question is not whether to have a shared IR but how to widen it without turning every optimization into a case analysis.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationLLVM IR is not a stable interface across versions. The bitcode format has compatibility guarantees, but the textual IR, the pass APIs and the attribute set change every release, so an out-of-tree frontend tracks LLVM releases rather than pinning one forever. Contrast WebAssembly, which is a specified, versioned format precisely because it is meant to be shipped.
typicalThe claim that a new backend gives every frontend a new target holds for languages whose runtime requirements the target can satisfy. A language needing precise garbage collection or structured unwinding does not get a target for free just because instruction selection works there: the stack maps, safepoints and unwind tables the compiler has to emit are separate, per-target work, and [[stack-unwinding]] is where that half lives.

If you were asked this in an interview

  • A team wants to add a fourth target architecture. Walk me through what that costs with and without a shared IR.
  • Rust knows two mutable references cannot alias. Where does that information go when the program is lowered to LLVM IR?
  • When would you not build an IR?

Connections

Computer Architectureinstruction-set-architecture
Performancejit-and-warmup
Domains that do not exist yet
  • DevOps / Production Engineering — Toolchain dependency management and the cost of pinning a large upstream project
    Depending on LLVM is a build and release decision as much as a compiler-design one: every release cadence, patch backport and vendored fork question belongs to that domain, and it is frequently the deciding factor.