Skip to content

Step 1 of 3 · Reading · ~3 min

Efficient Dispatch and Global Variables

Bytecode Virtual Machine

Instruction Dispatch & The Main Loop

The last piece before your bytecode VM can run real programs with variables: globals and locals need instructions of their own, and locals in particular need to be fast, since they're what every loop body and function call touches constantly.

Global variables: name-table lookup

Globals are looked up by name, so the VM keeps a hash table mapping names to values, and three opcodes drive it — each carrying a one-byte operand indexing into the constant pool for the variable's name (stored as a string constant):

python

Note OP_SET_GLOBAL peeks rather than pops — assignment (x = 5) is itself an expression that evaluates to the assigned value, so the value must stay on the stack for whatever comes next (an enclosing OP_POP from the expression-statement wrapper is what actually discards it).

Local variables: compile-time stack slots, not a hash table

This is the performance-critical idea of the whole lesson. A hash-table lookup for every single local variable access — inside a hot loop, potentially millions of times — is far too slow. Instead, the compiler figures out, at compile time, exactly which stack slot a local will occupy, and bakes that slot number directly into the bytecode as a one-byte operand. At runtime, reading a local is nothing more than indexing into the VM's own stack array:

python

No dictionary, no string comparison, no hashing — just an array index. This is the single biggest reason bytecode VMs with resolved local slots vastly outperform both tree-walking interpreters and naive bytecode VMs that treat all variables as globals. It's the direct runtime payoff of the static-resolution idea from the tree-walker's Resolver lesson, applied even more aggressively: instead of just recording a scope distance, the compiler now commits to an exact array index.

String interning

Since variable names are compared constantly (as dictionary keys for globals, and to detect declaring the same name twice in one scope), it pays to guarantee that equal strings are the same object in memory — "interning" them. With interned strings, equality checks can short-circuit on pointer/identity comparison before ever comparing character-by-character, and duplicate string constants (the same variable name appearing in many places) share one allocation instead of being duplicated throughout the constant pool. A simple approach: keep a single global table mapping string content to the canonical string object, and every time the compiler wants a string constant, look it up (or insert it) there first.

Edge cases to watch

  • Shadowing: a local declared with the same name as an outer local in a nested block must get its own new slot — the compiler needs to track scope depth per local, not just a flat name-to-slot map, or an inner var x will silently reassign the outer x's slot.
  • Reading a slot before it's initialized: if your compiler pushes a placeholder (e.g. nil) at the point of declaration and only marks the slot "ready" after the initializer runs, you get the same self-reference protection (var a = a;) the tree-walker's resolver gave you — but now enforced by which slot indices the compiler is willing to emit.
  • Popping locals at end of scope: when a block ends, every local slot declared inside it must be popped off the stack (each becoming an OP_POP), or slot numbers computed for code after the block will be wrong.
Up nextTypes & Value RepresentationBytecode Virtual Machine

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…