Lesson 8.2 — Basic blocks, control-flow graphs and traversal orders¶
Techniques: basic blocks and the leaders algorithm, control-flow graphs and critical edges (and their splitting), depth-first orders (preorder, postorder, reverse postorder) · Pebble uses: PIR is written as basic blocks; every analysis from Ch 14 on walks CFGs in RPO · Lab: labs/ch08-cfg · Prerequisites: Lesson 8.1 · Time: 4–6 hours
A TAC listing (Lesson 8.1) is a flat list with jumps. Almost every analysis wants a graph instead: nodes that execute as a unit, and edges for the possible jumps. This lesson builds that graph (the control-flow graph), fixes its one troublesome kind of edge, and defines the traversal orders that the rest of the course iterates in.
1. Problem and motivation¶
The problem. Given a listing \(c_0 \dots c_{n-1}\) (Definition 8.1.2), partition it into the largest pieces that always execute from start to end, connect the pieces by the jumps between them, and number the pieces so that "earlier" means "before, unless a loop brings you back".
Basic blocks and the leaders algorithm¶
A basic block is a maximal straight-line run: control enters only at its first instruction and leaves only after its last. Treating a block as one node shrinks the graph (the running example has 19 instructions and 10 blocks) and makes local reasoning (Lesson 8.3) sound. The one-pass leaders algorithm is in every compiler textbook [Dragon2, §8.4.1; EaC3, Ch. 4]. Allen's 1970 paper made basic blocks and flow graphs the foundation of control-flow analysis [All70].
Control-flow graphs and critical edges¶
The control-flow graph (CFG) has the blocks as nodes and an edge \(u \to v\) when control can pass from the end of \(u\) to the start of \(v\). One kind of edge causes trouble: a critical edge leaves a block with several successors and enters a block with several predecessors. No existing block can hold code that runs only on that edge (Lemma 8.2.11). Out-of-SSA translation (Ch 16), partial redundancy elimination (Ch 17) and register allocation (Ch 22) all need to place code on edges, so compilers split critical edges. LLVM's break-crit-edges pass and Swift's mandatory critical-edge splitting [SIL-Docs] are two examples.
Depth-first orders¶
A depth-first search (DFS) from the entry numbers blocks in preorder (on discovery) and postorder (when finished). Reversing the postorder gives reverse postorder (RPO), which puts every block before its successors except along loop back edges (Theorem 8.2.14). This is why forward dataflow analyses converge fastest in RPO (Ch 14), why dominator algorithms process blocks in RPO (Ch 15), and why LLVM ships a ReversePostOrderTraversal class [LLVM-RPOT]. Tarjan's paper introduced the DFS numbering and edge classification used here [Tar72].
2. Definitions and algorithms¶
Definition 8.2.1 (Jumps, targets, fall-through)
In a listing \(c_0 \dots c_{n-1}\) (Definition 8.1.2), \(c_i\) is a jump if it is goto,
if, ifz or return. Its targets \(T(i)\) are the positions of the labels it names
(none for return). It falls through to \(i+1\) unless it is goto or return. A
listing is closed if every named label exists and \(c_{n-1}\) does not fall through.
(The lab's TAC reader accepts only closed listings.) An execution is the sequence of
positions \(p_0 = 0, p_1, \dots\) visited by the semantics.
Basic blocks and the leaders algorithm¶
Definition 8.2.2 (Basic block)
A basic block of a closed listing is an interval \([s, e]\) of positions such that (i) no instruction \(c_i\) with \(s \le i < e\) is a jump, and (ii) no position \(j\) with \(s < j \le e\) is the target of any jump. A partition of \(\{0, \dots, n-1\}\) into basic blocks is maximal if no two consecutive blocks \([s, e]\), \([e+1, e']\) can be merged into the basic block \([s, e']\).
Algorithm 8.2.3 (Leaders)
- Input: a closed listing \(c_0 \dots c_{n-1}\), \(n \ge 1\).
- Output: the sorted leaders \(\ell_0 = 0 < \ell_1 < \dots < \ell_{m-1}\) and the blocks \([\ell_k, \ell_{k+1} - 1]\) (the last one ends at \(n - 1\)).
- Precondition: the label table (label to position) has been built.
- Postcondition: the blocks form the unique maximal partition into basic blocks (Theorem 8.2.9).
- Invariant: after scanning \(c_0 \dots c_i\), the set \(\mathit{Lead}\) contains \(0\), every target of \(c_0 \dots c_i\), and every \(j + 1 \le i + 1\) with \(c_j\) a jump, \(j \le i\).
function Leaders(c[0..n)):
Lead ← {0} # rule 1: the first instruction
for i in 0 .. n-1:
for t in T(i): Lead ← Lead ∪ {t} # rule 2: a jump target
if c[i] is a jump and i + 1 < n:
Lead ← Lead ∪ {i + 1} # rule 3: after a jump
ℓ ← sort(Lead)
blocks ← [ (ℓ[k], ℓ[k+1] - 1) for k in 0 .. |ℓ|-2 ] + [ (ℓ[|ℓ|-1], n - 1) ]
return ℓ, blocks
Control-flow graphs and critical edges¶
Definition 8.2.4 (Control-flow graph of a listing)
Let \(B_0, \dots, B_{m-1}\) be the blocks of Algorithm 8.2.3 in order, \(B_k = [\ell_k, e_k]\). The CFG is \(G = (N, E, r)\) with \(N = \{B_0, \dots, B_{m-1}\}\), entry \(r = B_0\), and \(B_k \to B_j \in E\) iff \(\ell_j \in T(e_k)\), or \(c_{e_k}\) falls through and \(j = k + 1\). The successor list of \(B_k\) is ordered: the jump target first, then the fall-through block, with duplicates removed. (This order matters for DFS.) \(\mathrm{preds}(v)\) and \(\mathrm{succs}(v)\) are as in NOTATION.md §3.
Definition 8.2.5 (Critical edge; splitting)
An edge \(u \to v\) is critical if \(\lvert \mathrm{succs}(u) \rvert > 1\) and \(\lvert \mathrm{preds}(v) \rvert > 1\). Splitting \(u \to v\) adds a new block \(x\) and replaces the edge by \(u \to x \to v\), where \(x\) contains only a jump to \(v\).
Algorithm 8.2.6 (Split every critical edge of a listing)
- Input: a closed listing \(L\) with CFG \(G = (N, E, r)\) (Definition 8.2.4).
- Output: a closed listing \(L'\) whose CFG is \(G\) with every critical edge split.
- Precondition: the critical edges \(C \subseteq E\) are computed on \(G\) (not updated during the loop).
- Postcondition: \(L'\) has no critical edge; \(\lvert N' \rvert = \lvert N \rvert + \lvert C \rvert\) and \(\lvert E' \rvert = \lvert E \rvert + \lvert C \rvert\); \(L'\) and \(L\) return the same value (Theorem 8.2.12).
- Invariant: every edge already processed is either not critical in \(G\) or has been replaced by a path through a new block whose only instruction is
goto.
function SplitCriticalEdges(L):
(G, blocks) ← CFG(L); C ← { u→v ∈ E : |succs(u)| > 1 and |preds(v)| > 1 }
appended ← []; after ← empty map
for each block u whose last instruction j = c[e_u] is `if a goto L` or `ifz a goto L`:
taken ← block of L; fall ← the block after u
if u→taken ∈ C:
x ← FreshLabel(); appended.add( x: goto LabelOf(taken) )
retarget j to x # u → x → taken
if fall ≠ taken and u→fall ∈ C:
after[e_u] ← goto LabelOf(fall) # a new block right after u
return L with after[i] inserted after position i, then appended at the end
# LabelOf(b) returns b's first label, adding a fresh one if b has none.
# A `goto` or `return` block has one successor at most, so only if/ifz blocks are sources.
Depth-first orders¶
Definition 8.2.7 (DFS, pre-, post- and reverse postorder, edge kinds)
Let \(G = (N, E, r)\) have ordered successor lists. The DFS from \(r\) is
Visit(r) with Visit(n): mark \(n\); append \(n\) to the preorder; for each
\(s \in \mathrm{succs}(n)\) in order, if \(s\) is unmarked, Visit(s); append \(n\) to the
postorder. \(\mathrm{pre}(n)\) and \(\mathrm{post}(n)\) are the positions (from 0) in these
sequences. Reverse postorder is the postorder reversed:
\(\mathrm{rpo}(n) = \lvert N_r \rvert - 1 - \mathrm{post}(n)\), where \(N_r\) is the set of
nodes reachable from \(r\). An edge \(u \to v\) between reachable nodes is a tree edge if
Visit(u) called Visit(v). Otherwise it is a back edge if \(v\) is an ancestor of \(u\)
in the DFS tree \(T\) (\(v \preceq_T u\), including \(u = v\)), a forward edge if
\(u \prec_T v\), and a cross edge otherwise.
Algorithm 8.2.8 (Iterative DFS with orders and edge kinds)
- Input: \(G = (N, E, r)\) with ordered successor lists.
- Output: preorder, postorder, RPO of the nodes reachable from \(r\), and the kind of every edge between reachable nodes.
- Precondition: none (any graph, reducible or not).
- Postcondition: the output equals that of the recursive
Visitof Definition 8.2.7. - Invariant: the stack holds exactly the nodes whose
Visithas started but not finished, in call order, each with the index of the next successor to try. A node is on the stack iff it is an ancestor of the node on top (the "gray" nodes).
function DFS(G = (N, E, r)):
pre ← []; post ← []; kind ← {}; onStack ← {}
Enter(r)
while stack is not empty:
(n, i) ← top(stack)
if i < |succs(n)|:
top(stack).i ← i + 1; s ← succs(n)[i]
if s not in pre: kind[n→s] ← tree; Enter(s)
else if s ∈ onStack: kind[n→s] ← back
else if index of s in pre > index of n in pre: kind[n→s] ← forward
else: kind[n→s] ← cross
else:
pop(stack); onStack ← onStack \ {n}; append n to post
return pre, post, reverse(post), kind
function Enter(n): append n to pre; onStack ← onStack ∪ {n}; push (n, 0) on stack
3. Worked example¶
The running example's TAC (Lesson 8.1 §3, labs/ch08-cfg/inputs/euler1.tac).
Leaders on the running example¶
One pass over the 19 instructions; each row is one rule firing (from tools/course/lib/irforms.py, leaders(listing, trace)):
| step | instruction | rule |
|---|---|---|
| 1 | 0 | first instruction |
| 2 | 18 | target of ifz t.0 goto L.1 at 4 |
| 3 | 5 | follows the jump ifz t.0 goto L.1 at 4 |
| 4 | 13 | target of if t.3 goto L.4 at 7 |
| 5 | 8 | follows the jump if t.3 goto L.4 at 7 |
| 6 | 13 | target of if t.5 goto L.4 at 10 |
| 7 | 11 | follows the jump if t.5 goto L.4 at 10 |
| 8 | 14 | target of goto L.5 at 12 |
| 9 | 13 | follows the jump goto L.5 at 12 |
| 10 | 16 | target of ifz t.1 goto L.3 at 14 |
| 11 | 15 | follows the jump ifz t.1 goto L.3 at 14 |
| 12 | 3 | target of goto L.0 at 17 |
| 13 | 18 | follows the jump goto L.0 at 17 |
Leaders (sorted): 0 3 5 8 11 13 14 15 16 18, so ten blocks:
| block | instructions | last instruction | successors (target first) |
|---|---|---|---|
| B0 | 0..2 | i = 1 |
B1 (falls through) |
| B1 | 3..4 | ifz t.0 goto L.1 |
B9, B2 |
| B2 | 5..7 | if t.3 goto L.4 |
B5, B3 |
| B3 | 8..10 | if t.5 goto L.4 |
B5, B4 |
| B4 | 11..12 | goto L.5 |
B6 |
| B5 | 13..13 | t.1 = 1 |
B6 (falls through) |
| B6 | 14..14 | ifz t.1 goto L.3 |
B8, B7 |
| B7 | 15..15 | s = add s, i |
B8 (falls through) |
| B8 | 16..17 | goto L.0 |
B1 |
| B9 | 18..18 | return s |
none |
flowchart TD
B0([B0]) --> B1[B1]
B1 --> B9[B9]
B1 --> B2[B2]
B2 --> B5[B5]
B2 --> B3[B3]
B3 --> B5
B3 --> B4[B4]
B4 --> B6[B6]
B5 --> B6
B6 --> B8[B8]
B6 --> B7[B7]
B7 --> B8
B8 --> B1
Critical edges on the running example¶
| edge | \(\lvert\mathrm{succs}(u)\rvert\) | \(\lvert\mathrm{preds}(v)\rvert\) | critical? |
|---|---|---|---|
| B0→B1 | 1 | 2 | no |
| B1→B9 | 2 | 1 | no |
| B1→B2 | 2 | 1 | no |
| B2→B5 | 2 | 2 | yes |
| B2→B3 | 2 | 1 | no |
| B3→B5 | 2 | 2 | yes |
| B3→B4 | 2 | 1 | no |
| B4→B6 | 1 | 2 | no |
| B5→B6 | 1 | 2 | no |
| B6→B8 | 2 | 2 | yes |
| B6→B7 | 2 | 1 | no |
| B7→B8 | 1 | 2 | no |
| B8→B1 | 1 | 2 | no |
Algorithm 8.2.6 handles all three as taken edges: it retargets if t.3 goto L.4 to crit.0, if t.5 goto L.4 to crit.1 and ifz t.1 goto L.3 to crit.2, then appends crit.0: goto L.4, crit.1: goto L.4, crit.2: goto L.3. The new listing has 22 instructions and 13 blocks, the report of ch08-cfg ends in critical: (none), and it still returns 33 (ch08-cfg --split euler1.tac | ch08-run --form=tac -; the lab's lit test tests/ch08/lit/cfg-split.test).
DFS orders on the running example¶
Every event of Algorithm 8.2.8 (successors in the order of the table above; tree edges show up as the following "visit" row):
| # | event | DFS stack (bottom→top) | preorder so far | postorder so far |
|---|---|---|---|---|
| 1 | visit B0 | B0 | B0 | |
| 2 | visit B1 | B0 B1 | B0 B1 | |
| 3 | visit B9 | B0 B1 B9 | B0 B1 B9 | |
| 4 | finish B9 | B0 B1 | B0 B1 B9 | B9 |
| 5 | visit B2 | B0 B1 B2 | B0 B1 B9 B2 | B9 |
| 6 | visit B5 | B0 B1 B2 B5 | … B2 B5 | B9 |
| 7 | visit B6 | B0 B1 B2 B5 B6 | … B5 B6 | B9 |
| 8 | visit B8 | B0 B1 B2 B5 B6 B8 | … B6 B8 | B9 |
| 9 | edge B8→B1: B1 is on the stack → back | B0 B1 B2 B5 B6 B8 | B9 | |
| 10 | finish B8 | B0 B1 B2 B5 B6 | B9 B8 | |
| 11 | visit B7 | B0 B1 B2 B5 B6 B7 | … B8 B7 | B9 B8 |
| 12 | edge B7→B8: finished, not a descendant → cross | B0 B1 B2 B5 B6 B7 | B9 B8 | |
| 13 | finish B7 | B0 B1 B2 B5 B6 | B9 B8 B7 | |
| 14 | finish B6 | B0 B1 B2 B5 | B9 B8 B7 B6 | |
| 15 | finish B5 | B0 B1 B2 | … B6 B5 | |
| 16 | visit B3 | B0 B1 B2 B3 | … B7 B3 | … B5 |
| 17 | edge B3→B5: cross | B0 B1 B2 B3 | ||
| 18 | visit B4 | B0 B1 B2 B3 B4 | … B3 B4 | |
| 19 | edge B4→B6: cross | B0 B1 B2 B3 B4 | ||
| 20 | finish B4 | B0 B1 B2 B3 | … B5 B4 | |
| 21 | finish B3 | B0 B1 B2 | … B4 B3 | |
| 22 | finish B2 | B0 B1 | … B3 B2 | |
| 23 | finish B1 | B0 | … B2 B1 | |
| 24 | finish B0 | (empty) | B0 B1 B9 B2 B5 B6 B8 B7 B3 B4 | B9 B8 B7 B6 B5 B4 B3 B2 B1 B0 |
- preorder: B0 B1 B9 B2 B5 B6 B8 B7 B3 B4
- postorder: B9 B8 B7 B6 B5 B4 B3 B2 B1 B0
- RPO: B0 B1 B2 B3 B4 B5 B6 B7 B8 B9
- back: B8→B1; forward: none; cross: B3→B5, B4→B6, B7→B8.
Preorder visits the exit B9 third, and RPO puts it last. Every edge except the back edge B8→B1 goes forward in RPO (Theorem 8.2.14). Here RPO happens to equal the layout order, because Algorithm 8.1.4 emits structured code in source order. LLVM's layout differs (the RPO box in §7).
The edge cases (labs/ch08-cfg/inputs/edges.tac)
0 x = 3 6 if x goto next
1 y = 0 7 ifz y goto done
unused: 8 y = add y, 100
2 y = add y, 1 done:
3 if x goto next 9 return y
next: dead:
4 x = sub x, 1 10 y = 7
5 y = add y, x 11 goto done
Leaders 0 4 7 8 9 10. The label unused is not a leader: no jump names it, so
instructions 1 and 2 stay in one block (maximality, Theorem 8.2.9). Instruction 3 jumps to
its own fall-through, which gives one edge B0→B1 (Definition 8.2.4 removes the
duplicate). B1 = [4, 6] has the self-loop B1→B1, a back edge. The unreachable block
B5 = [10, 11] appears in the block list but not in any order, and its edge B5→B4 still
makes B2→B4 critical. The self-loop B1→B1 is critical too (B1 has two successors and two
predecessors).
Try it
./course drill leaders --seed 5 --difficulty hard --solution (leaders, successors and critical
edges of a random listing), and ./course drill traversal-orders --seed 3 --difficulty medium
--solution (the same DFS trace as above on a random CFG; traversal-orders is an alias
of the rpo drill that Chapter 15 also uses).
4. Invariants and correctness¶
Basic blocks and the leaders algorithm¶
Theorem 8.2.9 (The leaders partition is the unique maximal basic-block partition)
For a closed listing, the intervals produced by Algorithm 8.2.3 (a) partition \(\{0, \dots, n-1\}\), (b) are basic blocks (Definition 8.2.2), and (c) form a maximal partition. (d) Every partition of the listing into basic blocks refines it: each of its blocks lies inside one leaders block.
Proof
(a) The leaders are sorted, and \(\ell_0 = 0\) because of rule 1. The intervals \([\ell_k, \ell_{k+1} - 1]\) and \([\ell_{m-1}, n - 1]\) are consecutive and cover \(0 \dots n-1\). (b) Take a block \([s, e]\) and \(s \le i < e\). If \(c_i\) were a jump, rule 3 would make \(i + 1\) a leader, with \(s < i + 1 \le e\), contradicting the fact that the block contains no leader other than \(s\). So condition (i) holds. If some \(j\) with \(s < j \le e\) were a target, rule 2 would make \(j\) a leader. So (ii) holds. (c) Take consecutive blocks \([s, e]\) and \([e + 1, e']\). Their boundary \(e + 1\) is a leader other than \(0\), so it was added by rule 2 (then \(e + 1\) is a target inside \([s, e']\), violating (ii)) or by rule 3 (then \(c_e\) is a jump with \(e < e'\), violating (i)). Either way the merge is not a basic block. (d) Let \(P\) be any partition into basic blocks and \([a, b] \in P\). Suppose some leader \(\ell\) satisfies \(a < \ell \le b\). Rule 1 does not apply (\(\ell > 0\)). Rule 2 makes \(\ell\) a target inside \([a, b]\), violating (ii). Rule 3 makes \(c_{\ell-1}\) a jump with \(a \le \ell - 1 < b\), violating (i). So \([a, b]\) contains no leader except possibly \(a\) and lies inside the leaders block that starts at the last leader \(\le a\). By (d), a maximal partition cannot be strictly finer than the leaders partition (some two of its blocks would lie in one leaders block and could be merged), so the maximal partition is unique.
Lemma 8.2.10 (Blocks execute as units; executions are CFG paths)
In every execution, whenever position \(\ell_k\) (the start of \(B_k\)) is visited, the next
\(e_k - \ell_k\) steps visit \(\ell_k + 1, \dots, e_k\) in order (unless the program stops at
a return). The sequence of blocks entered is a path in the CFG starting at \(B_0\).
Proof
By Definition 8.2.2(i), no instruction before \(e_k\) in the block is a jump, so each one falls through to the next. The step after \(e_k\) goes to a target of \(c_{e_k}\) or to \(e_k + 1\) (if it falls through). Both positions are leaders: a target by rule 2, and \(e_k + 1\) is \(\ell_{k+1}\) because the blocks are consecutive. The block started there is a successor of \(B_k\) by Definition 8.2.4. Induction on the number of blocks entered gives the path, starting at \(B_0\) because execution starts at 0.
Control-flow graphs and critical edges¶
Lemma 8.2.11 (Why a critical edge needs a new block)
Let \(u \to v\) be critical. Code placed at the end of \(u\) (before its jump) also runs on some other edge \(u \to w\) with \(w \ne v\). Code placed at the start of \(v\) also runs on some other edge \(x \to v\) with \(x \ne u\). So no existing block can hold code that executes exactly when control traverses \(u \to v\).
Proof
Since \(\lvert \mathrm{succs}(u) \rvert > 1\), there is a successor \(w \neq v\) (successor lists have no duplicates), and every execution that leaves \(u\) toward \(w\) runs the end of \(u\). Since \(\lvert \mathrm{preds}(v) \rvert > 1\), there is a predecessor \(x \ne u\), and every entry into \(v\) from \(x\) runs the start of \(v\). Any other block is not on every traversal of \(u \to v\), because traversing the edge enters \(v\) directly after leaving \(u\) (Lemma 8.2.10).
Theorem 8.2.12 (Algorithm 8.2.6 is correct)
For a closed listing \(L\) with critical edges \(C\), Algorithm 8.2.6 returns a closed listing \(L'\) that (a) returns the same value as \(L\) on every run, (b) has CFG \(G'\) equal to \(G\) with every edge \(u \to v \in C\) replaced by \(u \to x_{uv} \to v\) for a fresh block \(x_{uv}\), and (c) has no critical edge.
Proof
(a) Each change inserts a block whose only instruction is goto L_v. A retargeted jump
now reaches crit.k: goto L_v, which transfers to the same position as before in one
extra step. An inserted fall-through goto L_fall executes exactly when the branch falls
through and then transfers to the old fall-through position. No variable changes, so
the value returned is the same. The appended blocks come after the final instruction of
\(L\), which is a goto or return (closedness), so nothing falls into them, and each ends
in goto, so \(L'\) is closed. (b) The new instructions are jumps, and each is preceded by
a jump or carries a fresh label that is a jump target. So by rules 2 and 3 each is its own
leader and its own block, and the old blocks keep their extents. Old edges not in \(C\)
are unchanged, and each \(u \to v \in C\) now passes through its new block, which has
exactly one predecessor and one successor. (c) An edge of \(G'\) is an old non-critical
edge (its endpoints' degrees are unchanged: in each critical edge replaced at \(u\) or \(v\),
one edge was swapped for another), or \(u \to x\) with \(\lvert\mathrm{preds}(x)\rvert = 1\),
or \(x \to v\) with \(\lvert\mathrm{succs}(x)\rvert = 1\). None is critical.
Depth-first orders¶
Lemma 8.2.13 (Postorder decreases along every edge that is not a back edge)
For every edge \(u \to v\) between reachable nodes: if it is not a back edge, then \(\mathrm{post}(v) < \mathrm{post}(u)\). If it is a back edge, then \(\mathrm{post}(v) \ge \mathrm{post}(u)\).
Proof
Consider the moment Algorithm 8.2.8 examines \(u \to v\), while \(u\) is on top of the stack. Case 1: \(v\) is unvisited. Then \(u \to v\) is a tree edge, \(v\) is pushed and finishes before \(u\) resumes, so \(\mathrm{post}(v) < \mathrm{post}(u)\). Case 2: \(v\) is on the stack. Then \(v\) is an ancestor of \(u\) (invariant of Algorithm 8.2.8), so this is a back edge, and \(v\) finishes after \(u\) or is \(u\) itself, so \(\mathrm{post}(v) \ge \mathrm{post}(u)\). Case 3: \(v\) was visited and is no longer on the stack. Then \(v\) has already finished, while \(u\) has not, so \(\mathrm{post}(v) < \mathrm{post}(u)\). The edge is forward or cross, not back, because back edges are exactly case 2 (an ancestor of \(u\) is still on the stack).
Theorem 8.2.14 (RPO is a topological order of the graph without its back edges)
Let \(G_r\) be the subgraph of \(G\) induced by the nodes reachable from \(r\), and let \(A\) be \(G_r\) minus the DFS back edges. Then \(A\) is acyclic, and for every edge \(u \to v\) of \(A\), \(\mathrm{rpo}(u) < \mathrm{rpo}(v)\). In particular the entry comes first in RPO, and if \(G\) is acyclic, RPO is a topological order of \(G_r\).
Proof
By Lemma 8.2.13, every edge \(u \to v\) of \(A\) has \(\mathrm{post}(v) < \mathrm{post}(u)\), so \(\mathrm{rpo}(u) = \lvert N_r \rvert - 1 - \mathrm{post}(u) < \lvert N_r \rvert - 1 - \mathrm{post}(v) = \mathrm{rpo}(v)\). A cycle \(u_1 \to u_2 \to \dots \to u_1\) in \(A\) would give \(\mathrm{rpo}(u_1) < \mathrm{rpo}(u_2) < \dots < \mathrm{rpo}(u_1)\), a contradiction, so \(A\) is acyclic. The entry \(r\) is the last node to finish, so \(\mathrm{post}(r) = \lvert N_r \rvert - 1\) and \(\mathrm{rpo}(r) = 0\). If \(G\) is acyclic it has no back edges (a back edge \(u \to v\) with \(v \preceq_T u\) closes a cycle through tree edges), so \(A = G_r\).
Corollary 8.2.15 (Every cycle contains a back edge; RPO is not preorder)
(a) Every cycle of \(G_r\) contains at least one DFS back edge. (b) Preorder is not a topological order of \(A\) in general: in the running example, the cross edge B7→B8 has \(\mathrm{pre}(B7) = 7 > 6 = \mathrm{pre}(B8)\).
Proof
(a) Otherwise the whole cycle lies in \(A\), which Theorem 8.2.14 shows is acyclic. (b) The preorder of §3 is B0 B1 B9 B2 B5 B6 B8 B7 B3 B4, which puts B8 (position 6) before B7 (position 7), yet B7→B8 is an edge of \(A\). Cross edges always point from a later-visited subtree to an earlier one in preorder. RPO puts B7 (position 7) before B8 (position 8).
Back edges depend on the DFS — unless the CFG is reducible
Which edges are back edges depends on the successor order in general. For reducible CFGs (all of the running examples, all Tiny and PIR programs from structured sources) the back edges are the same for every DFS: exactly the edges whose target dominates their source. Chapter 15 proves this (Theorem 15.6.4 in Lesson 15.6).
5. Complexity¶
Variables: \(n\) = instructions, \(m = \lvert N \rvert\) blocks, \(e = \lvert E \rvert\) edges (\(e \le 2m\) for TAC, since a block has at most two successors), \(c\) = number of critical edges.
| Technique | Time (worst) | Time (typical) | Space | Justification |
|---|---|---|---|---|
| Leaders (Alg. 8.2.3) | \(O(n + m \log m)\); \(O(n)\) with a boolean array instead of sorting | \(O(n)\) | \(O(n)\) label table | one pass, \(O(1)\) per instruction with a hash map of labels |
| CFG construction | \(O(n)\) | \(O(n)\) | \(O(m + e)\) | one lookup per block's last instruction |
| Critical edges and splitting (Alg. 8.2.6) | \(O(n + e)\) | \(O(n)\) | \(c\) new blocks | in-degrees in one pass; \(O(1)\) per edge; \(c \le e\) |
| DFS orders (Alg. 8.2.8) | \(\Theta(m + e)\) | same | \(O(m)\) stack | each node entered once, each edge examined once |
Pathological family. The listing \(\mathsf{if}\ x\ \mathsf{goto}\ L_0; \mathsf{if}\ x\ \mathsf{goto}\ L_1; \dots\) where every instruction is a jump makes every instruction a leader: \(m = n\). The CFG is then no smaller than the listing, and a chain of \(n\) conditional jumps all targeting one label \(L\) gives \(n\) critical edges into \(L\), so splitting doubles the number of blocks. For DFS, a chain of \(m\) blocks makes the recursive formulation \(m\) frames deep. This is why Algorithm 8.2.8 and LLVM's po_iterator keep an explicit stack instead of recursing (a 100 000-block function is realistic in generated code).
At scale. Splitting all critical edges can add many blocks. That is why LLVM splits lazily, only the edges a pass needs (SplitCriticalEdge [LLVM-BCE]), while Swift SIL splits eagerly only for terminators that cannot carry block arguments (§7).
6. Variants and refinements¶
Basic blocks and the leaders algorithm¶
- Extended basic blocks (a tree of blocks in which each non-root block has one predecessor) enlarge the scope of local optimizations such as value numbering (superlocal VN, [EaC3, Ch. 8]) without needing dataflow. The trade-off: a tree, not a path, must be tracked.
- Traces and superblocks (Fisher's trace scheduling; superblocks = traces with a single entry) optimize along a likely path and duplicate code to remove side entrances (Ch 23). The trade-off is code growth.
- Exceptions. Instructions that may throw end a block in some IRs (LLVM
invoke), or blocks carry "abnormal" exceptional edges (GCC). The trade-off: more, smaller blocks versus special edges every analysis must respect.
Control-flow graphs and critical edges¶
- Lazy splitting (LLVM
SplitCriticalEdgeon demand; GCCsplit_critical_edgeswhen a pass requires it) keeps the CFG small until code must be placed on an edge [LLVM-BCE; GCC-TreeCFG]. - Block arguments instead of edge code (Lesson 8.4): an edge's parallel copy is written in the branch, so many clients (for example SSA destruction in MLIR and Cranelift) never need a separate block. A critical edge whose two branch operands differ still needs splitting when lowering to phi (Lemma 8.4.11).
- Multi-edges. Some IRs keep duplicate edges (LLVM allows
br i1 %c, label %a, label %aand aswitchwith several cases to one block). Then phi nodes must list the predecessor once per edge with equal values. Definition 8.2.4 deduplicates instead.
Depth-first orders¶
- Postorder on the reverse CFG (from the exit) is the right order for backward problems such as liveness (Ch 14). Note that the reverse graph's RPO is not the forward postorder in general.
- Iterative worklists in RPO priority (Ch 14) process each block once per pass in RPO. On reducible graphs this bounds the number of passes by the loop-connectedness \(d(G) + 2\) [Dragon2, §9.6].
- Numbered DFS intervals (\(\mathrm{pre}\), \(\mathrm{post}\)) give \(O(1)\) ancestor tests: \(u \preceq_T v\) iff \(\mathrm{pre}(u) \le \mathrm{pre}(v)\) and \(\mathrm{post}(v) \le \mathrm{post}(u)\). LLVM's dominator tree uses the same trick with its
{in,out}numbers (Ch 15).
7. In real compilers¶
Basic blocks and the leaders algorithm¶
GCC partitions GIMPLE into blocks in make_blocks and connects them in make_edges, both in gcc/tree-cfg.cc (build_gimple_cfg; gcc 15.1) [GCC-TreeCFG]. LLVM IR is written directly as basic blocks. Every block must end in exactly one terminator, which the IR verifier checks (llvm/lib/IR/Verifier.cpp) [LLVM-LangRef]. PIR blocks are numbered bb0, bb1, … and each ends in exactly one terminator (pir-spec §6).
GCC's CFG dump: the leaders of the GIMPLE listing
Reproduce (gcc 14.2.0; euler1.c as in Lesson 8.1's GIMPLE box):
Output (the function body; the ;; Function header, loop summary and ;; N succs { … } lines that precede it are omitted):
long int euler1 (long int n)
{
long int i;
long int s;
long int D.2781;
<bb 2> :
s = 0;
i = 1;
goto <bb 7>; [INV]
<bb 3> :
_1 = i % 3;
if (_1 == 0)
goto <bb 5>; [INV]
else
goto <bb 4>; [INV]
<bb 4> :
_2 = i % 5;
if (_2 == 0)
goto <bb 5>; [INV]
else
goto <bb 6>; [INV]
<bb 5> :
s = s + i;
<bb 6> :
i = i + 1;
<bb 7> :
if (i <= n)
goto <bb 3>; [INV]
else
goto <bb 8>; [INV]
<bb 8> :
D.2781 = s;
<bb 9> :
<L6>:
return D.2781;
}
What to notice: the GIMPLE listing of Lesson 8.1 cut at its leaders: every label that is
a jump target starts a block, and every instruction after a goto or if starts one.
Blocks 0 and 1 are GCC's artificial entry and exit. bb 9 exists only because the
return carries a label (<L6>), which GCC treats as a leader. Algorithm 8.2.3 would
merge it into bb 8 because nothing jumps to it (Theorem 8.2.9(c)), and GCC's later
cfgcleanup does the same. [INV] means "no profile count yet".
Control-flow graphs and critical edges¶
LLVM: isCriticalEdge in llvm/lib/Analysis/CFG.cpp, SplitCriticalEdge and the break-crit-edges pass (BreakCriticalEdgesPass::run) in llvm/lib/Transforms/Utils/BreakCriticalEdges.cpp (LLVM 23.1.2) [LLVM-BCE]. GCC: split_critical_edges in gcc/tree-cfg.cc [GCC-TreeCFG]. Swift: swift::splitCriticalEdge in lib/SILOptimizer/Utils/CFGOptUtils.cpp (swift-6.1-RELEASE) [SWIFT-CFGOpt]. Swift's SIL documentation lists critical-edge splitting among the mandatory passes, for "terminators that don't support arbitrary basic block arguments (all non cond_branch terminators)" [SIL-Docs].
LLVM splits the three critical edges of the running example
Reproduce (clang 23.1.2, opt 23.1.2):
clang-23 -O0 -Xclang -disable-O0-optnone -fno-discard-value-names -S -emit-llvm euler1.c -o euler1.O0.ll
opt -passes=sroa -S euler1.O0.ll -o euler1.ssa.ll
opt -passes=break-crit-edges -S euler1.ssa.ll | sed -n '/define/,/^}/p'
Output (complete function):
define dso_local i64 @euler1(i64 noundef %n) #0 {
entry:
br label %for.cond
for.cond: ; preds = %for.inc, %entry
%s.0 = phi i64 [ 0, %entry ], [ %s.1, %for.inc ]
%i.0 = phi i64 [ 1, %entry ], [ %inc, %for.inc ]
%cmp = icmp sle i64 %i.0, %n
br i1 %cmp, label %for.body, label %for.end
for.body: ; preds = %for.cond
%rem = srem i64 %i.0, 3
%cmp1 = icmp eq i64 %rem, 0
br i1 %cmp1, label %for.body.if.then_crit_edge, label %lor.lhs.false
for.body.if.then_crit_edge: ; preds = %for.body
br label %if.then
lor.lhs.false: ; preds = %for.body
%rem2 = srem i64 %i.0, 5
%cmp3 = icmp eq i64 %rem2, 0
br i1 %cmp3, label %lor.lhs.false.if.then_crit_edge, label %lor.lhs.false.if.end_crit_edge
lor.lhs.false.if.end_crit_edge: ; preds = %lor.lhs.false
br label %if.end
lor.lhs.false.if.then_crit_edge: ; preds = %lor.lhs.false
br label %if.then
if.then: ; preds = %lor.lhs.false.if.then_crit_edge, %for.body.if.then_crit_edge
%add = add nsw i64 %s.0, %i.0
br label %if.end
if.end: ; preds = %lor.lhs.false.if.end_crit_edge, %if.then
%s.1 = phi i64 [ %add, %if.then ], [ %s.0, %lor.lhs.false.if.end_crit_edge ]
br label %for.inc
for.inc: ; preds = %if.end
%inc = add nsw i64 %i.0, 1
br label %for.cond, !llvm.loop !5
for.end: ; preds = %for.cond
ret i64 %s.0
}
What to notice: clang lowers || as jumping code, so its CFG differs from ours, but it
has three critical edges as well: for.body→if.then, lor.lhs.false→if.then and
lor.lhs.false→if.end. Each gets a …_crit_edge block with a single br, which is
Definition 8.2.5's splitting, and the phi in if.end now names the new block as its
predecessor. That block is where an out-of-SSA copy %s.1 ← %s.0 can go (Lemma 8.2.11).
Depth-first orders¶
LLVM: post_order and ReversePostOrderTraversal in llvm/include/llvm/ADT/PostOrderIterator.h, an explicit-stack DFS over any graph with GraphTraits (LLVM 23.1.2) [LLVM-RPOT]. GCC: post_order_compute and pre_and_rev_post_order_compute in gcc/cfganal.cc [GCC-CFGAnal]. GCC's value numbering is even named for the order: do_rpo_vn in gcc/tree-ssa-sccvn.cc.
LLVM's own postorder and RPO of the running example
Reproduce (clang++ 23.1.2 against the LLVM 23.1.2 libraries; euler1.ssa.ll from the previous box):
cat > rpo.cpp <<'EOF'
// Print LLVM's own DFS orders for every function in a module.
#include "llvm/ADT/PostOrderIterator.h"
#include "llvm/IR/CFG.h"
#include "llvm/IR/LLVMContext.h"
#include "llvm/IR/Module.h"
#include "llvm/IRReader/IRReader.h"
#include "llvm/Support/SourceMgr.h"
#include "llvm/Support/raw_ostream.h"
int main(int argc, char **argv) {
llvm::LLVMContext Ctx;
llvm::SMDiagnostic Err;
auto M = llvm::parseIRFile(argv[1], Err, Ctx);
if (!M) { Err.print(argv[0], llvm::errs()); return 1; }
for (llvm::Function &F : *M) {
if (F.isDeclaration()) continue;
llvm::outs() << F.getName() << "\n postorder:";
for (llvm::BasicBlock *BB : llvm::post_order(&F))
llvm::outs() << " " << BB->getName();
llvm::outs() << "\n rpo: ";
llvm::ReversePostOrderTraversal<llvm::Function *> RPOT(&F);
for (llvm::BasicBlock *BB : RPOT)
llvm::outs() << " " << BB->getName();
llvm::outs() << "\n";
}
}
EOF
clang++-23 -std=c++23 $(llvm-config --cxxflags) rpo.cpp $(llvm-config --ldflags --libs core irreader support) -Wl,-rpath,$(llvm-config --libdir) -o rpo
./rpo euler1.ssa.ll
Output (complete):
euler1
postorder: for.inc if.end if.then lor.lhs.false for.body for.end for.cond entry
rpo: entry for.cond for.end for.body lor.lhs.false if.then if.end for.inc
What to notice: LLVM visits a br instruction's successors in operand order
(for.body before for.end). The whole loop body finishes first, so the exit block
for.end lands third in RPO, before the loop body, although it is last in the file.
Every non-back edge still goes forward in this order (Theorem 8.2.14). The only back edge is
for.inc→for.cond. (On Linux with the course's conda LLVM, add
--gcc-install-dir=/usr/lib/gcc/x86_64-linux-gnu/14 -Wl,-rpath,$(llvm-config --libdir).)
8. Comparison¶
| Technique | Power / precision | Speed (asymptotic · practical) | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| Basic blocks and the leaders algorithm | exact: the unique maximal partition (Theorem 8.2.9) | \(O(n)\) · one pass; 19 instructions → 10 blocks on the running example | blocks named by their leaders; unreferenced labels vanish | very low | every compiler that starts from a linear IR (GCC's make_blocks, JVM and Wasm JITs) |
| Control-flow graphs and critical edges | exact successor relation; splitting adds one block per critical edge | \(O(n + e)\) · 3 critical edges, 10 → 13 blocks on the running example | a new block per split edge (…_crit_edge in LLVM) |
low | every optimizer; splitting before out-of-SSA, PRE, code placement |
| Depth-first orders | RPO is a topological order modulo back edges (Theorem 8.2.14) | \(\Theta(m + e)\) · linear, explicit stack | three sequences plus an edge classification | low (iterative DFS needs care) | dataflow iteration order, dominators, SSA construction, scheduling |
Choose the leaders algorithm whenever you start from a linear IR, since it is cheap and gives the unique answer. Split critical edges before any transformation that places code on edges (out-of-SSA copies, PRE insertions, spill code), and preferably only the edges it needs. Iterate in RPO for forward problems, in postorder of the reverse CFG for backward ones, and never in preorder (Corollary 8.2.15).
9. Assessment¶
| Technique | Quiz ids (solutions/quizzes/ch08.yaml) |
Drill | Flashcard tag | Exercises |
|---|---|---|---|---|
| Basic blocks and the leaders algorithm | leaders-listing, leader-unreferenced-label |
./course drill leaders |
leaders |
L2 (R1–R2) |
| Control-flow graphs and critical edges | critical-edges-set, split-count, llvm-where-critical |
./course drill leaders --difficulty hard |
critical-edges |
L2 (R5–R7) |
| Depth-first orders | rpo-sequence, llvm-where-rpo |
./course drill traversal-orders (alias of rpo) |
dfs-orders |
L2 (R3–R4) |
RPO is not 'the order of the blocks in the file'
It happens to coincide for the lab's structured TAC, but not for LLVM's layout (the box above), nor after any pass that moves blocks. Always compute it.
References¶
See the chapter references.