Infratypical

The Three-Phase Architecture

Language frontend, shared middle-end, target backend — with one intermediate representation at each seam. That factoring turns M languages times N targets into M frontends plus N backends, and it is the reason a new language gets twelve architectures on its first release.

The question

Why is a shared intermediate representation such a big deal architecturally?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

One representation at each of two seams, and a different program on each side. To the left of the first seam the program is about a source language: its syntax, its type system, its scoping rules. Between the seams it is a target-independent instruction graph — the form on which every optimization is written, once, for everyone. To the right of the second seam it is about one machine: its instructions, its registers, its calling convention. The question the middle representation exists to answer is "what does this program compute", stated in a way that mentions neither the source language nor the machine.

What this phase may assume or do

Each phase may assume the previous one discharged its obligations, and the seams define exactly what those are. The middle-end may assume the frontend emitted a module that passes the verifier and that every assumption the source language licenses — signed overflow being undefined, two pointers not aliasing, a value being aligned — has been written down as an explicit flag or attribute, because the middle-end cannot see the source. The backend may assume the IR it receives is semantically valid and that any target-specific choice already baked in by the frontend, such as how a struct is passed by value, is the one the ABI requires. A transformation anywhere is legal only if it preserves the IR's defined semantics, which is a narrower and more precise thing than preserving the source language's semantics.

Key points

  • Three phases with a shared representation at each seam turn M languages times N targets into M frontends plus N backends.
  • The optimizer is written once against the IR and serves every language and every target, which is the largest single saving.
  • Anything a source language guarantees must be encoded explicitly at the frontend seam, because nothing downstream can see the source.
  • Two languages using the same middle-end can generate very different code, because one frontend communicated more.
  • The middle-end does not do language-specific optimization; languages with rich semantics add their own IR before it, which is why rustc and Swift each have one.
  • The middle is target-independent in its transformations but not in its input: ABI decisions are baked in by the frontend.

M times N, and how it becomes M plus N

typicalThis three-phase shape is common to LLVM, GCC and most retargetable compilers, but the number of representations inside each phase differs enormously: GCC has GENERIC, GIMPLE and RTL where LLVM has one IR and a machine IR, and rustc has four of its own before it reaches LLVM at all. The shape transfers; the count does not.

Take the problem at face value. You have M source languages and N target architectures, and you want every language to run on every machine. Built naively, that is M times N complete compilers, each containing its own optimizer — and an optimizer is the largest and most subtle part. Adding a language means N new backends; adding an architecture means M new ones.

Now insert one representation in the middle. A frontend translates its language into the shared IR and stops. A backend translates the shared IR into one machine's instructions and knows nothing about any source language. The optimizer is written once, against the IR, and serves everything. Adding a language costs one frontend; adding an architecture costs one backend; every existing language gets the new architecture for free.

That is the whole argument, and it is not a small one. It is why a language released in 2015 could target x86-64, AArch64, ARM, PowerPC, MIPS and WebAssembly on day one with a team of five, and it is the concrete payoff of [[why-ir-exists]] and [[multiple-frontends-one-backend]].

Three phases, two seamstypical
  1. Source languageyou write it
    Text in one specific language, with its own syntax and type system.
  2. Frontendbuild time
    Tokens, a tree, a typed tree, and the language's own checks.
    Every guarantee the language makes. This is the only phase that knows what the source meant.
    The source language. After this seam nothing downstream can tell C from Rust.
  3. Shared IRbuild time
    A target-independent, typed, SSA instruction graph with explicit attributes and flags.
    A common vocabulary — and the assumptions the frontend chose to write down, which is the only channel by which language semantics reach the optimizer.
  4. Middle-endbuild time
    The same IR, repeatedly rewritten by analyses and transformations.
    Everything an optimizer does, written once and shared by every frontend and every target.
    Correspondence with the source. After inlining and folding, a line of IR may come from three functions — see [[debugging-optimized-code]].
  5. Target backendbuild time
    Machine IR: target instructions over virtual registers, then physical ones.
    A commitment to one instruction set, one register file and one calling convention.
    Portability, and the last of the target independence.
  6. Machine codebuild time
    Encoded instructions in an object file.
    Something an assembler or a linker can consume.

Read it asThe two seams are where the economics live. Everything left of the first is written once per language; everything right of the second is written once per architecture; everything between is written once, full stop. Read the loses column to see what each seam costs: the frontend seam loses the source language, so anything the optimizer needs to know must be encoded before it.

The seam is a contract, and what does not cross it is lost

The clean picture hides the part that decides real performance. The backend and the middle-end cannot see the source language, so anything the source language guarantees must be written into the IR explicitly or it does not exist. A frontend that knows signed overflow is undefined must say so on each arithmetic instruction. A frontend that knows two parameters cannot alias must say so with an attribute. A frontend that knows a pointer is aligned, or that a function has no side effects, or that a load is dereferenceable, must say each of those things.

This is why two languages emitting IR for the same algorithm can produce very different machine code. It is not that one optimizer is better — it is the same optimizer — it is that one frontend told it more. Rust's ability to attach aliasing information that a C frontend cannot recover is the clearest example: the information came from the borrow checker, several representations earlier, and the seam is where it gets communicated. See [[rust-pipeline]].

The same argument runs in reverse for what the middle-end cannot express. A language with precise garbage collection needs safepoints and stack maps the IR must be extended to carry; a language with exact exception semantics needs unwind edges modelled; a language with a memory model stricter than the IR's needs to constrain reordering explicitly. Every one of those is a place where a shared representation designed around one family of languages costs the others real work.

What the factoring does not give you

It is worth being precise about the limits, because the M+N argument is often stated as though the middle-end made frontends easy. It does not. The frontend is still the entire language: parsing, name resolution, type checking, diagnostics, whatever the language proves before lowering, and the lowering itself. For a language with a sophisticated type system that is the majority of the work, and none of it is shared.

Nor does the shared middle-end do language-specific optimization. Rust runs optimizations on MIR before emitting IR, because facts about ownership and drops are cheaper to exploit while ownership and drops still exist. Swift has SIL for the same reason. Both are admissions that some transformations must happen while the program is still about the source language — and once you accept that, you have a fourth phase, and the tidy diagram has an extra box.

And the backend seam is leakier than it looks. The frontend must already know the target's type sizes, its struct-passing rules and its calling convention, because those decide the IR it emits — a struct passed by value becomes different IR on different ABIs. So the "target-independent" middle is target-independent in its transformations and not in its input, which is why a bitcode file is not a portable program. See [[abi]] and [[target-triples]].

Where each kind of work actually livestypical
WorkWritten once perWhy it lands there
Parsing, name resolution, type checkingLanguageIt is the language; nothing about it generalises
Language-specific optimizationLanguageThe facts it exploits stop existing after lowering — Rust's MIR passes, Swift's SIL passes
Encoding language assumptions as attributesLanguageOnly the frontend knows what the source guaranteed
Inlining, folding, dead-code elimination, loop transformsEveryone — written onceThey are properties of the IR, not of any language or machine
Instruction selection, scheduling, register allocationTargetThey are properties of one machine
Calling convention and struct-passing decisionstargetLanguage and target, jointlyThe frontend must emit ABI-correct IR, so this leaks across the first seam

How it works

The steps, in the order the compiler takes them.

  • The frontend parses, checks and lowers its language into the shared IR, emitting attributes and flags for every assumption the language licenses.
  • A verifier confirms the module is structurally well-formed before any transformation runs.
  • The middle-end runs a pipeline of analyses and transformations over the IR, each preserving the IR's defined semantics and none of them aware of the source language.
  • The optimized IR is handed to a target backend, which lowers it to machine IR: target instructions over virtual registers.
  • The backend performs instruction selection by pattern matching, schedules for the target's pipeline, allocates physical registers and spills what does not fit.
  • The result is emitted as assembly or directly as object code, with relocations for anything the linker must resolve.

How it breaks

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

  • A new language's generated code is much slower than C for the same algorithm, and the cause is missing attributes at the frontend seam rather than anything in the optimizer.
  • A program compiles on one architecture and fails on another because the frontend baked in an ABI assumption, and the bitcode that "should be portable" was never portable.
  • A language with precise garbage collection finds that the shared IR loses track of which stack slots hold pointers across an optimization, and the collector reads garbage.
  • A team writes a language-specific optimization as a middle-end pass, discovers the information it needs no longer exists at that level, and rewrites it as a frontend pass over their own IR.
  • A debugging session shows a single instruction attributed to three different source functions after inlining, and stepping behaves in a way that looks like the debugger is broken.

When it helps

  • Implementing a new language, where the factoring is the difference between a five-person team shipping and not shipping.
  • Deciding where in your own compiler a transformation belongs — the answer is usually the highest level at which the facts it needs still exist.
  • Understanding a performance difference between languages that share a backend, which is nearly always a question about what the frontends communicated.
  • Porting to a new architecture, where the work is one backend rather than one backend per language.

When it hurts

  • Languages whose semantics do not match the IR's model. Precise GC, exact exceptions and stricter memory models all cost real work that the factoring does not save.
  • Treating the middle-end as the whole compiler. The frontend is still the language, and for a sophisticated type system it is most of the project.
  • Assuming target independence extends to the IR itself. It does not, and code that assumes it produces bugs that only appear on the second architecture.

What it costs

Every one of these is paid by something.

  • A shared IR buys M+N economics and an optimizer nobody has to rewrite, and pays by forcing every language into one model of what a program is — which languages with unusual semantics pay for in extra work and lost information.
  • Making the seam explicit buys separability and testability at each phase, and pays with an encoding obligation: every language guarantee must be written down as an attribute, and anything not written down is silently unavailable.
  • A target-independent middle buys transformations written once, and pays by requiring the frontend to know target details anyway, so the independence is partial and the boundary is a frequent source of confusion.
  • Adding a language-specific IR before the shared one buys optimizations that need language-level facts, and pays with another representation to build, verify, test and maintain — and another place for information to be lost.

What else you could do

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

  • A single-language compiler with no shared middle can exploit its own semantics everywhere and cannot be reused; that is what a bespoke backend buys, and what it costs — see [[go-pipeline]].
  • GCC uses the same three-phase shape with three IRs in the middle rather than one, giving different tradeoffs in where transformations live — see [[gcc]].
  • MLIR generalises the idea to many coexisting IRs with defined lowerings between them, which is a response to LLVM IR being too low-level to be the first target for some domains.
  • Emitting C or another high-level language as the "IR" gets you every C compiler's backend for free and costs you control over semantics and debugging.
  • A bytecode VM stops at the middle representation and interprets it, giving up native code for portability and startup — see [[bytecode]].

See it for yourself

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

  • clang -S -emit-llvm -o - x.c shows the first seam; llc x.ll -march=aarch64 -o - shows the second, in isolation from the middle-end.
  • opt -O2 -S x.ll runs only the middle-end, so the difference between its input and output is precisely what the shared phase contributed.
  • clang --print-supported-cpus and llc -version list the backends a build actually contains — the N in M+N, concretely.
  • opt -passes="print<...>" and -print-after-all show the IR between passes, which is how you locate a transformation to a phase.
  • Compiler Explorer with two languages side by side, both set to emit IR, makes the "same middle-end, different frontend information" argument visible in one screen.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The IR makes the compiler portable." It makes the *optimizer* portable. The frontend still knows the target and the backend is entirely about it.
  • "With a shared middle-end, all languages get the same performance." They get the same optimizer. What it can do depends on what each frontend told it, and frontends differ enormously in that.
  • "Three phases means three passes." Each phase contains many passes; the phases are about who wrote the code and what it may assume, not about how many times the program is walked.
  • "If I emit IR, my language is done." You have skipped the optimizer and the backend. Everything about your language — checking, diagnostics, lowering, runtime — is still ahead of you.
  • "Language-specific optimizations belong in the middle-end where all the machinery is." They belong wherever the facts they need still exist, which for ownership, drops or exhaustiveness is before the seam.

Misconceptions

The claim, and what is actually true.

The frontend is the easy part now that backends are shared.
The frontend is the language: parsing, resolution, type checking, diagnostics and lowering. For a language with a rich type system it is the majority of the compiler and none of it is shared.
A retargetable compiler means one binary that produces code for every target.
It means the source is written once per target rather than once per language-target pair. Whether a given build contains every backend is a build configuration question.
Optimizations should all live in the shared middle-end.
They should live at the highest level where the facts they exploit still exist. Ownership, drop placement and exhaustiveness stop existing after lowering, so those optimizations must run earlier.
The IR is where language semantics are expressed.
The IR expresses computation. Language semantics reach the optimizer only through attributes and flags the frontend chose to emit, and anything not emitted is unavailable forever.

Go deeper

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

overview

Split a compiler into three parts: one that understands your language, one that improves programs in a neutral format, and one that knows a particular processor. Then the middle part can be shared. Ten languages and twelve processors need ten front parts and twelve back parts instead of a hundred and twenty whole compilers, and every new language gets all twelve processors immediately.

practical

When you are asking why code from language A is slower than the same algorithm in language B on the same backend, look at the IR both frontends emit, not at the optimizer. The difference is usually attributes: overflow flags, aliasing, alignment, nounwind, readonly. When you are deciding where to put your own optimization, ask which facts it needs and whether those facts still exist at that level — if they do not, the pass belongs earlier, in your own IR.

advanced

The deep property of this architecture is that the seam is a *lossy, explicit* interface, and the losses are chosen rather than inherent. The frontend decides what to encode; whatever it does not encode is gone, permanently, and no amount of analysis downstream recovers it — a middle-end can attempt [[alias-analysis]] on C pointers forever and never derive what a Rust frontend states outright. This reframes what a good frontend is: not the one that emits the cleanest IR, but the one that emits the most *informative* IR, because information is the currency the optimizer spends. It also explains the pressure that produced MLIR. If a seam is lossy and the losses depend on how well the shared representation matches your domain, then a single fixed representation is a compromise for everyone, and the answer is infrastructure for defining your own representations with a shared lowering framework — an M+N argument applied one level up.

How much this depends on

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

typicalThree phases with a shared middle is the common architecture of retargetable compilers — LLVM, GCC and others — but the internal structure differs sharply: GCC uses three successive IRs in the middle where LLVM uses one plus a machine IR, and rustc and Swift each interpose their own language-level IR before reaching a shared one. Any statement about "the middle-end" needs a toolchain named.
targetThe claim that the middle is target-independent applies to the transformations, not to the input. The frontend must already know type sizes, alignment, struct-passing rules and the calling convention, so IR emitted for x86-64 System V differs from IR emitted for AArch64 or Windows x64 for the same source — which is why bitcode is not a portable program.
implementationWhether a given optimization runs in the frontend, in a language-specific IR or in the shared middle-end is an engineering decision that has moved over time within individual compilers: rustc added MIR-level optimizations after MIR existed, and passes have migrated between GCC's GIMPLE and RTL levels. The placement is not a property of the architecture.

If you were asked this in an interview

  • Explain the M times N problem and how a shared intermediate representation solves it.
  • Two languages share a backend and one generates faster code. Where do you look first?
  • Why do Rust and Swift each have their own IR before they emit LLVM IR?

Connections

Performancebenchmarking
Domains that do not exist yet
  • Software Design — Designing a stable interface between components that evolve independently
    The seam here is a textbook case of an interface that decouples two teams, with the textbook consequence: what the interface cannot express is lost regardless of how well either side is implemented. The general design principle is owned there; the compiler instance is ours.