Topic 4 · Lecture notes · Weeks 9–11

Control Flow, Flow Graphs, and What Optimization Actually Is

Lowering structured statements to jumps; basic blocks and the flow graph; and the data-flow framework that makes optimization something you can reason about rather than guess at.

Read alongside this

0Where this topic sits

1. Lexicalchars → tokensscanner.l2. Syntaxtokens → ASTparser.y3. SemanticAST → checked ASTsemantic.c4. IRAST → TACtac.c5. OptimizeTAC → better TACtac.c6. CodeGenTAC → MIPScodegen.c

Two things arrive together in this topic, and their arrival together is not a coincidence. Loops mean the program's control flow is no longer a straight line, so the compiler needs a way to talk about the paths a program may take. Optimization becomes worth doing for the first time, because an instruction inside a loop that runs a thousand times costs a thousand instructions.

The connecting idea is the flow graph. It is the representation that makes 'this instruction is inside a loop' a statement the compiler can check rather than a remark a programmer makes.

1There are no loops in a machine

A processor has exactly two control-flow instructions: jump unconditionally, and jump if a register is (or is not) zero. Every while, for, if and switch in every language you have used is a pattern of those two. Turning a structured statement into that pattern is called lowering, and Dragon §6.6.3 gives the standard schemes.

while (C) Bbegin:    t = C    ifFalse t goto after    B    goto beginafter:for (I; C; U) B    Ibegin:    t = C    ifFalse t goto after    B    U    goto beginafter:do B while (C)begin:    B    t = C    ifTrue t goto beginafter:
Three loops, one pattern (Dragon §6.6.3). Every structured loop becomes a label, a test, a conditional jump out, the body, and a jump back. A for is a while with two extra pieces; a do-while is a while with the test moved to the bottom. Once you have lowered one, you have lowered all of them — which is the payoff for having done the work carefully the first time.

Read the three columns side by side. The for loop is a while loop with an initializer hoisted above and an update appended to the body; the do-while is a while with the test at the bottom. This is why many compilers desugar — rewrite one form into another in the front end — and only ever lower one loop shape.

Test at the top means zero iterations are possible

A while whose condition is initially false must execute its body zero times. That is not an implementation detail, it is what the construct means, and it is fixed by where the test is emitted. Put the test at the bottom and you have silently implemented do-while — a real language construct, but not the one you documented. Your test suite needs a program whose loop runs zero times, or this bug will not show up.

break, and why it is not a grammar question

A break means jump to the exit label of the innermost enclosing loop. The compiler needs no analysis to work that out: when it starts lowering a loop it pushes that loop's exit label on a stack, and pops it when it finishes. Any break in between emits a jump to whatever is on top. Nesting then works for free.

Whether a break is legal is a different question, and it is the smallest possible example of a context-sensitive rule. No grammar can express 'this statement is legal only inside a loop', because the grammar for break_stmt is satisfied wherever the statement appears. A depth counter in the semantic analyzer settles it — and that is exactly the territory Topic 2 §8 said phase 3 exists to cover.

2Basic blocks and the flow graph

Once control flow branches, the instruction list is no longer the right way to think about the program. Dragon §8.4 introduces the two structures that are.

A basic block is a maximal sequence of consecutive instructions such that control enters only at the first and leaves only at the last. Finding them is a three-rule algorithm:

1i = 1leader: first instruction2s = 03L1:  t1 = i <= 10leader: target of a jump4ifFalse t1 goto L25s = s + ileader: follows a jump6i = i + 17goto L18L2:  print sleader: target of a jump
Finding the leaders (Dragon §8.4.1, Algorithm 8.5). A basic block starts at a leader, and there are exactly three kinds: the first instruction, any jump target, and any instruction immediately following a jump. Each block runs from its leader to just before the next. This is the whole algorithm — three rules and one pass — and it is what turns a flat instruction list into a graph.

A flow graph then has one node per basic block and an edge from B to C whenever control can pass directly from the end of B to the start of C:

B1i = 1s = 0B2t1 = i <= 10ifFalse t1 goto B5B3s = s + iB4i = i + 1goto B2B5print sback edgefalse
A flow graph (Dragon §8.4). The TAC is partitioned into basic blocks — maximal straight-line runs, entered only at the top and left only at the bottom — and the edges show where control can go. The highlighted blocks form a loop, identified by the back edge from B4 to B2. Almost every optimization worth having is stated in terms of this graph rather than in terms of the instruction list.

Two things become sayable that were not sayable before. A loop is a set of nodes with a single entry point, reachable from each other — identified in practice by a back edge, an edge from a node to one that dominates it (Dragon §9.6). And this instruction executes n times per entry to the procedure becomes a property of the graph rather than a guess.

Why our optimizer forgets everything at a label

A label is the start of a basic block, which means control can arrive there from any of its predecessors. A fact established on one incoming path — 'x holds 5' — is not a fact on the others. Without a flow graph the compiler cannot tell which predecessors exist, so the only safe policy is to discard what it knew. Building the flow graph is exactly what would let it do better, and that is the single largest improvement available to your optimizer.

3The principal sources of optimization

Dragon §9.1 catalogues the transformations, and divides them in a way worth keeping:

transformationbeforeafterCommon subexpressiont1 = b - ct2 = a * t1t3 = b - ct4 = a * t3t1 = b - ct2 = a * t1t4 = t2Copy propagationt1 = xy  = t1 + 2t1 = xy  = x + 2Constant foldingt = 2 * 3t = 6Dead code eliminationt1 = xy  = x + 2   (t1 never read)y = x + 2Code motion (loop-invariant)while (i <= n - 1)    ...t = n - 1while (i <= t)    ...Strength reductiont = i * 4    (in a loop)t = t + 4   (per iteration)
The principal sources of optimization (Dragon §9.1). The first four are function-preserving transformations that apply anywhere; the last two are loop optimizations, and they matter far more, because an instruction removed from a loop body is removed once per iteration.

Function-preserving transformations

These change how a computation is performed without changing what it computes, and they apply anywhere:

TransformationThe condition that makes it safe
Common subexpression eliminationthe expression was previously computed and none of its operands has changed since
Copy propagationafter x = y, uses of x may become y until either is reassigned
Constant foldingboth operands are literals — so the compiler can do the arithmetic instead of the processor
Constant propagationthe variable provably holds that constant at this point on every path reaching it
Dead-code eliminationnothing reads the result afterward, and computing it has no other effect

Note how many of those conditions contain the words previously, since, afterward, or every path. Each one is a question about the flow graph, which is why §4 exists.

Loop optimizations, which matter more

TransformationWhat it doesWhy it pays
Code motionmove a loop-invariant computation out of the loopexecutes once instead of once per iteration
Induction-variable eliminationrecognize variables that advance in lockstep and keep only oneremoves a whole variable and its updates
Strength reductionreplace an expensive operation with a cheaper one computing the same thinga multiply per iteration becomes an add
Loop unrollingreplicate the body, doing several iterations per testremoves tests and jumps — at the cost of code size

You have already met one strength reduction: array indexing emits a shift rather than a multiply, because the element width is a power of two. It was there before phase 5 existed, in the code generator, which is a reminder that optimization is not confined to the phase named after it.

4Data-flow analysis

Every condition in the table above is a question of the form what is true at this point, given all the ways control could have reached it? Dragon §9.2 gives a single framework that answers all of them.

basic block Bthe transfer function f_BIN[B]OUT[B]predecessorsIN[B] = ∧ over predecessors P of OUT[P]OUT[B] = f_B( IN[B] )Solve by iterating both equations over every block until nothing changes.
The data-flow abstraction (Dragon §9.2). Every classical analysis has this shape: a value at the entry of each block, a value at the exit, a transfer function describing what the block does, and a meet operator combining information arriving from several paths. Reaching definitions, live variables and available expressions differ only in what the values are and which direction the equations run.

Three classical analyses instantiate the framework, and they are worth knowing by name because almost every optimization is built on one of them:

AnalysisQuestion it answersDirectionEnables
Reaching definitionswhich assignments to x could be the one in effect here?forwardconstant propagation; use-before-definition warnings
Live variableswill the value in x be read again before being overwritten?backwarddead-code elimination; register allocation
Available expressionshas b - c already been computed on every path here?forwardcommon-subexpression elimination

The solution method is the same in all three cases: initialize, then apply the equations repeatedly until nothing changes. That it terminates is not obvious, and Dragon §9.3 proves it: the values form a lattice of finite height, the transfer functions are monotone, so the iteration can only move in one direction and must stop. You do not need the proof — but knowing that a termination argument exists is the difference between using a technique and hoping.

What ours does instead

Our optimizer performs no data-flow analysis. It walks the instruction list, keeps a small set of facts about constants and copies, and discards all of them at every label. That is local optimization — Dragon §8.5, within a basic block — and it is safe precisely because it never claims anything across a control-flow join.

The upgrade path is clear and is the best optional extension in this topic: build the flow graph, implement reaching definitions, and constant propagation becomes valid across blocks. That is the difference between a local optimizer and a global one.

Why is it unsafe to propagate a constant across a label without further analysis?

5Optimizing within a basic block

Dragon §8.5 shows what can be done with no flow analysis at all, using a DAG built from the block: one node per computed value, with identical subexpressions sharing a node. Building it detects common subexpressions automatically, since two occurrences of b - c map to the same node. Reading the DAG back out yields the optimized block, and dead code is simply whatever the reconstruction does not visit.

Peephole optimization (Dragon §8.7) is the other technique that needs no analysis. Slide a small window over the generated code and rewrite recognizable patterns:

    redundant load/store        eliminate flow-of-control
      sw  $t0, x                    goto L1        ->    goto L2
      lw  $t0, x        ->        L1: goto L2
      (the load is redundant)

    algebraic simplification      strength reduction
      add $t0, $t0, 0   ->  gone    mul $t, $t, 4  ->  sll $t, $t, 2

Peephole passes are cheap, are usually run repeatedly because one rewrite can enable another, and catch a surprising amount of what earlier phases leave behind.

6Running to a fixed point

The transformations feed each other, so no single pass finds everything:

pass 1t0 = 2 * 3x  = t0t1 = x + 0y  = t1t2 = y * 1print t26 instructionspass 2t0 = 6x  = 6t1 = xy  = xt2 = yprint t2folded, propagatingpass 3x = 6y = 6print 6copies collapsed, temps deadpass 4print 6no further change — fixed point
Running to a fixed point. No single pass finds all of this: folding enables propagation, propagation enables more folding, and the resulting dead temporaries can only be removed once nothing reads them. The optimizer therefore repeats until a pass changes nothing. Terminating is guaranteed because every transformation here strictly reduces a measure that cannot go below zero — the argument Dragon makes in general in §9.3.

This is why optimizeTAC repeats until a pass reports no changes, and why it reports how many passes it took. It is also a small instance of the same iterate-to-convergence idea that underlies data-flow analysis — which is why understanding it here makes §9.2 much easier to read.

7Measuring the result

Project 4 asks you to quantify the gain, and quantifying is where most reported speedups fall apart. Two distinct numbers are available:

CODE SIZEcounts each instruction oncemeasured: TAC or MIPS instruction countremoving 17 setup instructions saves 17reported by the compiler itselfWORK DONEcounts each instruction per executionmeasured: instructions executed, from SPIMremoving 1 instruction from a 300-iteration loop saves 300reported by the simulatorThese two numbers will disagree, and the size of the disagreement tells you how muchof what you removed was inside a loop.
Two different questions. Project 4 asks you to quantify the gain from optimization, and the first thing to get right is which gain you are quantifying. Report both, and explain the gap — the explanation is worth more than either number on its own.

What makes a number defensible

  1. State the baseline. 'Thirty per cent faster' is meaningless without 'than the unoptimized output of the same compiler on the same program'.
  2. State the benchmark. A speedup measured on a program with a hot loop tells you about hot loops. Include the source.
  3. Repeat the measurement. Three runs minimum, and report the spread. If the spread is eight per cent, a five per cent improvement is not a result.
  4. Say which metric. Code size and work done are different questions, and the gap between them is itself informative.
SPIM is not a timing simulator

It does not model pipelines, caches or memory latency, so wall-clock seconds from SPIM mean very little. Instructions executed is the honest metric: it is deterministic, exactly reproducible, and directly attributable to your compiler's output rather than to the machine you happened to run on.

8The approach for this milestone

Lower one loop form completely before starting the second

Get while through all six phases and running in SPIM before touching for. The second form then takes an hour, because the machinery — labels, jumps, the break stack, the branch cases in the back end — already exists and is tested.

Decide desugaring versus direct lowering deliberately

Desugaring for into while in the parser keeps the back end smaller and guarantees the two forms cannot drift apart. A dedicated case keeps the generated TAC readable and leaves room to optimize for specifically. Both are defensible; Lab Question 18 asks which you chose.

Write the zero-iteration test first

A loop whose condition is false at entry must run its body zero times. It is the one behavior that distinguishes correct lowering from plausible lowering, and it is the test people forget.

Make the optimizer report what it did

A count per technique, and a pass count. Without it you cannot tell a working optimizer from one that silently does nothing, and you have no evidence for the write-up. The report is the deliverable for half of Project 4.

Do the measurement in week 11, not the night before

It takes longer than expected, and the same numbers are reused in Project 6. Doing it properly once saves doing it badly twice.

9Self-check

Where does a basic block begin?

Your optimizer reports 35% fewer TAC instructions, but SPIM reports only 4% fewer instructions executed. What is the most likely explanation?

Which analysis would you need in order to eliminate t = a + b when t is never used again?

Why must the optimizer be run repeatedly rather than once?

A while loop whose condition is false at entry runs its body zero times. What in the lowering guarantees that?

10References