Dead-Code Elimination and Liveness Analysis
A compiler emits code for `t1 = a + b` even though `t1` is never read again. Every wasted register costs time. Every extra instruction costs time too. This happens across billions of executions worldwide. The question "will this computed value ever be used?" sounds trivial for one line. But answering it precisely across loops, branches, and function calls is harder. It requires a systematic algorithm, not guesswork. Dead-code elimination (DCE) is one of the oldest and most reliably profitable optimizations a compiler performs. It removes assignments and computations whose results can never affect observable program behavior. To find these, the compiler needs liveness analysis. This means checking, for every point in the program, which variables might still be read later. A variable that is never live after its definition is dead. The instruction defining it — if it has no other effect — can be deleted safely. This article builds liveness analysis as a concrete instance of the general Data-Flow Analysis Framework. It then shows how the resulting liveness sets drive dead-code elimination. Liveness is a backward analysis: information flows against the direction of control flow. This is because "will this be used later" depends on what happens after the current point, not before it. This backward orientation is the article's central technical idea. It distinguishes liveness from forward analyses like reaching definitions. By the end, you will be able to compute liveness sets by hand on a control-flow graph. You will also prove the fixpoint iteration terminates at the right answer. You will eliminate dead code correctly, including the subtle cases that trip up naive implementations. Finally, you will see why this analysis is a stepping stone toward Static Single Assignment (SSA) Form. There, liveness becomes even cheaper to compute, and register allocation becomes a graph-coloring problem built directly on it.
- Dead-code elimination deletes instructions whose results are never subsequently used, shrinking code size and register pressure.
- Liveness analysis, a backward data-flow analysis, computes for each program point the set of variables that may be read before being overwritten.
- The transfer function is `IN[n] = use[n] ∪ (OUT[n] − def[n])`, joined over successors with union (a "may" analysis).
- The iteration is a monotone fixpoint computation over the lattice of variable sets, guaranteed to terminate by the finite-height lattice argument.
- An instruction is dead if its defined variable is not in the liveness OUT-set, has no side effects, and isn't otherwise required (e.g. volatile, address-taken).
- DCE and liveness are iterated together with other cleanups, and both become simpler once code is transformed to Static Single Assignment (SSA) Form.
A variable is dead the instant no path forward can read it again before it is overwritten — find that by propagating "used" information backward through the control-flow graph until it stabilizes.
The idea
Liveness is a property of a variable at a specific program point: a variable `v` is live at point `p` if there exists some path in the control-flow graph (CFG) from `p` to a use of `v`, along which `v` is not redefined first. Liveness is inherently a backward question — whether `v` is live at `p` depends on what the program does after `p`, so information must be propagated from later points to earlier ones. This is the opposite flow direction from an analysis like reaching definitions, which propagates forward. Liveness is computed per CFG node (basic block or individual instruction) using two local sets. `use[n]` is the set of variables read in `n` before any local redefinition — variables `n` needs as input. `def[n]` is the set of variables assigned in `n`. The data-flow equations, instantiated from the general Data-Flow Analysis Framework, are: ``` IN[n] = use[n] ∪ (OUT[n] − def[n]) OUT[n] = ∪ (IN[s] for each successor s of n) ``` This is a may analysis (union at merge points, not intersection), because a variable is live if it is used along any possible future path, not all of them. The transfer function is monotone and the lattice (subsets of the variable universe, ordered by inclusion) has finite height, which is exactly what the general framework requires for a fixpoint to exist and be computable by iteration.
Dead-code elimination consumes the OUT-sets produced by liveness. An assignment `v = e` at point `p` is a candidate for deletion if `v ∉ OUT[p]` — no path forward reads `v` before it is redefined — and evaluating `e` has no observable side effect (no I/O, no memory write through a pointer, no exception that must be preserved, no volatile access). Removing such an instruction cannot change any observable behavior of the program, which is the correctness contract every optimization must uphold.
How it works
The overall pipeline has three phases: build the CFG and per-instruction `use`/`def` sets, run the backward fixpoint iteration to compute `IN`/`OUT` liveness sets at every point, then sweep the code deleting dead instructions — repeating, since deleting one dead instruction can make an operand of its definition dead in turn.
Step 1 — CFG and local sets. For each instruction (or basic block, for a coarser but cheaper analysis), compute `use` and `def` by simple syntactic inspection.
Step 2 — backward fixpoint. Initialize all `IN`/`OUT` sets to empty, then repeatedly apply the equations until nothing changes. Because it's a backward analysis, blocks are conventionally visited in reverse postorder of the CFG (i.e., postorder of the reverse CFG) for fast convergence, though any visitation order eventually reaches the same fixpoint.
Step 3 — dead-code sweep. Walk the code; delete any side-effect-free definition of a variable not in that point's `OUT` set. Because deletions change liveness, re-run liveness (or maintain it incrementally) until a full pass deletes nothing. ``` function LIVENESS(CFG): for each node n in CFG: IN[n] ← ∅ OUT[n] ← ∅ changed ← true while changed: changed ← false for each node n in reverse-postorder(reverse(CFG)): old_IN ← IN[n] OUT[n] ← ∪ { IN[s] : s ∈ succ(n) } IN[n] ← use[n] ∪ (OUT[n] − def[n]) if IN[n] ≠ old_IN: changed ← true return IN, OUT
function DEAD_CODE_ELIMINATION(CFG): (IN, OUT) ← LIVENESS(CFG) repeat removed ← false for each instruction i : "v = e" in CFG, in any order: if v ∉ OUT[i] and e has no side effect and v is not address-taken / volatile / observable: delete i from CFG removed ← true if removed: (IN, OUT) ← LIVENESS(CFG) -- recompute (or update incrementally) until not removed return CFG ``` A subtlety in `use`/`def` computation: for an instruction like `x = x + y`, the read of `x` on the right-hand side happens before the write, so `use = {x, y}`, `def = {x}` — never treat `x` as merely defined when it also appears as an operand. For an instruction with multiple destinations or a call that may both read and write through pointers (aliasing), `use` must conservatively include everything that might be read, since liveness is a may-analysis and under-approximating `use` breaks soundness.
The diagram shows exactly why `c = a + b` in `B1` is dead: liveness flows backward from the `return a` in `B3` and the `print(t)` in `B2`, and neither path ever needs `c`. Note `a` and `b` are live out of `B1`, so `a = 1` and `b = 2` survive — only the unused computation of `c` is removed.
Complexity & correctness
Time complexity. Let the CFG have `N` nodes (blocks) and `E` edges, and let `V` be the number of distinct variables/temporaries. Each iteration of the outer loop visits every node once and does set operations costing `O(V)` (using bitvectors, a union or difference is `O(V/w)` machine words for word size `w`). The number of iterations is bounded by the height of the lattice, which for a "may" analysis with union as the join is at most `V` (each `IN[n]` can only grow, one bit at a time, before hitting the full set). So the naive bound is:
In practice, using reverse-postorder visitation on (structured, reducible) CFGs, the number of passes needed is bounded by the loop-nesting depth plus a small constant, so real compilers observe near-linear behavior, `O(N + E)` per pass with very few passes (often 2–3, even for large functions). This matches the general fact that data-flow analyses with distributive transfer functions (liveness's transfer function is distributive, not just monotone) converge to the meet-over-all-paths solution in at most `d + 2` iterations, where `d` is a measure of graph depth (loop connectedness), for a suitable traversal order — a stronger and tighter bound than the raw lattice-height bound.
Space complexity. `O(N · V)` bits to store `IN` and `OUT` sets at every node (two bitvectors of length `V` per node), which is the dominant memory cost; local `use`/`def` sets add the same order.
Correctness argument. Three things must be shown: (1) the equations have a fixpoint, (2) iteration reaches it, (3) the fixpoint is a sound approximation to true liveness, and hence DCE is safe. Existence and reachability of the fixpoint. The lattice is the powerset of variables under `⊆`, a finite-height lattice; the transfer function `f(OUT) = use ∪ (OUT − def)` and the join `∪` are both monotone. By the Knaster–Tarski framework underlying the Data-Flow Analysis Framework, monotone functions on a finite-height lattice have a fixpoint, and starting from the bottom element (`∅`) and repeatedly applying monotone updates produces a strictly increasing chain (in the round it changes) that must stabilize within a bounded number of steps, since the sets can only grow and are capped at size `V`. Soundness relative to true liveness. True (meet-over-all-paths) liveness is defined as: `v` is live at `p` iff some CFG path from `p` reaches a use of `v` with no intervening redefinition. One shows by induction on path length that the fixpoint computed by the algorithm equals exactly this set — not merely an over-approximation — because liveness's transfer functions are distributive: `f(A ∪ B) = f(A) ∪ f(B)`. Distributivity means the iterative (maximum fixpoint) solution coincides precisely with the meet-over-all-paths solution; there is no precision loss from merging at join points, unlike analyses with only monotone (non-distributive) transfer functions. Safety of deletion. If `v ∉ OUT[p]` for the point right after instruction `i: v = e`, then by the soundness result above, no path from `p` reads `v` before it is overwritten. Removing `i` therefore cannot change the value observed by any future read of any variable (since no future read of `v` along any executable path exists), provided `e`'s evaluation itself has no side effect. This is precisely the condition guarded in the pseudocode.
Tradeoffs. Per-instruction granularity gives maximal precision (finds dead code inside a block) but costs more memory and bookkeeping than per-block granularity, which only tracks liveness at block boundaries and locally recomputes within a block. Iterating DCE with liveness recomputation to a fixpoint is more thorough but costs extra passes; production compilers typically bound the number of DCE+liveness rounds or interleave it with other cleanups (copy propagation, constant folding) so that newly-dead code surfaces incrementally rather than requiring full re-analysis every time.
Worked example
Consider this small function (three-address code), computing a factorial-like loop, with one genuinely dead computation buried inside: ``` B0: n = input() acc = 1 dbg = n 2 ; computed but never used goto B1 B1: if n <= 1 goto B3 B2: acc = acc n n = n - 1 goto B1 B3: return acc ``` CFG: `B0 → B1`, `B1 → B2` and `B1 → B3`, `B2 → B1` (a loop).
Local sets:
| Block | use | def |
|---|---|---|
| B0 | {} | {n, acc, dbg} |
| B1 | {n} | {} |
| B2 | {acc, n} | {acc, n} |
| B3 | {acc} | {} |
Backward fixpoint iteration (processing in reverse-postorder of the reverse CFG: `B3, B2, B1, B0`, iterating to convergence): Round 1:
- `OUT[B3] = ∅` (exit block, no successors) → `IN[B3] = {acc} ∪ (∅ − ∅) = {acc}`
- `OUT[B2] = IN[B1] = ∅` (not yet computed) → `IN[B2] = {acc,n} ∪ (∅ − {acc,n}) = {acc,n}`
- `OUT[B1] = IN[B2] ∪ IN[B3] = {acc,n} ∪ {acc} = {acc,n}` → `IN[B1] = {n} ∪ ({acc,n} − ∅) = {acc,n}`
- `OUT[B0] = IN[B1] = {acc,n}` → `IN[B0] = {} ∪ ({acc,n} − {n,acc,dbg}) = {}`
Round 2 (re-visit since `IN[B1]` changed after `B2` was computed using a stale value):
- `OUT[B2] = IN[B1] = {acc,n}` → `IN[B2] = {acc,n} ∪ ({acc,n} − {acc,n}) = {acc,n}` — unchanged.
- `OUT[B1]`, `IN[B1]` unchanged. `OUT[B0]`, `IN[B0]` unchanged.
No further changes — fixpoint reached in 2 rounds (matching the expected small constant, here driven by one loop back-edge).
Result: `OUT[B0] = {acc, n}`. Check each definition in `B0`:
| Statement | defines | in OUT[B0]? | side effect? | dead? |
|---|---|---|---|---|
| `n = input()` | n | yes | yes (I/O) | keep regardless |
| `acc = 1` | acc | yes | no | keep (live) |
| `dbg = n * 2` | dbg | no | no | delete |
Only `dbg = n 2` is removed. Note `n = input()` defines a variable that is* live, but even if it weren't, it would be kept because `input()` has an observable side effect — a case the naive rule "delete if unused" would get wrong without the side-effect guard. After deletion, liveness is recomputed (here it doesn't change further, since `dbg` contributed nothing to any `use` set), and the sweep terminates with no more dead code found.
Common pitfalls & traps
| Pitfall | Why it's wrong | Fix |
|---|---|---|
| Deleting `v = e` because `v` unused, ignoring side effects in `e` | Removes observable behavior (I/O, traps, memory writes) | Check `e` is pure before deleting |
| Computing `use`/`def` wrong for `x = x + 1` | Treating `x` as purely `def`, missing that it's also read | `use` must include operands read before the write |
| Forgetting aliasing / address-taken variables | `&x` passed to a function means `x` may be read indirectly — deleting `x`'s def is unsound | Treat address-taken or escaping variables as always live, or use points-to analysis |
| Running DCE only once | Deleting one dead instruction can make its operands' definitions dead too | Iterate DCE + liveness to a fixpoint |
| Using intersection instead of union at join points | Liveness is a "may" property; using "must" semantics under-approximates liveness and deletes live code | Always join with `∪` for liveness |
| Forward traversal order for a backward analysis | Converges correctly eventually, but far more slowly (many extra passes) | Visit in reverse postorder of the reverse CFG |
Pioneers & history
Liveness analysis and dead-code elimination grew out of the broader development of systematic data-flow analysis for optimizing compilers in the 1960s–70s, formalized with lattice-theoretic rigor once the connection to fixpoint theory (via Data-Flow Analysis Framework) was made explicit.
Origins · 1 minFrances E. Allen and how this was discovered
Allen developed foundational techniques for analyzing and transforming control flow in compilers, including work that made dominance, loop structure, and data-flow reasoning practical for real optimizing compilers. She was the first woman to win the Turing Award (2006), recognized explicitly for contributions to compiler optimization theory and practice that underlie analyses like liveness and dead-code elimination used in every modern compiler.
Where this matters
Every mainstream optimizing compiler — GCC, LLVM/Clang, the JVM's JIT (HotSpot's C2), .NET's RyuJIT, V8's TurboFan — runs liveness analysis and dead-code elimination as core, repeatedly-applied passes. Liveness is not only used for DCE: it is the direct input to register allocation, since two variables can share a register only if they are never simultaneously live (this is the interference-graph construction in Chaitin-style allocators). It also underlies dead store elimination (removing a write that is immediately overwritten with no intervening read), which matters heavily in tight numerical and systems code. In JIT compilers, cheap approximate liveness runs at every optimization tier because compile time itself is a runtime cost; ahead-of-time compilers like LLVM can afford more precise, iterative versions. Static analysis tools and linters reuse the same machinery to warn about "unused variable" and "unreachable code" diagnostics. The concept generalizes beyond compilers: liveness-style backward reachability appears in garbage collection (an object is "live" if reachable from a future use, closely paralleling variable liveness), in dependency-driven build systems, and in dataflow-based security analyses (taint tracking runs a structurally similar, though forward, analysis). The natural next step in a compiler's optimization pipeline is transforming the program into Static Single Assignment (SSA) Form, where each variable has exactly one definition, making liveness ranges contiguous and the interference graph for register allocation dramatically cheaper to build and reason about.
Ask the tutor: How does liveness analysis change when a variable's address is taken and passed to another function?
Is dead-code elimination always safe to apply automatically?
Yes, as long as the "dead" definition truly has no observable side effects and liveness has been computed soundly, including correct handling of aliasing and address-taken variables. The danger is not in the DCE step itself but in mis-computing liveness or under-approximating what counts as a side effect — for example forgetting that a function call might write through a pointer parameter. Production compilers guard DCE with conservative side-effect and alias analyses precisely so the transformation remains behavior-preserving. When those guards are correct, DCE can never change a program's observable output, only its size and speed.
How is liveness analysis different from reaching definitions analysis?
Reaching definitions is a forward "may" analysis answering "which assignments might still be in effect at this point," useful for constant propagation. Liveness is a backward "may" analysis answering "which variables might be read in the future." Both are instances of the general Data-Flow Analysis Framework and share the fixpoint-iteration machinery, but they propagate information in opposite directions through the CFG and use different local sets (`gen`/`kill` for reaching definitions versus `use`/`def` for liveness). Confusing the two is a common student error since the equations look structurally similar.
Why must liveness use union rather than intersection at merge points?
Liveness asks whether a variable might be used along some future execution path, which is an existential ("may") question, not a universal ("must") one. If a variable is used on only one of two branches after a merge, it must still be treated as live before the branch, because that branch might actually be taken at runtime. Using intersection instead would incorrectly mark it dead and could cause DCE to delete a definition that a real execution path still needs, which is unsound and can change program behavior.
Does dead-code elimination need to run more than once?
Almost always, yes. Deleting one dead instruction can make the variables it used dead too, exposing further dead code — a cascading effect. Compilers typically iterate liveness computation and dead-code deletion to a fixpoint, or interleave DCE with other cleanups like constant folding and copy propagation, since those transformations also frequently expose new dead code. Stopping after a single pass leaves optimization opportunities on the table, though it is still sound; it's a completeness tradeoff, not a correctness one.
How does liveness analysis relate to register allocation?
Liveness directly determines which variables can share a hardware register: two variables can be assigned the same register only if their live ranges never overlap, i.e., they are never simultaneously live. Compilers build an interference graph from liveness information — one node per variable, an edge between any two variables live at the same point — and then perform graph coloring to assign registers, spilling variables to memory when not enough colors (registers) exist. This is why liveness analysis is computed even when no dead code exists to remove; it's foundational infrastructure, not just a DCE helper.
Why is DCE described as becoming "cheaper" under SSA form?
In Static Single Assignment (SSA) Form, each variable is defined exactly once, so its live range is a single contiguous region from definition to its last use, rather than a set of possibly-overlapping ranges from multiple reassignments. This structural property lets compilers compute liveness with simpler, often sparse algorithms that touch only relevant program points rather than iterating over the entire CFG repeatedly. Dead-code elimination under SSA also becomes a simple mark-and-sweep: a definition is dead if its SSA value is never used, discoverable in one linear pass rather than an iterated fixpoint in many practical cases.
Can dead-code elimination remove entire basic blocks or only single instructions?
The instruction-level DCE described here removes individual dead assignments, but it composes with a related pass, unreachable-code elimination, which removes entire basic blocks unreachable from the entry (found via forward reachability from the entry node, not liveness). The two are complementary: liveness-based DCE cleans up computations within reachable code, while reachability analysis discards blocks control can never enter, such as code after an unconditional `return` or behind a statically-false branch condition. Compilers typically run both, since each can expose further opportunities for the other.