· 6 min read
Just one more IR
Compilers need several intermediate representations because each lowering stage should discard source meaning only after the analyses and transformations that depend on it have finished.

I kept a dump directory for a four-line max function because I thought the x86 backend had performed the clever part. The assembly used cmov, so I went looking for the pass that removed the branch. GCC's GIMPLE dump already contained MAX_EXPR, and clang's LLVM IR had already turned the function into llvm.smax.
The extra layers look bureaucratic until you ask one representation to preserve Rust ownership facts, expose SSA for common-subexpression elimination, model x86 register constraints, and emit ELF relocations. No useful IR can carry all of that at once — each one keeps the facts its stage needs and lets the rest go.
An IR is a controlled act of forgetting.
Four lines, four vocabularies#
I ran GCC 14.2 and clang 17 on the same function. The generated names and attributes below are trimmed, but the operations are the ones those compilers emitted.
int max2(int a, int b) { return a > b ? a : b;}int max2 (int a, int b){ int result; result = MAX_EXPR <b, a>; return result;}define i32 @max2(i32 %a, i32 %b) { %result = call i32 @llvm.smax.i32(i32 %a, i32 %b) ret i32 %result}max2: mov eax, esi cmp edi, esi cmovg eax, edi retThe C source names a comparison and two possible values. GCC's GIMPLE, a restricted three-address representation derived from GENERIC, keeps the operation as a language-independent maximum. LLVM IR expresses the same fact as an SSA call to its signed-max intrinsic. Only the x86 form commits to flags, eax, and a conditional move.
By the time you reach assembly, the compiler has spent information on purpose. The cmovg no longer tells you whether the programmer wrote a ternary, called max, matched an enum, or triggered an optimizer pattern — and nothing downstream of instruction selection has any use for knowing.
The cross-product is the trap#
A tiny compiler can walk an AST and print assembly. The approach becomes ugly when every language feature has to know every target: Rust drops meet target ABIs, Haskell thunks meet stack maps. Add an architecture and the front end has to learn another register file. Add a language feature and every backend has to learn what it means.
Industrial compilers split that cross-product into handoffs, although they disagree about where each semantic debt should be paid. Their main routes look like this:
| Compiler | Main lowering path | What the layers retain |
|---|---|---|
| GCC | GENERIC → GIMPLE → RTL | Common front-end trees, restricted three-address optimization, then machine operations described nearly one by one. |
| rustc | HIR → THIR → MIR → LLVM IR | Type and trait checking, explicit typed expressions, borrow and drop control flow, then a shared native backend. |
| GHC | Core → STG → Cmm | Typed functional simplification, closures and lazy evaluation, then explicit runtime control and storage layout. |
| LLVM backend | LLVM IR → SelectionDAG/GlobalISel → MachineIR → MC | Portable SSA, legal target operations, virtual and physical registers, then labels, sections, and encodable instructions. |
Those pipelines disagree on names because their source languages owe different debts. Rust needs a place where implicit dereferences and method calls become explicit before control-flow lowering, and GHC needs a form its own source calls “ideally suited” to spineless-tagless code generation. Forcing both through one universal mid-level language would erase those facts early, or bloat the shared IR with features most clients cannot interpret.
The last IR still is not assembly#
LLVM IR already looks low-level, yet LLVM's own code generator keeps lowering. Four more stops sit between portable SSA and bytes in a file:
Instruction selection
SSA becomes legal target operations on unlimited virtual registers
Scheduling
orders those operations for the pipeline
Register allocation
maps virtual registers onto the physical file and inserts spills
MC layer
MachineInstr lowers to labels, sections, relocations, and encodable instructions
That separation earns its keep on awkward instructions. Signed x86 division requires particular physical registers, and a target-neutral division operation should not drag EAX and EDX through every optimizer. The constraint appears at instruction selection, where something can finally act on it, and register allocation handles the surrounding traffic.
Even after assembly text appears, an object writer still has to choose sections, create symbols, encode relocations, and preserve debug locations. The MC layer holds those facts once, for both the .s writer and the .o writer. Printing assembly directly from LLVM IR would fold all of it into one enormous pass with nowhere stable to stop and inspect the result.
Just one more IR#
I used to roll my eyes at rustc's THIR sitting between HIR and MIR. Then I followed one method call through the compiler: type checking had resolved the method and inserted implicit dereferences, while MIR wanted simple typed statements and explicit control-flow edges. THIR is where that semantic debt gets paid once instead of leaking into every MIR construction path.
MLIR makes the same argument openly. Its progressive lowering keeps tensors, loop nests, and data layouts visible while high-level transformations can still use them. Once pointers and branches replace those structures, a backend close to assembly cannot reliably reconstruct them — it would be reverse-engineering its own output.
Some IRs really are historical sediment, and I have not measured what each layer costs in compile time or maintenance. When two adjacent representations express the same invariants, one of them is a deletion candidate. My claim is only that the count by itself does not tell you which.
“Just one more IR!” Fine. Without the extra boundary, the borrow checker starts learning spill slots or the register allocator starts fielding questions about exhaustive patterns.
My dump directory still contains max2.c.006t.gimple, max2.c.253r.expand, max2.ll, and max2.s. The assembly is the shortest file. The moment I was trying to understand happened two representations earlier, before either compiler had chosen a register.