Viscosity
Review
Part IV · A bytecode virtual machine · Day 12

45. Why bytecode?

The tree-walker spends most of its time getting around the tree. Flatten the program into a list of tiny numbered instructions, and a simple loop can run it.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Turn a small expression tree into a flat list of instructions, children first.
  • Explain what an opcode is, and why a byte is enough to hold one.
  • Describe what a virtual machine does, and write a tiny one in JavaScript.
  • Read the tree-walker vs VM benchmark honestly, including where the VM lost.

Getting around costs more than the work

At the end of Part III you counted what the tree-walker does for n - 2: three recursive calls, three switches on the node kind, two Map lookups and two type checks, all to do one subtraction. The subtraction was the cheap part. Getting to it was expensive.

None of that bookkeeping changes from one run to the next. The node for n - 2 is a subtraction every time fib is called. So why work it out again a million times? Work it out once, write the answer down in a form that is quick to follow, and then just follow it.

Flatten the tree

Intuition

A family tree tells you who is related to whom. A recipe tells you what to do next. You can cook from a family tree of dishes (“the sauce needs a roux, the roux needs butter…”), but you keep climbing up and down it. A recipe has already done that climbing for you: step 1, step 2, step 3.

A tree can be turned into a recipe by visiting each node’s children before the node itself, and writing down one step per node:

Step throughFlattening 1 + 2 * 3
The tree for 1 + 2 * 3 is visited children first. Each node appends its instruction to a list after its children, giving CONSTANT 1, CONSTANT 2, CONSTANT 3, MULTIPLY, ADD, which is the byte array 0 0 0 1 0 2 20 18.+1*23THE LIST SO FAR
The tree for 1 + 2 * 3, as the parser built it in Part III. The goal: the same meaning, written as a flat list of steps instead of a web of nodes.
1/9

The steps come out in exactly the order the work has to happen: every operand is ready before the operator that needs it. Each step is tiny, and the list is a plain array, so following it means moving to the next index, not chasing a pointer to another node.

Definition

Bytecode is a program written as a compact, flat list of simple instructions, stored as numbers in an array. A compiler produces it once, before the program runs. Each instruction does one small thing, like “push a constant” or “add the top two values”.

Opcodes: instructions as numbers

To store a step as a byte, give each kind of step a number. Drip’s VM has a table of them in companion/ts/src/vm/chunk.ts:

// chunk.ts (excerpt)
export const OP = {
  CONSTANT: 0, NIL: 1, TRUE: 2, FALSE: 3,
  POP: 4,
  // …
  ADD: 18, SUBTRACT: 19, MULTIPLY: 20,
  DIVIDE: 21, NOT: 22, NEGATE: 23,
  PRINT: 24,
  // …
  RETURN: 33,
} as const;

There are 37 of them in the finished VM, so one byte (0 to 255) has room to spare. Some instructions need extra information. “Push a constant” must say which constant, so the byte after it is an operand: 0, 2 means “constant number 2”. ADD needs nothing more, because it always works on the two newest values.

Definition

An opcode is the number that says which instruction this is. An instruction is its opcode followed by zero or more operand bytes that give it details, such as which constant to use. Different instructions can therefore take different numbers of bytes.

A machine made of software

Something has to read those bytes and do what they say. It is a loop: read an opcode, do that instruction, move on, repeat. Here is a whole one, for three opcodes, with each value stored straight after its PUSH to keep it short:

Try itA whole VM, three opcodes
const PUSH = 0, ADD = 1, MUL = 2;
// 1 + 2 * 3, flattened:
const code = [
  PUSH, 1, PUSH, 2, PUSH, 3, MUL, ADD,
];

const st = [];
let ip = 0;
while (ip < code.length) {
  const op = code[ip++];
  if (op === PUSH) {
    st.push(code[ip++]);
  } else {
    const b = st.pop();
    const a = st.pop();
    st.push(op === ADD ? a + b : a * b);
  }
}
console.log(st.pop()); // 7

Notice what is missing: no recursion, no kind field, no lookups. The loop keeps its own small stack of values, st, and an index, ip, saying where it is in the code. A real CPU works the same way on machine code, which is why a loop like this is called a virtual machine: it’s a pretend processor, built out of software.

Definition

A virtual machine (VM) is a program that executes bytecode. It repeatedly fetches the next instruction, decodes its opcode, and performs it, keeping its working values in its own data structures. The instruction set is designed for the language, not for any real chip.

The same Drip programs run unchanged on the VM. This one uses the bytecode VM you’ll build in the rest of Part IV:

Try itDrip on the bytecode VM
fn fib(n) {
  if (n < 2) return n;
  return fib(n - 1) + fib(n - 2);
}

print fib(20);
print "same Drip, new engine";

Did it pay off?

The companion code already contains the finished VM, so you can see where this road leads before you walk it. These are the same benchmark runs as chapter 44, this time in seconds rather than as a ratio:

Where did the time go?
Seconds per benchmark program, including compiling (shorter is faster)
tree-walkerbytecode VMtrees: tree-walker 53136 ms, VM 21327 mstrees53.1 s21.3 sfib: tree-walker 23695 ms, VM 5685 msfib23.7 s5.7 sbinary_trees: tree-walker 19018 ms, VM 13078 msbinary_trees19.0 s13.1 sequality: tree-walker 15533 ms, VM 16501 msequality15.5 s16.5 sproperties: tree-walker 11853 ms, VM 2781 msproperties11.9 s2.8 szoo: tree-walker 8358 ms, VM 1749 mszoo8.4 s1.7 smethod_call: tree-walker 4106 ms, VM 1085 msmethod_call4.1 s1.1 sinvocation: tree-walker 2962 ms, VM 2042 msinvocation3.0 s2.0 sinstantiation: tree-walker 2742 ms, VM 3570 msinstantiation2.7 s3.6 sstring_equality: VM refused to compile it (too many constants)zoo_batch: stops after 10 s of clock time, so no comparison
Table view
programtree-walker msVM ms
binary_trees1901813078
equality1553316501
fib236955685
instantiation27423570
invocation29622042
method_call41061085
properties118532781
string_equality13874[line 32] Error at 'a4': Too many constants in one chunk.
trees5313621327
zoo83581749
zoo_batch1002710011

From companion/ts/results/drip-bench-2026-10-31 (.md and .json): Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz, 2026-10-31. Best of N, each in a fresh process. Includes compile time. Shared cloud VM. Runs recorded per program: 1.

Big savings on the slow, call-heavy programs; equality and instantiation got slower.

The wins are real. The programs the tree-walker found hardest, full of calls, method lookups and field reads, are where the VM saved the most time. That’s where the tree-walker’s overhead piled up.

But read the rest honestly:

  • zoo_batch isn’t a race. It loops until ten seconds of clock time have passed, then reports how much work it finished, so both engines take about ten seconds by design.
  • string_equality has no VM time at all. The compiler gave up: this VM’s instructions only have room for a one-byte constant number, so a chunk holds at most 256 constants. The script only has eight variables, but the compiler adds a variable’s name as a new constant every time the name appears, and this script names them hundreds of times. Chapter 46 shows where that limit lives.
  • instantiation and equality got slower. One run on a shared machine can’t say why. There are suspects, though: every VM object is also tracked by the VM’s own heap and garbage collector, running on top of V8’s; and the VM’s hash tables are hand-written TypeScript where the tree-walker borrows V8’s built-in Map. Later chapters test those suspects.
Under the hood

V8 made this same move for JavaScript. Its interpreter, Ignition, runs bytecode, and the V8 team reported that the bytecode is between a quarter and half the size of the machine code their earlier baseline compiler produced[2]. Even a fast interpreter keeps some cost it can’t remove: decoding each instruction and dispatching to the next one. V8’s Sparkplug compiler exists to remove exactly that[3]. Drip’s VM follows clox, the bytecode VM from Crafting Interpreters[1], so it can be checked against the same tests.

Your turn

ExerciseFlatten a tree into bytes

Nodes are { kind: "num", value }, { kind: "add", left, right } and { kind: "mul", left, right }. Write flatten(node), which returns an array of numbers: a number becomes OP.CONST, value; an add or mul becomes its left operand’s bytes, then its right operand’s, then OP.ADD or OP.MUL. The number case is done for you.

const OP = { CONST: 0, ADD: 1, MUL: 2 };

function flatten(node, out = []) {
  if (node.kind === 'num') {
    out.push(OP.CONST, node.value);
    return out;
  }
  // children first, then the operator
  return out;
}
Extra practiceCounting instructions, not bytes

In this bytecode, opcode 0 (CONST) is followed by one operand byte, and every other opcode (1 = ADD, 2 = MUL) has none. Write countInstrs(code), which returns how many instructions the array holds. [0, 1, 0, 2, 1] is three instructions in five bytes.

function countInstrs(code) {
  return code.length;
}

What you learned

  • Bytecode is a program as a flat array of small instructions, produced once by a compiler, in the order the work must happen.
  • Visiting children before parents turns an expression tree into that order.
  • An opcode is the number naming an instruction; operands are the extra bytes some instructions need.
  • A virtual machine is a loop that fetches, decodes and performs instructions: a pretend CPU for your language.
  • The VM beat the tree-walker on most programs, lost on two, and couldn’t compile one. A better design is not faster at everything.

Next: the container bytecode lives in, and a tool to read it.

Check your understanding

0/6
  1. 1.

    Flattening (1 + 2) * 3 children first gives which instruction order?

  2. 2.

    What is an opcode?

  3. 3.

    Which work does a tree-walker repeat on every run of a node that a bytecode compiler does only once?

  4. 4.

    Why is the bytecode interpreter called a virtual machine?

  5. 5.

    Why was there no VM time for string_equality?

  6. 6.

    What is the fair conclusion from the VM losing on instantiation and equality?

References

  1. [1] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)
  2. [2] Ross McIlroy (2016). Firing up the Ignition interpreter. V8 blog (v8.dev), 23 August 2016. v8.dev/blog/ignition-interpreter (checked 2026-10-31)
  3. [3] Leszek Swirski (2021). Sparkplug — a non-optimizing JavaScript compiler. V8 blog (v8.dev), 27 May 2021. v8.dev/blog/sparkplug (checked 2026-10-31)