Chapter 13: The VM core
This chapter:
- Give the bytecode VM its own real runtime value type,
Value, and rename Chapter 8's tree-walker types out of the way to make room for the plain name. - Build the VM itself: a shared value stack, a call-frame stack, and a flat dispatch loop.
- Make
musc runexecute through the real VM by default,--walkfalling back to the tree-walker for comparison. - Revisit Chapter 9's benchmark with real bytecode-VM numbers, closing that chapter's loop.
Where we left off: musc compile produces correct bytecode for everything through Chapter 12, proven by disassembly and by hand-tracing jump offsets. Nothing has ever actually run one of these chunks.
13.1 Renaming Chapter 8 out of the way
Chapter 8's tree-walker has had a Value, a Function, and a Native_Fn since the chapter that introduced them, and Chapter 10 already flagged that this couldn't last: those names were always provisional, held by the tree-walker only because it got there first. This chapter is where that bill comes due.
// treewalk.odin, after this chapter's rename:
Walk_Value :: union {
f64,
string,
bool,
^Walk_Function,
Walk_Native_Fn,
}
Value, Function, and Native_Fn become Walk_Value, Walk_Function, and Walk_Native_Fn, a mechanical, package-wide rename with no behavioural change whatsoever, every example this book has run through --walk still produces exactly the same output afterward. What it buys is a plain, unprefixed Value for the VM's own, real runtime representation, the one every future chapter through Chapter 21 actually means when it says "a Muspel value":
Value :: union {
f64,
string,
bool,
^Func_Proto,
Builtin,
^Struct_Instance,
^Array_Instance,
}
Three differences from Walk_Value are worth naming. First, ^Func_Proto (Chapter 12's compiled function) stands in for ^Walk_Function directly, no separate "runtime function object" wrapping it: because Muspel's functions capture nothing (section 12.3's honestly-scoped closures), a compiled prototype already is a complete, callable value, with nothing left to attach to it at the moment it's created. Second, Builtin replaces Walk_Native_Fn, same idea, new name, still exactly one registered function (print) until Chapter 16 makes registration a real, curated API. Third, ^Struct_Instance and ^Array_Instance don't have a Walk_-prefixed predecessor to rename at all: they're new here, section 13.2's own reason why this VM's Value needs an arena and Chapter 8's tree-walker never did.
Struct_Instance :: struct {
type_name: string,
field_names: []string,
values: []Value,
}
Array_Instance :: struct {
values: []Value,
}
Both are arena-allocated (section 13.2), never on Odin's general heap the way Chapter 8's Walk_Struct_Instance/Walk_Array_Instance are: this is the point Chapter 5 flagged as not carrying over once a real VM instance exists to own an arena, and this is that promise kept.
value_is_truthy and values_equal are, deliberately, line-for-line the same logic as Chapter 8's walk_is_truthy/walk_values_equal: a value is truthy unless it's false or nil, and two values are only ever equal if they share both an underlying type and that type's own notion of equality. Muspel's semantics don't change between the tree-walker and the VM; only how fast they run does.
13.2 The VM struct
Frame :: struct {
chunk: ^Chunk,
ip: int,
base: int,
}
VM :: struct {
stack: [dynamic]Value,
frames: [dynamic]Frame,
globals: map[string]Value,
value_arena: Value_Arena,
}
stack is genuinely shared across every active call: Chapter 12's Call doesn't give a called function a stack of its own, it just marks out a region of this one stack, starting at base, for that call's locals to live in. Get_Local/Set_Local (Chapter 11) never touch an absolute stack index, only frame.base + slot, which is exactly what lets the same compiled function be called from anywhere, at any stack depth, and always find its own locals starting at slot 0 regardless of how deep the call happens to be.
frames is the call stack: pushing a Frame is the entirety of what a function call does to the VM's own state, and popping one is the entirety of what returning does. There's no separate, Odin-level call for "invoke a Muspel function" the way Chapter 8's call_function was a real, recursive Odin proc call; section 13.3 explains why that difference is the chapter's real point.
value_arena is section 10.5's Design decision, finally with somewhere to live: vm_init calls value_arena_init, sizing its backing buffer once, and every Make_Struct/Make_Array this VM instance ever executes allocates from it and nothing else.
13.3 The dispatch loop
vm_run :: proc(vm: ^VM) {
for {
frame := &vm.frames[len(vm.frames) - 1]
op := Op_Code(read_u8(frame))
switch op {
// ... one case per Chapter 10 opcode ...
}
}
}
Design decision: a flat loop over the current frame's next instruction,
Callpushing a new frame onto the same loop, rather than a recursive Odin proc call per Muspel call. Alternatives considered: the obvious, direct translation of Chapter 8's tree-walker,vm_call(fn, args)as a real Odin procedure that itself callsvm_runrecursively on the callee, returning a value back up through Odin's own call stack exactly the waycall_functiondoes today. Trade-off: the recursive version is arguably simpler to read for exactly the depth Chapter 6 needed, at the cost of tying every Muspel call to a real Odin stack frame underneath it. That's invisible right up until a script needs to pause mid-call and be resumed later (Chapter 15's whole premise): a paused Odin call stack has no way to be detached, stored, and reattached, Odin gives no primitive for that. A flat loop over an explicit, VM-ownedframesarray has no such problem, because there's nothing hiding in Odin's own stack to lose: the entire state of every call in progress, arguments, locals, the instruction pointer, is sitting in plain data on theVMstruct itself. Choice: the flat loop,Callappending tovm.framesandReturnpopping from it, both handled as ordinary cases inside the one loop that's already running, never a nested call intovm_runitself. This costs nothing extra today, and it's the entire reason Chapter 15 gets to addYieldas one opcode and a status enum instead of solving stack-switching from scratch.
Call and Return show the pattern directly:
case .Call:
argc := int(read_u8(frame))
callee := vm.stack[len(vm.stack) - 1 - argc]
if native, ok := callee.(Builtin); ok {
args := vm.stack[len(vm.stack) - argc:]
result := native(vm, args)
resize(&vm.stack, len(vm.stack) - argc - 1)
append(&vm.stack, result)
} else {
fn := callee.(^Func_Proto)
new_base := len(vm.stack) - argc
append(&vm.frames, Frame{chunk = &fn.chunk, ip = 0, base = new_base})
}
case .Return:
result := pop(&vm.stack)
old := pop(&vm.frames)
if len(vm.frames) == 0 do return
resize(&vm.stack, old.base - 1)
append(&vm.stack, result)
Call's callee and arguments are already sitting on the stack exactly where Chapter 12 compiled them to be, new_base just names that position as the new frame's own base, nothing is copied or moved. Return discards the whole region a call occupied, old.base - 1 (the - 1 drops the callee itself, which was sitting one slot below its own arguments), in one resize, the flat design's real payoff: no per-local cleanup, no walking anything, one call frame's entire lifetime ends in a single array truncation.
Design decision:
switch-based dispatch, not computed goto. Alternatives considered: threaded dispatch (direct or indirect), where each opcode handler ends by jumping straight to the next one's address, computed from the next opcode byte, rather than returning to aswitchand re-testing which case to run. Trade-off: computed goto can measurably reduce branch-misprediction cost in a real, hot dispatch loop, real production VMs (CPython's, some Lua forks) use it. It costs portability and readability: it depends on a compiler extension (GCC/Clang's labels-as-values, which Odin doesn't expose as a language feature the way C does) and turns the opcode table into scattered jump targets instead of one readableswitch. Choice: plainswitchdispatch. It's simplest, it's what every opcode's stack-effect table (Chapter 10) maps onto directly, onecaseper row, and it's fast enough: section 13.6's real numbers, not a hand-wave, back that claim up.
13.4 Struct, array, and enum opcodes
Make_Struct, Get_Field/Set_Field, Make_Array, and Get_Index/Set_Index (Chapter 10) join the dispatch loop as five more ordinary cases, each a direct translation of the pseudocode Chapter 10 already walked through, now against a real stack and a real arena:
case .Make_Struct:
idx := read_u8(frame)
layout := frame.chunk.constants[idx].(^Struct_Layout)
values := make([]Value, len(layout.field_names), value_arena_allocator(&vm.value_arena))
for i := len(layout.field_indices) - 1; i >= 0; i -= 1 {
v := pop(&vm.stack)
fi := layout.field_indices[i]
if fi >= 0 do values[fi] = v
}
inst := new(Struct_Instance, value_arena_allocator(&vm.value_arena))
inst^ = Struct_Instance{type_name = layout.type_name, field_names = layout.field_names, values = values}
append(&vm.stack, Value(inst))
case .Get_Field:
idx := read_u8(frame)
name := frame.chunk.constants[idx].(string)
obj := pop(&vm.stack)
if inst, ok := obj.(^Struct_Instance); ok {
for n, i in inst.field_names {
if n == name {
append(&vm.stack, inst.values[i])
break
}
}
} else {
runtime_error(vm, "'.%s' used on a non-struct value", name)
}
Get_Field's operand is the field name itself, a constant-pool string, and the case scans inst.field_names for it at run time, exactly the by-name lookup section 10.6 named and defended as this book's honest trade-off against a full static type system. Make_Struct pops its pushed values in reverse, len(layout.field_indices) - 1 down to 0, because the compiler (section 11.4) pushed them in forward, literal-written order, last pushed is first popped, off the top of the stack.
Make_Array/Get_Index/Set_Index follow the identical shape, make/new against value_arena_allocator(&vm.value_arena) instead of context.allocator, and a bounds check on every index before touching arr.values, a graceful runtime_error (Chapter 18 gives this proc its full treatment) rather than an Odin-level out-of-bounds panic on a script's own mistake.
An enum member needs no dispatch case at all: it was already compiled straight to Constant (section 11.4), so by the time any of this runs, Phase.Attacking is indistinguishable from a numeric literal that happened to be written differently.
13.5 Wiring it up
musc run compiles and executes through the real VM by default now; --walk still runs the tree-walker, for exactly the comparisons this book keeps making:
if walk {
interp := muspel.interp_init()
muspel.exec_program(&interp, stmts)
} else {
chunk := muspel.chunk_init()
muspel.compile_program(&chunk, stmts)
vm := muspel.vm_init()
muspel.vm_run_chunk(&vm, &chunk)
}
Every example this book has built runs identically either way:
./run.sh run examples/fib.mus
./run.sh run --walk examples/fib.mus
55
55
13.6 Revisiting Chapter 9's benchmark
Chapter 9 measured the tree-walker against bench_fib.mus and bench_loop.mus and promised this chapter would fill in the bytecode-VM column. musc bench now runs both back to back:
./build.sh
./musc bench examples/bench_fib.mus
317811
tree-walk: 139ms
317811
bytecode vm: 52ms
./musc bench examples/bench_loop.mus
1.24999975e+13
tree-walk: 457ms
1.24999975e+13
bytecode vm: 386ms
fib's speed-up (roughly 2.7x) is far larger than bench_loop's (roughly 1.2x), and section 7.3's revised explanation of why bench_loop is slow says exactly why the gap shouldn't be expected to close: total and i are declared at the top level, so both the tree-walker and the VM treat them as globals, a map[string]Value lookup by name, interp.globals[node.name] on one side, Get_Global/Set_Global against vm.globals on the other. That cost is paid by both interpreters equally; a bytecode VM doesn't make a hash lookup faster, it only removes the tree-walker-specific costs (AST pointer-chasing, per-call heap allocation) sitting alongside it.
It's tempting to predict that rewriting bench_loop so its variables become real locals (wrap the loop in a function, call it once) would close most of that gap, on the theory that removing the shared global-lookup cost should let the VM's other advantages show through the way they do for bench_fib. Measured, it doesn't: the function-wrapped version runs in 325ms on the tree-walker and 261ms on the VM, a 1.25x speed-up, barely different from the original's 1.2x, even though both absolute numbers dropped substantially (457ms โ 325ms tree-walking, 386ms โ 261ms VM). Both interpreters got faster by removing the global lookups; the ratio between them barely moved. The honest reading is that this benchmark's remaining cost, on both sides, is dominated by something the local/global distinction doesn't touch at all, plausibly the sheer per-iteration dispatch overhead of five million passes through either eval_expr's switch or vm_run's, a cost this book doesn't have the tooling in hand to separate out further without an actual profiler. Report what you measure, not what a clean story predicts; the exercise below asks you to reproduce this apparent non-result yourself, precisely because it's a more honest lesson than a tidy one would have been.
Checkpoint
musc run executes every example this book has built, correctly, through a real bytecode VM, including struct, enum, and array construction/access/iteration, agreeing with --walk's tree-walker output exactly in every case, and musc bench reports real, reproducible bytecode-VM timings alongside the tree-walker's own, closing Chapter 9's loop with numbers instead of a promise.
Exercises
- Add a
Dup-using construct of your own (Chapter 10 namedDupbut nothing has used it yet) and confirm it disassembles and executes correctly. A pre-increment-likex = x + 1readsxonce for the addition and doesn't needDupat all; find a construct that genuinely does, a postfix operation that needs to use a value and keep it on the stack simultaneously is the right shape to look for. - Trace
Call/Return's stack arithmetic by hand for a two-argument call: draw the stack before the call, immediately afterCallpushes the new frame, and immediately afterReturnpops it, and confirmnew_baseandold.base - 1land exactly where you'd expect. - Rewrite
bench_loop.musso its loop runs inside a function instead of at the top level (call it once, from aprintat the top level), forcingtotalandito become real locals. Rerunmusc benchand confirm section 13.6's own measurement: both absolute times drop, but the tree-walk-to-VM ratio barely changes, the opposite of what "locals should behave likebench_fib's" would predict. Then try to improve on section 13.6's own honest "we'd need a profiler" shrug: with nothing buttime.now()/time.since()calls of your own placed insidevm_run's dispatch loop, see how much of the VM's remaining~260msyou can attribute to a specific opcode case rather than dispatch overhead in general. - Section 13.3's Design decision promises the flat dispatch loop is "the entire reason Chapter 15 gets to add
Yieldas one opcode." Without reading ahead, write down, in your own words, what you think would go wrong ifCallinstead calledvm_runrecursively (a real Odin call), and a script tried to pause partway through a deeply nested call. Check your answer against Chapter 15's own opening once you get there. - Run
examples/structs_enums.musandexamples/arrays.musthrough bothmusc runandmusc run --walkand confirm every line of output matches exactly. Then deliberately corrupt one: change aGet_Field's constant-pool index by hand (in a copy of the compiled bytecode, viamusc compile's own listing as your guide) to name a field that doesn't exist, and confirmGet_Field'sruntime_errorfires instead of an Odin-level panic.
Further reading
- Crafting Interpreters,
A Virtual Machine, the direct model for this chapter's dispatch loop andVMstruct: https://craftinginterpreters.com/a-virtual-machine.html - Crafting Interpreters,
Calls and Functions, for the call-frame mechanics this chapter'sCall/Returnfollow closely: https://craftinginterpreters.com/calls-and-functions.html - A short, credited pointer to computed-goto dispatch as a real, faster-in-practice alternative this chapter declines: Anton Ertl and David Gregg's "The Structure and Performance of Efficient Interpreters" is a well-regarded, freely available treatment of dispatch technique trade-offs in general, not Muspel-specific.