Virtual Machines & Runtimes

A VM executes the bytecode from the previous chapter: fetching instructions, maintaining frames and the operand stack, and providing the runtime services the language promised — memory, interop, and (eventually) speed.

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.

Resources