IRimplementation

Designing an IR: The Four Decisions

SSA or not, typed or untyped, how much target detail to admit, and linear or graph. LLVM IR, Cranelift CLIF, GCC GIMPLE and V8 TurboFan answer those four differently and all four are correct — because they were built to be fast at different things.

The question

If I were designing an IR, what are the decisions, and what does each one actually cost me?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

The IR under design — a data structure that has not been committed to yet. What this lesson exists to answer is not a question about a program but about a design space: which invariants the representation enforces, and therefore which invariants no pass has to re-establish and which pass bugs are unrepresentable.

What this phase may assume or do

An IR design is sound when every program the representation can express has exactly one meaning, and every transformation a pass can perform maps a well-formed program to a well-formed program. The practical form of this: if the representation can encode a use before its definition, or a phi with the wrong number of operands, then every consumer must either defend against it or be a latent miscompilation — which is the argument for [[ir-verification]].

Key points

  • The four decisions are: SSA or not, typed or untyped, how much target detail, and linear or graph.
  • SSA buys free def-use chains and cheaper dataflow, and costs a construction algorithm, a destruction algorithm, and the critical-edge and parallel-copy problems that come with leaving it.
  • A typed IR makes a class of pass bug unrepresentable rather than detectable, and costs every transformation the work of maintaining the types.
  • "Target-independent" in practice means "parameterised by a data layout", not "identical on every target".
  • Sea-of-nodes makes reordering free by never having an order, and pays in legibility, tooling and debuggability — costs real enough that V8 moved away from it for newer tiers.

Decision one: SSA or not

implementationLLVM IR is SSA by construction — the textual form cannot express two definitions of the same value, so the invariant is enforced by the type system of the IR itself. GCC GIMPLE exists in both forms and converts between them. V8 TurboFan is SSA within a graph that has no basic blocks until scheduling. CPython bytecode is not SSA and has no equivalent notion, because it is a stack machine with no value names at all. All four are current as of the mid-2020s and all four change.

In SSA form, every value is assigned exactly once, and a merge of two definitions is written explicitly as a phi node. The benefit is that a use points at exactly one definition, so def-use chains are free and most dataflow analyses become substantially cheaper — constant propagation, for instance, no longer needs a separate reaching-definitions analysis because the answer is in the name.

The cost is real. Construction is a nontrivial algorithm requiring dominance and the dominance frontier; leaving SSA again requires resolving phi nodes into copies, and doing that correctly requires handling parallel-copy cycles and critical edges. AtlasLang implements all of it, and the sharp edges — the swap problem, the critical edge — are visible in outOfSSA because they are unavoidable, not because the implementation is naive.

GCC's GIMPLE has both: it is used in a non-SSA form early and converted to SSA for the main optimization pipeline. That is the honest middle answer, and it costs a conversion in each direction.

Decision two: typed or untyped

A typed IR carries a type on every value and rejects an instruction whose operand types do not match its signature. LLVM IR is typed: add i32 %a, %b will not verify if %a is an i64, and a getelementptr must be given a type to index into. The benefit is that a whole class of pass bug becomes unrepresentable rather than merely detectable — a pass that builds an ill-typed instruction fails immediately, at the pass that built it, rather than three passes later in a code generator that assumed otherwise.

The cost is that every transformation must maintain the types, and any transformation that would be natural to express without them becomes more work. LLVM famously spent years moving from typed pointers (i32*) to opaque pointers (ptr) precisely because the pointee type was carrying no information the optimizer could trust and a great deal of complexity every pass had to maintain. That is a real, documented example of a typing decision being partly reversed after the costs were measured.

An untyped IR — or a weakly typed one, where everything is a machine word — is simpler to build and simpler to transform, and pushes the checking to the frontend and the human. Cranelift takes a middle path: values have machine types (i32, i64, f64, vector types) rather than source types, which is enough to catch operand mismatches without carrying any source-language type system.

Decision three: how much target detail to admit

An IR that knows nothing about the target is maximally portable and maximally unable to make target-sensitive decisions. An IR that knows the register file can schedule for it, and can no longer be shared. Every real design picks a point on that line and then leaks a little.

LLVM IR is nominally target-independent, and in practice is not quite: pointer size, endianness, alignment rules and the availability of vector widths all reach it through the data layout string and target features. Anyone who has moved LLVM IR from a 64-bit to a 32-bit target has discovered that "target-independent" means "parameterised by the data layout", not "identical everywhere".

Cranelift admits more, on purpose. It was built to compile WebAssembly quickly inside a running process, where compile time is a latency budget rather than a build-time cost, so it accepts target awareness earlier in exchange for a shorter path to machine code. That is not a worse design; it is a design for a different constraint — and it is the same reasoning that makes a [[jit-compilation]] tier-one compiler look nothing like an ahead-of-time one.

Four real IRs, four different sets of answersimplementation
IRSSATypedShapeBuilt to be fast at
LLVM IRimplementationYes, by constructionYes — a full type system with a verifierLinear instructions in basic blocksProducing good code for many languages and many targets, with compile time as a secondary concern
Cranelift CLIFimplementationYesMachine types only, not source typesLinear instructions in basic blocks, with block parameters instead of phi nodesCompiling fast enough to be on a request path, with predictable compile time
GCC GIMPLEimplementationBoth — non-SSA early, SSA for the optimizerYes, carrying GENERIC typesLinear three-address statementsA mature multi-language optimizer with a long history of target support
V8 TurboFan (sea-of-nodes)implementationYes, within the graphTypes as an analysis result, refined by the optimizerOne graph of value, effect and control edges; no blocks until schedulingSpeculative optimization of JavaScript using runtime type feedback

Decision four: linear, tree or graph

implementationV8's TurboFan used sea-of-nodes; its Maglev tier and the newer Turboshaft framework use block-based CFG representations instead, and the V8 team has written publicly about the debugging and compile-time costs that motivated the change. Any claim about "what V8 uses" needs a version attached. HotSpot C2 remains sea-of-nodes.

A linear IR is a list of instructions per block. Order is explicit, printing is trivial, and every pass knows exactly where an instruction sits. A tree IR keeps the nesting and is what an AST-based compiler uses. A graph IR — sea-of-nodes, as in V8 TurboFan and historically in HotSpot C2 — records only *dependencies*: value edges, effect edges and control edges, with no instruction order at all until a scheduling pass invents one at the end.

The sea-of-nodes argument is that most reordering optimizations are free, because there was never an order to preserve. Global value numbering falls out of node identity, and code motion is a matter of where the scheduler eventually places a node rather than a transformation that has to move anything. That is genuinely powerful for a JIT doing aggressive speculation.

The cost is legibility and debuggability, and it is not a small cost. There is no natural textual form, so tooling is a graph viewer rather than a diff. Reasoning about *when* something happens requires reasoning about the scheduler. And a bug in the effect-edge chain produces a reordering that is very hard to see. V8 has publicly moved away from sea-of-nodes for its newer Maglev and Turboshaft tiers, citing exactly these costs — which is the strongest available evidence that this is a genuine tradeoff and not a settled question.

How it works

The steps, in the order the compiler takes them.

  • Decide what the IR must make impossible, not what it must make possible — every invariant enforced is a defensive check that a hundred passes do not have to write.
  • Decide whether values are single-assignment, and if so, budget for construction, destruction and a verifier from the start rather than retrofitting.
  • Decide what a type means in this IR: source types, machine types, or nothing, and what the verifier will check as a result.
  • Decide the target-independence boundary explicitly, and write down what the IR is parameterised by — pointer size, endianness, alignment — so the leaks are documented rather than discovered.
  • Decide whether instruction order is part of the representation or produced by a scheduler, and accept the tooling consequences of a graph if the answer is the latter.
  • Give the IR a textual form and a parser early, because every test that cannot be written by hand is a test that will not be written.

How it breaks

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

  • The IR can represent something meaningless — a phi with too few operands, a use before its definition — and a pass produces one. The symptom appears in a much later pass or in the code generator, and the stack trace points at the innocent consumer.
  • The IR is untyped and a pass mixes a 32-bit and a 64-bit value. The program compiles and produces a wrong result on inputs above a threshold, which is the hardest possible bug to reproduce.
  • The IR leaked a target assumption, and the first cross-compile produces code that is subtly wrong about pointer size or struct alignment — usually surfacing as memory corruption rather than as a compile error.
  • The graph IR reorders two operations because an effect edge was missing between them, and a volatile write or an atomic operation moves. Nothing is detectable in a single-threaded test.
  • There is no textual form, so no pass can be tested in isolation, and every regression test is an end-to-end compile of a whole program.

When it helps

  • Choosing an existing IR to build on. The four decisions are exactly the axes on which LLVM, Cranelift and a hand-rolled IR differ, and knowing which one you need decides the project.
  • Diagnosing why a compiler is slow to compile. Compile time is usually dominated by IR design decisions — how much is allocated per instruction, whether analyses are cached, how often the CFG is rebuilt.
  • Reviewing a pass. Most pass bugs are invariant violations, and knowing which invariants the IR enforces tells you which ones the pass must maintain itself.

When it hurts

  • Designing a fresh IR for a project that could have used an existing one. The design space is interesting and the maintenance cost is permanent; most projects that build their own do so for a reason they could state in one sentence, and the ones that cannot should not.
  • Optimising the representation before there are passes. Which decisions matter depends on what the passes need, and building a beautiful IR with no consumers reliably produces a beautiful IR that is wrong for its eventual consumers.

What it costs

Every one of these is paid by something.

  • SSA buys cheap dataflow and def-use chains, and pays with construction, destruction, phi handling, and the critical-edge case that must be solved before any copies can be placed correctly.
  • A typed IR buys early detection and unrepresentable bug classes, and pays in every transformation having to maintain types — a cost LLVM measured and partly reversed with opaque pointers.
  • Target independence buys retargetability and pays in code quality and compile time, because target-sensitive decisions must be deferred to a later representation.
  • Sea-of-nodes buys free reordering and pays in tooling, legibility and debuggability — the reason it remains a minority choice despite the theoretical advantages.
  • Every invariant the IR enforces buys correctness everywhere and pays at the construction sites, which must now satisfy it.

What else you could do

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

  • Do not design one: emit LLVM IR or Cranelift CLIF and inherit an optimizer, a verifier, a textual form and a set of backends. This is the right default for a new language and the reason [[llvm-architecture]] matters.
  • Use continuation-passing style or A-normal form, the standard choices in functional-language compilers, where control flow and data flow are unified and phi nodes are replaced by parameters.
  • Use block parameters instead of phi nodes — Cranelift, Swift SIL and MLIR all do this. It is provably equivalent to SSA and removes a whole family of phi-placement bugs, at the cost of being unfamiliar to anyone who learned SSA from LLVM.
  • Use MLIR, which is a framework for defining IRs rather than an IR: dialects let a project keep a high-level representation and a low-level one in the same infrastructure, at the cost of a large dependency and a steep concept count.

See it for yourself

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

  • llvm-as < file.ll runs the LLVM parser and verifier over textual IR and reports exactly which invariant a hand-written file violates.
  • clif-util compile --verbose file.clif compiles Cranelift IR from its textual form with the verifier enabled, which is the same test loop for a different design point.
  • gcc -fdump-tree-ssa writes GIMPLE in SSA form, and -fdump-tree-gimple writes it before the conversion — the two files side by side are decision one, made visible.
  • node --trace-turbo writes TurboFan graph data for the Turbolizer viewer, which is the only practical way to look at a sea-of-nodes IR.
  • MLIR's mlir-opt --show-dialects lists the dialects available, which is the concrete form of "an IR framework rather than an IR".

Plausible wrong readings

Stated the way a confident engineer states them.

  • "LLVM IR is the right design and the others are compromises." LLVM IR is a design for a multi-language ahead-of-time compiler where compile time is secondary. Cranelift is a design for compiling on a request path. Neither would do the other's job well.
  • "A typed IR prevents miscompilation." It prevents ill-typed IR. A pass can build a perfectly well-typed instruction that computes the wrong thing, and no verifier will notice.
  • "Sea-of-nodes is strictly more powerful." It makes reordering free and everything else harder. The teams with the most experience of it have been moving away from it, which is the relevant evidence.
  • "SSA is a property of the IR, so I can just declare it." It is an invariant that construction must establish and every pass must maintain, and it must be checked, or it is a claim rather than a property.

Misconceptions

The claim, and what is actually true.

Phi nodes are fundamental to SSA.
They are one encoding of it. Block parameters — Cranelift, Swift SIL, MLIR — express exactly the same thing by making the merge an argument list on the block, and remove a whole class of phi-placement bug.
A more powerful IR produces faster code.
It produces a larger space of expressible transformations. Whether the code is faster depends on which passes exist and how much compile time they are allowed, and compile-time budget is a first-class constraint for anything running in a JIT.
You can add a verifier later.
You can, and by then the invariants have been violated in places that now depend on the violation. The cheap time to enforce an invariant is before there is code that relies on breaking it.

Go deeper

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

overview

Designing an IR comes down to four questions: is each value assigned exactly once, do values carry types, how much does the representation know about the machine, and is the program a list of instructions or a graph of dependencies. Different compilers answer differently because they are optimising for different things — LLVM for code quality across many languages, Cranelift for compile speed, V8 for speculating on runtime types.

practical

If you are building a language, the practical version of this lesson is: do not design an IR. Emit LLVM IR for code quality and target coverage, or Cranelift CLIF if you need compilation to be fast enough to happen at run time, and revisit only when you can name the specific thing your language knows that the shared IR cannot express. That is the same conclusion Swift and Rust reached, and both of them kept a private IR *above* the shared one rather than replacing it.

internals

The decisions interact in ways that are only visible once you build one. SSA plus critical edges means phi resolution needs edge splitting, which changes the CFG, which invalidates dominance, which the next pass will recompute — so an IR that enforces "no critical edges" as an invariant makes out-of-SSA trivial at the cost of extra blocks everywhere. Block parameters make phi arity a structural property that cannot be wrong, at the cost of every jump carrying an argument list. Types plus a graph representation means the scheduler must preserve typing as well as effects. There is no configuration of these choices with no cost; the design work is choosing which cost lands on which team.

How much this depends on

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

implementationEvery IR described here is a moving target. LLVM removed typed pointers over several releases; V8 replaced TurboFan's sea-of-nodes for newer tiers; Cranelift changed its terminator and block-parameter design during its own development. Any claim of the form "X uses Y" needs a version, and this lesson describes the mid-2020s state.
typicalThe four decisions are the ones that discriminate between mainstream compiler IRs. They are not exhaustive: memory modelling, how effects are ordered, and whether the IR can express undefined behavior explicitly are each large design areas that a real project would also have to settle.
simplifiedAtlasLang answers all four cheaply: SSA as an optional pass rather than an invariant, types on instructions but no verifier enforcing them, no target detail at all, and a linear form. That is a defensible design for a teaching compiler and would be an indefensible one for a production toolchain, for reasons this lesson lists.

If you were asked this in an interview

  • You are designing an IR for a new language. Walk me through the four decisions and justify each answer.
  • What does SSA cost? Not what it buys — what it costs.
  • Why would a JIT choose a different IR design from an ahead-of-time compiler?
  • Block parameters or phi nodes. Which, and why?

Connections

Performancejit-and-warmup
Domains that do not exist yet
  • Software Design — Making illegal states unrepresentable
    The strongest argument in IR design is the same one that domain makes about type-driven design: an invariant the representation enforces is one no consumer has to check. This lesson is that principle applied to a compiler's central data structure.