Skip to content
ComputingAmerica

Engineering

What building a compiler teaches you about modeling anything

Pick the wrong intermediate representation and every later stage pays for it. That is not a compiler problem; it is the problem.

5 min readComputing America

In short

  • The intermediate representation, not the algorithms, decides how expensive every later stage is. Choosing it well makes analyses that were fixed-point iterations into single lookups.
  • Static single assignment is a one-sentence constraint, one value assigned exactly once, that collapses the cost of reaching definitions, constant propagation and dead-code elimination all at once.
  • The naive placement of merge nodes is correct and unusable. Restricting placement to dominance frontiers, and pruning to variables actually live at the join, is the difference between a working pass and one nobody can run.
  • Every transformation that is easy to construct is hard to reverse. Lowering back out of SSA is where the lost-copy and swap bugs live, and every textbook covers it in a paragraph.
  • The same lesson governs business systems: a domain model that expresses the awkward case directly costs one hard week, and a model that cannot express it costs a special case in every feature thereafter.

Our engineers have built a compiler end to end: a typed source language, a hand-written scanner and recursive-descent parser, a type checker, an SSA-based intermediate representation, six optimizing passes and a graph-coloring register allocator, emitting machine code that runs. Roughly thirty-seven thousand lines. No client paid for it and none ever will.

It stays on the list of things we think an engineer should have done once, because a compiler is the purest available exercise in the skill that decides whether a business system ages well: choosing the representation that makes everything downstream cheap.

The front end is not the interesting part

Lexing and parsing get half the pages in most compiler books and roughly a tenth of the difficulty. A scanner is a state machine over a character window. A recursive-descent parser is one function per grammar rule with a constant-time membership check against each rule’s first set. Both are well-trodden, both are pleasant to write, and neither is where a compiler goes wrong.

This is worth saying because the same distortion shows up in business software. The part that is comfortable to build, the forms and the screens, absorbs attention that belongs to the part that decides whether the system can change: how the domain is represented.

compiler/Scanner.javaJava
public class Scanner implements Iterator<Token> {    private BufferedReader input;    private int lineNum, charPos;    private int nextChar, secondChar;   // 2-char lookahead     public Scanner(String name, Reader r) {        this.input = new BufferedReader(r);        this.nextChar   = readChar();        this.secondChar = readChar();    }    @Override public Token next() { /* DFA over the two-char window */ }}
The entire front end is this shape: a two-character window, a state machine, and an iterator so the parser can just call next(). Pleasant to write, well documented everywhere, and not where a compiler goes wrong.

One constraint that pays for everything

Static single assignment is a rule with one clause: every value is assigned exactly once. Where two control-flow paths merge and both define a value, you insert a merge node saying that the value here is whichever predecessor you arrived from. Cytron and colleagues published the efficient construction in 1991 and essentially every serious optimizing compiler since has used a descendant of it.

That single constraint collapses a pile of separately hard problems. Reaching definitions becomes a lookup rather than a fixed-point iteration. Use-def chains are implicit in the representation rather than a structure you maintain. Constant propagation becomes a graph traversal. Dead-code elimination becomes two passes over the use graph. None of those algorithms got smarter; the representation stopped fighting them.

Correct and unusable is a real category

The naive way to build SSA is to insert a merge node at every join for every variable. It is correct. It also produces an intermediate representation so large that the passes it was supposed to accelerate become slower than they were before. Cytron’s contribution is a criterion, the dominance frontier, that inserts only the merge nodes that can actually be needed, which in turn requires a working dominator tree, which requires implementing Lengauer-Tarjan first.

There is a further refinement worth naming because it is the cheapest one available: skip the merge node when the variable is not live at the join. Adding that liveness check turns minimal SSA into pruned SSA and stops every later pass from chasing definitions that can never be read. It is a two-line condition that measurably shrinks the work of everything after it.

compiler/SSA.java — phi placementJava
// For each variable v with definition sites D(v),// place a phi at every block in DF+(D(v)).var worklist = new ArrayDeque<>(entry.getValue());var hasPhi   = new HashSet<BasicBlock>();while (!worklist.isEmpty()) {    BasicBlock b = worklist.poll();    for (BasicBlock y : df.getOrDefault(b, Set.of())) {        if (hasPhi.contains(y)) continue;        if (!liveIn.get(y).contains(s)) continue;   // pruned SSA        y.addPhi(new Phi(s, y.getPredecessors().size()));        hasPhi.add(y);        worklist.add(y);    }}
Two of those lines are the whole difference between an intermediate representation you can run passes over and one that is technically correct and unusably large: the dominance-frontier lookup that bounds where a merge node can be needed, and the liveness check on line 10 that prunes the ones nothing will ever read.

That liveness check is a dataflow analysis in its own right, and it is worth watching settle rather than reading about: it runs backwards over the control-flow graph and iterates until nothing changes, which takes more than one pass the moment the code contains a loop.

Interactive · Dataflow analysis

Liveness, iterated to a fixpoint

Liveness analysis · backward dataflow to fixpoint

For every block: live_out = ⋃ live_in[s] for s ∈ successors, live_in = uses ∪ (live_out − defs). Process bottom-up; iterate until nothing changes. Hover any variable name to see where it’s live.

step 0 / 13

initial state: all sets empty

in: ∅B0 · entryx = 1out: ∅in: ∅B1 · headert = x + 1if t < 10 B2 else B3out: ∅in: ∅B2 · bodyx = x * 2out: ∅in: ∅B3 · exitreturn tout: ∅
live-inlive-outusesdefs
What this isA four-block control-flow graph with a backedge, so the analysis takes more than one pass to settle. This is the check that turns minimal SSA into pruned SSA and the input the interference graph is built from. Four blocks, not thirty-seven thousand lines: the shape is faithful, the scale is a teaching one.

Construction is easy. Deconstruction is not.

SSA is a fiction that real machines do not implement. Before registers can be allocated, merge nodes have to be lowered back into ordinary copies. The obvious lowering, copy your value into the merge destination at the end of each predecessor block, is correct in isolation and wrong in two specific situations.

  • The lost copy. If the value being copied is also read after the join, the copy can clobber something the join still needs.
  • The swap. If a block has two merge nodes that exchange values, sequential copies destroy one before the other can read it.

The fix is to lower through a parallel-copy primitive that detects cycles in the copy graph and breaks them with a temporary. It is a small amount of code and, for us, two days of debugging, because every reference describes SSA construction at length and its deconstruction in a sentence. That asymmetry is not an accident of the literature. The pattern generalizes: the transformation that is elegant to apply is rarely elegant to undo, and systems get built on the assumption that they will never need to.

Both failures are easier to see than to describe. Step the naive lowering through either scenario and the register that gets clobbered is visible on the step it happens.

Interactive · Compilers

Lowering out of SSA: the lost copy and the swap

Phi deconstruction · The swap problem

Two phis at a join exchange values. Naive copies destroy one before the other can read it.

step 0 / 3

Naive sequential copies

Lower each phi independently into a copy at the end of the predecessor block. Order matters and there is none.

x = y     // x_1 ← y_0
x7
y11

Parallel copy (cycle broken by temp)

Detect the cycle in the copy graph (x ↔ y) and insert one temporary to break it.

t = x     // save x_0 before it’s clobbered
x7
y11
t·
What this isTwo merge-node scenarios that the obvious lowering gets wrong, stepped one copy at a time, next to the parallel-copy lowering that gets them right. The register values and the orderings are worked through by hand and stored as a table: this panel steps you through the outcome the compiler’s rule produces, it does not run that rule.

Register allocation, and knowing what to spill

The classical formulation builds an interference graph, joining two values with an edge when both are live at the same point, and colors it with as many colors as there are physical registers. Coloring is NP-hard in general, so real allocators use a heuristic: repeatedly remove any node with fewer neighbors than there are registers, since those are guaranteed colorable, and when none remain, choose something to spill to memory and continue.

The interesting engineering is entirely in that choice. An allocator that keeps per-value statistics can prefer to spill a short-lived value outside a loop over a long-lived one carried across it, and the difference between those two decisions is a memory access on every iteration. Coalescing, which merges values connected by a copy when they do not interfere, removes the copy entirely. Both are heuristics. Both are where the quality lives.

Interactive · Register allocation

Graph coloring, and what gets spilled

Simplify · Color · 3 registers

Toy interference graph, K = 3. Phase 1 pushes any node with degree < K onto the stack and removes it. Phase 2 pops the stack and assigns each node the lowest color not used by its (original) neighbors.

abcde

phase

1 · simplify

stack (0)

empty

next

push a (deg 2 < 3)

palette

R0R1R2
What this isFive values, three registers, the simplify-then-color heuristic run one step at a time. The real allocator carries per-value statistics so it can prefer to spill something short-lived outside a loop; this graph is small enough that the heuristic never has to make that call, which is precisely the part the write-up says is where the quality lives.
compiler/RIG.javaJava
public class RIG {    record Edge(Variable a, Variable b) {}    private Set<Edge> edges = new HashSet<>();     private Stack<Variable> varStack = new Stack<>();    private Set<Variable> spilled    = new HashSet<>();     static class VarStats {        int usesDefs     = 0;        int maxLoopDepth = 0;    }    private Map<Variable, VarStats> varStats = new HashMap<>();    private Map<Variable, Variable> alias    = new HashMap<>();   // coalescing}
Two integers per value, and they are the entire spill policy: how often it is touched, and how deep in a loop nest. Everything else in the allocator is the textbook algorithm; those two fields are the part that decides whether the generated code takes a memory access on every iteration.

Where this lands in client work

We are not selling compilers. We are regularly asked to build the things that are compilers with the name filed off: pricing engines that evaluate versioned rule sets, underwriting appetite expressed as configuration a business user edits, eligibility logic that has to explain its own output, and integration layers that translate one domain’s vocabulary into another’s without letting either leak.

Every one of those is a question about representation before it is a question about logic. Can the model express the awkward case directly, or only as an exception? Can a rule change be reviewed, dated and reverted, or does it live in a WHERE clause? Can the system explain why it produced an answer, or only that it did? Teams that have thought hard about intermediate representations ask those questions in week one, which is the only week the answer is cheap.

Sources

  1. 1.Efficiently Computing Static Single Assignment Form and the Control Dependence Graph, Cytron, Ferrante, Rosen, Wegman & Zadeck,

Next step

Tell us what’s breaking.

Forty-five minutes, no charge, no deck. We’ll tell you what we’d do, what it would likely cost, and whether you should be building this at all.