all_lessons/ai_compilers / lessons/04 · rewriteslesson 5 / 20

Part III — Middle end

Rewrites & canonicalization

Lesson 03 handed you a typed SSA dataflow graph: every value defined once, every tensor carrying its shape, dtype, and device, every op drawn from a small, well-defined set. That representation has rules — which is precisely what makes it safe to rewrite. But a freshly captured graph is a mess: constants you could have folded at compile time, the same subexpression computed twice, nodes whose results nobody reads, and dozens of x + 0 / x · 1 spelled forty different ways. This lesson builds the engine that cleans all of that up — match a subgraph, replace it with an equivalent one, repeat until nothing changes — and the catalog of cheap, always-valid passes it drives. None of these are the big win. They are the setup: every one of them shrinks the graph and normalizes its shape so the high-leverage pass next door — fusion — has clean, matchable patterns to grab.

The previous step left this broken
You now have a principled IR — typed, single-assignment, a dataflow DAG you can transform locally and soundly. What you do not have is anything that transforms it. The captured graph is whatever the front end emitted: a trace will have baked scale = 1 / √64 as a live div node instead of the constant 0.125; the residual block recomputes x + bias in two places; a debugging print branch left a whole subgraph dangling with no consumer; and a generic Linear emitted matmul; add(bias); mul(1.0) where the mul(1.0) does nothing. Hand that graph to a fusion pass and it will choke — it can't recognize patterns that appear in five spellings, and it will dutifully fuse dead nodes and redundant copies into kernels that never needed to exist. Before you can do anything clever, you need the boring, correct machinery that puts the graph into one normal form and deletes everything pointless.
Linear position
Forced by: a raw captured SSA graph is full of redundancy (repeated subexprs, dead nodes, foldable constants) and non-canonical spellings (x·1, 0+x, div-by-a-constant) that block every later pass from matching what it's looking for.
New idea: the pass / pattern-rewrite engine — match a subgraph, replace it with a provably-equivalent one, iterate to a fixed point — driving the staple cleanups: constant folding, common-subexpression elimination (CSE), dead-code elimination (DCE), algebraic simplification, all funneled through canonicalization into one normal form. The hard invariant: every rewrite must preserve semantics — and floating-point is not associative, so reassociation is only legal under fast-math.
Forces next: these cleanups still leave one kernel per op — each surviving node is its own HBM round trip. The highest-leverage rewrite is to merge ops into one kernel: fusion (lesson 05).
The plan
Five moves. (1) Build the rewrite engine: pattern → replacement → iterate to a fixed point, and what makes a rewrite legal. (2) The staples with tiny before→after IR — constant folding, CSE, DCE. (3) Algebraic simplification, and the float-associativity caveat made concrete (a reassociation that changes the answer; fast-math). (4) Canonicalization — why one normal form multiplies the value of every pattern. (5) Phase ordering: passes interact, greedy order isn't optimal, and e-graphs / equality saturation as the principled escape. Then drive the playground until the graph hits its fixed point.

1 · The rewrite engine: match, replace, iterate to a fixed point

Every middle-end optimization in this lesson is the same shape. A rewrite rule is a pair: a pattern — a small subgraph template, possibly with wildcards (“any value a times a constant 1”) — and a replacement — the equivalent subgraph to splice in (“just a”). The engine walks the IR, finds where a pattern matches, swaps in the replacement, and rewires the SSA edges so every consumer of the old result now reads the new one. Because the IR is single-assignment with value semantics (lesson 03), that rewire is purely local: no aliasing, no hidden mutation, nothing downstream silently changes meaning.

The catch is that one rewrite enables another. Fold a constant and a subexpression two nodes over suddenly becomes a duplicate that CSE can collapse; collapse it and a third node loses its last consumer and becomes dead. So the engine doesn't run each pass once — it iterates the whole rule set until a full sweep changes nothing. That terminal state is a fixed point: applying any rule produces a graph identical to the one you have. f(g) = g. As long as every rule strictly simplifies (fewer nodes, or a canonical form that's never undone), the iteration is guaranteed to terminate — there's a finite amount of graph to shrink.

rewrite rule
A pattern (subgraph template + wildcards) paired with an equivalent replacement. mul(a, 1) → a. Must be a provable no-op on the math.
fixed point
The state where no rule fires anymore — one full sweep leaves the graph unchanged. The engine loops until it reaches it.
worklist
In practice you don't re-scan the whole graph each sweep; you queue only the nodes whose inputs just changed, so the engine touches each node ≈ once per enabling event.

This is not exotic. It is exactly what MLIR's PatternRewriter and applyPatternsAndFoldGreedily do over a set of RewritePatterns; what XLA's HLO passes do as an algebraic-simplifier fixpoint; and what torch.compile's Inductor does with its declarative pattern matcher (register_replacement) before it lowers to Triton. Different IRs, identical engine: match a subgraph, replace it with an equivalent, iterate.

2 · The staples: constant folding, CSE, DCE

Constant folding evaluates, at compile time, any op whose inputs are all known constants, and replaces the op with its result. The trace baked 1 / √64 as a live computation; folding turns it into the literal 0.125 so it costs nothing at run time. It cascades: fold one node and its constant output may make the next node foldable too.

before                              after constant folding
  %a = const 64                       %a = const 64        (now dead — see DCE)
  %b = sqrt %a        ; = 8.0         %scale = const 0.125
  %s = div 1.0, %b    ; = 0.125
  %y = mul %x, %s                     %y = mul %x, 0.125

Common-subexpression elimination (CSE) finds two nodes that compute the same op on the same inputs and keeps one, redirecting all consumers of the second to the first. Trivial in SSA: hash each node by (op, input-ids, attrs); equal hashes with equal inputs are the same value. The residual block that recomputed x + bias twice now computes it once.

before                              after CSE
  %h1 = add %x, %bias                 %h1 = add %x, %bias
  %h2 = add %x, %bias    ; dup        ; %h2 removed; its uses point to %h1
  %p  = relu %h1                       %p = relu %h1
  %q  = gelu %h2                       %q = gelu %h1

Dead-code elimination (DCE) deletes any node whose result has no consumers and no side effect — the leftover print-branch subgraph, the constant %a = 64 that folding orphaned, the second add that CSE just disconnected. In an SSA DAG this is one reverse sweep: anything not reachable backward from the graph's outputs is dead. DCE is what makes folding and CSE pay off — they disconnect nodes; DCE actually removes them so the kernel count and memory plan shrink.

before                              after DCE
  %a = const 64        ; no uses      ; %a removed
  %dbg = sum %x        ; no uses      ; %dbg removed
  %y = mul %x, 0.125                  %y = mul %x, 0.125
  return %y                           return %y

The three run as a fixpoint loop, not once each, because they feed each other: fold → orphans constants → DCE; CSE → disconnects a node → DCE; DCE → exposes a now-single-use node → in-place fusion later. Run them to a fixed point and the graph contains exactly the live, distinct computation and nothing else.

3 · Algebraic simplification — and the float-associativity trap

Algebraic simplification rewrites a subgraph into a cheaper algebraically-equal one. These are the identity rules every compiler ships:

identity elements
x + 0 → x, x · 1 → x, x · 0 → 0, x / 1 → x. The generic Linear's leftover mul(1.0) just vanishes.
strength reduction
a / √b → a · rsqrt(b) (one rsqrt beats a sqrt + div); x / c → x · (1/c) for constant c; as mul(x,x).
structural cancels
two consecutive transposes that invert each other → drop both; reshape to the same shape → drop; cast to the current dtype → drop; exp(log x) → x.
before                              after algebraic simplify
  %t1 = transpose %w  (0,1)→(1,0)     ; both transposes removed
  %t2 = transpose %t1 (1,0)→(0,1)     %y = mul %x, %w        ; (uses %w directly)
  %r  = div %a, sqrt(%b)              %r = mul %a, rsqrt(%b)
  %z  = mul %r, 1.0                   ; mul-by-1 removed

Every rule above is an exact identity on real numbers — that's the license to apply it. But tensors are floating-point, and floating-point is not the real numbers. The one rule that looks just as innocent and is not safe is reassociation: (a + b) + c → a + (b + c). Over reals these are equal; over IEEE-754 they are not, because each + rounds.

(1×10²⁰ + (−1×10²⁰)) + 1.0 = 0.0 + 1.0 = 1.0
1×10²⁰ + ((−1×10²⁰) + 1.0) = 1×10²⁰ + (−1×10²⁰) = 0.0

Same three numbers, different grouping, answers that differ by 1.0 — because in the second grouping the 1.0 is swallowed by the rounding of a number 20 orders of magnitude larger. Reassociation can also create or destroy overflow/NaN/Inf. So a correct compiler treats + and · on floats as non-associative and non-distributive by default: it will not reorder a reduction, balance a sum tree, or distribute a·(b+c) → a·b + a·c unless you opt in. That opt-in is fast-math--ffast-math, XLA's xla_allow_excess_precision / reduced-precision-reassociation, the LLVM fast / reassoc / contract flags, PyTorch's TF32 and reduced-precision-reduction toggles. Fast-math says “I accept that the compiler may reassociate and contract, and my result may differ in the last bits.” It is the explicit boundary of the preserve-semantics invariant: every other rule in this lesson is bit-exact; reassociation trades a few ulps of accuracy for the freedom to reorder. Know which side of that line a rewrite sits on, because a silent reassociation is how a model's loss curve quietly diverges from the reference run.

4 · Canonicalization: one normal form so patterns match

Here is the move that makes everything above worth more than the sum of its parts. Canonicalization rewrites every equivalent spelling of a thing into one chosen canonical form — a single normal form. Commutative ops get their operands sorted into a fixed order (constant on the right: add(2, x) → add(x, 2)); x - c becomes add(x, -c); div-by-constant becomes mul by the reciprocal; subtraction folds into addition; a chain of reshapes collapses to one. The point is not that the canonical form is faster — it's that it is unique.

Why does uniqueness matter so much? Because every later pass is a pattern matcher, and a pattern matcher only fires when the IR looks like its template. Without canonicalization, a fusion rule that wants “matmul followed by add(bias)” would need to also spell out add(bias, matmul), matmul then add-via-sub-of-negative, and every other variant — a combinatorial explosion of near-duplicate patterns, most of which someone forgets to write. Canonicalize first and you write the pattern once, against the one form the IR is guaranteed to be in. Canonicalization doesn't optimize; it makes optimization matchable. In MLIR it's literally a built-in pass (-canonicalize) that ops register getCanonicalizationPatterns into; XLA folds it into the algebraic simplifier; Inductor normalizes before its pattern matcher runs. The discipline: canonicalize → fold → CSE → DCE, iterate, and only then reach for the passes that actually move bytes.

add(2, x) x · 1 + 2 2 + x · 1.0 x − (−2) add(x, 2) ⟵ canonical 1 pattern matches

5 · Phase ordering, and the e-graph escape

There's a worm in the greedy fixpoint. Passes interact, and the order you run them in changes the result — this is the phase-ordering problem. A rewrite that looks like a win locally can block a much bigger win two passes later. Classic case: greedily fold a·b + a·c down by some local rule and you may destroy the a·(b+c) factoring that fusion wanted; canonicalize a transpose away and you might forfeit a layout that a later pass could have exploited. Greedy rewriting commits to the first improvement it sees and can never take a step that's temporarily worse to reach a better global form. No fixed pass order is optimal for all graphs, and finding the best sequence is, in general, intractable.

The principled escape is equality saturation over an e-graph (equality graph). Instead of destructively replacing the matched subgraph — throwing away the original — you add the equivalent form and record that the two are equal. An e-graph compactly represents all the equivalent programs you've discovered at once: nodes are grouped into equivalence classes (“e-classes”), and a rewrite just merges classes rather than mutating the graph. You apply every rule everywhere, repeatedly, until the e-graph saturates (no rule adds anything new — a fixed point over the whole space of rewrites), and only then run a cost-model-driven extraction that picks the single cheapest program out of the exponentially many the e-graph encodes. Because nothing was ever destroyed, you sidestep phase ordering entirely: the engine never had to choose an order, it kept everything.

This is the idea behind TASO and PET (superoptimizing tensor graphs by exploring equivalent subgraphs) and the egg library (fast, general e-graphs and equality saturation) now used in several ML and DSL compilers. The cost is real — the e-graph can blow up in memory, and extraction with a realistic cost model (one that accounts for fusion, layout, and traffic, not just node count) is itself hard. So today's production stacks are mostly greedy-fixpoint with carefully tuned phase orders, and equality saturation is the frontier you reach for when the greedy order leaves measurable speed on the table. Either way, the engine's job is done: a clean, canonical, redundancy-free graph — handed to the pass that actually collapses the bytes.

6 · Drive it: the rewrite playground

The graph below is a deliberately messy little program with one of each problem: a constant expression (1/√64), a repeated subexpression (x + bias twice), a dead node (an unused debug sum), and a no-op mul by 1.0. Apply the passes in any order and watch #nodes, #ops (the kernels you'd launch), and est. cost shrink toward a fixed point — when every button is disabled, the graph is saturated. Then flip “reassociate floats?”: it offers an extra reduction-reordering rewrite, and warns you that it may change the result in the last bits. That toggle is the preserve-semantics line drawn as a switch.

Rewrite playground — clean a messy graph to its fixed point
Each pass matches a subgraph and replaces it with an equivalent. Apply them in any order; the order can differ but the fixed point is the same (for these exact-identity rules). The knob that breaks: turn on reassociate floats and the engine offers a rewrite that is not bit-exact — fast-math only.
#nodes
#ops (kernels)
est. cost
at fixed point?
no

Notice what the scoreboard cannot do: it bottoms out with one live op per surviving node. Constant fold deleted compute, CSE deleted a duplicate, DCE deleted the dead, algebraic deleted the no-ops — but matmul, add(bias), and gelu all remain as separate ops, and in eager execution that's three kernels and three HBM round trips. Cheap rewrites shrink the graph; they don't merge the survivors. That's the wall this lesson hands off.

Failure modes & checklist

Failure modes

  • Reassociating floats by default. Treating (a+b)+c = a+(b+c) as always-safe to reorder a reduction or balance a sum tree. Signal: compiled loss curve drifts from the eager reference, worse in fp16/bf16; differences vanish when you disable fast-math.
  • Distributing over floats. Rewriting a·(b+c) → a·b + a·c “to expose fusion.” Signal: last-bit numerical mismatches and occasional new overflow/NaN that the original grouping never produced.
  • Skipping canonicalization. Writing fusion/layout patterns directly against raw captured IR. Signal: a pattern that “sometimes” fires — it matches add(matmul, bias) but misses add(bias, matmul); speedups are flaky across models.
  • Folding a non-constant. Evaluating an op at compile time whose input only looks constant (a buffer mutated elsewhere, a shaped-but-dynamic value). Signal: wrong results that depend on runtime data the compiler baked in.
  • No fixed point / oscillation. Two rules undo each other (canonicalize x-c → x+(-c) while another rewrites the reverse). Signal: the fixpoint loop never terminates or hits its iteration cap.
  • DCE deleting a side effect. Removing a node with no SSA consumer that nonetheless has an effect (RNG state advance, collective, in-place write). Signal: dropout stops being random, or a missing all-reduce corrupts a distributed run.

Checklist

  • Canonicalize first. One normal form, then fold/CSE/DCE/algebraic — so every later pattern is written once.
  • Iterate to a fixed point. Run the rule set in a worklist loop until a full sweep changes nothing; don't run each pass once.
  • Prove each rule exact, or gate it. Bit-exact identities run by default; reassociation/distribution/contraction only under an explicit fast-math flag.
  • DCE only the truly dead. No SSA consumer and no side effect; treat RNG, collectives, and in-place writes as effectful.
  • Hash CSE by (op, inputs, attrs). Equal hash + equal inputs ⇒ same value in SSA; redirect uses, then let DCE remove the duplicate.
  • Reach for e-graphs when greedy stalls. If phase ordering is leaving speed on the table, equality saturation + cost-model extraction is the principled fix.

Checkpoint

Try it
Open the playground and apply the passes in the order fold → CSE → DCE → algebraic; record the #nodes after each. Reset, then run algebraic → DCE → fold → CSE and confirm you reach the same fixed point — different path, identical destination, because every rule here is an exact identity. Now reset and turn on reassociate floats: a new rewrite becomes available and the warning lights up. Apply it and note that #ops drops further — the standalone scale multiply is contracted into a reordered (scaled_reduce) reduction, so the separately-computed scale becomes dead — but you've left the bit-exact regime, because reordering the reduction rounds differently. This is the rewrite a default-correct compiler refuses without fast-math. Finally, read the floor: the graph stalls at matmul · add(bias) · gelu, three separate ops. Predict the kernel count and HBM round trips an eager run pays for that floor, then read lesson 05 to see fusion collapse it to one.

Where this points next

The engine did its job: a typed SSA graph is now canonical, constant-folded, free of duplicates and dead nodes, and algebraically tightened — and crucially, it's in the one normal form that later pattern matchers expect. But look at what survives. Every cleanup in this lesson deleted work; none of them merged work. The graph still runs as one kernel per op, and from lesson 01 we know that's the expensive part: each surviving memory-bound op pays its own HBM round trip, loading and storing the whole activation just to do a few FLOPs per element. Constant folding and CSE removed nodes that never needed a kernel; they did nothing about the kernels that remain. The single highest-leverage rewrite left is categorically different — instead of removing a node, fuse a chain of them into one kernel that reads from HBM once, does all the math while the data is hot in registers, and writes once. Everything in this lesson was setup: a clean, canonical graph is exactly what makes fusion patterns findable. That is lesson 05, Operator fusion — the central win.

Takeaway
A captured SSA graph arrives full of redundancy and a dozen spellings of the same thing, which blocks every later pass from matching what it wants. The fix is one reusable engine: a rewrite rule = pattern + equivalent replacement; run the rule set in a worklist loop until a fixed point where nothing fires. The staples — constant folding (evaluate all-constant ops at compile time), CSE (collapse duplicate subexpressions, trivial in SSA via a (op, inputs, attrs) hash), DCE (delete results with no consumer and no side effect), and algebraic simplification (x·1→x, a/√b → a·rsqrt(b), cancel consecutive transposes) — feed each other and run together. Canonicalization drives them all into one normal form so each downstream pattern is written once instead of in every variant; it doesn't optimize, it makes optimization matchable. The bounding invariant is preserve semantics: every rule here is bit-exact except reassociation — floats are non-associative ((1e20 + −1e20) + 1 = 1 but 1e20 + (−1e20 + 1) = 0), so reordering reductions is legal only under explicit fast-math. Greedy fixpoint order isn't optimal (phase ordering); the principled escape is equality saturation over an e-graph (TASO, egg) — keep all equivalent forms, then extract the cheapest. And yet every cleanup only deleted work; the survivors still run one kernel per op, each paying its own HBM round trip — which forces the rewrite that merges ops: fusion.

Interview prompts