Skip to content
Lesson 21 of 38

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:

  1. code — a flat array of bytes: opcodes and their operands, all mixed together in the order they should execute.
  2. 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.
  3. lines — one entry per byte in code, recording which source line produced it, so runtime errors can still report a line number even though all the AST structure is gone.
python

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:

python

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:

python

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.
Up nextThe Stack-Based VMBytecode Virtual Machine

Discussion

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

Sign in to post a comment or reply.

Loading…