81. Dead code elimination
Some instructions do work that nobody ever looks at. Finding them is a lot like garbage collection, except the garbage is code, and in JavaScript even a lonely `+` may not be safe to throw away.
- Remove dead instructions from an SSA listing with a mark-and-sweep pass that starts from the instructions with effects.
- Explain why a value that has a use can still be dead.
- Compute liveness by walking straight-line code backwards, and use it to find dead stores.
- Explain why deleting an unused `w + h` in JavaScript needs a proof that `w` and `h` are numbers.
Work nobody asked for
Here is a function with a leftover line. Maybe p was used once, and
then the code around it changed:
function area(w, h) {
const p = w + h;
const a = w * h;
return a;
}
console.log(area(3, 4));
Nothing reads p. You met this situation from the other side in
chapter 24, where an optimizing JIT could quietly delete a benchmark
loop whose answer was never used. This chapter is about how a compiler
finds such code, and when it is allowed to delete it.
Here is area in Viscosity’s real IR, printed by the engine’s
printFunc:
func area(%0, %1):
b0:
%0 = param
%1 = param
%2 = binop + %0, %1
%3 = binop * %0, %1
return %3
%2 is p. Nobody uses it. Viscosity builds a use list for every
value: the instructions (and branches and returns) that read it. When
you ask the engine for values whose use list is empty, it names three:
no uses: %2 binop +
no uses: %5 storeBinding
no uses: %12 call
Only the first is junk. %5 stores the function area under its name,
and %12 is the call to console.log. Nobody reads their results, but
they do something: one updates a variable, the other prints. So
“unused” isn’t the same as “dead”.
Garbage collection for instructions
Think of a restaurant kitchen at the end of the night. A dish counts only if it was served. A sauce counts if it went into a served dish. A stock counts if it went into a sauce that counts. Start from the plates that left the kitchen, trace back through the ingredients, and everything you never reach was wasted effort.
That is the mark-and-sweep collector from chapter 58, applied to code. The roots are instructions whose effect the outside world can see: printing, storing somewhere another part of the program can read, calling code you can’t see into, returning, and branching. Mark the roots live. Whenever an instruction is live, its operands are live too. When marking stops, sweep away everything unmarked.
Dead code elimination (DCE) removes instructions whose results can never affect the program’s observable behaviour. An instruction is live if it has an observable effect, or if a live instruction uses its result. Everything else is dead. Mark-and-sweep DCE starts by presuming every instruction dead, marks live ones outward from the effectful roots, and deletes the rest.
Notice the step where %3 uses %2, yet both are deleted. A value with
a use can still be dead, if its users are dead. A simpler pass deletes
instructions that have no uses at all, then looks again at their
operands, which may have just lost their last use. LLVM ships both
kinds: its dce pass rechecks the operands of removed instructions, and
its adce (“aggressive” DCE) assumes values are dead until proven
otherwise[1], like the diagram does.
Is it really free of effects?
The whole method hinges on one judgement: does this instruction have an
effect? For binop + in JavaScript, the honest answer is “it depends”.
If w is an object, w + h converts it to a primitive by calling its
valueOf method, and that method can do anything:
function area(w, h) {
const p = w + h; // never used
return w * h;
}
const o = {
valueOf() {
console.log('valueOf ran');
return 3;
},
};
console.log(area(o, 4));
// Delete the line with p: one
// "valueOf ran" disappears.The “unused” w + h printed a line. Deleting it would change the output,
so a JavaScript compiler may delete it only if it knows w and h are
primitives. Property reads have the same problem (a getter is a hidden
function call), and so do calls to functions the compiler can’t see.
In C, an unused w + h on two ints is always dead. In JavaScript it is
dead only when the types are known. A DCE that forgets this is a bug
generator: it will look right on every test that passes numbers, and
break on the first program that passes an object.
This is where earlier analyses pay off. Viscosity’s numeric inference
(a dataflow fixpoint, the kind you met in chapter 79) checks every place area is
called. Both arguments are numbers at the only call site, so it proves
%0 and %1 are numbers, and therefore %2 runs no user code. Add a
second call, area("3", 4), and the fact for %0 turns false. (A
string + happens to be harmless too, but Viscosity’s fact is only “is
a number”, so a DCE built on it would keep the +. That is timid, and
safe.)
Liveness: before SSA
SSA made the question easy: each value has exactly one definition, so “is this definition needed?” is “does a live instruction use this value?”. Ordinary variables are messier, because one name can be assigned many times:
x = 1
x = 2
y = x + 1
print y
The first assignment is dead. Its value is overwritten before anyone reads it. To see that mechanically, walk backwards and track which variables will still be read:
live after
x = 1 { } x = 1 is dead
x = 2 { x }
y = x + 1 { y }
print y { }
Start at the bottom with nothing live. print y reads y, so y is
live above it. y = x + 1 defines y (remove it) and reads x (add
it). x = 2 defines x, so above it nothing is live. And x = 1
defines a variable that is not live afterwards: a dead store.
A variable is live at a point if some path from that point reads it before it is overwritten. Liveness is a backward dataflow problem: facts flow against the CFG’s edges. At a branch, a variable is live if it is live on any successor, so facts merge by union, and loops are solved by iterating to a fixpoint with a worklist.
In straight-line code one backward walk is enough. With branches and loops you use the dataflow machinery from chapter 79, run in reverse.
Viscosity does not run a DCE pass. It is a source-to-source tool: it
rewrites only the code that touches the collections it transforms, and
leaves everything else, dead or not, for the JavaScript engine to deal
with when it runs. But it is built on the same foundation. The use lists
that would drive DCE are the ones it builds in buildUses, and its
safety analysis asks the mirror-image question: instead of “does this
value have any live use?”, it asks “is every use of this array one I
understand?”. You will see that analysis in chapter 84.
DCE also rarely works alone. Constant propagation (chapter 80) turns a
branch condition into true, and then a whole arm of the if becomes
unreachable and can be deleted. Inlining, next chapter, copies a function
body into a caller where half of it may be useless. Compiler textbooks
treat these passes as a family that feed each other[2].
Your turn
Each instruction is { id, op, args }, where args lists the ids it uses. Instructions whose op is in EFFECTS are roots. Write dce(code) that returns the live instructions, in their original order: the roots, plus everything a live instruction uses, directly or indirectly.
const EFFECTS = ['print', 'store',
'return'];
function dce(code) {
// your code here
return code;
}
// I('p', '+', 'w', 'h') makes
// { id: 'p', op: '+',
// args: ['w', 'h'] }
const I = (id, op, ...args) =>
({ id, op, args });
const code = [
I('w', 'const'),
I('h', 'const'),
I('p', '+', 'w', 'h'),
I('a', '*', 'w', 'h'),
I('r', 'return', 'a'),
];
console.log(dce(code).map((i) => i.id));Straight-line code with ordinary variables: each instruction is { def, uses, effect }, where def is the variable it assigns (or null), uses the variables it reads, and effect is true for things like print. Write deadStores(code) returning the indexes (ascending) of instructions that assign a variable which is not live afterwards and have no effect. Treat a dead instruction’s reads as not happening.
function deadStores(code) {
// your code here
return [];
}
const st = (def, uses = []) =>
({ def, uses, effect: false });
const code = [
st('x'), // x = 1
st('x'), // x = 2
st('y', ['x']), // y = x + 1
{ def: null, uses: ['y'],
effect: true }, // print y
];
console.log(deadStores(code)); // [0]What you learned
- Dead code elimination (DCE) deletes instructions whose results can never affect what the program does.
- Mark-and-sweep DCE starts from the roots (instructions with observable effects), marks operands of live instructions, and deletes everything unmarked. A value whose only users are dead is dead too.
- “Unused” is not “dead”: a call or a store matters even if nobody reads its result.
- In JavaScript,
+, property reads and calls can run user code, so deleting them needs type facts, such as Viscosity’s proof that a value is a number. - Liveness says whether a variable may still be read. It flows backwards and merges by union; an assignment to a variable that isn’t live afterwards is a dead store. SSA makes the question nearly free.
Check your understanding
0/6- 1.
In this listing, which instructions does mark-and-sweep DCE delete?
%0 = const 2 %1 = binop * %0, %0 %2 = binop + %1, %0 print %0 - 2.
Why does mark-and-sweep remove a dead chain like
p = w + h; q = p * pin a single pass? - 3.
A JavaScript function contains
const t = o.size;and never usest. May a compiler delete the line? - 4.
How is liveness computed at a branch where one successor reads
xand the other does not? - 5.
What does Viscosity do about dead code?
- 6.
In
area(w, h), Viscosity proves the unusedw + hruns no user code. Which change to the program removes that proof?
References
- [1] The LLVM Project. LLVM’s Analysis and Transform Passes. LLVM documentation. llvm.org/docs/Passes.html (checked 2026-10-31)
- [2] 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)