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

79. Dataflow analysis

Is this value always a number? Inside a loop, the answer depends on itself. The way out is to guess, check, and keep correcting until the guesses stop changing.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Describe a dataflow analysis as facts, a rule per instruction, and a merge where paths meet.
  • Use a small lattice to merge facts, and explain why facts only move one way.
  • Run a worklist to a fixpoint by hand, and say why it must stop.
  • Explain why Viscosity’s numeric inference starts optimistic, and what a pessimistic start would miss.

A question that depends on itself

Part VII’s compiler wants to store a field like x in a Float64Array column (chapter 23’s SoA). That is only safe if every value ever written to x is a number. So Viscosity has to answer, for every SSA value: is this always a number?

For most instructions the answer is easy. const 0 is a number. const "!" isn’t. But look at a loop’s running total, in the VIR from chapter 77:

  b1:  ; preds b0 b3
    %6 = phi %1, %7
  ...
  b2:  ; preds b1
    %7 = binop + %6, %3

%6 is a number if %1 and %7 are. %7 is a number if %6 and %3 are. Each answer waits on the other. You can’t compute them in order, because there is no order: the loop is a cycle.

Facts, rules and merges

Intuition

A rumour passing round a group of friends, where each person repeats what they last heard from their neighbours. Nobody knows the truth at the start. But if everyone keeps updating from their neighbours, the story eventually stops changing, and that settled story is what the group “knows”.

An analysis like this has three parts:

  • a fact about each value (here: number, or maybe not);
  • a rule for each kind of instruction, computing its fact from its operands’ facts. From Viscosity’s analysis.ts:
case 'const':
  return typeof i.value === 'number';
case 'binop':
  return ARITH.has(i.operator) &&
    n(i.args[0]) && n(i.args[1]);
case 'phi':
  return i.args.every(n);
  • a merge where paths meet. In SSA, the merge is the phi rule: a phi is a number only if every incoming value is.
Definition

A dataflow analysis computes a fact for every point (or every value) in a program, where each fact depends on facts flowing in along CFG edges. Each instruction has a transfer function that turns incoming facts into outgoing ones, and facts from different predecessors are merged at joins. A dense analysis keeps facts per block, along CFG edges. A sparse one, like this, keeps one fact per SSA value and follows def-use links instead.

A ladder of facts

The facts need an order, so that merging always has an answer. Here there are just two, with “number” above “maybe-not”. Merging two facts gives the lower one: if either side might not be a number, the result might not be either.

Real analyses use taller ladders. Next chapter’s constant analysis has three levels: “no information yet”, “the constant 4”, and “varies”.

Definition

A lattice is a set of facts with an order (higher means “knows more” or “promises more”), where any two facts have a merge: the highest fact below both. If the lattice has a finite height and every rule only moves facts in one direction, an analysis can only change each fact a bounded number of times, so it must stop.

Guess, check, repeat

To break the cycle, start with a guess and correct it. Which guess? Try starting everything at “maybe-not” (pessimistic):

Try itTwo starting guesses for a loop
// s = 0; loop: s = s + i (i: number)
// v2 = φ(v1, v3), v3 = v2 + i
function solve(start) {
  let v2 = start;
  let v3 = start;
  for (let r = 0; r < 5; r++) {
    v2 = true && v3; // φ(v1, v3)
    v3 = v2 && true; // v2 + i
  }
  return v2;
}
console.log('optimistic', solve(true));
console.log('pessimistic',
  solve(false));

Pessimistic gets stuck. %6 is maybe-not because %7 is; %7 is maybe-not because %6 is. That answer is consistent, but it’s useless. Starting optimistic, at “number”, the loop also stays consistent, and this time the answer is the useful one. Both are fixpoints: states where applying every rule again changes nothing. Optimism finds the best one, as long as you keep checking until the guesses stop being contradicted.

Definition

A fixpoint is a state where applying every rule once more changes no fact. An optimistic analysis starts every unknown fact at the top of the lattice and only lowers facts that the rules contradict; it reaches the most precise fixpoint. A pessimistic one starts at the bottom and may stop at a weaker, still-consistent answer.

A worklist

Rerunning every rule until nothing changes is wasteful: most values didn’t change since the last round. Instead, keep a queue of values that might need updating. When a value’s fact changes, put its users on the queue.

Step throughA worklist reaching a fixpoint
Six SSA values in a loop, each with its current fact, number or maybe-not. On the right, the two-point lattice. At the bottom, the worklist.VALUEFACTv1= 0numberv2= φ(v1, v6)numberv3= v2 + 1numberv4= "!"maybe-notv5= φ(v3, v4)numberv6= v5 + 1numberLATTICEnumbermaybe-notfacts onlymove downWORKLISTv2v3v5v6front of the queue is on the left
Constants get their fact straight away: 0 is a number, "!" is not. Everything else starts OPTIMISTIC, at "number", and every non-constant goes on the worklist.
1/9
Definition

A worklist is the queue of items (values, or blocks in a dense analysis) whose facts may be out of date. The solver takes one off, reapplies its rule, and, if its fact changed, adds the items that depend on it. When the worklist is empty, the analysis is at a fixpoint.

The real thing in Viscosity

Viscosity’s numeric inference is exactly this optimistic analysis, over SSA values plus the bindings and collection fields that values flow through. It skips the worklist: each round, it reruns every rule, and it repeats rounds until a whole round changes nothing (with a cap of 1000 rounds, after which it gives up every guess: it fails closed). Simplified from analysis.ts:

for (const v of m.values)
  num.set(v.id, optimistic(v)
    ? true : compute(v));
for (let changed = true, rounds = 0;
     changed && rounds < 100;
     rounds++) {
  changed = false;
  for (const v of m.values) {
    const nv = compute(v);
    if (num.get(v.id) !== nv) {
      num.set(v.id, nv);
      changed = true;
    }
  }
  // ...then bindings and fields
}

optimistic(v) is true for phis, binding loads, field reads and parameters, the values that can sit on a cycle. On the sum loop the real analysis reports %3 (i), %6 (s) and %7 as numbers. The parameter %0 comes out maybe-not: nothing in the program calls sum, so nothing is known about n. Add a call sum(3) and %0 becomes a number too: sum is only ever called directly, so the param rule can check the argument at every call site. Replace the loop body with something that can add a string, and the doubt travels round the back edge, just as in the diagram.

Under the hood

The same machinery, facts on a lattice, a rule per instruction, a merge and a worklist, underlies most of the middle of an optimizing compiler: reaching definitions, liveness, constant propagation and more. Textbooks such as Cooper and Torczon’s treat it as one framework with many instances[1]. Viscosity’s numeric inference is one instance; the next two chapters are two more.

Your turn

ExerciseAn optimistic worklist solver

Write numeric(insts). Each instruction has an op and a list of args (ids of other instructions):

  • 'num' is a number constant, and 'str' a string constant (no args);
  • 'add' is a number only if all its args are;
  • 'phi' is a number only if all its args are.

Start optimistic: every fact is true except 'str'. Put every id on a worklist (a queue). Repeatedly take the first id, recompute its fact from its args, and if it changed, add its users to the end of the queue (unless already there). Return the facts when the worklist is empty. Loops are allowed: a phi may use a value defined later.

// insts: { id: { op, args } }
// op: 'num' | 'str' | 'add' | 'phi'
// Return { id: true if a number }.
function numeric(insts) {
  const fact = {};
  for (const id in insts) {
    fact[id] = insts[id].op === 'num';
  }
  return fact;
}
Extra practiceReaching definitions

A classic dense analysis. Each block has preds and defs: the assignments it makes, as [name, defId], at most one per name. A definition reaches the start of block b if some path to b passes it and doesn't reassign its name afterwards.

Write reaching(blocks), returning for every block the sorted list of defIds reaching its start. IN[b] is the union of OUT over b's preds; OUT[b] is IN[b] minus defs of names b assigns, plus b's own defs. Iterate until nothing changes.

// blocks: { b: { preds: [...],
//   defs: [[name, defId], ...] } }
// Return { b: sorted defIds that
//   reach the start of b }.
function reaching(blocks) {
  const inn = {};
  for (const b in blocks) inn[b] = [];
  return inn;
}

What you learned

  • A dataflow analysis has a fact per value or point, a rule per instruction, and a merge where paths meet. In SSA, the phi’s rule is the merge.
  • A lattice orders the facts. Merging takes the highest fact below both. Finite height plus one-way movement means the analysis ends.
  • A fixpoint is where no rule changes anything. Starting optimistic finds the most precise one; starting pessimistic can get stuck on loops.
  • A worklist only revisits values whose inputs changed.
  • Viscosity’s numeric inference is an optimistic fixpoint, run in rounds over values, bindings and fields.

Next: the most famous analysis of all, and the transformation it enables.

Check your understanding

0/6
  1. 1.

    Why can’t Viscosity decide whether a loop’s running total is a number by visiting instructions once, in order?

  2. 2.

    A phi has operands with facts "number", "number" and "maybe-not". What is its fact?

  3. 3.

    What guarantees that the worklist solver for number/maybe-not stops?

  4. 4.

    Starting pessimistic, a counting loop’s phi ends up "maybe-not". Starting optimistic, it ends up "number". Which is right?

  5. 5.

    In a worklist solver, value v5’s fact just changed. What should be added to the worklist?

  6. 6.

    In Viscosity’s numeric inference, why does the param for n in an uncalled sum(n) come out "maybe-not"?

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)