Topic 04

Interpreters and Virtual Machines

Languages

A checked syntax tree is still only a description of a search. Something has to run it: fetch the records by Le Guin, fetch the records after 1970 and intersect the two. There are two ways to do that. Walk the tree directly, doing each node's work as you reach it, or first flatten the tree into a list of simple instructions and run them on a small virtual machine.

CPython runs every Python program the second way. Lantern should run its queries the first way. What each approach costs depends less on the machinery than on how many times the same code runs, and on how much work each step does compared with the cost of deciding what the step is.

Walking the Tree

A tree-walking interpreter is one recursive function. A field-match node returns the postings list for its term, from the hash-table index of Chapter 5. A comparison node returns the records whose year passes. An AND node evaluates both children and intersects the two sorted lists, the walk Chapter 7 describes. OR takes the union, and NOT takes everything in the catalogue that its child did not return.

Lantern's evaluator, excerpted: one case per kind of node
def evaluate(node, index):
    match node:
        case Match(field, value):
            return index.lookup(field, value)
        case Compare(field, op, value):
            return index.years(op, int(value))
        case And(left, right):
            return intersect(evaluate(left, index), evaluate(right, index))
        case Or(left, right):
            return union(evaluate(left, index), evaluate(right, index))
        case Not(child):
            return sorted(set(index.all_ids) - set(evaluate(child, index)))

Each case in the excerpt matches one kind of node and returns a sorted list of record ids. The leaves ask the index; the operators call evaluate on their children and combine the lists. For a small language that runs each tree once, this shape is natural, short and correct, and it is the right one for Lantern.

What the Walk Costs Lantern

The interpreter's own overhead for the canonical query is a handful of function calls. The real cost is the data. The author match returns a few dozen records. The year comparison returns most of the 2 million, and a naive walker builds that whole list before intersecting it with the few dozen. A query that is a bare NOT is worse, since it returns nearly the whole catalogue.

What each node of the canonical tree returns, to scale
each node's result, drawn to the scale of the 2,000,000-record catalogueANDa few dozen: the answerauthor : "le guin"a few dozenyear > 1970most of the cataloguethe whole catalogue: 2,000,000 records

On the scale of the catalogue, the author list and the answer are slivers, and the year list crosses most of the figure. That is where a search spends its time. Interpretation overhead matters in proportion to how many times the code runs against how much work each step does: Lantern runs each tree once per search and each step touches thousands of records, while a loop body in a Python program can run a million times doing one addition each time.

Compiling to Bytecode

The alternative flattens the tree first. Emit each node after its children, a post-order traversal from Chapter 6, and the result is a list of instructions for a stack machine. For the canonical query Lantern's compiler emits three: MATCH author "le guin", COMPARE year greater than 1970, and AND.

The canonical query as three instructions, and the operand stack after each
instructions, in post-orderoperand stack after each1MATCH author "le guin"Le Guin's records2COMPARE year > 1970Le Guin's recordsrecords after 19703ANDLe Guin after 1970left = bottom of the stack; AND pops two lists, pushes their intersection

A dispatch loop runs the list: read one instruction, perform it on an operand stack, move to the next. MATCH pushes Le Guin's records. COMPARE pushes the records after 1970. AND pops both lists and pushes their intersection, which is the answer. This is the fetch, decode and execute cycle of Chapter 3, running in software.

This operand stack is not the call stack from Chapter 3. The call stack holds one frame per active function. In CPython each frame carries its own small operand stack, where the instructions of that one function push and pop their values.

Why Bytecode Beats a Tree

A bytecode interpreter runs faster than a tree walker for code that repeats. The instructions sit in one contiguous array, friendly to the caches of Chapter 4. The work of deciding what each node means was done once, at compile time, instead of again on every run by matching on the node. There is no recursive call per node, and no pointer to chase from a parent to its children.

Bytecode costs a compile step and a second representation to keep correct. Stack machines, such as CPython, the Java virtual machine and WebAssembly, use small, simple instructions that take their operands from the stack. Register machines, such as Lua since version 5.0 and Android's original Dalvik, use fewer, larger instructions that name their operands, so they run fewer instructions for the same work.

CPython's Bytecode

Every Python program is compiled before it runs: source to tokens, tokens to a tree, the tree to bytecode in a code object. The bytecode is cached in a __pycache__ directory as a .pyc file, so the next import skips the whole front end. The standard library's dis module prints bytecode, and it is the one tool this chapter names.

count += 1 inside a function, as dis prints it on Python 3.15 (count is a global)
LOAD_GLOBAL              0 (count)
LOAD_SMALL_INT           1
BINARY_OP               13 (+=)
STORE_GLOBAL             0 (count)

On Python 3.15 the line compiles to four instructions: load the global count, load the small integer 1, add in place and store the result back into the global. That is the read, the add and the write from the race conditions topic in Chapter 11, with the constant loaded on its own. The instruction set changes between Python versions, and 3.15 has instructions that 3.12 did not, which is why every .pyc file begins with a version stamp and a .pyc from another version is recompiled from source.

The Interpretation Tax

Each bytecode instruction pays for dispatch and for checking its operands' types before doing its work. One Python-level addition therefore costs dozens to hundreds of machine instructions where compiled code would spend one. A Python loop pays that price on every step, which explains a measurable gap. On Python 3.15, summing a list of a million integers with the built-in sum ran about eight times faster than a hand-written loop over the same list, because the loop inside sum runs in C and pays one dispatch per call instead of several per element.

The same tax is why numeric libraries such as NumPy exist: they move whole loops out of the interpreter. The next two topics are the two general ways to cut it: compile to machine code before the program runs, or while it runs.

Language VM vs System VM

A language virtual machine, such as CPython's, the JVM or a WebAssembly runtime, is an ordinary program that executes an invented instruction set inside one process. Its "machine" exists only as the dispatch loop.

A system virtual machine, such as a cloud VM or a hypervisor guest, presents real or emulated hardware so that a whole operating system can run on it. Same word, different layer: the first sits above the operating system, the second below it.

Misconceptions
  • "Python is interpreted, so it is never compiled." CPython compiles every module to bytecode before running a line of it. What it does not do by default is compile to machine code.
  • "A .pyc file is compiled Python, so it runs faster." It saves the lexing, parsing and compiling at import. The bytecode inside is identical and executes at the same speed, so a long-running program gains nothing after start-up.
  • "The interpreter reads the source one line at a time as it runs." The whole file is parsed and compiled first. A syntax error on line 500 stops the program before line 1 runs.
  • "Interpretation overhead is why an interpreted service is slow." For Lantern the time is in the postings lists, not the dispatch loop. Overhead dominates only where tiny operations repeat millions of times.
  • "Bytecode is portable across Python versions." The instruction set changes in most minor releases. A .pyc from one version is rejected by another and recompiled from source.
Why It Matters
  • Use a tree-walking interpreter for a small language evaluated once per request. Compile to bytecode only when the same code runs many times.
  • Measure where interpreted code spends its time before blaming the interpreter. The data a step touches usually outweighs the step.
  • Move hot loops into built-ins or vectorized libraries. One dispatch per call replaces one per element.
  • Evaluate the more selective side of an AND first. An intersection's cost is driven by the smaller list, and the order of AND's children does not change its result.
RelatedThe instruction cycle the hardware loop a VM imitates in software (Chapter 3)Compilers translate further, to machine code, before running (next topic)Database query plans SQL compiled to a plan tree and interpreted, in PostgreSQL Deep Dive's planner chapter

Knowledge Check

Would compiling Lantern's queries to bytecode instead of walking the tree noticeably speed up searches?

  • Yes, by several times, since bytecode always beats a tree walker
  • No, since the time goes to reading postings lists, not to dispatching instructions
  • Yes, since the compile step would cache the results of each query
  • No, since bytecode cannot express AND, OR and NOT operations

A tree has OR at the root, with an AND of a and b on the left and c on the right. What is its post-order instruction list?

  • OR, AND, a, b, c
  • a, AND, b, OR, c
  • a, b, AND, c, OR
  • a, b, c, AND, OR

A long-running web service is deployed with its .pyc files already built. How much faster does it run after start-up?

  • About twice as fast, since all of the code is already compiled
  • Somewhat faster, since bytecode loaded from a .pyc is more optimized
  • Slower, since loading from .pyc files adds a check on every function call
  • No faster at all; only the import step at start-up is quicker

A Python file has a syntax error on line 500. What happens when it is run?

  • Lines 1 to 499 run, and the program stops at line 500
  • Nothing runs; the error is reported before line 1 executes
  • Lines 1 to 499 run, and line 500 is skipped with a warning
  • Only functions defined before line 500 can be called

Why does sum(numbers) beat a hand-written Python loop over the same list?

  • The built-in sum uses a cleverer algorithm than adding the numbers in order
  • The built-in sum splits the list and runs on several cores at the same time
  • The loop version copies the list before adding it up
  • Its loop runs in C, paying one dispatch per call, not several per element

You got correct