Viscosity
Review
Part XII · Shipping · Day 50

200. Capstone: the whole machine

Eighty chapters ago you printed “Hello, Drip!” from Rust. Since then you have rebuilt a virtual machine and an optimizing compiler in it, checked byte for byte against the TypeScript versions, and shipped the compiler to the browser. Here is the whole machine, one program through every stage, the list of what is still open, and where you could take it next.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Trace one program through the Rust engine, naming the stage and the chapter behind each step of its output.
  • Say what each of the book’s implementations is, and how each one is checked against another.
  • List what is unfinished or open, and tell a chore from a research question.
  • Rebuild a piece of the machine, the planner’s decision, from memory.

Where Track B started

Chapter 121’s first program was three lines:

fn main() {
    println!("Hello, Drip!");
}

That chapter’s argument was about memory. Viscosity exists because JavaScript gives you no say over layout, so it has to trick the engine into a struct of arrays; Rust lets you decide, with a strict inspector checking the plans. Eighty chapters later, both of the book’s large programs exist in both languages.

Intuition

A carpenter’s apprentice builds a chair with the master watching, then builds the same chair again alone, in a harder wood. The second chair isn’t more useful than the first. It’s the proof that the apprentice knows why every joint is where it is.

What you built

Drip, three times. A tree-walking interpreter in TypeScript (Part III), a bytecode VM in TypeScript (Part IV), and drip-rs, the same VM design in Rust (Part X): typed arena handles instead of pointers, a mark-sweep collector with explicit roots, #![forbid(unsafe_code)], and the shared corpus as its judge.

Viscosity, twice. The TypeScript reference (Parts VII and VIII), and its Rust port (Part XI): VIR in arenas, the plugin trait, oxc for parsing, the SSA builder and dataflow framework, the analyses, the cost model, the code generators for SoA and AoSoA, explain with code frames, and PGO.

Two languages. The JavaScript plugin and the Python plugin both lower to the same VIR, so the analyses and the planner never see syntax. Both exist in both engines.

Three ways to run it. A native CLI (chapter 197), the same engine as WebAssembly in an npm package (189, 190), and that WebAssembly inside this book’s Playground (191).

Every one of these is checked against another. drip-rs runs the corpus the TypeScript VM runs. The Rust engine reproduces every stored output of the TypeScript engine byte for byte, and the fuzzer keeps generating programs to compare them on. The compiled programs are run against their originals. That chain of checks is what makes “the same machine, in Rust” a claim you can test rather than a hope.

One program, all the way through

Here is a small program with two collections, one worth transforming and one that can’t be. The step-through follows it through the Rust engine. The VIR, analysis and plan listings come from a small program of mine (not in the repository) that calls the crates directly; the code and the report come from the viscosity CLI. All of it is real output, trimmed to fit:

Step throughOne program, in Rust: source
const ps = [];
for (let i = 0; i < 1000; i++) {
ps.push({ x: i, vx: 1, hp: 3 });
}
for (let t = 0; t < 10; t++) {
for (const p of ps) p.x += p.vx;
}
const qs = [];
for (let i = 0; i < 100; i++) {
qs.push({ x: i, vx: 1, hp: 3 });
}
let s = 0;
for (const p of ps) s += p.x;
console.log(s, qs[0]);
The program: a thousand particles moved ten times, a hundred more that are only printed, and a sum. Line 6 is the hot loop.
1/7

Read the stages against the book. oxc parsed it (chapter 172) and the plugin lowered it to VIR in SSA form, where line 6’s for…of became a phi, a length check, an index.get, two field.gets and a field.set (173, 176). The analyses saw only VIR: they found two collections (178), three numeric fields each (179), and an escape for qs when qs[0] was passed to console.log (180). They found the two loops over ps and their fields (181), and proved both of its indexes in bounds. The cost model wrote its reasons as it decided (182), the emitter turned ps into three Float64Array columns (183), and explain --frames put carets under qs[0] (185). Both programs print 509500 { x: 0, vx: 1, hp: 3 }. The TypeScript CLI prints the same report, byte for byte.

Run it yourself, on the Rust engine compiled to WebAssembly (the toggle switches to TypeScript, and the decisions don’t change):

PlaygroundThe whole machine, in Rust

Try breaking it: change line 14 to print qs[0].hp instead, and qs is refused for a different reason, because now no loop touches its fields. Make the first loop run to 10, and ps falls under the 64-element threshold.

What it measured

Chapter 199’s matrix holds the summary. On Node, Viscosity sped up all five end-to-end programs, with a geometric mean of 2.54×; on Bun, 1.74×, with heat gaining nothing. The Rust compiler is up to 5.26× faster than the TypeScript one inside a warm Node process (an upper bound, chapter 199 explains why), and 10.9× faster as a command-line tool, mostly because it doesn’t start Node. drip-rs is 4× faster than the TypeScript VM. Every figure is from one shared cloud machine, and the files record the noise beside them.

What is still open

An honest capstone lists what isn’t done. Some items are chores; some are research.

  • Nothing is published (chapter 198). Every crate now packages, but there is no changelog yet, and whether and when to publish is Tariq’s decision.
  • The Rust port has gaps. It rejects TypeScript input (the reference strips types with sucrase, and copying sucrase’s exact output is out of scope), and its syntax errors are worded by oxc, not acorn. Neither engine profiles Python programs.
  • The cost model can’t see the engine. It decides from size and access patterns, with a 64-element threshold, and the matrix showed the same output gaining a lot on V8 and nothing on JSC for heat. Why is still a hypothesis without an engine trace (chapter 199).
  • Many ordinary programs are refused, as chapter 118 listed: sorting, classes as records, varying shapes, exported arrays. Hot/cold splitting is by type only.
  • Every version emits source code. There is no WebAssembly back end for the compiled programs (chapter 117); WebAssembly runs the compiler, not its output.
  • Drip’s VMs share a limit: 256 constants per chunk, which is why string_equality runs only on the tree-walker.

Going further

If you want to keep going, each of these starts from code you have read:

  • Confirm or refute the heat hypothesis. Take an engine trace of heat’s objects on V8 (chapter 72 shows how to watch the engine work), and see whether its fields really are boxed doubles.
  • Teach the cost model about engines. Run the matrix on more machines and engines, and see whether a per-engine threshold would have predicted Bun’s results.
  • Add a third language. The plugin trait (chapter 171) is the whole contract. The Python plugin shows what the minimum looks like.
  • Grow the subset. Pick one refusal from chapter 118, make the analysis prove it safe, and let the fuzzer tell you whether you were right (node scripts/fuzz.mjs --count 20000).
  • Fix the small things: the wasm smoke test in CI, a changelog, a Python fuzzer (chapter 188).
Definition

Mastery, as this book means it, is being able to build the thing again yourself: to start from an empty folder and get back to a working version, knowing why each piece is there and how you would check it. It isn’t remembering every line. It’s knowing which questions to ask, and which test would answer each one.

Under the hood

Building a language twice, simply and then fast, and the second time in a lower-level language, is the shape this book borrowed from Crafting Interpreters[1]. Track B took it one step further, to an optimizing compiler, and added a rule that book didn’t need: the second implementation must agree with the first on every output, not just every result. Holding to it, porting and then fuzzing turned up fifteen bugs, listed in engine/rust/README.md and fixed in both engines. Five were soundness bugs, where the compiled program would have behaved differently from the original.

Your turn

The core exercise rebuilds the heart of the planner from memory: the order of its questions and the exact words of its answers.

ExerciseThe planner, from memory

Write plan, a simplified plan_one from engine/rust/crates/core/src/plan.rs. Ask its questions in its order, and stop at the first that refuses. Any bailouts: Aos, with one reason per bailout, unsafe: <bailout>. Not numeric: Aos, NO_NUM. No loops: Aos, NO_LOOP. Size below min: Aos, about <size> elements: below the <min>-element threshold, where the benefit is too small. Otherwise Soa, with one reason per loop, loop at line <line> in <func>() touches <k> of <n> fields (<names>), then typed Float64Array columns for all <n> fields, then about <size> elements.

// A collection as the analyses saw it
// (simplified from core::Collection).
struct Loop {
    line: u32,
    func: &'static str,
    // the fields it reads or writes
    touches: Vec<&'static str>,
}

struct Coll {
    fields: Vec<&'static str>,
    numeric: bool, // every field
    loops: Vec<Loop>,
    size: f64,
    bailouts: Vec<&'static str>,
}

#[derive(Debug, PartialEq)]
enum Layout {
    Aos,
    Soa,
}

const NO_NUM: &str = "no field is \
    provably numeric, so there are no \
    typed columns to gain";
const NO_LOOP: &str = "no loop reads \
    or writes its fields, so a new \
    layout cannot pay off";

fn plan(
    c: &Coll,
    min: f64,
) -> (Layout, Vec<String>) {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • The book built Drip three times and Viscosity twice, for two languages, and runs the Rust engine natively, in Node and in the browser.
  • Every implementation is checked against another: the shared corpus, byte-identical fixtures, the fuzzer, and original-versus-compiled runs.
  • One program passes through parse, lower, analyze, plan, emit and explain, and each line of its output can be traced to a chapter.
  • What is open is known and written down: publishing, the port’s gaps, an engine-blind cost model, refused programs, no WebAssembly output.
  • Mastery is being able to build it again, and to say how you would know it works.

That is the end of the book. It started from a question about where an array of objects sits in memory, and every chapter since has been one step towards being able to answer it with a tool you understand all the way down. The code is in the repository, the numbers are in its result files, and the rough edges are listed above. Fifteen minutes at a time, you built the whole machine.

Check your understanding

0/6
  1. 1.

    In the chapter’s program, which stage first notices that qs can’t be transformed?

  2. 2.

    What does the TypeScript CLI print for explain --frames on the chapter’s program?

  3. 3.

    Which of these is still open?

  4. 4.

    Why does the book treat “not published” as a decision rather than a bug?

  5. 5.

    What does the WebAssembly in this book do?

  6. 6.

    By this book’s definition, which shows mastery of Viscosity best?

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)