Lesson 14.3 — The classic analyses¶
Techniques: reaching definitions, live variables (including SSA liveness), available expressions, very busy expressions, constant propagation, definite initialization · Pebble implements: SSA liveness (E2), reaching stores (E3), definite initialization (E4) and the
pebble-uninitwarning (E5) inpebble/lib/Analysis/Dataflow/andpebble/lib/Passes/Dataflow/· Lab: liveness as Datalog rules (labs/ch14-dataflow) · Prerequisites: Lesson 14.2 · Time: 6–8 hours
Four questions are older than SSA and still asked by every compiler: which assignments may produce the value read here (reaching definitions), which variables may still be read (liveness), which expressions have certainly been computed already (available expressions), and which will certainly be computed before their operands change (very busy expressions). Each is one monotone framework — a direction, a merge, and gen/kill sets — so each is ten lines on top of the solver of Lesson 14.4. Two more complete the lesson: constant propagation, the standard non-distributive framework, and definite initialization, which turns the must/may pair into a compiler warning.
1. Problem and motivation¶
All six analyses run in the middle end on a CFG of basic blocks. They became standard in the optimizing compilers of the early 1970s — Allen's and Cocke's work at IBM [All70, AC76], Ullman's common-subexpression algorithms [Ull73] — and were unified by Kildall's framework [Kil73]. The Dragon book and Engineering a Compiler still teach them in this form [Dragon2, §9.2; EaC3, Ch. 9]. In LLVM most of them survive in SSA-specialized form: SSA is reaching definitions for registers, so the IR-level analyses are about memory, liveness and values.
Reaching definitions¶
A definition \(d\) of \(v\) reaches a point \(p\) if some path from \(d\) to \(p\) does not redefine \(v\). The answer gives use-def chains: constant propagation, loop-invariant detection and SSA construction all start from it [All70]. Pebble uses it for memory: which stores to a local variable reach a load (E3) — exactly the question mem2reg answers when it turns loads into phis.
Live variables¶
\(v\) is live at \(p\) if some path from \(p\) reads \(v\) before writing it. Register allocation needs it (a register can be reused when its value is dead), dead-code elimination needs it (a store to a dead variable is useless), and SSA construction uses it to avoid useless phis (pruned SSA, Ch 16). Pebble computes liveness of SSA values (E2); the result is reused by SSA destruction (Ch 16) and register allocation (Ch 22).
Available expressions¶
\(e\) is available at \(p\) if every path from the entry to \(p\) computes \(e\) and does not redefine its operands afterwards. Then a recomputation at \(p\) is redundant: global common-subexpression elimination [Coc70, Ull73]. LLVM's GVN and EarlyCSE are its SSA descendants.
Very busy expressions¶
\(e\) is very busy (anticipable) at \(p\) if every path from \(p\) computes \(e\) before redefining its operands. Then \(e\) can be hoisted to \(p\) without adding work on any path: code hoisting, and the "anticipability" half of partial-redundancy elimination [MR79, KRS92].
Constant propagation¶
Which variables hold a known constant at \(p\)? Kildall's own example [Kil73]; the lattice \(\mathbb{Z}_\bot^\top\) makes it the canonical non-distributive framework (Lesson 14.2), and its sparse, conditional form SCCP (Lesson 14.6) is in every compiler.
Definite initialization¶
Is \(v\) assigned on every path from the entry to a read of \(v\)? Java and C# make this a language rule [JLS, Ch. 16]; C compilers turn its failure into -Wuninitialized and -Wsometimes-uninitialized. It is available expressions' twin: a forward must-analysis. Pebble's pebble-uninit pass (E5) combines it with reaching stores to say "is" or "may be" uninitialized.
2. Definitions and algorithms¶
Throughout, a program is a flowgraph \(G = (N, E, r)\) of basic blocks of three-address statements x = y op z, x = y, x = 7, if y < z, ret y (the format of tools/course/lib/dataflow.py and the dataflow-table drill). A block \(B\) with statements \(s_1, \dots, s_k\) has program points before \(s_1\) (the start, \(\mathrm{IN}[B]\)) and after \(s_k\) (the end, \(\mathrm{OUT}[B]\)).
Definition 14.3.1 (Definitions, uses, expressions)
A definition is a statement that assigns a variable; the definitions are numbered \(d_1, d_2, \dots\)
in program order and \(\mathrm{var}(d)\) is the variable assigned. \(\mathrm{uses}(s)\) is the set of
variables \(s\) reads. An expression is the right-hand side y op z of an assignment with at least
one variable operand (written without spaces, a*b, i+1); an assignment to one of its variable
operands kills it. For a block \(B\):
\(\mathrm{defs}(B)\) = variables assigned in \(B\);
\(\mathrm{UEVar}(B)\) = variables read in \(B\) before any assignment to them in \(B\) (upward exposed);
\(\mathrm{VarKill}(B) = \mathrm{defs}(B)\).
Reaching definitions¶
Definition 14.3.2 (Reaching definitions)
A definition \(d\) reaches the start of block \(B\) if there is a path from the point just after \(d\) to the start of \(B\) containing no other definition of \(\mathrm{var}(d)\). \(\mathrm{RD}_{\mathrm{in}}(B)\) is the set of such \(d\); \(\mathrm{RD}_{\mathrm{out}}(B)\) likewise for the end of \(B\).
Algorithm 14.3.3 (Reaching definitions)
- Input: a program; its definitions \(d_1, \dots, d_m\).
- Output: \(\mathrm{IN}[B], \mathrm{OUT}[B] \subseteq \{d_1, \dots, d_m\}\) for every block.
- Precondition: none (any CFG).
- Postcondition: \(\mathrm{IN}[B] = \mathrm{RD}_{\mathrm{in}}(B)\) and \(\mathrm{OUT}[B] = \mathrm{RD}_{\mathrm{out}}(B)\) (Theorem 14.3.5).
- Invariant: the solver's (Lesson 14.4): the current sets are below the least fixed point.
function ReachingDefinitions(G):
for B in N: # local sets, one pass over each block
gen[B] ← { the last definition of v in B : v ∈ defs(B) }
kill[B] ← { d : var(d) ∈ defs(B) } ∖ gen[B]
framework ← forward, may:
join = ∪ # Init = {} everywhere
boundary = IN[r] = {}
transfer f_B = X ↦ gen[B] ∪ (X ∖ kill[B])
return Solve(framework) # Algorithm 14.4.1 or 14.4.2
Pebble's E3 is the same algorithm over the store instructions to tracked allocas
(pebble/include/pebble/Analysis/DataflowMemory.h).
Lemma 14.3.4 (Block transfer of reaching definitions)
For a set \(X\) of definitions reaching the start of \(B\), the definitions reaching the end of \(B\) through \(B\) are exactly \(f_B(X) = \mathrm{gen}[B] \cup (X \setminus \mathrm{kill}[B])\).
Proof
A definition outside \(B\) survives \(B\) iff \(B\) assigns no variable it defines, i.e. iff it is not in \(\mathrm{kill}[B]\) (a definition outside \(B\) of a variable in \(\mathrm{defs}(B)\) is in \(\mathrm{kill}[B]\) because \(\mathrm{gen}[B]\) contains only definitions in \(B\)). A definition in \(B\) reaches the end of \(B\) iff no later statement of \(B\) redefines its variable, i.e. iff it is the last one for its variable: exactly \(\mathrm{gen}[B]\). Definitions in \(B\) that are not last are in \(\mathrm{kill}[B]\) and are removed from \(X\) if they arrive around a loop. \(\square\)
Theorem 14.3.5 (Correctness of Algorithm 14.3.3)
The least fixed point of the reaching-definitions equations is \(\mathrm{RD}_{\mathrm{in}}, \mathrm{RD}_{\mathrm{out}}\) for every block reachable from \(r\).
Proof
By Lemma 14.3.4 and induction on path length, \(f_p(\emptyset)\) is the set of definitions that reach the end of path \(p\) along \(p\), so \(\mathrm{MOP}(B) = \bigcup_{p} f_p(\emptyset)\) is the set of definitions reaching \(B\) along some path — Definition 14.3.2. The framework is distributive (Lemma 14.1.10), so \(\mathrm{MFP} = \mathrm{MOP}\) for reachable blocks (Theorem 14.2.13). \(\square\)
Reaching stores become phis: LLVM's mem2reg
Reproduce (clang 23.1.2, opt 23.1.2):
cat > rd.c <<'EOF'
int sometimes(int c) {
int x;
if (c)
x = 1;
return x;
}
int both_paths(int c) {
int z;
if (c)
z = 1;
else
z = 2;
return z;
}
EOF
clang-23 -O0 -Xclang -disable-O0-optnone -fno-discard-value-names -S -emit-llvm rd.c -o rd.ll
grep -E 'store i32 [0-9]|load i32, ptr %[xz]' rd.ll
opt -passes=mem2reg -S rd.ll | grep -E 'phi|^ ret'
Output:
store i32 1, ptr %x, align 4
%1 = load i32, ptr %x, align 4
store i32 1, ptr %z, align 4
store i32 2, ptr %z, align 4
%1 = load i32, ptr %z, align 4
ret i32 1
%z.0 = phi i32 [ 1, %if.then ], [ 2, %if.else ]
ret i32 %z.0
What to notice: in both_paths two stores reach the load of %z (Definition 14.3.2), and
mem2reg replaces the load by a phi of exactly those two stored values. In sometimes the
store x = 1 and the uninitialized entry reach the load; mem2reg builds phi [1, undef] and
folds it to 1, since undef may be chosen to be 1 — the reason C compilers warn about
uninitialized variables (see Definite initialization below) instead of "helping". Run
print<pebble-reaching-stores> on the same file after E3: it prints reached by {S1} and
reached by {S1, S2}.
Live variables¶
Definition 14.3.6 (Liveness)
\(v\) is live at a point \(p\) if some path from \(p\) reaches a read of \(v\) with no assignment to \(v\) in between (the read may be at \(p\) itself). \(\mathrm{live\text{-}in}(B)\) and \(\mathrm{live\text{-}out}(B)\) are the variables live at the start and end of \(B\).
Algorithm 14.3.7 (Live variables)
- Input: a program.
- Output: \(\mathrm{IN}[B], \mathrm{OUT}[B] \subseteq \mathit{Vars}\) for every block.
- Precondition: none.
- Postcondition: \(\mathrm{IN}[B] = \mathrm{live\text{-}in}(B)\), \(\mathrm{OUT}[B] = \mathrm{live\text{-}out}(B)\) (Theorem 14.3.8).
- Invariant: the solver's (Lesson 14.4).
function LiveVariables(G):
for B in N:
UEVar[B] ← ∅; VarKill[B] ← ∅
for s in B, top to bottom:
UEVar[B] ← UEVar[B] ∪ (uses(s) ∖ VarKill[B])
if s assigns x: VarKill[B] ← VarKill[B] ∪ {x}
framework ← backward, may:
join = ∪; boundary OUT[B] = {} for every exit B
transfer f_B = X ↦ UEVar[B] ∪ (X ∖ VarKill[B]) # X = OUT[B], result = IN[B]
return Solve(framework)
Theorem 14.3.8 (Correctness of Algorithm 14.3.7)
The least fixed point of the liveness equations is \(\mathrm{live\text{-}in}, \mathrm{live\text{-}out}\) for every block from which an exit is reachable; for blocks that reach no exit it is the set of variables read on some path before being written (Definition 14.3.6 read on infinite paths).
Proof
Backward version of Theorem 14.3.5: on \(G^{R}\), \(f_B\) is gen/kill (distributive), and by induction on path length \(f_p\) applied to \(\emptyset\) yields the variables read along the reversed path before being written; the join over paths is Definition 14.3.6, and distributivity gives MFP = MOP (Theorem 14.2.13 on \(G^{R}\)). For blocks that reach no exit, \(\mathrm{Paths}\) in \(G^{R}\) from the exits are empty; the least fixed point still collects every read reachable forward, because the equations propagate along every edge regardless of the boundary. \(\square\)
Definition 14.3.9 (Liveness of SSA values)
In SSA form (LLVM IR) the variables are the SSA values: arguments and value-producing instructions. Each has exactly one definition. A phi \(v = \phi(\dots, u_P, \dots)\) in block \(B\) defines \(v\) at the start of \(B\) and uses \(u_P\) at the end of predecessor \(P\), on the edge \(P \to B\). Hence
with \(\mathrm{Defs}(B)\) including \(B\)'s phis and \(\mathrm{UpwardExposed}(B)\) the non-phi reads in \(B\).
A phi result is therefore never live-in to its own block. (The SSA book's formulation of the same
sets [SSAB, Ch. 9] is this one; some register allocators additionally treat phi results as
live-in — Ch 22.) This is the contract of pebble/include/pebble/Analysis/Liveness.h (E2).
SSA liveness on the running example
After mem2reg, the running example's loop head %while.cond has phis %i.0, %s.0, %a.addr.0.
print<pebble-liveness> (the solution, tests/ch14/lit/liveness.test) prints
%while.cond: in = {%b, %n} out = {%b, %n, %i.0, %s.0, %a.addr.0}: the phis are live-out but not
live-in, and %a (the parameter) is live-out of %entry only because the phi reads it on the edge
%entry → %while.cond.
Liveness in Clang's static analyzer and its dead-store checker
Reproduce (clang 23.1.2):
cat > live.c <<'EOF'
int sum(int n) {
int s = 0, i = 0, dead = 7;
while (i < n) {
s = s + i;
i = i + 1;
}
dead = s;
return s;
}
EOF
clang-23 --analyze -Xclang -analyzer-checker=debug.DumpCFG live.c 2>&1 | grep -E '^ \[B|Succs'
clang-23 --analyze -Xclang -analyzer-checker=debug.DumpLiveVars,deadcode.DeadStores live.c
Output:
[B6 (ENTRY)]
Succs (1): B5
[B1]
Succs (1): B0
[B2]
Succs (1): B4
[B3]
Succs (1): B2
[B4]
Succs (2): B3 B1
[B5]
Succs (1): B4
[B0 (EXIT)]
[ B0 (live variables at block exit) ]
[ B1 (live variables at block exit) ]
[ B2 (live variables at block exit) ]
n <live.c:1:13>
s <live.c:2:7>
i <live.c:2:14>
[ B3 (live variables at block exit) ]
n <live.c:1:13>
s <live.c:2:7>
i <live.c:2:14>
[ B4 (live variables at block exit) ]
n <live.c:1:13>
s <live.c:2:7>
i <live.c:2:14>
[ B5 (live variables at block exit) ]
n <live.c:1:13>
s <live.c:2:7>
i <live.c:2:14>
[ B6 (live variables at block exit) ]
n <live.c:1:13>
live.c:7:3: warning: Value stored to 'dead' is never read [deadcode.DeadStores]
7 | dead = s;
| ^ ~
1 warning generated.
What to notice: Clang's CFG has the loop test in B4, the body in B3, the latch B2 and the
return in B1. n, s, i are live at the exit of every loop block (Definition 14.3.6); after
B1 (return s) nothing is live. dead is never live after dead = s, so the store is useless:
deadcode.DeadStores is a liveness client. The analysis behind it is
clang/lib/Analysis/LiveVariables.cpp, a backward worklist solver [CLANG-LV].
Available expressions¶
Definition 14.3.10 (Available expressions)
\(e\) is available at \(p\) if every path from \(r\) to \(p\) contains an evaluation of \(e\) that is not followed (on that path, before \(p\)) by an assignment to an operand of \(e\).
Algorithm 14.3.11 (Available expressions)
- Input: a program; its expressions \(U\) (the universe).
- Output: \(\mathrm{IN}[B], \mathrm{OUT}[B] \subseteq U\).
- Precondition: none.
- Postcondition: for every block reachable from \(r\), \(\mathrm{IN}[B]\) is the set of expressions available at its start (Theorem 14.3.12).
- Invariant: the solver's, in the dual order: sets only shrink from \(U\).
function AvailableExpressions(G):
for B in N:
avail ← ∅
for s in B, top to bottom:
if s evaluates an expression e: avail ← avail ∪ {e}
if s assigns x: avail ← { e ∈ avail : x is not an operand of e }
DEExpr[B] ← avail # downward exposed
ExprKill[B] ← { e ∈ U : an operand of e ∈ defs(B) } ∖ DEExpr[B]
framework ← forward, must:
join = ∩; Init = U; boundary IN[r] = {}
transfer f_B = X ↦ DEExpr[B] ∪ (X ∖ ExprKill[B])
return Solve(framework)
Theorem 14.3.12 (Correctness of Algorithm 14.3.11)
For every block reachable from \(r\), the solution is the set of available expressions; for unreachable blocks it is \(U\) (vacuously: there is no path).
Proof
The lattice is \((\mathcal{P}(U), \supseteq)\) with join \(\cap\) and \(\bot = U\); gen/kill functions distribute over \(\cap\) just as over \(\cup\) (\(G \cup ((X \cap Y) \setminus K) = (G \cup (X \setminus K)) \cap (G \cup (Y \setminus K))\)). By induction along a path, \(f_p(\emptyset)\) is the set of expressions evaluated and not killed afterwards on \(p\), so MOP is the intersection over paths — Definition 14.3.10 — and Theorem 14.2.13 gives MFP = MOP on reachable blocks. An unreachable block has no path and keeps \(\mathrm{Init} = U\). \(\square\)
Available expressions in LLVM: EarlyCSE removes a recomputation
Reproduce (opt 23.1.2):
cat > ae.ll <<'EOF'
define i32 @ae(i1 %c, i32 %a, i32 %b) {
entry:
%x = mul i32 %a, %b
br i1 %c, label %then, label %join
then:
%t = add i32 %x, 1
br label %join
join:
%p = phi i32 [ %t, %then ], [ 0, %entry ]
%y = mul i32 %a, %b
%r = add i32 %p, %y
ret i32 %r
}
EOF
opt -passes=early-cse -S ae.ll | grep -E 'mul|%r ='
Output:
What to notice: a*b is computed in %entry, which dominates %join, and SSA operands are
never reassigned, so a*b is available at %join on every path (Definition 14.3.10). EarlyCSE
keeps a scoped table of available expressions while walking the dominator tree
(llvm/lib/Transforms/Scalar/EarlyCSE.cpp) [LLVM-EARLYCSE] and replaces %y by %x. In SSA the
kill sets are empty, which is why a dominator-tree walk can replace the iterative solver.
Very busy expressions¶
Definition 14.3.13 (Very busy expressions)
\(e\) is very busy at \(p\) if every path from \(p\) to an exit evaluates \(e\) before any assignment to an operand of \(e\).
Algorithm 14.3.14 (Very busy expressions)
- Input: a program; its expressions \(U\).
- Output: \(\mathrm{IN}[B], \mathrm{OUT}[B] \subseteq U\).
- Precondition: every block reaches an exit (otherwise see §4).
- Postcondition: \(\mathrm{IN}[B]\) is the set of expressions very busy at the start of \(B\) (Theorem 14.3.15).
- Invariant: the solver's, in the dual order.
function VeryBusyExpressions(G):
for B in N:
defined ← ∅; UEExpr[B] ← ∅
for s in B, top to bottom:
if s evaluates e and no operand of e ∈ defined: UEExpr[B] ← UEExpr[B] ∪ {e}
if s assigns x: defined ← defined ∪ {x}
ExprKill[B] ← { e ∈ U : an operand of e ∈ defined } ∖ UEExpr[B]
framework ← backward, must:
join = ∩; Init = U; boundary OUT[B] = {} for every exit B
transfer f_B = X ↦ UEExpr[B] ∪ (X ∖ ExprKill[B])
return Solve(framework)
Theorem 14.3.15 (Correctness of Algorithm 14.3.14)
If every block reaches an exit, the solution is the set of very busy expressions.
Proof
Dual of Theorem 14.3.12 on \(G^{R}\): gen/kill over \((\mathcal{P}(U), \supseteq)\) is distributive, a path's function applied to \(\emptyset\) yields the expressions evaluated on the path before their operands change, and the intersection over all paths to an exit is Definition 14.3.13. \(\square\)
Very busy expressions in LLVM: GVNHoist hoists a*b above a branch
Reproduce (opt 23.1.2):
cat > vbe.ll <<'EOF'
define i32 @h(i1 %c, i32 %a, i32 %b) {
entry:
br i1 %c, label %then, label %else
then:
%x = mul i32 %a, %b
%x1 = add i32 %x, 1
br label %join
else:
%y = mul i32 %a, %b
%y1 = sub i32 %y, 1
br label %join
join:
%r = phi i32 [ %x1, %then ], [ %y1, %else ]
ret i32 %r
}
EOF
opt -passes=gvn-hoist -S vbe.ll | sed -n '/^entry:/,/^then:/p'
Output:
What to notice: a*b is evaluated on both paths out of %entry before a or b change,
so it is very busy at the end of %entry (Definition 14.3.13) and can move there without adding
work to any path. GVNHoist computes these "anticipable" (ANTIC) points on the post-dominator tree
(llvm/lib/Transforms/Scalar/GVNHoist.cpp) [LLVM-GVNHOIST].
Constant propagation¶
Definition 14.3.16 (Constant-propagation lattice)
\(L_{\mathrm{CP}} = \mathit{Vars} \to \mathbb{Z}_\bot^\top\) (Definition 14.1.7), where \(\bot\) = "no
value yet" (not reached, or not yet assigned) and \(\top\) = "not a constant" (NAC). The transfer
function of x = y op z sets \(x\) to \(\hat{\mathrm{op}}(\sigma(y), \sigma(z))\) where
\(\hat{\mathrm{op}}(c_1, c_2) = c_1 \mathbin{\mathrm{op}} c_2\) for constants, \(\bot\) if either operand
is \(\bot\), and \(\top\) otherwise. Branches are ignored (both successors are assumed reachable).
Algorithm 14.3.17 (Simple constant propagation)
- Input: a program.
- Output: \(\sigma_B \in L_{\mathrm{CP}}\) at the start of every block.
- Precondition: none.
- Postcondition: the MFP solution; every \(x\) with \(\sigma_B(x) = c\) holds \(c\) at the start of \(B\) on every execution (Proposition 14.3.18).
- Invariant: the solver's.
Proposition 14.3.18 (Constant propagation is monotone, not distributive)
Each \(f_B\) is monotone; \(h(L_{\mathrm{CP}}) = 2 \lvert \mathit{Vars} \rvert\); the framework is not distributive; the MFP solution is sound: if \(\mathrm{MFP}(B)(x) = c \in \mathbb{Z}\) then \(x = c\) at the start of \(B\) in every execution reaching \(B\) in which \(x\) has been assigned.
Proof
\(\hat{\mathrm{op}}\) is monotone in each argument on \(\mathbb{Z}_\bot^\top\) (case analysis: raising an argument from \(\bot\) to \(c\) raises the result from \(\bot\) to a constant or keeps it; raising to \(\top\) raises the result to \(\top\) or keeps it), and a sequence of updates of monotone maps is monotone. The height is Proposition 14.1.8. Non-distributivity: the example after Lemma 14.1.10. Soundness: the concrete execution along a path \(p\) is abstracted by \(f_p\) (each statement's abstract effect contains its concrete effect), and \(\mathrm{MOP} \sqsubseteq \mathrm{MFP}\) (Theorem 14.2.12). \(\square\)
Constant propagation in GCC: CCP folds a branch and a multiplication
Reproduce (gcc 14.2.0; LLVM's equivalent is opt -passes=sccp):
cat > cp.c <<'EOF'
int h(int n) {
int k = 3;
int r;
if (k > 2)
r = n + k;
else
r = n - k;
return r * k;
}
EOF
gcc-14 -O1 -fdump-tree-ccp1 -S cp.c -o /dev/null
sed -n '/^Folding/,$p' cp.c.*t.ccp1
Output:
Folding predicate 1 != 0 to 1
Removing basic block 4
Merging blocks 2 and 3
Merging blocks 2 and 5
int h (int n)
{
int r;
int k;
int _6;
<bb 2> :
r_5 = n_3(D) + 3;
_6 = r_5 * 3;
return _6;
}
What to notice: k is the constant 3 everywhere (Definition 14.3.16), so k > 2 folds to
true, the else block disappears, and r * k becomes r_5 * 3. r itself is \(\top\): it depends on
the parameter n. GCC's CCP is the conditional, sparse variant of Algorithm 14.3.17
(gcc/tree-ssa-ccp.cc, lattice ccp_lattice_t) [GCC-CCP]; Lesson 14.6 traces it.
Definite initialization¶
Definition 14.3.19 (Definite initialization)
A local variable \(v\) is definitely initialized at \(p\) if every path from \(r\) to \(p\) contains an assignment to \(v\). A read of \(v\) at a point where \(v\) is not definitely initialized is a possibly uninitialized use; if moreover no assignment to \(v\) reaches it (Definition 14.3.2), it is a definitely uninitialized use.
Algorithm 14.3.20 (Definite initialization and uninitialized-use warnings)
- Input: a program with local variables \(V\) (in Pebble: the tracked allocas).
- Output: \(\mathrm{IN}[B] \subseteq V\), and one warning per possibly uninitialized read.
- Precondition: none.
- Postcondition: \(\mathrm{IN}[B]\) = variables definitely initialized at the start of \(B\) for reachable \(B\) (Theorem 14.3.21); a warning says "is" iff no assignment reaches the read.
- Invariant: the solver's, in the dual order (sets shrink from \(V\)).
function DefiniteInit(G, V):
for B in N: stored[B] ← { v ∈ V : B assigns v }
framework ← forward, must:
join = ∩; Init = V; boundary IN[r] = {}
transfer f_B = X ↦ X ∪ stored[B]
IN ← Solve(framework)
RD ← ReachingDefinitions(G) # Algorithm 14.3.3, for the "is/may" split
for B in N:
init ← IN[B]
for s in B, top to bottom:
if s reads v ∈ V and v ∉ init:
if no definition of v reaches s: warn "v is uninitialized"
else: warn "v may be uninitialized"
if s assigns v ∈ V: init ← init ∪ {v}
Theorem 14.3.21 (Correctness of Algorithm 14.3.20)
For every block reachable from \(r\), \(\mathrm{IN}[B]\) is the set of variables definitely initialized at its start. A warning is issued exactly at the reads of Definition 14.3.19, and says "is" exactly for definitely uninitialized uses.
Proof
\(f_B\) is gen-only, distributive over \(\cap\); along a path \(p\), \(f_p(\emptyset)\) is the set of variables assigned on \(p\); the intersection over all paths is Definition 14.3.19, and Theorem 14.2.13 gives MFP = MOP for reachable blocks. The inner loop extends the block-entry fact statement by statement with the same function, so it tests the definition at each read. For the "is/may" split: if no definition reaches the read (Definition 14.3.2), then on every path the read happens before any assignment reachable along it, so every execution reading it reads an uninitialized value; if some definition reaches, the path it reaches along does initialize \(v\), and the path witnessing "not definitely initialized" does not: "may". \(\square\)
Uninitialized-variable warnings: Clang, GCC and pebble-uninit side by side
Reproduce (clang 23.1.2, gcc 14.2.0; pebble-uninit is your E5, run by tests/ch14/lit/uninit.c):
cat > uninit.c <<'EOF'
int never(void) { int y; return y + 1; }
int sometimes(int c) { int x; if (c) x = 1; return x; }
int in_loop(int n) { int last; for (int i = 0; i < n; i++) last = i; return last; }
EOF
clang-23 -fsyntax-only -Wuninitialized -Wsometimes-uninitialized -Wconditional-uninitialized uninit.c 2>&1 | grep ': warning:'
LC_ALL=C gcc-14 -O2 -Wall -c uninit.c -o /dev/null 2>&1 | grep ': warning:'
Output:
uninit.c:1:33: warning: variable 'y' is uninitialized when used here [-Wuninitialized]
uninit.c:2:35: warning: variable 'x' is used uninitialized whenever 'if' condition is false [-Wsometimes-uninitialized]
uninit.c:3:77: warning: variable 'last' may be uninitialized when used here [-Wconditional-uninitialized]
uninit.c:1:35: warning: 'y' is used uninitialized [-Wuninitialized]
uninit.c:3:77: warning: 'last' may be used uninitialized [-Wmaybe-uninitialized]
What to notice: Clang runs Algorithm 14.3.20's must-analysis on its source-level CFG
(clang/lib/Analysis/UninitializedValues.cpp, a forward worklist over bit vectors) [CLANG-UNINIT]
and grades the result the same way: "is" when no assignment reaches, "sometimes"/"may" otherwise.
GCC warns from its optimized SSA form (gcc/tree-ssa-uninit.cc) [GCC-UNINIT], after sometimes
has been simplified to return 1 (compare the mem2reg box above) — so it misses the second
warning. pebble-uninit reports all three, like Clang with -Wconditional-uninitialized.
3. Worked examples¶
Running example (used for every technique in this lesson and in Lesson 14.4; tools/course/lib/dataflow.py, RUNNING_EXAMPLE). Successors are listed left to right in the order of the diagram.
flowchart TD
A(["A: x = a * b<br/>i = 0<br/>s = 0"]) --> B["B: if i < n"]
B --> C["C: t = a * b<br/>if t < s"]
B --> F["F: ret s"]
C --> D["D: s = s + t<br/>a = t - 1"]
C --> E["E: u = a * b<br/>i = i + 1"]
D --> E
E --> B
Definitions: \(d_1\) x = a*b, \(d_2\) i = 0, \(d_3\) s = 0 (in A), \(d_4\) t = a*b (C), \(d_5\) s = s+t, \(d_6\) a = t-1 (D), \(d_7\) u = a*b, \(d_8\) i = i+1 (E). Expressions \(U = \{a{*}b, i{+}1, s{+}t, t{-}1\}\). Each trace is round-robin iteration (Algorithm 14.4.1) in reverse postorder of the analysis direction: forward \(A\,B\,F\,C\,D\,E\), backward \(F\,B\,E\,D\,C\,A\). Bold = changed in that pass; every trace ends with a pass that changes nothing.
Reaching definitions on the running example¶
| block | gen | kill |
|---|---|---|
| A | {d1,d2,d3} | {d5,d8} |
| B | {} | {} |
| C | {d4} | {} |
| D | {d5,d6} | {d3} |
| E | {d7,d8} | {d2} |
| F | {} | {} |
| block | init | pass 1 IN | pass 1 OUT | pass 2 IN | pass 2 OUT | pass 3 IN | pass 3 OUT |
|---|---|---|---|---|---|---|---|
| A | {} | {} | {d1,d2,d3} | {} | {d1,d2,d3} | {} | {d1,d2,d3} |
| B | {} | {d1,d2,d3} | {d1,d2,d3} | {d1,...,d8} | {d1,...,d8} | {d1,...,d8} | {d1,...,d8} |
| F | {} | {d1,d2,d3} | {d1,d2,d3} | {d1,...,d8} | {d1,...,d8} | {d1,...,d8} | {d1,...,d8} |
| C | {} | {d1,d2,d3} | {d1,d2,d3,d4} | {d1,...,d8} | {d1,...,d8} | {d1,...,d8} | {d1,...,d8} |
| D | {} | {d1,d2,d3,d4} | {d1,d2,d4,d5,d6} | {d1,...,d8} | {d1,d2,d4,d5,d6,d7,d8} | {d1,...,d8} | {d1,d2,d4,d5,d6,d7,d8} |
| E | {} | {d1,d2,d3,d4,d5,d6} | {d1,d3,d4,d5,d6,d7,d8} | {d1,...,d8} | {d1,d3,d4,d5,d6,d7,d8} | {d1,...,d8} | {d1,d3,d4,d5,d6,d7,d8} |
(\(\{d1,...,d8\}\) abbreviates all eight.) Pass 1 follows the forward edges; pass 2 brings \(d_5..d_8\) around the back edge \(E \to B\); pass 3 confirms. All eight definitions reach the loop head: \(s\) may come from \(d_3\) or \(d_5\), \(i\) from \(d_2\) or \(d_8\).
Live variables on the running example¶
| block | UEVar | VarKill |
|---|---|---|
| A | {a,b} | {i,s,x} |
| B | {i,n} | {} |
| C | {a,b,s} | {t} |
| D | {s,t} | {a,s} |
| E | {a,b,i} | {i,u} |
| F | {s} | {} |
| block | init | pass 1 IN | pass 1 OUT | pass 2 IN | pass 2 OUT | pass 3 IN | pass 3 OUT |
|---|---|---|---|---|---|---|---|
| F | {} | {s} | {} | {s} | {} | {s} | {} |
| B | {} | {i,n,s} | {s} | {a,b,i,n,s} | {a,b,i,n,s} | {a,b,i,n,s} | {a,b,i,n,s} |
| E | {} | {a,b,i,n,s} | {i,n,s} | {a,b,i,n,s} | {a,b,i,n,s} | {a,b,i,n,s} | {a,b,i,n,s} |
| D | {} | {b,i,n,s,t} | {a,b,i,n,s} | {b,i,n,s,t} | {a,b,i,n,s} | {b,i,n,s,t} | {a,b,i,n,s} |
| C | {} | {a,b,i,n,s} | {a,b,i,n,s,t} | {a,b,i,n,s} | {a,b,i,n,s,t} | {a,b,i,n,s} | {a,b,i,n,s,t} |
| A | {} | {a,b,n} | {i,n,s} | {a,b,n} | {a,b,i,n,s} | {a,b,n} | {a,b,i,n,s} |
- Pass 1: in backward RPO, B is visited before its loop predecessor E, so \(\mathrm{OUT}[B]\) first sees only \(\mathrm{IN}[F] = \{s\}\) and \(\mathrm{IN}[C] = \bot\).
- Pass 2: \(\mathrm{OUT}[B]\) picks up \(\mathrm{IN}[C] = \{a,b,i,n,s\}\); only B's IN changes, and E's and A's OUT follow.
- Pass 3: nothing changes. \(x\) and \(u\) are never live: their assignments are dead code.
Available expressions on the running example¶
| block | DEExpr | ExprKill |
|---|---|---|
| A | {a*b} | {i+1,s+t} |
| B | {} | {} |
| C | {a*b} | {s+t,t-1} |
| D | {t-1} | {a*b,s+t} |
| E | {a*b} | {i+1} |
| F | {} | {} |
| block | init | pass 1 IN | pass 1 OUT | pass 2 IN | pass 2 OUT |
|---|---|---|---|---|---|
| A | \(U\) | {} | *{ab} | {} | {a*b} |
| B | \(U\) | *{ab} | *{ab} | {a*b} | {a*b} |
| F | \(U\) | *{ab} | *{ab} | {a*b} | {a*b} |
| C | \(U\) | *{ab} | *{ab} | {a*b} | {a*b} |
| D | \(U\) | *{ab} | {t-1} | {a*b} | {t-1} |
| E | \(U\) | {} | *{ab} | {} | {a*b} |
In pass 1 the back edge \(E \to B\) still carries \(\mathrm{Init} = U\), so \(\mathrm{IN}[B] = \{a{*}b\} \cap U\); that optimistic start is what makes the result the greatest solution in \(\subseteq\). \(a{*}b\) is available at C, so t = a * b in C is redundant with x = a * b in A — but not at E, because D reassigns a: the recomputation u = a * b in E is needed.
Very busy expressions on the running example¶
| block | UEExpr | ExprKill |
|---|---|---|
| A | {a*b} | {i+1,s+t} |
| B | {} | {} |
| C | {a*b} | {s+t,t-1} |
| D | {s+t,t-1} | {a*b} |
| E | {a*b,i+1} | {} |
| F | {} | {} |
| block | init | pass 1 IN | pass 1 OUT | pass 2 IN | pass 2 OUT |
|---|---|---|---|---|---|
| F | \(U\) | {} | {} | {} | {} |
| B | \(U\) | {} | {} | {} | {} |
| E | \(U\) | *{ab,i+1} | {} | {a*b,i+1} | {} |
| D | \(U\) | {i+1,s+t,t-1} | *{ab,i+1} | {i+1,s+t,t-1} | {a*b,i+1} |
| C | \(U\) | *{ab,i+1} | {i+1} | {a*b,i+1} | {i+1} |
| A | \(U\) | *{ab} | {} | {a*b} | {} |
\(i{+}1\) is very busy at the end of C (both successors compute it before changing \(i\)), so it could be hoisted into C; nothing is very busy at B because the path to F computes nothing.
Constant propagation on the running example¶
Parameters \(a, b, n\) are \(\top\) at the entry (unknown inputs); every other variable starts at \(\bot\). The table shows \(\mathrm{IN}[B]\) of each block for the three interesting variables as \((i, s, t)\); \(x\) and \(u\) are \(\top\) as soon as they are assigned, since a*b is \(\top\).
| block | init | pass 1 | pass 2 | pass 3 |
|---|---|---|---|---|
| A | \((\bot, \bot, \bot)\) | \((\bot, \bot, \bot)\) | \((\bot, \bot, \bot)\) | \((\bot, \bot, \bot)\) |
| B | \((\bot, \bot, \bot)\) | \((0, 0, \bot)\) | \((\top, \top, \top)\) | \((\top, \top, \top)\) |
| F | \((\bot, \bot, \bot)\) | \((0, 0, \bot)\) | \((\top, \top, \top)\) | \((\top, \top, \top)\) |
| C | \((\bot, \bot, \bot)\) | \((0, 0, \bot)\) | \((\top, \top, \top)\) | \((\top, \top, \top)\) |
| D | \((\bot, \bot, \bot)\) | \((0, 0, \top)\) | \((\top, \top, \top)\) | \((\top, \top, \top)\) |
| E | \((\bot, \bot, \bot)\) | \((0, \top, \top)\) | \((\top, \top, \top)\) | \((\top, \top, \top)\) |
- Pass 1: the back edge \(E \to B\) still carries \(\bot\), so B sees only A's constants \(i = 0, s = 0\); at E, \(s\) merges \(0\) (from C) with \(s + t = \top\) (from D).
- Pass 2: B joins \(i = 0\) from A with \(i = 1\) from E: \(0 \sqcup 1 = \top\). Everything becomes \(\top\).
- Pass 3: no change. No constant survives the loop, which is correct: \(i\) and \(s\) really change. The instructive instance for constant propagation is the non-distributive one of Lesson 14.2 §3 (\(z = 5\) on both paths, \(\top\) after the merge).
Definite initialization on the running example¶
Treat \(a, b, n\) as parameters (initialized). Every other variable is assigned before it is read on every path — i, s in A; t in C before D reads it; u, x are never read — so the analysis finds no uninitialized read. On tests/ch14/Inputs/c/uninit.c, print<pebble-definite-init> prints for in_loop:
Pebble definite initialization for function 'in_loop'
%entry: in = {} out = {%n.addr, %i}
%for.cond: in = {%n.addr, %i} out = {%n.addr, %i}
%for.body: in = {%n.addr, %i} out = {%n.addr, %last, %i}
%for.inc: in = {%n.addr, %last, %i} out = {%n.addr, %last, %i}
%for.end: in = {%n.addr, %i} out = {%n.addr, %i}
...
%4 = load %last in %for.end: maybe uninitialized
%last is in \(\mathrm{OUT}[\texttt{for.body}]\) but not in \(\mathrm{IN}[\texttt{for.cond}] = \mathrm{OUT}[\texttt{entry}] \cap \mathrm{OUT}[\texttt{for.inc}]\), so it is not definitely initialized at %for.end; since the store in %for.body reaches the load, the warning says "may be".
Try it
./course drill dataflow-table --seed 7 --difficulty medium --solution draws one of the four
classics on a random program and prints the local sets and the round-robin table in this format.
Check your own table for the running example with --difficulty hard seeds until the analysis
you want comes up.
4. Invariants and correctness¶
Reaching definitions¶
Lemma 14.3.4 (block transfer) and Theorem 14.3.5 (MFP = definition). The solver's invariant is Lemma 14.4.3. When it breaks: with pointers, a store through p may or may not define x; treating it as a definition that kills other definitions of x (a strong update) is unsound unless p certainly points to x. Pebble's E3 avoids the problem by tracking only allocas whose address never escapes (isTrackedAlloca).
Live variables¶
Theorem 14.3.8. When it breaks: a volatile or escaped variable can be read by code the analysis cannot see; liveness must treat it as always live. In SSA, a phi's operand counted as live-in to the phi's block (instead of live-out of the predecessor) makes values live on edges that never carry them: with two predecessors both supplying different values, both values would appear live in both predecessors.
Available expressions¶
Theorem 14.3.12. The optimistic initialization \(\mathrm{Init} = U\) is essential. Take A: t = a*b -> B; B: -> C, X; C: (no assignment) -> B. The equations \(\mathrm{IN}[B] = \mathrm{OUT}[A] \cap \mathrm{OUT}[C]\) and \(\mathrm{OUT}[C] = \mathrm{IN}[B]\) have two solutions: \(\mathrm{IN}[B] = \{a{*}b\}\) (the right one: \(a{*}b\) is computed before the loop and never killed) and \(\mathrm{IN}[B] = \emptyset\). Starting from \(\emptyset\) the solver stops at the second — sound, but useless. Starting from \(U\) it finds the first, the greatest solution in \(\subseteq\), which is the least fixed point in the order the framework uses (\(\supseteq\)). When it breaks: calls that may modify operands (globals, memory) must kill the expressions that read them.
Very busy expressions¶
Theorem 14.3.15. When it breaks: blocks that reach no exit (infinite loops) keep \(\mathrm{Init} = U\) — "every path to an exit computes \(e\)" is vacuous — so hoisting based on it can move an expression onto a path that never needed it. Hoisting must also not move an expression that may trap (division) above the test that guards it, even when it is very busy.
Constant propagation¶
Proposition 14.3.18. MFP is sound but below MOP (Lesson 14.2 §3). When it breaks: treating \(\bot\) ("not yet assigned") as an ordinary constant makes a read of an uninitialized variable look constant; C's undefined behavior permits that, which is exactly why mem2reg folded phi [1, undef] to 1 above.
Definite initialization¶
Theorem 14.3.21. When it breaks: the analysis is flow-sensitive but path-insensitive: if (c) x = 1; ... if (c) use(x); is safe but warned about (a false positive, since the two conditions are correlated); Clang's -Wsometimes-uninitialized suppresses warnings it cannot attribute to a single branch for this reason, and GCC's -Wmaybe-uninitialized uses predicate analysis to prune such paths.
5. Complexity¶
Let \(n\) = blocks, \(e\) = edges, \(s\) = statements, \(k\) = size of the universe (definitions, variables or expressions), \(w\) = machine word size, \(d = d(G)\) the loop connectedness (Lesson 14.4).
| Technique | Time (worst) | Time (typical) | Space | Variables |
|---|---|---|---|---|
| Reaching definitions | \(O((d + 2) \cdot e \cdot k / w)\) | \(\le 3\)–\(4\) passes | \(O(n k / w)\) | \(n, e, k, d, w\) |
| Live variables | \(O((d + 2) \cdot e \cdot k / w)\) on \(G^{R}\) | same | \(O(n k / w)\) | same |
| Available expressions | \(O((d + 2) \cdot e \cdot k / w)\) | same | \(O(n k / w)\) | same |
| Very busy expressions | \(O((d + 2) \cdot e \cdot k / w)\) on \(G^{R}\) | same | \(O(n k / w)\) | same |
| Constant propagation | \(O(2 \lvert \mathit{Vars} \rvert \cdot e \cdot \lvert \mathit{Vars} \rvert)\) (Kildall, height \(2\lvert \mathit{Vars} \rvert\)) | 2–3 passes | \(O(n \lvert \mathit{Vars} \rvert)\) | \(\mathit{Vars}\) |
| Definite initialization | as available expressions, \(k = \lvert V \rvert\) | same | \(O(n k / w)\) | \(V\) |
Proposition 14.3.22 (Cost of the bit-vector classics)
Computing the local sets costs \(O(s + n k / w)\); solving costs \(O((d(G) + 2) \cdot (n + e) \cdot k / w)\) word operations with round-robin iteration in RPO.
Proof
Each statement updates one bit (or scans its operands) once: \(O(s)\), plus initializing \(2n\) vectors of \(\lceil k / w \rceil\) words. Gen/kill frameworks are rapid, so round-robin in RPO needs at most \(d(G) + 2\) passes (Theorem 14.4.6); each pass computes one join per edge and one transfer per node, each \(O(k / w)\) word operations. \(\square\)
Pathological input. For reaching definitions the universe itself can be quadratic: a program with \(m\) variables and \(m\) assignments to each in straight-line code followed by a loop has \(k = m^2\) definitions and \(O(n m^2)\) bits of IN/OUT storage; SSA replaces this by one definition per value (Lesson 14.6).
At scale: GCC's RTL passes compute live registers and reaching definitions this way on every function (gcc/df-core.cc, bitmaps per block) [GCC-DF]; the comparison lab (ch14-solverbench) solves 256-fact gen/kill problems on 10 000-block CFGs in milliseconds (reproduce with build/<preset>/bin/ch14-solverbench).
6. Variants and refinements¶
Reaching definitions¶
- Use-def and def-use chains [Dragon2, §9.2.4]: store, per use, the reaching definitions — trade-off: quadratic space in the worst case; SSA's single definitions make them linear.
- SSA [CFRWZ91]: renaming gives each use exactly one reaching definition, making the analysis unnecessary for registers — trade-off: requires construction and destruction (Ch 16).
Live variables¶
- SSA liveness by path exploration [BBD+11]: one backward walk per value, from its uses to its definition — trade-off: no iteration, work proportional to live ranges (Lesson 14.6, the lab's
computeLivenessSparse). - Liveness checking [BHG+08]: answer "is \(v\) live at \(p\)?" on demand using the dominator tree and loop forest, without computing sets — trade-off: fast queries, no sets to iterate.
Available expressions¶
- Dominator-tree CSE (LLVM EarlyCSE): in SSA, an expression dominating a use is available — trade-off: misses redundancies along joins (an expression computed on both arms of an
if). - Value numbering (GVN, Ch 17): available values rather than lexically equal expressions — trade-off: finds
a*b=b*a=c*bafterc = a, at higher cost.
Very busy expressions¶
- Partial-redundancy elimination [MR79, KRS92]: combines availability and anticipability to move computations to where they are needed on every path; lazy code motion places them as late as possible — trade-off: several bit-vector problems per function.
- SSA-based hoisting (GVNHoist): ANTIC on the post-dominator tree — trade-off: limited to hoisting through single-entry regions.
Constant propagation¶
- Sparse conditional constant propagation [WZ91]: propagate along SSA edges only and treat branches with constant conditions as one-way — trade-off: finds strictly more constants, needs SSA (Lesson 14.6).
- Interprocedural (IPSCCP) and IDE-based constant propagation [SRH96]: across calls — trade-off: context sensitivity costs time or precision (Lesson 14.8).
Definite initialization¶
- Predicate-aware uninitialized analysis (GCC
-Wmaybe-uninitialized) [GCC-UNINIT]: tracks the conditions guarding a definition and a use — trade-off: fewer false positives, exponential in the worst case. - Language-level definite assignment (Java, C#) [JLS, Ch. 16]: the same must-analysis on the AST with rules per construct — trade-off: must be conservative and simple enough to specify in a language standard.
7. In real compilers¶
Reaching definitions¶
LLVM
llvm/lib/CodeGen/ReachingDefAnalysis.cpp — ReachingDefInfo::run, reaching definitions of
physical registers after register allocation, used by the x86 execution-domain fix and
false-dependency breaking (LLVM 23.1.2) [LLVM-RDA]. At IR level mem2reg
(llvm/lib/Transforms/Utils/PromoteMemoryToRegister.cpp) answers the same question for promotable allocas.
- GCC
gcc/df-problems.cc— theDF_RDproblem of the dataflow framework (GCC 15) [GCC-DF].
Find where LLVM does it. Open llvm/lib/CodeGen/ReachingDefAnalysis.cpp. Question: which method handles a block the second time LoopTraversal visits it because it is part of a loop? (Quiz llvm-where-reaching-defs.)
Live variables¶
LLVM
llvm/lib/CodeGen/LiveVariables.cpp — LiveVariables::analyze and MarkVirtRegAliveInBlock, SSA
liveness of virtual registers on Machine IR, by the path-exploration of Lesson 14.6 (LLVM 23.1.2)
[LLVM-LV]. clang/lib/Analysis/LiveVariables.cpp — LiveVariables::computeLiveness, a backward
worklist on the source CFG [CLANG-LV].
- GCC
gcc/df-problems.cc—DF_LR(live registers) andDF_LIVE(GCC 15) [GCC-DF]. - rustc
compiler/rustc_mir_dataflow/src/impls/liveness.rs—MaybeLiveLocals, a backward analysis on MIR (rustc 1.90.0) [RUSTC-DF]. - MLIR
mlir/include/mlir/Analysis/DataFlow/LivenessAnalysis.h—LivenessAnalysis, a sparse backward analysis (LLVM 23.1.2) [MLIR-DF].
Available expressions¶
LLVM
llvm/lib/Transforms/Scalar/EarlyCSE.cpp — scoped hash tables of available expressions along the
dominator tree (LLVM 23.1.2) [LLVM-EARLYCSE]; llvm/lib/Transforms/Scalar/GVN.cpp for full
redundancy elimination with value numbers.
- GCC
gcc/tree-ssa-dom.cc(dominator-based redundancy elimination) andgcc/tree-ssa-pre.cc(GCC 15).
Very busy expressions¶
LLVM
llvm/lib/Transforms/Scalar/GVNHoist.cpp — class GVNHoist; the file comment: "This pass hoists
expressions from branches to a common dominator. ... This is based on finding the ANTIC points in
the CFG which do not change during hoisting" (LLVM 23.1.2) [LLVM-GVNHOIST].
- GCC
gcc/tree-ssa-pre.cc— ANTIC_IN/ANTIC_OUT sets of partial-redundancy elimination (GCC 15).
Constant propagation¶
LLVM
llvm/lib/Transforms/Utils/SCCPSolver.cpp — the conditional sparse variant; ValueLatticeElement
is its lattice (LLVM 23.1.2) [LLVM-SCCP].
- GCC
gcc/tree-ssa-ccp.cc—ccp_lattice_t(UNINITIALIZED,UNDEFINED,CONSTANT,VARYING) (GCC 15) [GCC-CCP]. - MLIR
mlir/include/mlir/Analysis/DataFlow/ConstantPropagationAnalysis.h—SparseConstantPropagation(LLVM 23.1.2) [MLIR-DF].
Definite initialization¶
Clang
clang/lib/Analysis/UninitializedValues.cpp — runUninitializedVariablesAnalysis (a
ForwardDataflowWorklist over CFGBlockValues, bit vectors per block) (LLVM 23.1.2) [CLANG-UNINIT];
clang/lib/Sema/AnalysisBasedWarnings.cpp turns its results into -Wuninitialized and
-Wsometimes-uninitialized.
- GCC
gcc/tree-ssa-uninit.cc—warn_uninitialized_vars(GCC 15) [GCC-UNINIT]. - rustc
compiler/rustc_mir_dataflow/src/impls/initialized.rs—MaybeUninitializedPlaces, which the borrow checker uses to reject reads of uninitialized places (rustc 1.90.0) [RUSTC-DF].
Find where Clang does it. Open clang/lib/Analysis/UninitializedValues.cpp and find runUninitializedVariablesAnalysis. Question: which worklist class does it instantiate? (Quiz llvm-where-uninit-worklist.)
8. Comparison¶
| Technique | Power / precision | Speed | Output / error quality | Implementation effort | Typical use |
|---|---|---|---|---|---|
| Reaching definitions | Exact (distributive): MFP = MOP | \(\le d + 2\) bit-vector passes | Use-def chains; quadratic sets without SSA | Low (gen/kill) | Memory SSA construction, post-RA register reasoning |
| Live variables | Exact (distributive) | \(\le d + 2\) passes on \(G^{R}\) | Live sets per block; SSA version needs phi conventions | Low | Register allocation, DCE, pruned SSA |
| Available expressions | Exact (distributive), must | \(\le d + 2\) passes | Redundant expressions to delete | Low | Global CSE; availability half of PRE |
| Very busy expressions | Exact (distributive), must, backward | \(\le d + 2\) passes | Hoisting points | Low | Code hoisting; anticipability half of PRE |
| Constant propagation | Sound, below MOP (not distributive) | \(\le 2\lvert \mathit{Vars} \rvert\) height; 2–3 passes | Constants per point | Low–medium (lattice of maps) | Folding; superseded by SCCP |
| Definite initialization | Exact for its definition; path-insensitive | as available expressions | Warnings with "is/may" | Low | -Wuninitialized, Java/C# definite assignment |
Choose reaching definitions when you need use-def chains for memory or for code not in SSA. Choose liveness for any register or dead-store question. Choose available/very busy expressions when you implement CSE or PRE on a non-SSA IR (on SSA use GVN, Ch 17). Choose constant propagation only as the stepping stone to SCCP. Choose definite initialization to diagnose — and pair it with reaching definitions for good messages.
9. Assessment¶
| Technique | Quiz ids | Drill | Flashcard tag | Exercises |
|---|---|---|---|---|
| Reaching definitions | rd-in-running, llvm-where-reaching-defs |
./course drill dataflow-table |
reaching-definitions |
E3 |
| Live variables | live-in-running, ssa-liveness-phi |
./course drill dataflow-table |
liveness |
E2, lab L4 |
| Available expressions | ae-in-running, classic-directions |
./course drill dataflow-table |
available-expressions |
— |
| Very busy expressions | vbe-in-running, classic-directions |
./course drill dataflow-table |
very-busy-expressions |
— |
| Constant propagation | mop-vs-mfp-const, cp-height |
./course drill lattice-props |
constant-propagation |
— (SCCP in Ch 17) |
| Definite initialization | uninit-is-vs-may, llvm-where-uninit-worklist |
./course drill dataflow-table (available expressions has the same shape) |
definite-init |
E4, E5 |
Pitfall
Mixing up the direction and the merge: liveness is backward/may (∪), available expressions forward/must (∩), very busy expressions backward/must (∩), reaching definitions forward/may (∪). Getting either wrong still produces a fixed point — just of a different analysis. And a must analysis must start from the universe, not from \(\{\}\).
References¶
See the chapter references.