Viscosity
Review
Part VI · The compiler middle-end · Day 19

75. Basic blocks and control-flow graphs

Jumps turn a list of instructions into a maze. Cut the list wherever control can enter or leave, and the maze becomes a map: a few straight corridors, and arrows between them.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Find the leaders of a list of instructions and cut it into basic blocks.
  • Build a control-flow graph’s successors and predecessors.
  • Read VIR’s Block and Terminator types and the real successors function.
  • Spot loops and unreachable code in a control-flow graph.

A list with holes in it

Here is a classic loop: halve n if it’s even, otherwise triple it and add one, until it reaches 1.

function g(n) {
  while (n > 1) {
    if (n % 2) n = 3 * n + 1;
    else n = n / 2;
  }
  return n;
}

Written as flat three-address code with jumps, the way chapter 55’s compiler wrote bytecode, it’s ten lines:

0  t1 = n > 1
1  ifnot t1 goto 9
2  t2 = n % 2
3  ifnot t2 goto 7
4  t3 = 3 * n
5  n = t3 + 1
6  goto 0
7  n = n / 2
8  goto 0
9  return n

Now a simple question: when line 7 runs, which line ran just before it? You can’t tell by looking at line 6. You have to search the whole list for jumps to 7. Every question about order, “is this value computed before that use?”, has the same problem. A compiler needs a better map.

Basic blocks

Intuition

A road map. Between two junctions, a road is just a road: once you are on it, you drive to the end, and nobody joins halfway. All the decisions happen at junctions. A map draws the roads as lines and the junctions as points, and suddenly the whole city fits on a page.

The roads are runs of instructions with no jump into their middle and no jump out of their middle. To find them, first mark the leaders, the instructions where a run must start. Step through it:

Step throughFrom a flat list to a graph
Ten lines of code with jumps, cut at leaders 0, 2, 4, 7 and 9 into five basic blocks, which become a graph: B0 to B1 and B4, B1 to B2 and B3, B2 and B3 back to B0.0t1 = n > 11ifnot t1 goto 92t2 = n % 23ifnot t2 goto 74t3 = 3 * n5n = t3 + 16goto 07n = n / 28goto 09return n
The Collatz loop as flat three-address code, with numbered lines and jumps. ifnot t goto k jumps to line k when t is false. Where can control arrive from? Hard to say by reading top to bottom.
1/7

Three rules find every leader: the first instruction, every jump target, and every instruction straight after a jump. Each block then runs from one leader up to, but not including, the next.

Definition

A basic block is a maximal sequence of instructions with one entry and one exit: control enters only at the first instruction and leaves only after the last. If the first instruction runs, they all run, in order. Leaders (the first instruction, jump targets, and the instructions following jumps) are where blocks begin.

The control-flow graph

The blocks are the nodes of a graph. An edge from block A to block B means “control can go straight from the end of A to the start of B”, by a jump or by falling through.

Definition

A control-flow graph (CFG) has a node for each basic block and an edge A → B whenever B can run immediately after A. B is a successor of A, and A is a predecessor of B. It has one entry block, where the function starts. A cycle in the graph is a loop.

Inside a block, order is trivial: top to bottom. Between blocks, it is whatever the arrows allow. So a compiler can study each block on its own, cheaply, and then combine the results along the edges. Nearly every analysis in the rest of Part VI works that way.

Blocks in VIR

Viscosity never has to look for leaders, because its lowering builds blocks directly. An if makes a block for each branch and one for the code after; a while makes a header, a body, a “continue” block (where the update of a for goes) and an exit. The types from packages/core/src/vir.ts make “one exit” impossible to get wrong:

interface Block {
  id: BlockId;
  insts: Inst[];
  term: Terminator;
  preds: BlockId[];
}

type Terminator =
  | { kind: 'br'; target: BlockId }
  | { kind: 'condbr'; cond: ValueId;
      then: BlockId; else: BlockId }
  | { kind: 'return';
      value: ValueId | null }
  | { kind: 'throw'; value: ValueId }
  | { kind: 'unreachable' };

Ordinary instructions can’t jump: only the terminator can, and every block has exactly one, at the end. Reading off a block’s successors is then one line, from packages/core/src/cfg.ts:

function successors(f: Func, b: BlockId) {
  const t = f.blocks[b].term;
  return t.kind === 'br' ? [t.target]
    : t.kind === 'condbr' ? [t.then, t.else]
    : [];
}

Predecessors are kept on each block, filled in as edges are made. When the builder sets a block’s terminator, it adds the block to each successor’s preds.

Here is the graph Viscosity builds for g:

b0:
  br b1
b1:  ; preds b0 b3
  ...n > 1
  condbr %3, b2, b4
b2:  ; preds b1
  ...n % 2
  condbr %5, b5, b6
b3:  ; preds b7
  br b1
b4:  ; preds b1
  return %1
b5:  ; preds b2
  ...3 * n + 1
  br b7
b6:  ; preds b2
  ...n / 2
  br b7
b7:  ; preds b5 b6
  br b3

(Trimmed: ... stands for the instructions that compute the value.)

The control-flow graph of g: b0 to b1; b1 to b2 and b4; b2 to b5 and b6; b5 and b6 to b7; b7 to b3; b3 back to b1. b4 returns.b0entryb1n > 1 ?b2n % 2 ?b4return nb5n = 3n+1b6n = n/2b7joinb3br b1
Eight blocks, and the loop is the cycle b1 → b2 → b5 or b6 → b7 → b3 → b1. The real VIR control-flow graph of g. b1 tests the loop condition, b2 the parity, b5 and b6 are the two branches, b7 is where they meet, and b3 jumps back to the test (the highlighted edge). b4 is the exit.

That is eight blocks where the hand-written list had five. The lowering is mechanical: it gives the if a join block (b7) and the loop a “continue” block (b3), even when they hold nothing but a jump. A block with a single br is cheap and keeps the lowering simple. Notice the predecessors, too: b1 has two, the entry and the back edge, and b7 has two, the two branches. Blocks with several predecessors are where values from different paths meet, which is what chapter 77’s phis are for.

Try itThe Collatz CFG, both directions
// successors of b0..b7, from VIR
const succ = [[1], [2, 4], [5, 6],
  [1], [], [7], [7], [3]];
const preds = succ.map(() => []);
succ.forEach((ss, b) => {
  for (const s of ss) preds[s].push(b);
});
preds.forEach((ps, b) => {
  console.log('b' + b, 'preds', ps);
});

Unreachable blocks

What does the lowering do with code after a return?

func f(%0):            // return x; x = 2;
  b0:
    %0 = param
    return %0
  b1:
    %1 = const 2
    return

It keeps going, in a fresh block, b1. No terminator anywhere points at b1, so it has no predecessors and no path from the entry reaches it. In a CFG that is a fact you can see, and a compiler can drop such blocks without asking anything else. Viscosity’s dominator code simply leaves them out, as you’ll see next chapter.

Pitfall

“No predecessors” means unreachable only for blocks other than the entry. The entry has no predecessors either, and it is the one block that always runs.

Under the hood

Basic blocks and CFGs are the standard skeleton of a compiler’s middle end, and the textbook analyses are written over them[1]. They also line up with the hardware you met in Part II. Inside a basic block the CPU’s pipeline streams straight ahead; every edge out of a block with two successors is a conditional branch the branch predictor has to guess. Viscosity uses its CFG to find loops (chapter 83), because a loop over an array of particles is exactly where a change of layout pays off.

Your turn

ExerciseFind the leaders

Each instruction is an object with an op. { op: "goto", to } always jumps to index to; { op: "ifnot", to } may jump there; { op: "return" } leaves the function; anything else (like { op: "calc" }) just falls through. Write leaders(code) that returns the indices of the leaders, sorted in increasing order, without repeats. Treat return like a jump: whatever follows it starts a new block.

function leaders(code) {
  const ls = new Set([0]);
  for (const ins of code) {
    if (ins.op === 'goto') {
      ls.add(ins.to);
    }
  }
  return [...ls];
}
Extra practicePredecessors from successors

A CFG is given as succ, an array where succ[b] lists block b’s successors. Write preds(succ) that returns the reverse: an array where entry b lists the predecessors of b, in increasing block order. Every block gets an entry, even if it is empty.

function preds(succ) {
  return succ;
}

What you learned

  • A basic block is straight-line code with one entry and one exit: if its first instruction runs, all of it does.
  • Leaders start blocks: the first instruction, every jump target, and every instruction after a jump.
  • A CFG has a node per block and an edge where control can pass directly. Successors come from the terminator; predecessors are the reverse edges. A cycle is a loop.
  • In VIR, only a block’s single terminator can jump. The lowering builds blocks directly and records preds as it links them.
  • A block (other than the entry) with no predecessors is unreachable.

Next: which blocks must run before which, whatever path is taken.

Check your understanding

0/6
  1. 1.

    Which instructions are leaders?

  2. 2.

    Why can a compiler analyse one basic block in isolation so cheaply?

  3. 3.

    In VIR, how many terminators does a block have, and where?

  4. 4.

    A block ends with condbr %5, b5, b6. What does successors return for it?

  5. 5.

    In the VIR CFG for the Collatz loop, block b1 has predecessors b0 and b3. What does that tell you?

  6. 6.

    What does Viscosity’s lowering do with x = 2; written after return x;?

References

  1. [1] Keith D. Cooper, Linda Torczon (2022). Engineering a Compiler (3rd edition). Morgan Kaufmann (Elsevier). shop.elsevier.com/books/engineering-a-compiler/cooper/978-0-12-815412-0 (checked 2026-10-31)