How a bytecode VM in Rust beat CPython 3.14

Eleven profile-guided steps with real Rust: lexical-addressed locals, unboxed i64 arithmetic, an unsafe hot loop, and self-tail-calls in place — taking a tree-walking Lisp from 1682 ms to 95 ms on fib(30) and past CPython on every loop.

Source: github.com/kissishka/risp

risp’s tree-walking interpreter took 1682 ms to compute fib(30). Its bytecode VM does it in 95 ms — and on loops and arithmetic it beats CPython 3.14 outright. None of that came from one clever trick. It came from removing one bottleneck at a time and letting the next one surface. Here’s the whole arc, in real code:

#changefibtailsumarithwhat it removed
0tree-walker (baseline)1682 ms760 ms2459 ms
1bytecode VM (naïve)270 ms90 msAST re-analysis + string-hashed env
2inline integer arithmetic235 ms78 msgeneric builtin dispatch for + - < =
4register-cached frame170 ms60 msframe-stack indexing + Rc bump per call
8unchecked hot path108 ms35 ms94 msbounds-check branches per op
10self-tail-call in place106 ms25 ms72 msframe setup per loop iteration
11stack-based locals95 ms28 ms78 msthe per-call frame vector
CPython 3.1461 ms31 ms96 ms

Compile once: variables become array indices

A tree-walker re-derives the meaning of every node every time it runs it: is the head a special form? a macro? Then it allocates a vector for the operands and recurses. Reading a variable means hashing its name and probing a HashMap at every scope frame up the chain. All of that is a function of the program text, not the runtime values, so it can be done once. The compiler resolves every variable reference to a fixed slot at compile time:

fn resolve(&mut self, name: &Rc<str>) -> Resolved {
    let last = self.fns.len() - 1;
    // `rposition`, not `position`: a shadowing `let` sits at a HIGHER slot,
    // so the innermost binding must win.
    if let Some(i) = self.fns[last].slots.iter().rposition(|s| s == name) {
        return Resolved::Local(i as u32);
    }
    for f in &self.fns[..last] {
        if f.slots.iter().any(|s| s == name) { return Resolved::Capture; }
    }
    Resolved::Global(self.g.intern(name))
}

A local reference then compiles to Op::LoadLocal(i), and at runtime that’s a single stack[base + i] read, an array index the CPU can keep in a register. No hashing, no map probe. This is the single biggest win — fib dropped from 1682 ms to 270 ms in one step — and it’s exactly why CPython’s LOAD_FAST (also an array index) is its fast opcode.

Keep integers unboxed

This is the structural reason a Rust VM can beat CPython at all. The compiler emits dedicated opcodes for two-argument arithmetic instead of a generic call:

let fast = match &**op {
    "+" => Some(Op::Add), "-" => Some(Op::Sub), "*" => Some(Op::Mul),
    "<" => Some(Op::Lt),  ">" => Some(Op::Gt),  "=" => Some(Op::NumEq),
    // ... <= >=
    _ => None,
};
if let Some(fop) = fast
    && !self.redefined_ops.contains(&**op)        // (def + ...) suppresses inlining
    && matches!(self.resolve(op), Resolved::Global(_))
{
    self.compile(&parts[1], false)?;
    self.compile(&parts[2], false)?;
    self.emit(fop);
    return Ok(());
}

The VM’s fast path handles the (Int, Int) case in a few instructions and delegates everything else to the real builtin, so semantics (including the overflow trap) stay identical:

fn bin_arith(
    stack: &mut Vec<Value>,
    int_fast: fn(i64, i64) -> Option<i64>,
    builtin: fn(&[Value]) -> RispResult,
) -> Result<(), RispError> {
    // SAFETY: a binary op is emitted only after its two operands are pushed.
    let b = unsafe { stack.pop().unwrap_unchecked() };
    let a = unsafe { stack.pop().unwrap_unchecked() };
    if let (Value::Int(x), Value::Int(y)) = (&a, &b)
        && let Some(z) = int_fast(*x, *y)
    {
        stack.push(Value::Int(z));
        return Ok(());
    }
    let r = builtin(&[a, b])?;
    stack.push(r);
    Ok(())
}
// dispatch: Op::Add => bin_arith(&mut stack, i64::checked_add, b_add)?,

Value::Int(i64) lives inline on the stack: no heap pointer, no refcount. The fast path matches two ints, calls i64::checked_add (an add plus an overflow check), and pushes the result, never touching the allocator. CPython, by contrast, heap-allocates and refcounts a PyLong object for every arithmetic intermediate, even 1 + 1. That’s why the arithmetic-dense loop is where risp wins by the widest margin. And the redefined_ops scan keeps it honest: if a program ever does (def + …), + is removed from the inline set and routed through the normal call path, so rebinding an operator still works.

The hot loop: registers and unchecked indexing

Once allocation and lookups were gone, the residue was the dispatch machinery itself. The VM holds the active frame entirely in local variables (closure, ip, base, cells), so suspended callers live in a Vec while the running frame’s instruction pointer is just a usize in a register. And on the paths where the compiler guarantees an index is valid, it skips the bounds check:

loop {
    // SAFETY: every function ends in `Return` and all jumps target valid indices,
    // so `ip` is always in range. The VM<->tree-walker differential tests guard this.
    let op = unsafe { *closure.func.code.get_unchecked(ip) };
    ip += 1;
    match op {
        Op::Const(i) => {
            let v = unsafe { closure.func.consts.get_unchecked(i as usize) }.clone();
            stack.push(v);
        }
        Op::LoadLocal(i) => {
            // SAFETY: base + i indexes this frame's locals; i < n_slots (compiler).
            let v = unsafe { stack.get_unchecked(base + i as usize) }.clone();
            stack.push(v);
        }
        // ...
    }
}

Each removed check is a compare-and-branch the CPU was running tens of millions of times. On the arithmetic loop that’s hundreds of millions of branches gone, and arith went 129 → 94 ms. There’s no per-call locals vector either: locals live directly on the operand stack at [base .. base + n_slots], so a call’s already-evaluated arguments simply become the callee’s locals in place. The unsafe is a real trade, since a compiler bug could now cause UB, which is why those accesses are the most heavily tested code in the project (more below).

Self-tail-call in place — the decisive loop win

A tail loop like (loop (- n 1) (+ acc n)) was still doing a full frame switch every iteration. The fix detects the common case, a function tail-calling itself, and overwrites its own locals instead:

Op::TailCall(argc) => {
    // ... resolve callable ...
    Value::Compiled(c) => {
        let f = &c.func;
        check_arity(f, argc)?;
        // Shift the (already-evaluated) args down onto `base` — the TCO.
        for i in 0..argc {
            let v = std::mem::replace(&mut stack[callee + 1 + i], Value::Nil);
            stack[base + i] = v;
        }
        stack.truncate(base + argc);
        stack.resize(base + f.n_slots, Value::Nil);
        // A self-recursive loop keeps the same closure and (empty) cells —
        // skip that register churn. `n_cells == 0` is load-bearing.
        if !(Rc::ptr_eq(&c, &closure) && f.n_cells == 0) {
            cells = make_cells(f.n_cells);
            closure = c;
        }
        ip = 0;   // jump to the top
    }
}

Three guards make this correct, and all three earn their place. Rc::ptr_eq(&c, &closure) ensures it’s the same activation tail-calling itself, not mutual recursion between two closures from the same template. f.n_cells == 0 ensures no inner closure captured this frame’s locals; if something did, reusing the slots would make two iterations share a binding, so the code falls through to allocate fresh cells. And check_arity runs unconditionally. When the guards pass, a tail-recursive Lisp loop becomes what it morally is, a machine loop: write the new locals, jump to ip = 0. That pushed tailsum to 25 ms (past CPython’s 30) and arith to 72 ms (past CPython’s 96).

Guarded by a second interpreter

Every unsafe block above names a compiler-enforced invariant, and risp doesn’t just assert those invariants, it tests them. The VM’s output is held byte-identical to the tree-walker’s by differential tests: the same program runs on both engines and the results must match exactly. If a compiler bug ever emitted an out-of-range index, the test would catch it as a wrong value before it could ever become undefined behavior in the field. The compiler even defers a malformed but unreached cond clause’s error to runtime via an Op::Raise, so the VM errors at exactly the moment, with the same message, the tree-walker would. The unsafe rests on a simpler interpreter that never changed.

The one shape it couldn’t win

After eleven steps, risp’s VM beats CPython on sum (28 ms vs 31), the arithmetic loop (78 vs 96), and a cond/let-dense dispatch loop (203 vs 208), but trails 1.55× on call-bound fib (95 vs 61). fib’s recursion is non-tail (two calls feed a +), so it never hits the cheap self-tail-call path and pays full call overhead ~2.7M times, against CPython’s specialised, computed-goto’d inline call path. No amount of polishing a portable match-dispatched bytecode loop was going to beat native code at pure recursion. So the next step stopped interpreting and started emitting machine code, closing the gap by an order of magnitude.

Frequently asked questions

Can a bytecode interpreter written in Rust beat CPython?
Yes. risp's bytecode VM computes fib(30) in 95 ms versus its tree-walker's 1682 ms, and it beats CPython 3.14 on arithmetic (78 vs 96 ms) and tail-recursive loops (25 vs 31 ms). It trails CPython only on call-bound recursion like fib.
Why is a Rust VM faster than CPython at arithmetic?
risp keeps integers unboxed: Value::Int(i64) lives inline on the stack with no heap pointer or refcount. CPython heap-allocates and refcounts a PyLong object for every intermediate, even 1 + 1, so the arithmetic-dense loop is where risp wins by the widest margin.
How do you make a bytecode interpreter faster, step by step?
Resolve variables to array indices at compile time instead of hashmap lookups, emit dedicated arithmetic opcodes, cache the active frame in registers, skip bounds checks on compiler-guaranteed indices, and turn self-tail-calls into in-place loops. Each step removed one profiled bottleneck, taking fib(30) from 1682 ms to 95 ms.

← All posts