Viscosity
Review
Part X · Drip in Rust · Day 38

150. The VM loop in Rust

The heart of drip-rs is a loop and a match, much like the TypeScript switch. The interesting part is what the borrow checker won’t let the loop hold on to, and the “register” fields that fix it: in my measurement, fib ran twice as fast with them as with a walk through the heap for every byte.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Read drip-rs’s dispatch loop and map each part to the TypeScript VM’s switch.
  • Explain what an exhaustive match buys over a switch with a default case.
  • Explain why the loop can’t keep a reference to the running chunk, and how the ip and Rc<Chunk> registers solve it.
  • Write a small stack VM loop over an opcode enum, with tests.

The same loop, a different switch

Chapter 47’s dispatch loop in TypeScript, trimmed:

for (;;) {
  const instruction = readByte();
  switch (instruction) {
    case OP.CONSTANT:
      this.push(readConstant()); break;
    case OP.NIL: this.push(null); break;
    // ... 35 more cases
    default:
      throw new Error(`unknown opcode`);
  }
}

Here is drip-rs’s, adapted from run in src/vm.rs:

// the body of fn run(&mut self) -> Run
loop {
    let byte = self.read_byte();
    let op = OpCode::from_byte(byte)
        .expect("valid opcode");
    match op {
        OpCode::Constant => {
            let v = self.read_constant();
            self.push(v);
        }
        OpCode::Pop => {
            self.pop();
        }
        OpCode::Subtract => {
            let (a, b) =
                self.pop_numbers()?;
            self.push(Value::Number(a - b));
        }
        // ... one arm per opcode
    }
}

The shape is the same: read a byte, decode it, jump to the code for that instruction, repeat. Two lines differ. from_byte (chapter 148) turns the byte into an OpCode, and returns None for a byte no opcode has. And there is no default.

Intuition

A switch with a default case is a receptionist who says “not my department” to any visitor nobody expected. A match over an enum is a building whose doors were counted by the architect. If a door has no room behind it, the building doesn’t open.

Step throughOne turn of the loop per instruction
loop {
let byte = self.read_byte();
let op = OpCode::from_byte(byte)
.expect("valid opcode");
match op {
OpCode::Constant => { .. }
OpCode::Multiply => { .. }
OpCode::Add => self.add()?,
OpCode::Print => { .. }
OpCode::Nil => self.push(Nil),
OpCode::Return => { .. }
// ... 31 more arms
}
}
The chunk's bytes with the ip register, and the value stackself.chunk.code000102130425206187248193310▲ ip = 0stack(empty)stdoutconstants: [1, 2, 3]
The chunk for `print 1 + 2 * 3;`, as the compiler left it. The register self.ip is 0, and self.chunk is an Rc pointing at this chunk. The stack is empty.
1/9

No default case

The match is over OpCode, an enum with 37 variants, and a Rust match must be exhaustive[1]. Add a 38th opcode to the enum and forget its arm, and the VM no longer compiles. Here is rustc 1.97 on a three-variant version of the same mistake (trimmed):

error[E0004]: non-exhaustive patterns:
  `Op::False` not covered
 --> e2.rs:9:11
  |
9 |     match op {
  |           ^^ pattern `Op::False`
  |              not covered

In TypeScript, the same slip would surface only when a program ran the new instruction and hit default. In drip-rs, a bad byte is still possible, but only in one place: from_byte returning None. The compiler only ever writes valid opcodes, so the expect there is a statement of trust, and it would panic loudly if that trust were broken.

Definition

Match-based dispatch: the loop decodes each byte into an opcode enum and runs one match over it, with one arm per instruction. Because the match must be exhaustive, every opcode is guaranteed a handler at compile time, and the only unchecked step is decoding the byte.

Errors are returned, not thrown

The TypeScript VM’s runtimeError throws, and interpret catches it. drip-rs returns errors as values instead. run returns Run, short for Result<(), RuntimeError>. An arm that can fail uses ?, as pop_numbers()? does above, so the error leaves run at once. Here is the start of pop_numbers:

let (Value::Number(a), Value::Number(b))
    = (self.peek(1), self.peek(0))
else {
    let m = "Operands must be numbers.";
    return Err(self.runtime_error(m));
};

That is a let ... else: if the pattern doesn’t match, the else block runs, and it must leave the function. RuntimeError carries no data, because runtime_error has already written the message and the stack trace to stderr.

The loop can’t hold the chunk

The TypeScript loop starts by caching the running frame’s code in local variables, and calls reload() after every call and return. clox does the same with a local frame pointer[2]. Try that in Rust, on a toy VM:

fn run(&mut self) {
    let code = &self.code;
    for &b in code {
        self.push(b as f64);
    }
}

rustc 1.97 refuses (trimmed):

error[E0502]: cannot borrow `*self` as
  mutable because it is also borrowed
  as immutable
12 |  let code = &self.code;
   |             ---------- immutable
   |               borrow occurs here
14 |      self.push(b as f64);
   |      ^^^^^^^^^^^^^^^^^^^ mutable
   |               borrow occurs here

self.push needs &mut self: all of the VM, code included. A shared borrow of a field and a mutable borrow of the whole can’t overlap (chapter 125). The borrow checker is being right, too. In drip-rs, the chunk belongs to an ObjFunction in the heap’s arena, and allocating can grow that arena’s Vec and move every object in it. A reference into it could dangle.

So drip-rs keeps no long-lived reference at all. It copies the running frame’s state into fields of the Vm, which the README calls registers:

pub struct Vm {
    // ...
    ip: usize,          // next byte to run
    chunk: Rc<Chunk>,   // running bytecode
    slots: usize,       // frame's slot 0
}

fn read_byte(&mut self) -> u8 {
    let byte = self.chunk.code[self.ip];
    self.ip += 1;
    byte
}

Each read borrows self for one expression, and the borrow is over before push runs. The chunk is behind an Rc (chapter 137) because two owners need it: the function in the heap, and the VM while that function runs. Cloning an Rc just bumps a count. The compiler freezes each chunk with Rc::new when the function is done, so it is never changed afterwards and needs no RefCell. call saves the caller’s ip into its frame and loads the callee’s ip, chunk and slots. pop_frame loads the caller’s back.

What the registers bought

The README says the registers made fib “about 1.4× faster”. The committed benchmark run has drip-rs at 1460 ms on fib, against 5685 ms for the TypeScript VM.

To see the effect myself, I built two variants of drip-rs in a scratch directory. Neither is committed. They differ only in read_byte, read_constant and the jumps. One keeps ip and the Rc<Chunk> in the frame and goes through self.frames.last_mut() for every byte. The other has no cached chunk at all: every byte walks from the frame to its closure, to the function, to the chunk, through the heap. All three passed the 246-program corpus. I timed fib.drip with release builds (chapter 142), best of 5, alternating, on a shared 4-core cloud VM, so expect noise:

registers (committed)    1396 ms
ip + Rc<Chunk> in frame  1716 ms
walk through the heap    2983 ms

Every Drip instruction reads at least one byte, so the cost of reaching that byte is paid on every step of the loop.

Under the hood

Why are fields of self cheaper than self.frames.last_mut()? A Vec access must check that there is a last element and find it, and the frame is one more pointer away. The fields are at fixed offsets from self. This is the same trade the TypeScript VM and clox make with their local variables, just expressed in a way the borrow checker accepts. The price is that the copy and the frame must be kept in sync: call, pop_frame and runtime_error (which needs the running frame’s ip for the line number) all read or write the registers explicitly.

Your turn

ExerciseA tiny stack VM

Write the dispatch loop of a tiny stack VM. Its instructions are an enum, Op: Num(n) pushes the number n; Add, Sub and Mul pop two numbers and push the result; Neg negates the top; Ret stops.

  • step runs one instruction: read self.code[self.ip], move ip on, and match. It returns false after Ret and true otherwise.
  • run calls step until it returns false, then pops and returns the result.

For Sub, the right operand is on top: [Num(5), Num(2), Sub] is 5 − 2.

#[derive(Clone, Copy, Debug)]
enum Op {
    Num(f64),
    Add,
    Sub,
    Mul,
    Neg,
    Ret,
}

struct Vm {
    code: Vec<Op>,
    ip: usize,
    stack: Vec<f64>,
}

impl Vm {
    fn new(code: Vec<Op>) -> Vm {
        let stack = Vec::new();
        Vm { code, ip: 0, stack }
    }

    fn push(&mut self, v: f64) {
        self.stack.push(v);
    }

    fn pop(&mut self) -> f64 {
        self.stack.pop().expect("empty")
    }

    fn step(&mut self) -> bool {
        todo!()
    }

    fn run(&mut self) -> f64 {
        todo!()
    }
}
Open in Rust Playground ↗
Extra practiceErrors as values

Now values can be numbers or booleans, and an instruction can fail. Write:

  • add(a, b): Ok of the sum if both are numbers, else Err("Operands must be numbers.").
  • neg(v): Ok of the negated number, else Err("Operand must be a number.").
  • run(code): run the instructions in order. Push(v) pushes v, Add and Neg use your functions **with ?**, so an error stops the program, and Ret returns Ok of the top value.

The messages are drip-rs’s own.

#[derive(Clone, Copy, Debug, PartialEq)]
enum Value {
    Num(f64),
    Bool(bool),
}

#[derive(Clone, Copy)]
enum Op {
    Push(Value),
    Add,
    Neg,
    Ret,
}

use Value::*;

fn pop(s: &mut Vec<Value>) -> Value {
    s.pop().expect("stack underflow")
}

fn add(a: Value, b: Value)
    -> Result<Value, String> {
    todo!()
}

fn neg(v: Value)
    -> Result<Value, String> {
    todo!()
}

fn run(code: &[Op])
    -> Result<Value, String> {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • Match-based dispatch: decode the byte with from_byte, then one exhaustive match with an arm per opcode. A forgotten opcode is a compile error (E0004), not a default case.
  • Runtime errors are Result values. Arms use ?, and let ... else handles operands of the wrong type.
  • The loop can’t keep &self.chunk across self.push: a shared and a mutable borrow would overlap, and the arena could move.
  • Registers: ip, chunk: Rc<Chunk> and slots are fields of the Vm, synced with the frame on calls and returns. In my measurement, fib took 1396 ms with them and 2983 ms walking through the heap.

Check your understanding

0/6
  1. 1.

    What replaces the TypeScript switch’s default: throw case in drip-rs?

  2. 2.

    Why does this fail to compile?

    let code = &self.code;
    for &b in code {
        self.push(b as f64);
    }
  3. 3.

    Why does drip-rs keep the running chunk as an Rc<Chunk> field of the Vm?

  4. 4.

    In drip-rs, what does call do with the registers?

  5. 5.

    In my measurement (best of 5, shared cloud VM), how did the variant that walks through the heap for every byte compare on fib?

  6. 6.

    An arm calls self.pop_numbers()? and the operands are strings. What happens?

References

  1. [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)
  2. [2] 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)