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

60. Making the VM fast

The biggest speed-up this VM ever got came from deleting a safety net, and a profiler found it, not a clever idea. Then three clever ideas you can try yourself: NaN-boxing, a generational collector, and superinstructions.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Tell the story of the VM’s biggest measured speed-up, and what it teaches about guessing.
  • Encode and decode values with NaN-boxing using typed-array views.
  • Explain the generational hypothesis and why a generational collector needs a write barrier.
  • Find a superinstruction candidate by counting instruction pairs, and name its costs.

The fastest fix was a deletion

Chapter 58 ended with a safety net. When the heap frees an object, it can poison it, so that any later use throws:

function poison(o: Obj) {
  const type = o.type;
  for (const k of Object.keys(o)) {
    delete (o as any)[k];
  }
  Object.defineProperty(o, 'type', {
    get() { throw new Error(/* ... */); },
  });
}

At first the heap poisoned every freed object, always. Then the VM was profiled. On allocation-heavy programs, poisoning was more than half the running time. The commit that fixed it gives the reason: deleting every property pushed each dead object into V8’s dictionary mode. V8 keeps such “slow properties” for objects whose properties are added and deleted a lot, and they are slower than fast properties (chapter 22)[1]. A program that frees millions of objects paid that millions of times.

The fix was one flag. Poisoning now runs only in stress and debug mode, where the tests use it, and the normal VM skips it. That commit recorded the instantiation benchmark, which makes fifteen million instances, going from 19.4 seconds to 3.6:

The instantiation benchmark, before and after
Whole-run time in seconds (shorter is faster)
VM, poisoning always on: 19400 ms, from the commit bd4b1baVM, poisoning always oncommit bd4b1ba19.4 sVM, poisoning debug-only: 3570 ms, from the results fileVM, poisoning debug-onlyresults file3.6 stree-walker, for scale: 2742 ms, from the results filetree-walker, for scaleresults file2.7 s
Where these numbers come from

“Poisoning always on”: the message of commit bd4b1ba in this book’s repository, which records the instantiation benchmark going from 19.4 s to 3.6 s once poisoning became debug-only. The other two bars: companion/ts/results/drip-bench-2026-10-31 (.md and .json), 3570 ms for the VM and 2742 ms for the tree-walker; 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.

A debugging aid was costing most of the run. Even after the fix, this allocation-heavy program is still slower on the VM than on the tree-walker.

The same commit moved the hash table to chapter 23’s struct-of-arrays layout: parallel keys and values arrays instead of an array of {key, value} entry objects, saving an allocation per slot. The commit doesn’t time that change on its own, so this book won’t claim a number for it.

Two lessons. Nobody would have guessed that a debugging aid was the bottleneck; a profile showed it. And even after the fix, this benchmark is slower on the VM than on the tree-walker, in this book’s one recorded run. Why is an open question. Node’s built-in profiler (node --cpu-prof) is the place to start, not a hunch.

The rest of this chapter is ideas the companion does not implement. They are what real VMs do next, and each is a good project.

NaN-boxing

Intuition

Every value travels in the same 8-byte envelope. Almost every envelope holds a number. But one stamp, “not a number”, is almost never needed for arithmetic, and once it’s on the envelope, the rest of the space can carry anything you like.

Today a Drip Value is null | boolean | number | Obj, and V8 picks a representation for each (chapter 22). A VM in C needs its own. A tagged value (chapter 49) as a C struct, a type tag plus an 8-byte payload, usually takes 16 bytes once padded. NaN-boxing fits every value into one 8-byte double.

In IEEE 754, a double whose exponent bits are all ones and whose fraction is not zero is a NaN. That leaves a huge number of NaN bit patterns, and arithmetic only ever produces one or two of them. So pick a NaN pattern no arithmetic makes: here, the quiet-NaN bits plus one extra “box” bit. Any double with those bits set is not a number but a boxed value, and its other bits carry a tag and a payload:

Step throughOne 64-bit double, many kinds of value
The high word of the double for 1.5, bit by bit, with the low word in hexvalue: 1.5HIGH WORD (bits 63 to 32)00111111111110000000000000000000signexponentqbtag (16 bits)LOW WORD (bits 31 to 0)0x00000000high word = 0x3ff80000not boxed: read it as a double → number 1.5
An ordinary double. The exponent bits are not all ones, so this is a number, whatever the other bits hold.
1/6

In JavaScript you can try this with two views over one buffer (chapter 16): a Float64Array to read and write doubles, and a Uint32Array over the same bytes to reach their bits. On a little-endian machine, which is almost every machine today, the high word of slot i is u32[2 * i + 1]:

Try itNaN-boxing with two views
const f64 = new Float64Array(4);
const u32 = new Uint32Array(f64.buffer);
// quiet NaN + the "box" bit
const BOX = 0x7ffc0000;
const NIL = 1, TRUE = 3, OBJ = 4;

function box(i, tag, payload) {
  u32[2 * i] = payload; // low word
  u32[2 * i + 1] = BOX | tag; // high
}
function isNum(i) {
  return (u32[2 * i + 1] & BOX) !== BOX;
}
function show(i) {
  if (isNum(i)) return f64[i];
  const tag = u32[2 * i + 1] & 0xffff;
  const p = u32[2 * i];
  if (tag === NIL) return 'nil';
  if (tag === TRUE) return 'true';
  return 'object #' + p;
}

f64[0] = 1.5;
box(1, NIL, 0);
box(2, OBJ, 7);
f64[3] = NaN;
for (let i = 0; i < 4; i++) {
  const hi = u32[2 * i + 1];
  console.log(hi.toString(16), show(i));
}
// a boxed value *is* a NaN:
console.log(Number.isNaN(f64[2]));

Two catches. First, a real NaN must never look boxed, so a VM stores every NaN in one canonical form. That matters in JavaScript too: when a NaN is written into a typed array, the spec lets the engine choose its bit pattern[2], so a NaN carrying “boxed” bits can survive the trip and be misread. Second, JavaScript can’t put a pointer in a double, so the payload would be an index into an array of objects. In C, the spare bits can hold a real pointer.

Definition

NaN-boxing represents every value as one 64-bit IEEE 754 double. Numbers are stored as themselves. Every other value is stored as a NaN bit pattern that arithmetic never produces, with a tag (nil, boolean, object, …) and a payload (such as a pointer or index) in its fraction bits. Checking “is this a number?” is one mask and compare, and every value is the same size.

Generational collection

Chapter 58’s collector marks every live object on every collection. If a program keeps a large data structure alive, each collection traces all of it again, only to free a few short-lived strings.

Most collectors exploit an observation called the generational hypothesis: most objects die young[4]. So split the heap into a young generation and an old one. New objects start young. A minor collection looks only at the young generation, which is small and mostly garbage, so it is cheap. Objects that survive a few minor collections are promoted to old, which is collected rarely.

The catch is pointers from old objects to young ones. A minor collection doesn’t trace the old generation, so it would miss a young object kept alive only by an old one. The fix is a write barrier: a check on every store of a reference into an object, recording old objects that now point at young ones. That record joins the roots. In the companion, every SET_PROPERTY, SET_UPVALUE on a closed upvalue and OP_METHOD would need one. A sketch:

// a sketch: not in the companion
function barrier(owner: Obj, v: Value) {
  if (owner.old && isObj(v) && !v.old) {
    remembered.add(owner); // extra root
  }
}
Definition

A generational garbage collector divides the heap by object age. It collects the young generation often and cheaply, promotes objects that survive, and collects the old generation rarely. Because it skips old objects during a minor collection, it needs a write barrier that records old-to-young references, which serve as extra roots.

Superinstructions

Every instruction pays for a trip round the dispatch loop: fetch the opcode, branch to its case, come back. Fewer, bigger instructions mean fewer trips. To see which instructions travel together, I added a pair counter to a copy of the VM’s run() loop:

// per instruction, in a copy of run():
const key = prev + ' ' + instruction;
pairs.set(key, (pairs.get(key) ?? 0) + 1);
prev = instruction;

Running fib(20) on that copy executes 262,695 instructions (the same count the real VM’s instructionsExecuted reports). The most common pairs:

GET_LOCAL     -> CONSTANT        43781
CONSTANT      -> LESS            21891
LESS          -> JUMP_IF_FALSE   21891
JUMP_IF_FALSE -> POP             21891
CONSTANT      -> SUBTRACT        21890

n < 2 and n - 2 are each GET_LOCAL, CONSTANT, then an operator. One instruction could do all three, with the slot and the constant as operands:

// a sketch: not in the companion
case OP.SUB_LOCAL_CONST: {
  const a = stack[frame.slots + readByte()];
  const b = readConstant();
  // (check both are numbers)
  push(a - b);
  break;
}

It isn’t free. The compiler must spot the pattern. A jump must never land in the middle of a fused group. And every new opcode is more code in the dispatch loop. CPython does this for real: its compiler fuses two adjacent LOAD_FAST instructions into LOAD_FAST_LOAD_FAST, only within a basic block (a straight run of code that jumps enter only at the top), only when both operands fit in 4 bits, and turns the second instruction into a NOP[5].

Definition

A superinstruction is a single opcode that does the work of a common sequence of several, chosen by measuring which sequences run most often. It saves dispatches and stack traffic. The costs are more opcodes, a compiler pass that finds the pattern, and care that no jump targets the middle of the sequence.

Under the hood

These are the moves real engines make. SpiderMonkey, Firefox’s engine, stores JavaScript values NaN-boxed: on 64-bit machines it packs a tag into the fraction bits next to the exponent and up to 47 pointer bits into the rest, and canonicalizes NaNs before storing them[3]. V8’s heap is generational: a young generation split into nursery and intermediate parts, an old generation, and a fast collector for the young one that copies survivors, with write barriers keeping a list of old-to-new references[4]. The VM you just finished, like clox[6], is the plain version of all this, which makes it a good place to try each idea and measure it.

Your turn

ExerciseBox and unbox

Slot i of f64 is one value; its high 32-bit word is u32[2 * i + 1] and its low word u32[2 * i]. Write setValue(i, v) for a number, null, true or false: store numbers as doubles, but store every NaN as the canonical bits (high 0x7ff80000, low 0); store the others with low word 0 and high word BOX | tag. Write getValue(i) to turn the bits back into the same JavaScript value.

const f64 = new Float64Array(8);
const u32 = new Uint32Array(f64.buffer);
const BOX = 0x7ffc0000;
const NIL = 1, FALSE = 2, TRUE = 3;

function setValue(i, v) {
  f64[i] = v;
}

function getValue(i) {
  return f64[i];
}
Extra practiceFuse a superinstruction

Instructions are objects like { op: 'GET_LOCAL', a: 1 }. Write fuse(code, targets): return a new array where each pair of adjacent GET_LOCALs becomes { op: 'GET_LOCAL2', a: first.a, b: second.a } followed by { op: 'NOP' } (so positions don’t move). Don’t fuse if the second instruction’s index is in the Set targets (a jump lands there). Don’t change code.

function fuse(code, targets) {
  const out = code.slice();
  // fuse GET_LOCAL, GET_LOCAL pairs
  return out;
}

What you learned

  • The VM’s biggest measured win was making GC poisoning debug-only: deleting properties pushed dead objects into V8’s dictionary mode. A profile found it, and the result still needs explaining.
  • NaN-boxing: every value is one double; non-numbers hide in NaN bit patterns arithmetic never makes. NaNs must be canonical.
  • Generational GC: most objects die young, so collect the young generation often, promote survivors, and use a write barrier to remember old-to-young pointers.
  • Superinstructions: count which instructions run together, fuse the common sequences, and never let a jump land inside one.

That’s Part IV: a compiler, a VM, and a garbage collector, all yours.

Check your understanding

0/6
  1. 1.

    What made the instantiation benchmark several times faster?

  2. 2.

    In this chapter’s scheme, how do you tell whether a 64-bit slot holds a number?

  3. 3.

    Why does NaN-boxing require canonical NaNs?

  4. 4.

    What does the generational hypothesis say?

  5. 5.

    A minor collection traces only young objects. What else must it treat as roots?

  6. 6.

    In fib(20), GET_LOCAL is followed by CONSTANT 43,781 times. What does a superinstruction for that pair save?

References

  1. [1] Camillo Bruni (2017). Fast properties in V8. V8 blog (v8.dev), 30 August 2017. v8.dev/blog/fast-properties (checked 2026-10-31)
  2. [2] Ecma International, Technical Committee 39 (TC39). ECMAScript® Language Specification (ECMA-262), current draft. Ecma International; living draft at tc39.es/ecma262, source at github.com/tc39/ecma262. tc39.es/ecma262/ (checked 2026-10-31)
  3. [3] Mozilla contributors (2026). js/public/Value.h: JS::Value, SpiderMonkey’s representation of JavaScript values (Firefox source code). Firefox source repository (github.com/mozilla-firefox/firefox), Mozilla. github.com/mozilla-firefox/firefox/blob/main/js/public/Value.h (checked 2026-10-31)
  4. [4] Peter Marshall (2019). Trash talk: the Orinoco garbage collector. V8 blog (v8.dev), 3 January 2019. v8.dev/blog/trash-talk (checked 2026-10-31)
  5. [5] Python Software Foundation and CPython contributors (2026). CPython: Python/flowgraph.c (insert_superinstructions) and the dis module documentation (LOAD_FAST_LOAD_FAST). CPython source repository (github.com/python/cpython), main branch; docs.python.org. github.com/python/cpython/blob/main/Python/flowgraph.c (checked 2026-10-31)
  6. [6] 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)