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

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.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • 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

Intuition

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.

Step throughMark from the roots, then sweep
An instruction listing; live instructions are marked, dead ones are swept awayINSTRUCTIONSSTATUS%0 = param wdead?%1 = param hdead?%2 = binop + %0, %1dead?%3 = binop * %2, %2dead?%4 = binop * %0, %1dead?call log, %4dead?return %4dead?worklist: []
Start pessimistic about the code and optimistic about deleting it: every instruction is presumed dead until something proves it is needed.
1/7
Definition

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:

Try itAn “unused” + that prints
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.

Pitfall

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.

Definition

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.

Under the hood

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

ExerciseMark and sweep the dead instructions

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));
Extra practiceFind dead stores with liveness

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. 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. 2.

    Why does mark-and-sweep remove a dead chain like p = w + h; q = p * p in a single pass?

  3. 3.

    A JavaScript function contains const t = o.size; and never uses t. May a compiler delete the line?

  4. 4.

    How is liveness computed at a branch where one successor reads x and the other does not?

  5. 5.

    What does Viscosity do about dead code?

  6. 6.

    In area(w, h), Viscosity proves the unused w + h runs no user code. Which change to the program removes that proof?

References

  1. [1] The LLVM Project. LLVM’s Analysis and Transform Passes. LLVM documentation. llvm.org/docs/Passes.html (checked 2026-10-31)
  2. [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)