FForge
← Labspasswork-02Pass work●●○○

Read what one pass actually did

passwork/02_read_what_a_pass_did.md
evidence to leave with
Your before/after for one pass, and the single edit to the input that made the transformation illegal
<!-- track: passwork difficulty: 2 needs: opt (LLVM 17+) -->

Read what one pass actually did

WHAT THIS TEACHES

Interviews ask you to look at IR before and after and say which transformation ran and why it was legal. The way to get good at that is not to read the whole -O2 output — 217 passes report a change on a twelve-line function — but to run one pass at a time on input small enough that the diff is obvious, until you recognise each transformation on sight.

The input is hand-written IR rather than clang output, so there is nothing between you and the change.

BUILD

Nothing to build. 02_read_what_a_pass_did.ll is the input.

STEP 1 — GVN removes a redundant load

$ opt -passes=gvn -S 02_read_what_a_pass_did.ll -o - | sed -n '/@redundant_load/,/^}/p'
define i32 @redundant_load(ptr %p) {
entry:
  %a = load i32, ptr %p, align 4
  %sum = add i32 %a, %a
  ret i32 %sum
}

Two loads became one and %b was replaced by %a everywhere. Say why it is legal: nothing writes through %p between them, and %p is not volatile, so both loads must observe the same value.

Now make it illegal, and watch alias analysis decide. Put a store through a different pointer between the loads:

define i32 @redundant_load(ptr %p, ptr %q, i32 %v) { %a = load i32, ptr %p store i32 %v, ptr %q %b = load i32, ptr %p %sum = add i32 %a, %b ret i32 %sum }
$ opt -passes=gvn -S that.ll -o -
  %a = load i32, ptr %p, align 4
  store i32 %v, ptr %q, align 4
  %b = load i32, ptr %p, align 4      ; both loads kept
  %sum = add i32 %a, %b

GVN backs off, because %q might alias %p. Add one word:

$ sed 's/ptr %p, ptr %q/ptr noalias %p, ptr noalias %q/' that.ll | opt -passes=gvn -S -
  %a = load i32, ptr %p, align 4
  store i32 %v, ptr %q, align 4
  %sum = add i32 %a, %a               ; redundant load gone again

That is the entire value of restrict in C, in three lines of IR: the optimization was always legal, the compiler just could not prove it. Worth running once yourself — it is the clearest demonstration of alias analysis you will find, and it answers the interview question about restrict with a transcript instead of an opinion.

One more, because it surprises people: store a constant rather than %v through %p itself, and GVN does not keep two loads — it forwards the stored value directly (add i32 %a, 7). That is store-to-load forwarding, a different optimization wearing the same pass's name.

STEP 2 — DCE removes a value nothing reads

$ opt -passes=dce -S 02_read_what_a_pass_did.ll -o - | sed -n '/@dead_arithmetic/,/^}/p'
define i32 @dead_arithmetic(i32 %x) {
entry:
  %r = add i32 %x, 1
  ret i32 %r
}

The multiply is gone. Legal because the instruction has no users and no side effects — the second half matters, which is why DCE will not touch a store or a call it cannot prove is pure.

STEP 3 — SimplifyCFG, and a result worth stopping on

$ opt -passes=simplifycfg -S 02_read_what_a_pass_did.ll -o - | sed -n '/@constant_branch/,/^}/p'
define i32 @constant_branch(i32 %x) {
entry:
  %always = icmp sge i32 4, 3
  %x. = select i1 %always, i32 %x, i32 0
  ret i32 %x.
}

It flattened the branch into a select — and left icmp sge i32 4, 3 sitting there, obviously constant, unfolded. That is not a bug. SimplifyCFG's job is control flow; folding constants is InstCombine's. Passes are deliberately narrow.

STEP 4 — WHICH IS WHY THE PIPELINE ALTERNATES THEM

$ opt -passes=instcombine -S 02_read_what_a_pass_did.ll -o - | sed -n '/@constant_branch/,/^}/p'
define i32 @constant_branch(i32 %x) {
entry:
  br i1 true, label %yes, label %no
yes:
  ret i32 %x
no:
  ret i32 0
}

InstCombine folds the comparison to true — and leaves the dead block, because deleting blocks is SimplifyCFG's job. Each pass does half. Run both:

$ opt -passes='simplifycfg,instcombine' -S 02_read_what_a_pass_did.ll -o - | sed -n '/@constant_branch/,/^}/p'
define i32 @constant_branch(i32 %x) {
entry:
  ret i32 %x
}

Both orders reach the same answer here. That is why the real pipeline runs simplifycfg and instcombine repeatedly rather than once: each creates work the other can then do, and the fixed point is reached by alternating.

STEP 5 — WATCH THE WHOLE PIPELINE, BUT ONLY THE PARTS THAT CHANGED

$ opt -O2 -print-changed 02_read_what_a_pass_did.ll -o /dev/null 2>&1 | grep -c "IR Dump After"
269

-print-changed prints a dump only when a pass actually modified the IR, which is the difference between a readable log and forty thousand lines. -print-after=<pass> narrows it to one pass; -print-after-all is the blunt version and is almost never what you want.

WHAT TO SAY IN AN INTERVIEW

For any before/after, answer three things in order:

  1. What changed — name the instructions that appeared and disappeared.
  2. Which pass — from the shape of the change, not a guess.
  3. Why it was legal — the property of the input the pass relied on, and the single edit to the input that would make it illegal.

The third is what separates someone who has read about passes from someone who has debugged one.

TRY THIS YOURSELF

  1. Mark one load volatile and re-run. GVN backs off even with noalias. Say why volatile is different from "might alias" — one is about what the value is, the other about whether the access itself is observable.
  2. Run opt -passes=gvn -aa-trace or -print-changed on the aliasing version to see the query being answered rather than inferring it from the result.
  3. Replace the dead multiply with a call to an unknown function and re-run DCE. It stays, because the call might have side effects. Add readnone to the declaration and it disappears.
◇
Finish the lab: write the evidence note

A lab is done when you can say what it showed on your machine — not when you have read it.