Step 1 of 3 · Reading · ~3 min
The Bytecode Representation
Bytecode Virtual Machine
Chunks of Bytecode
Everything so far has been a tree-walking interpreter: the interpreter directly recursively evaluates AST nodes every time they run. That's simple to build but slow — every + re-dispatches through your visit_binary method, walks child nodes, does Python-level dynamic type checks, over and over, even inside a tight loop. This chapter starts a second execution engine for your language: compile the AST down to a flat, linear bytecode format first, then run that bytecode in a tight dispatch loop. This lesson builds the data structure that bytecode lives in — the Chunk — before any compiler or VM touches it.
Why bytecode instead of walking the tree
A tree is a pointer-chasing structure — evaluating it means following object references and re-checking node types at every step. Bytecode is a flat array of small integers (opcodes) the CPU (or here, your own interpreter loop) can stream through sequentially, which is dramatically more cache-friendly and removes almost all the dispatch overhead per operation. This is exactly the same reason real language runtimes (the JVM, CPython, Lua) compile to bytecode rather than interpreting a parse tree directly.
The Chunk data structure
A chunk bundles three parallel pieces of data:
code— a flat array of bytes: opcodes and their operands, all mixed together in the order they should execute.constants— a separate pool of actual values (numbers, strings) that opcodes reference by index rather than embedding inline, since values can be larger than one byte.lines— one entry per byte incode, recording which source line produced it, so runtime errors can still report a line number even though all the AST structure is gone.
Emitting instructions
An instruction like "push the constant 3.0" is actually two bytes in code: the opcode OP_CONSTANT, followed by an operand byte holding the index into constants where 3.0 lives:
Instructions with no operand, like OP_RETURN or OP_ADD, are just a single byte — the VM (built next lesson) will know how many bytes to consume based on which opcode it just read.
The disassembler
Because bytecode is just numbers, you need a debugging tool that turns it back into something readable — a disassembler that walks code and prints each instruction:
disassemble_instruction returns the offset of the next instruction, which is why variable-width instructions (OP_CONSTANT + 1-byte operand = 2 bytes total) have to explicitly return offset + 2 instead of always offset + 1. Getting this "how many bytes did this instruction consume" logic right is the detail that trips people up — get it wrong and the disassembler desyncs and starts printing garbage from the middle of an operand as if it were an opcode.
Edge cases to watch
- Line-run compression: naively storing one line number per byte works but wastes memory; a common optimization (not required, but worth knowing) is run-length-encoding consecutive same-line entries.
- Constant pool growth: unlike opcodes, constants aren't bounded to one byte's worth of values in a real implementation — worth thinking about what happens once you exceed 256 constants, even if your version doesn't handle it yet.
Discussion
Ask a question, share an insight, or help someone who’s stuck.
Sign in to post a comment or reply.
Loading…