Global and Local Variables in Bytecode

Writing a Compiler/Interpreter: Advanced

Chapter 4 · Global and Local Variables in Bytecode

Every chunk so far has computed one throwaway value and returned it — nothing has been able to remember anything between instructions. Course 1's own Environment (Chapter 5) solved this with a dict per scope, chained to its parent. This chapter solves it twice, on purpose: globals, looked up by name at runtime, much like Course 1's own approach — and locals, which this chapter resolves entirely differently: not by name, not at runtime, but by position, worked out once, at compile time.

Six New Opcodes

OpcodeDoes
OP_POPDiscard the top of the stack — needed for expression statements, and for locals leaving scope
OP_DEFINE_GLOBALPop a value, store it in the VM's own globals table under a name
OP_GET_GLOBALLook up a name in globals, push the result
OP_SET_GLOBALOverwrite an existing entry in globals — the new value stays on the stack
OP_GET_LOCALPush a copy of whatever's already sitting at a specific stack slot
OP_SET_LOCALOverwrite a specific stack slot with the current top of stack — which stays there

A minimal OP_PRINT is also added here, purely so a multi-statement program can actually show something observable — Course 1's own PrintStmt node needs somewhere to compile to.

Globals: Looked Up by Name, at Runtime

A top-level var x = 10; compiles to: compile the initializer, then OP_DEFINE_GLOBAL with an operand pointing at the constant pool, where the variable's own name — the string "x" — lives as a constant, exactly like any other literal.

def visit_var_stmt(self, node): self.visit(node.initializer) if self.scope_depth == 0: idx = self.chunk.add_constant(node.name) # the NAME, as a constant self.chunk.write(OP_DEFINE_GLOBAL, node.line) self.chunk.write(idx, node.line) else: self.declare_local(node.name) # see below -- no instruction at all

At runtime, the VM keeps one plain Python dict, self.globals. OP_GET_GLOBAL reads the name out of the constant pool and looks it up; OP_SET_GLOBAL overwrites an existing entry, matching Course 1's own rule that assignment never implicitly declares.

Verified directly — declaration, reading, and reassignment all work end to end
var x = 10; print x; compiles and runs to ['10']. var x = 10; x = 20; print x; runs to ['20'].
Verified directly — reading or assigning an undeclared global fails cleanly, matching Course 1's own rule
print nope; raises RuntimeError: Undefined variable 'nope'. — and so does nope = 1;, with the identical message. The VM's own OP_GET_GLOBAL/OP_SET_GLOBAL handlers both check if name not in self.globals before touching the dict, refusing to let a bare assignment silently create a new global — the same deliberate rule Course 1, Chapter 5 established.

Locals: Resolved by Position, at Compile Time

A local variable gets no runtime lookup at all — not by name, not by any kind of table. The compiler works out, while compiling, exactly which stack slot a local will occupy, and bakes that slot number directly into the instruction as a plain integer operand.

def declare_local(self, name): self.locals.append((name, self.scope_depth)) def resolve_local(self, name): for i in range(len(self.locals) - 1, -1, -1): # innermost first if self.locals[i][0] == name: return i # the index IS the slot number return None # not a local -- must be a global

The trick that makes this work at all: a local's value never moves anywhere new. Once its initializer is compiled, the result is already sitting on the VM's own operand stack — the exact same stack arithmetic uses — at exactly the slot it needs to stay at. declare_local doesn't emit a single instruction; it just remembers, at compile time, which position that value now occupies. Locals aren't stored anywhere separate from the rest of the VM's own working stack.

elif instr == OP_GET_LOCAL: slot = code[ip]; ip += 1 stack.append(stack[slot]) # a plain list index -- no dict, no name, anywhere elif instr == OP_SET_LOCAL: slot = code[ip]; ip += 1 stack[slot] = stack[-1] # peek, don't pop -- assignment is an expression
Verified directly — the classic shadowing test, resolved entirely at compile time
var x = 1; { var x = 2; print x; } print x; runs to ['2', '1']. Inside the block, resolve_local finds the inner x first (searching innermost-outward) and compiles the print to OP_GET_LOCAL. Outside the block, the inner x has already been removed from self.locals by the time the second print x; is compiled, so it resolves to the global x instead, via OP_GET_GLOBAL. Two completely different instructions, decided entirely while compiling — the VM itself never has to ask "which x did the programmer mean."
Verified directly — local assignment correctly updates the right slot without leaking out of its block
var x = 1; { var y = 2; y = 5; print y; } print x; runs to ['5', '1']y's reassignment only ever touches its own stack slot, and x in the outer scope is never disturbed by anything happening to a same-numbered-or-not slot inside the block.

Leaving a Block: OP_POP, Once Per Local

When a block ends, every local declared inside it has to actually come off the stack — otherwise the stack keeps growing forever and every slot number calculated afterward would be wrong.

def end_scope(self, line): self.scope_depth -= 1 while self.locals and self.locals[-1][1] > self.scope_depth: self.chunk.write(OP_POP, line) # pop it off the RUNTIME stack self.locals.pop() # AND stop tracking it at compile time

Two things happen for every local going out of scope, and both matter: an OP_POP is emitted so the VM's own stack actually shrinks back down at runtime, and the entry is removed from self.locals so any later resolve_local call can no longer find it — which is exactly the mechanism behind the shadowing test above.

A Real Performance Difference, Honestly Measured

A dict lookup by string name and a plain list index by integer are both, in the abstract, "fast" — but they're not the same cost, and it's worth actually measuring rather than assuming.

Verified directly — locals are measurably, consistently faster, but the gap is modest, not dramatic
Reading the same variable 200,000 times: global (OP_GET_GLOBAL, dict lookup) — 0.0931s. Local (OP_GET_LOCAL, list index) — 0.0882s. A 1.056× advantage for locals. A more realistic mixed workload — 200,000 repetitions of i = i + 1, one get and one set per iteration — gives 1.045×, the same modest margin. Both ratios held consistently across repeated runs (median of 11 each).
Why the gap isn't bigger — a direct echo of Chapter 1's own finding
In a real bytecode VM written in C (the design this course is modeled on), a local slot access is a handful of machine instructions — an array offset, nothing more — while a global lookup means hashing a string, probing a table, and comparing keys, a genuinely much larger cost. In Python, both operations are already running through CPython's own heavily-optimized, C-implemented dict and list types — the theoretical gap between "hash lookup" and "array index" gets mostly absorbed by Python's own implementation before it ever reaches this VM's own code. This is the same shape of result Chapter 1 found for tree-walking versus bytecode dispatch generally: architectural advantages that are large in a compiled, low-level implementation don't automatically transfer at full strength once everything is written in the same interpreted host language.

Same Ceiling, Different Reason

OP_GET_LOCAL/OP_SET_LOCAL encode a slot number as a single operand byte, the same way OP_CONSTANT encodes a constant-pool index — which means Chapter 2's own 256 ceiling shows up again here, for an entirely different quantity.

Verified directly — 256 simultaneously-live locals compile fine; 257 hits the exact same ValueError as Chapter 2's own constant-pool ceiling
A block declaring 256 distinct local variables (all still in scope at once) compiles without incident. The same shape with 300 locals fails immediately with ValueError: byte must be in range(0, 256) — not because there are too many constants this time, but because a single-byte slot operand can't address a 257th simultaneously-live local. Two genuinely different resources (the constant pool, and the local-variable stack region) hit the identical numeric ceiling, for the identical structural reason: a one-byte operand.

Where This Connects

This chapter's findingWhat it connects to
Constant-pool deduplication, reused for variable namesChapter 2, Exercise 2's own already-verified add_constant fix — without it, reading the same global repeatedly would exhaust the 256-constant ceiling after just 256 reads, a real bug this chapter's own benchmark hit while being built
Locals live directly on the VM's own value stack, with no separate storageChapter 6's own call frames will extend this same idea — a function call's own locals will occupy a fresh region of the same stack, offset from a frame's own base pointer, rather than needing a new data structure
Locals resolved entirely at compile time; globals resolved entirely at runtimeCourse 1, Chapter 5's own single, uniform Environment chain — this course deliberately splits what Course 1 handled one way into two genuinely different mechanisms, chosen for what each one is actually used for
A modest, honestly-measured ~5% speed advantage for locals over globalsChapter 1's own finding that Python-on-Python bytecode dispatch doesn't automatically inherit the performance characteristics of a real, compiled VM

Hands-On Exercises

Exercise 1

Compile { var a = 1; { var b = 2; print a; print b; } print a; } — two nested blocks, two locals at different depths — using this chapter's own Compiler. Trace through declare_local/resolve_local by hand to determine which stack slot each of a and b occupies, then verify your trace against the actual output.

📄 View solution
Exercise 2

Build a Chunk/Compiler pair using a version of add_constant without Chapter 2 Exercise 2's own deduplication fix. Compile a program that declares one global and then reads it 300 times in a row. Determine whether this fails, and if so, explain precisely why — given that the program only ever refers to a single variable name.

📄 View solution
Exercise 3

Compile and run var x = 1; var y = 2; x = y = 10; print x; print y; — a chained assignment, where the right-hand side of x = ... is itself an Assign expression. Verify the result, and explain specifically which line of visit_assign makes this work without any special-case code for chained assignment at all.

📄 View solution

Chapter 4 Quick Reference

  • Globals: a name-keyed dict on the VM; OP_DEFINE_GLOBAL/OP_GET_GLOBAL/OP_SET_GLOBAL, resolved by name at runtime
  • Locals: plain slots on the VM's own operand stack; OP_GET_LOCAL/OP_SET_LOCAL, resolved by position at compile time — declare_local emits zero instructions
  • Verified: the classic shadowing test — var x=1; {var x=2; print x;} print x; — correctly prints 2 then 1, decided entirely at compile time
  • Verified: undeclared reads and assignments both fail with a clean RuntimeError, matching Course 1's own rule that assignment never implicitly declares
  • Verified: locals beat globals by a real but modest ~5% in this pure-Python VM — architectural advantages don't fully transfer once both paths run through the same host interpreter
  • Verified: a 256-slot ceiling applies to simultaneously-live locals too — same one-byte-operand cause as Chapter 2's constant-pool ceiling, different resource
  • Next chapter: Control Flow & Jumps in Bytecode — compiling if/while using instructions that overwrite their own operand byte after the fact