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.
Why not just generate machine code straight from the typed tree, and skip a whole representation?
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.
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.
| Arrangement | Components for 6 languages x 5 targets | Cost of a new language | Cost of a new target |
|---|---|---|---|
| Direct: every frontend emits machine code | 30 code generators | 5 new backends, written by the language team | 6 new backends, one per language team |
| Shared IR in the middle | 6 frontends + 5 backends = 11 | 1 new frontend | 1 new backend; every language gets it |
The middle-end is where the work went
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.
- Frontendsbuild timeClang 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.
- LLVM IRbuild timeA 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.
- Middle-end passesbuild timeThe same IR, rewritten. Roughly a hundred and fifty passes in a default
-O2pipeline.Every target-independent optimization, written once for all languages.Correspondence with the source, unless debug metadata is maintained through each pass. - Backendsbuild timeTarget 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.candrustc --emit=llvm-ir -o - hello.rsproduce the same instruction set from different languages. The similarity is the lesson. llc -march=aarch64 file.llandllc -march=x86-64 file.llcompile the same IR file to two architectures — one input, two backends, no frontend involved.llc -versionlists 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.
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.
[[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
- DevOps / Production Engineering — Toolchain dependency management and the cost of pinning a large upstream projectDepending 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.