Virtual Machines & Runtimes
Dispatch
How the VM answers “what does this instruction do?” determines its speed and its size.
Switch Dispatch
The classic while (running) switch (opcode) loop. Twelve lines, trivially correct, and fast enough for DSL work — the default first implementation.
Threaded & Direct Threaded
Replace the switch with jumps through code pointers stored between instructions. Two to five times faster, at the cost of less readable internals — an optimization only when profiling asks for it.
The Machine State
Program counter, operand stack, call stack, and globals. Everything else (heap, IO) is a service the machine calls, not part of its core loop.
Call Frames and Locals
A frame per call holds return address, saved caller frame, and a locals slot array (resolved in Semantic Analysis). Frames push on calls and pop on RET, giving recursion for free and a stack trace on uncaught errors.
The Trace Is the Gift
With frames, the VM can print “at foo:3, called from bar:7” — the diagnostic CSS from Errors & Diagnostics comes alive. This alone justifies the frame discipline.
Runtime Services
Memory: The GC Sampler
Three classic strategies, enough to orient: mark-sweep (pause, walk, collect — simplest), copying (move survivors, compact — fast allocation), and generational (young vs old generations — production choice, seen in JVM/CPython’s hybrids). A DSL with reference/ownership semantics (Types & Values) needs no GC at all.
Host Interop
DSLs live inside hosts: your VM needs one door to call out (a HOSTCALL instruction or FFI table). Keep the interface narrow — a few verbs, typed arguments — and the host language stays a detail.
JIT Preview
The Interpret-JIT Spectrum
Between interpreting and native compilation lies just-in-time compilation: profile hot loops, compile them to machine code, and run that. Full JITs are a Phase-3-scale commitment (Julia and LLVM do this); DSLs typically stop at “bytecode in, interpreter in C” and let the host or LLVM provide the JIT.
Example: The Core Loop
Switch dispatch, the machine state, and one frame — everything until now, inside fifty lines.
vm.py (commented)
# vm.py — a minimal stack-machine interpreter
class VM:
def __init__(self, code, constants):
self.code, self.constants = code, constants
self.pc, self.stack, self.frames = 0, [], []
def run(self):
while self.pc < len(self.code):
op = self.code[self.pc]; self.pc += 1
if op == "PUSH": self.stack.append(self.constants[next(self.code)])
elif op == "OP": b = self.stack.pop(); a = self.stack.pop()
self.stack.append(apply(next(self.code), a, b))
elif op == "CALL": self.frames.append(self.pc) # return address
self.pc = next(self.code) # jump to callee
elif op == "RET": self.pc = self.frames.pop()
elif op == "HALT": return self.stack.pop()
How to Read It
Dispatch on the opcode, machine state in the instance, frames only for calls — the whole design of this chapter in miniature. Extend an instruction and the same shape holds for control flow, globals, and HOSTCALL.
Next Steps
Continue Phase 2
The bytecode path is complete; native speed is one rung down: Code Generation, then Code Optimization to spend speed wisely. Phase 3 hands the whole ladder to LLVM.