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

74. Three-address code

Drip’s VM never names the result of 2 * 3: it just leaves it on the stack. An optimizer needs to point at that result and ask questions about it. So every intermediate value gets a name.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Turn an expression tree into three-address code, children first.
  • Explain what a temporary is and why a compiler makes so many of them.
  • Read VIR’s Inst record and connect each field to a line of a listing.
  • Use names to answer “who uses this value?” with a lookup instead of a search.

Where did 2 * 3 go?

In chapter 47 the VM ran 1 + 2 * 3 like this:

CONSTANT 1
CONSTANT 2
CONSTANT 3
MULTIPLY
ADD

The 6 that MULTIPLY produced never had a name. It sat on the stack for one instruction and was gone. For running code that is perfect.

Now imagine an optimizer looking at it. It wants to say “the product is a constant, 6”, or “nobody uses this result, delete it”, or “this is the same product as three lines ago, reuse it”. Every one of those sentences needs to point at the product. On a stack, the only way to point is “whatever is on top right now”, and that changes with every instruction.

One operation per line

Intuition

Long multiplication at school. You didn’t write the answer in one go; you wrote each partial product on its own line, then added the lines. Each line was one small operation, and because each had its own place on the page, you could check any of them, or reuse one.

Three-address code is that page of working. The same expression:

t1 = 2 * 3
t2 = 1 + t1

Each line has at most one operator, at most two inputs, and one named result. t1 and t2 are names the compiler invented. You didn’t write them; they exist so that every intermediate value has an address.

Step through how lowering produces it for a function with three parameters. The walk is the same recursion as the tree-walker’s, but instead of computing values it emits instructions:

Step throughNaming every intermediate value
The tree of (a + b) * (a - c). As lowering visits it, children first, each node gets a name: a is %0, b is %1, c is %2, the sum %3, the difference %4, the product %5.*+-abac
func h(%0, %1, %2):
b0:
%0 = param
%1 = param
%2 = param
Before the body is lowered, the three parameters already have names: a is %0, b is %1, c is %2. Every value in VIR is a numbered temporary like these.
1/6

Children are lowered before their parent, because an instruction can only be written once its inputs have names. Here is the real code, from packages/frontend-js/src/lower.ts, trimmed:

function expr(e: Node): ValueId {
  switch (e.type) {
    case 'Literal':
      return fb.constant(e.value);
    case 'Identifier':
      return read(binding(e), e);
    case 'BinaryExpression':
      return fb.emit('binop',
        [expr(e.left), expr(e.right)],
        { operator: e.operator });
    // ...
  }
}

expr returns a ValueId: the name of the result, not the result. fb.emit appends one instruction to the current block and hands back its fresh id.

Definition

Three-address code is an IR whose instructions each perform one operation on at most two operands and put the result in a named destination: x = y op z. The name comes from the three addresses: two inputs and an output. Copies (x = y), jumps and conditional jumps complete the set.

Temporaries

t1, t2, and VIR’s %3, %4, %5 are temporaries. A compiler makes them freely, one for every result, because names cost it nothing at this stage. Deciding which temporaries share a machine register, or a stack slot, comes much later, if at all.

VIR goes one step further: every value is a temporary, even your own variables. Lower this:

function f(a) {
  let t = a;
  let u = t * 2;
  return u + t;
}

and you get:

func f(%0):
  b0:
    %0 = param
    %1 = const 2
    %2 = binop * %0, %1
    %3 = binop + %2, %0
    return %3

t and u have disappeared. let t = a produced no instruction at all: the front end simply remembers that t now means %0. When the code reads t, it gets %0. (Chapter 77 shows why giving each value exactly one definition like this is so useful.) Notice also that the literal 2 is an instruction, %1 = const 2. Constants are values, so they get names too.

Definition

A temporary is a name the compiler creates to hold an intermediate value: the result of one instruction. Temporaries are cheap and plentiful in an IR. In VIR every value is one, written %n, and the number is unique in the whole module.

Try itNames, not copies
// Lower: let t = a; let u = t * 2;
//        return u + t;
const names = new Map();
const code = [];
let n = 0;
const emit = (text) => {
  const t = 't' + ++n;
  code.push(t + ' = ' + text);
  return t;
};
const get = (x) => names.get(x);

names.set('a', 'a');
// let t = a: no instruction
names.set('t', get('a'));
// let u = t * 2
names.set('u', emit(get('t') + ' * 2'));
// return u + t
const r =
  emit(get('u') + ' + ' + get('t'));
code.push('return ' + r);
console.log(code.join('\n'));

An instruction is a record

What does %2 = binop * %0, %1 look like in memory? It is one object, of the Inst interface in packages/core/src/vir.ts, trimmed:

interface Inst {
  id: ValueId;       // 2: its own name
  op: Op;            // 'binop'
  args: ValueId[];   // [0, 1]: inputs
  block: BlockId;    // 0: where it lives
  operator?: string; // '*'
  value?: number | string | boolean
    | null | undefined;  // for const
  field?: string;    // for field.get/set
}

The listing is just printFunc turning these records into text. And because inputs are names, the question an optimizer asks most, “who uses this value?”, is easy. Walk every instruction once and file it under each of its arguments. That is buildUses:

for (const b of f.blocks)
  for (const i of b.insts)
    for (const a of i.args)
      add(a, { inst: i });

For f above, %0 is used by %2 and %3, and %3 by the return. A value with no uses is dead, and can usually be deleted. Chapter 81 builds that pass on exactly this map.

Pitfall

“Two operands” is a convention, not a law. VIR’s call takes the callee and every argument, and record.new takes one value per field. What matters is the other half of the idea: each instruction does one thing, and its result and inputs are named.

Under the hood

Ignition, V8’s interpreter, sits between the two designs. It is a register machine: most of its bytecodes name the registers they read, and an implicit accumulator holds the running result, so instructions stay short[1]. A stack machine names nothing, three-address code names everything, and an accumulator machine names all but one. For analysis, compilers want the fully named form, and compiler textbooks build their optimizations on IRs of this kind[2].

Your turn

ExerciseAn expression tree to three-address code

Expression trees have three kinds of node: a variable { v: "a" }, a number { n: 2 }, and an operator { op: "+", l, r }. Write toTac(e) that returns the three-address code as an array of strings like "t1 = b * c".

  • Variables and numbers need no instruction: use the name or the number directly.
  • Lower the left operand, then the right, then emit the operator’s line with a fresh temporary: t1, t2, ... in order.
  • Each call of toTac starts again at t1.
function toTac(e) {
  const out = [];
  return out;
}
Extra practiceWho uses this value?

A tiny buildUses. Each instruction is { dst, args }, for example { dst: "t2", args: ["a", "t1"] }. Write uses(code) that returns an object mapping each name to the list of dsts of the instructions that use it, in program order. A name used twice by one instruction appears twice. Names nobody uses have no entry.

function uses(code) {
  return {};
}

What you learned

  • Three-address code: one operation per instruction, at most two inputs, one named result, x = y op z.
  • Lowering an expression walks the tree children first, because an instruction needs names for its inputs.
  • A temporary is a compiler-made name for an intermediate value. In VIR every value is a temporary, %n; even let t = a emits nothing.
  • An instruction is a record: id, op, args, block. Named inputs make “who uses this?” a lookup (buildUses).

Next: straight runs of these instructions, and the jumps between them.

Check your understanding

0/6
  1. 1.

    Which of these is a three-address instruction?

  2. 2.

    Lowering (a + b) * c children first, which instruction comes first?

  3. 3.

    In VIR, function f(a) { let t = a; return t + 1; } lowers to what, besides the param and the return?

  4. 4.

    What is a temporary?

  5. 5.

    Why is “who uses value %3?” easy to answer in VIR but hard in stack bytecode?

  6. 6.

    How many instructions does Drip’s stack VM need for (a + b) * (a - c) with locals, against VIR’s three?

References

  1. [1] Ross McIlroy (2016). Firing up the Ignition interpreter. V8 blog (v8.dev), 23 August 2016. v8.dev/blog/ignition-interpreter (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)