Pipelinetypical

Frontend, Middle-End, Backend

The three-way split exists for one reason worth stating in arithmetic: it turns `m` languages times `n` targets into `m` plus `n` implementations. What it costs is everything that does not survive the crossing into the IR.

The question

Why do compilers split into a frontend, a middle-end and a backend, and what does each part know?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

Three components separated by two representations. The frontend owns everything up to a typed AST and answers "is this a valid program in this language, and what does it mean". The middle-end owns a target-independent IR and answers "what work can be removed or reordered without changing observable behavior". The backend owns machine IR and answers "which instructions on this machine implement it, and where does every value live".

What this phase may assume or do

The middle-end may assume only what the IR encodes. Any source-language guarantee that is not written into the IR as a flag, an attribute or a type is unavailable to it and cannot be used to justify a transformation — which is why Clang emits add nsw for signed C arithmetic. Symmetrically, the frontend must not emit an attribute the language does not actually guarantee: an unjustified noalias on a pointer argument licenses reordering that changes results, and the resulting bug is a miscompilation with a correct-looking frontend.

Key points

  • The split turns m x n implementations into m + n; that arithmetic, not elegance, is why the architecture won.
  • The middle-end knows only what the IR encodes. Source-language guarantees survive as attributes and flags, or not at all.
  • A frontend attaching an attribute the language does not guarantee produces miscompilation, and the backend tests will not find it.
  • Diagnostics are a frontend product because the frontend is the last component that still knows the source language.
  • Languages with guarantees the shared IR cannot express add their own IR above it — MIR for Rust, SIL for Swift — precisely to spend those guarantees before they vanish.

The arithmetic

Suppose you support six languages and eight targets. Written as one compiler per pair, that is forty-eight code generators, forty-eight sets of optimizations and forty-eight independent bug surfaces, and adding a target means writing six more. Split it: six frontends lower to one IR, one middle-end optimizes it, eight backends emit instructions. Fourteen components, and a new target costs one.

That is the entire argument for LLVM, and it is worth noticing that the argument is organisational before it is technical. The optimization that took someone two years to get right is now available to every language that lowers to the IR, including languages that did not exist when it was written. See [[multiple-frontends-one-backend]] and [[llvm-architecture]].

The cost is symmetric and less often stated. Anything the IR cannot express is invisible to the middle-end, and anything the IR expresses badly is optimized badly. A language whose semantics do not map cleanly onto the IR gets the IR's optimizations and not its own, which is why several languages carry a language-specific middle-end *above* LLVM — rustc has MIR and its borrow checker, Swift has SIL, and both exist because the guarantees they wanted to exploit vanish on the way down.

What each part knows and what it cannot seetypical
PartOwnsKnowsCannot see
FrontendLexer, parser, resolver, type checker, loweringThe source language in full: syntax, scopes, types, overloads, spansThe target machine. It does not know how many registers exist, and should not.
Middle-endA target-independent IR and the passes over itControl flow, data flow, aliasing facts, whatever attributes the frontend attachedThe source language. for loops, method calls and list comprehensions have all become the same branches and calls.
BackendtargetInstruction selection, scheduling, register allocation, encodingOne instruction set, one ABI, one register file, one cost modelWhy anything is there. It cannot recover intent that the IR did not record.

What crosses the boundary, and what does not

implementationThe nsw and nuw flags and this particular fold are LLVM. GCC represents the same information differently — through -fstrict-overflow and its internal value-range propagation — and can be turned off with -fwrapv, which makes signed overflow defined and costs exactly the analyses described here. The mechanism generalises; the spelling does not.

The frontend-to-middle-end handover is the most consequential interface in the whole pipeline, because it is where source-language meaning becomes annotations or disappears. Consider signed integer addition in C. The IR instruction is just add; the fact that signed overflow is undefined — and therefore that the compiler may assume a + 1 > a — is carried by an nsw flag on the instruction. Remove the flag and the same source produces correct but measurably weaker code, because loop induction analyses can no longer assume the counter does not wrap.

The same mechanism carries type-based aliasing, noalias for restrict pointers, nonnull and dereferenceable facts about references, and alignment. Each is a source-language guarantee compressed into an IR attribute, and each is a place where a frontend bug becomes a miscompilation that no amount of backend testing will find.

The same addition, with and without the guarantee the frontend knew about
Without the flag: overflow is defined to wrap
%sum = add i32 %a, 1
%cmp = icmp sgt i32 %sum, %a
br i1 %cmp, label %then, label %else
With `nsw`: overflow is undefined, so the comparison folds
%sum = add nsw i32 %a, 1
; icmp sgt (add nsw X, 1), X is always true
br label %then

Read it asNothing about the machine changed. The second version is smaller because the frontend told the middle-end something about the *language* — that this addition may be assumed not to overflow — and the middle-end spent it. A Rust frontend emitting the same addition in release mode cannot attach nsw, because Rust defines the wrap. Same instruction, different licence.

Where diagnostics live, and why

implementationGCC and Clang both emit a small number of optimization-derived diagnostics; the exact set and their sensitivity to optimization level differ between the two and between versions of each. rustc is the other extreme: essentially all of its diagnostics come from the frontend and MIR passes, so they are stable across optimization levels by construction.

Almost every error message a developer sees comes from the frontend, and this is not an accident of implementation. The frontend is the only component that still has the source language: it knows you wrote a method call, that the method exists on a similar type, and where in the file to point. By the time the middle-end sees the program, "you passed the arguments in the wrong order" is four indistinguishable loads.

The consequence is that a frontend is not just the first third of a compiler; it is the entire developer-facing product. A language server is a frontend that never terminates, running resolution and type checking continuously and answering queries against the result — which is why [[ast-as-shared-infrastructure]] and [[language-server]] belong to the same story and not to a tooling appendix.

The exceptions prove the rule and are worth knowing because they confuse people annually. -Wmaybe-uninitialized in GCC is a middle-end warning derived from data-flow analysis, so it appears at -O2 and vanishes at -O0, moves between releases, and produces false positives after inlining. A warning whose presence depends on the optimization level is a warning that comes from below the frontend.

The seam is where languages get stuck

A shared middle-end is a shared vocabulary, and a language whose ideas are not in the vocabulary has three options: encode them as attributes, add a language-specific IR above the shared one, or lose them.

Rust chose the middle option. Ownership and borrowing are checked on MIR, a Rust-specific IR, because by the time the program is LLVM IR the lifetimes are gone and every reference is an ordinary pointer. Swift did the same with SIL for ARC optimization: removing a redundant retain/release pair requires knowing which pointers are reference-counted, and LLVM IR does not have that concept. In both cases the language-specific IR exists to spend a guarantee that would otherwise evaporate at the seam. See [[ir-levels]] and [[ownership-types]].

Going the other way, the seam is also what lets a small language be fast for free. A hobby frontend that emits reasonable LLVM IR gets inlining, loop-invariant code motion, vectorization attempts and a competent register allocator without their author writing any of it — which is the strongest single argument for choosing an existing infrastructure over a hand-written backend, and it is argued out in [[dsl-implementation-strategies]].

How it works

The steps, in the order the compiler takes them.

  • The frontend parses, resolves and type-checks, then lowers the typed tree to IR, attaching attributes for every language guarantee the IR can express.
  • The middle-end runs target-independent analyses and transformations, each justified by facts derivable from the IR and its attributes alone.
  • A target-specific lowering converts remaining IR constructs into forms the target supports, replacing anything the machine lacks with a library call or an expansion.
  • Instruction selection pattern-matches IR against target instruction patterns; scheduling orders them for a pipeline model; register allocation assigns physical registers and inserts spills.
  • The emitted assembly or object code carries the symbols and relocations the linker will need, plus any debug and unwinding metadata the frontend requested.

How it breaks

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

  • Code compiled from a language with defined overflow runs measurably slower than the equivalent C, and the diff in the IR is a missing flag rather than anything in the generated instructions.
  • A miscompilation appears only at high optimization levels for one source language, because that frontend emits an aliasing attribute it is not entitled to and the middle-end believes it.
  • A warning appears at -O2 and disappears at -O0, and the developer concludes the compiler is unreliable rather than that the warning is derived from an optimization pass.
  • A new language feature works correctly but generates poor code, because it was lowered to IR constructs the middle-end has no pattern for and nobody looked at the output.
  • A backend bug on one target produces wrong results for every language that shares the middle-end, and the bug report arrives in a language whose maintainers cannot reproduce it.

When it helps

  • Building a new language: emitting an existing IR buys a competitive optimizer and every target that infrastructure supports, for the cost of one lowering.
  • Supporting a new processor: one backend makes every language that already targets the infrastructure work on it.
  • Attributing a performance problem. If the IR is already bad, the frontend is at fault; if the IR is good and the assembly is not, the backend or the cost model is.

When it hurts

  • When the language's core guarantees have no representation in the shared IR. Borrow checking, reference-counting optimization and effect tracking all need a level above it, and building one is a large project.
  • When compile time is the product. A shared middle-end is tuned for output quality; debug-build throughput often needs a separate, much simpler path, which is why several languages ship a second backend for unoptimized builds — Rust's Cranelift backend exists for exactly this.

What it costs

Every one of these is paid by something.

  • A shared middle-end buys every optimization and every target for free, and costs the loss of any semantics the IR cannot express, plus a dependency whose release cadence, bug surface and compile-time characteristics you do not control.
  • A language-specific IR above the shared one buys the ability to exploit your own guarantees, and costs another representation to define, print, verify and test, plus every pass having to be written twice conceptually.
  • A hand-written backend buys compile speed and exact control over the output, and costs an instruction selector, a register allocator, a scheduler and an object writer per target — plus, permanently, every optimization you did not write.

What else you could do

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

  • A monolithic compiler with no reusable middle-end: Go's toolchain deliberately owns its whole pipeline, which is a large part of how it achieves its compile speed, at the cost of implementing every optimization and every target itself.
  • Emitting C as the portable "IR" and delegating everything below it, which is how several languages bootstrapped. It is portable to anywhere a C compiler exists, and it makes debugging and precise control flow — tail calls, unwinding — genuinely painful.
  • Targeting a bytecode instead of native code, moving the entire backend to run time. See [[bytecode]] and [[jit-compilation]].
  • Targeting WebAssembly as a portable backend that is neither a bytecode VM in the traditional sense nor a native ISA — [[wasm-vs-native]].

See it for yourself

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

  • The frontend-to-middle-end handover: clang -S -emit-llvm -O0 -o - file.c shows exactly what the frontend produced, attributes included. Look for nsw, noalias, nonnull, align — those are the guarantees crossing the seam.
  • What the middle-end did with them: rerun with -O2 and diff. Then rerun with -fwrapv and diff again to see what the removed guarantee cost.
  • Rust's language-specific IR: rustc -Z unpretty=mir on nightly, before any LLVM IR exists.
  • Backend decisions only: llc -march=<target> file.ll runs the backend alone on IR you provide, which isolates a suspected backend problem from any frontend involvement.
  • Compiler Explorer with two panes, one per language, both compiled to the same target — the differences that remain after the middle-end are frontend differences.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The middle-end optimizes my code." It optimizes an IR that your code was translated into. If the translation lost the fact that made the optimization possible, there is nothing to optimize.
  • "LLVM is a compiler." LLVM is a middle-end, a set of backends and supporting libraries. Clang is the C and C++ frontend that uses it, and confusing the two makes the whole architecture unreadable — [[llvm]].
  • "The backend is where performance is decided." Instruction selection and register allocation matter, but the transformations that change the asymptotics of a loop all happened in the middle-end, and the decision to expose them happened in the frontend.
  • "Any language can be added by writing a frontend." Any language can be *executed* that way. Getting a language's own guarantees exploited usually requires an IR of your own above the shared one.

Misconceptions

The claim, and what is actually true.

The frontend is the small part.
Clang's C++ frontend is one of the largest bodies of code in the project, because a language's surface, its diagnostics and its tooling all live there. The middle-end is shared precisely so that it does not have to be rewritten per language.
Optimizations are language-agnostic.
The transformations are; their legality is not. What is legal depends on what the source language guarantees, which is why the same IR pattern is optimized differently depending on which attributes the frontend attached.
A shared IR means all languages get the same performance.
They get the same passes. Whether those passes can do anything depends entirely on how much of the language's meaning survived lowering.

Go deeper

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

overview

A frontend understands one language and produces a neutral intermediate form. A middle-end improves that form without knowing which language it came from. A backend turns it into instructions for one machine without knowing why any of it is there. Add a language, write a frontend; add a machine, write a backend.

practical

When output is worse than you expect, look at the IR before the assembly. Unoptimized IR shows what the frontend told the optimizer, attributes included, and most disappointing code generation turns out to be a missing guarantee rather than a missing optimization. When the IR is good and the assembly is not, you are looking at instruction selection or the cost model, and the target triple is now part of the bug report.

advanced

The design question the split forces is how much semantics to push into the shared IR versus how much to spend in a language-specific layer above it. Push too little and your guarantees evaporate at the seam; push too much and the IR accretes concepts that only one frontend emits, which every pass then has to handle or conservatively bail on. LLVM has taken both approaches at different times — noalias and lifetime intrinsics were pushed down, garbage-collection support largely was not — and the pattern in the successes is that a concept survives in a shared IR when several unrelated frontends want it and it can be expressed as a restriction on existing behavior rather than as a new kind of behavior.

How much this depends on

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

typicalThe clean three-way split describes LLVM-based toolchains and GCC in outline. Real compilers leak across the boundaries: Clang performs some constant evaluation itself, LLVM has target-specific passes in the middle-end, and several backends run their own IR-level transformations. The split is an architecture, not a partition.
implementationEverything about nsw, noalias and the pass structure is LLVM at present. GCC reaches the same conclusions through different internal representations and different flags, and the two disagree in detail about which transformations undefined signed overflow licences.
targetBackend properties — register count, whether an instruction exists, the calling convention, the cost model — are per-target and per-ABI. A backend claim without a target triple attached is not a claim. See [[target-triples]].

If you were asked this in an interview

  • Why is a compiler split into three parts rather than two or one? Give the arithmetic.
  • A C program and a Rust program compute the same integer loop and the C version is faster at -O2. What is your first hypothesis?
  • Why does Rust have MIR when it already emits LLVM IR?

Connections

Performancecpu-profiling
Domains that do not exist yet
  • DevOps / Production Engineering — Depending on a large third-party toolchain component and its release cadence
    Choosing a shared middle-end is a supply-chain and upgrade-cadence decision as much as a technical one, and the operational half of that argument lives there.