CONCEPT Cited by 1 source
Bytecode virtual machine¶
A bytecode virtual machine (bytecode VM) is a dynamic-language execution strategy that sits between AST interpreters and JIT compilers on the complexity/performance spectrum:
- A compiler walks the concepts/abstract-syntax-tree and emits bytecode — a compact binary encoding of higher-level instructions for a "virtual CPU".
- A VM decodes each instruction and dispatches to the corresponding operation. Control flow (loops, branches, function calls) is modelled by moving an instruction pointer along the bytecode stream.
Why bytecode VMs exist¶
AST interpreters are easy to write but slow: recursive tree walks spill registers on every node, and operand types must be checked at every recursive level. Bytecode VMs amortise this by executing linearly — the instruction pointer advances through a flat stream, each opcode handler is small enough to stay in cache, and the dispatch loop can be tightly optimised.
"A lot of it boils down to instruction dispatching, which can be made very fast." (Source: sources/2025-04-05-planetscale-faster-interpreters-in-go-catching-up-with-cpp)
The canonical ecosystem examples:
- Ruby MRI → YARV. Matz's original Ruby interpreter was a tree walker; YARV replaced it with a bytecode VM for a large speedup.
- CPython. Compiled to
.pycbytecode from the beginning. - Every modern JavaScript engine. V8, SpiderMonkey, and JavaScriptCore all compile to bytecode as the starting point; JIT layers (Maglev, TurboFan, Ion, FTL) kick in on hot paths.
Canonical implementation: the big-switch loop¶
The textbook VM loop is a while over instructions with a
switch on opcode type:
while (ip < code.length) {
switch (code[ip].opcode) {
case ADD: /* ... */ break;
case PUSH: /* ... */ break;
// ... dozens to hundreds of opcodes
}
ip++;
}
In C/C++ this is fast — compilers generate a jump table from the switch and dispatch is a single indirect jump.
Where big-switch fails¶
LuaJIT's Mike Pall catalogued the big-switch failure modes in a 2011 lua-users post:
- Compilers struggle with massive functions. Hundreds of case branches inside one function confuse register allocators; hot and cold branches get spilled equally.
- VM and compiler must stay in lockstep. Any mismatch between the bytecode emitter and the VM's decoder is a latent correctness bug.
In Go specifically the problems are "much worse" — Go's compiler often emits binary-search dispatch instead of a jump table, and optimization for large functions is weak. See concepts/jump-table-vs-binary-search-dispatch.
Alternatives that avoid the big switch¶
- Tail-call
continuation loops (C/C++, Python 3.14) — each opcode is
a free-standing function; the return is a callback to the
next step; LLVM
musttailforces tail conversion so dispatch becomes a jump, not a call. Doesn't work reliably in Go. - Callback-slice interpreters (Vitess, 2025) — don't emit bytecode at all; emit a slice of closures. Works in Go; exploits Go closures to capture instruction arguments without a bytecode encoding.
Bytecode-less VMs¶
A subtlety worth noting: the
Vitess evalengine VM is technically
a VM without bytecode — it compiles to []func(*VirtualMachine) int
rather than a byte stream. The design inherits bytecode VM
performance properties (linear dispatch, no per-op tree walk) but
drops the encode/decode layer. See
concepts/callback-slice-interpreter.
Seen in¶
- sources/2025-04-05-planetscale-faster-interpreters-in-go-catching-up-with-cpp — canonical taxonomy of AST / bytecode-VM / JIT with explicit dispatch-overhead analysis. Vitess's evalengine VM sits at the bytecode-VM performance point while avoiding the big-switch pitfall of Go.
Related¶
- concepts/ast-interpreter
- concepts/jit-compilation
- concepts/instruction-dispatch-cost
- concepts/callback-slice-interpreter
- concepts/static-type-specialization
- concepts/quickening-runtime-bytecode-rewrite
- concepts/tail-call-continuation-interpreter
- concepts/abstract-syntax-tree
- systems/vitess-evalengine