CONCEPT Cited by 1 source
Callback-slice interpreter¶
A callback-slice interpreter is a bytecode-VM design that
doesn't emit bytecode at all. The compiler emits a slice of
function pointers (or closures) — one per instruction — and
the VM's main loop just walks that slice, invoking each
callback in sequence. Each callback returns an offset telling
the VM how to advance its instruction pointer (1 for
sequential, positive/negative for control flow).
The design was canonicalised for Go by Vicent Martí in the 2025 Vitess evalengine rewrite (Source: sources/2025-04-05-planetscale-faster-interpreters-in-go-catching-up-with-cpp).
The VM¶
func (vm *VirtualMachine) execute(p *Program) (eval, error) {
code := p.code
ip := 0
for ip < len(code) {
ip += code[ip](vm)
if vm.err != nil {
return nil, vm.err
}
}
if vm.sp == 0 {
return nil, nil
}
return vm.stack[vm.sp-1], nil
}
That's the whole VM. No switch. No opcode decode. Each instruction runs itself.
The compiler¶
The compiler emits each instruction as a Go closure pushed
onto a []func(*VirtualMachine) int slice:
func (c *compiler) emitPushNull() {
c.emit(func(vm *VirtualMachine) int {
vm.stack[vm.sp] = nil
vm.sp++
return 1
})
}
Instruction arguments are captured in closure state by the Go compiler — no bytecode encoding, no argument decoding layer:
func (c *compiler) emitPushColumn_text(offset int, col collations.TypedCollation) {
c.emit(func(vm *VirtualMachine) int {
vm.stack[vm.sp] = newEvalText(vm.row[offset].Raw(), col)
vm.sp++
return 1
})
}
Both offset (an int) and col (a collation struct) are
baked into the generated instruction via closure capture.
Why this design works in Go specifically¶
The four properties that make Go an excellent target:
- Closures are cheap and first-class. Go's closure calling convention is efficient; the closure object is typically a stack-allocated pair of function pointer + captured state pointer.
- Slices give linear dispatch without a switch. The VM
loop's single indirect call through
code[ip](vm)is friendlier to Go's branch predictor than a largeswitch. - No bytecode encoding to maintain. The compiler-VM-in-sync discipline of traditional bytecode VMs evaporates. "Developing the compiler means developing the VM simultaneously."
- Tail-call loops don't work in Go, so callback-slice is the alternative that retains most of their benefits without needing guaranteed tail calls.
Novelty¶
Martí notes the design isn't strictly novel — he's seen it used before "for a rules-based authorization engine in the wild" — but as far as he can find it's never been used in Go. The callback-slice interpreter is a good fit for Go's strengths (closures, slice dispatch) that sidesteps Go's weaknesses (unreliable tail calls, fiddly switch jump tables, bad optimization of massive functions).
Properties¶
- Dispatch cost. One indirect call per opcode; low to medium — comparable to a well-optimised C big-switch VM, better than a Go big-switch VM that might be compiled to binary search.
- Memory. Closure allocations happen at compile time (once per program), not at execution time. Evaluation itself can be zero-allocation — Vitess evalengine allocates zero memory on 4/5 benchmarks.
- Instruction arguments. Free — closures capture anything of any complexity, no encoding needed.
- Control flow. Each callback returns its own offset:
1for sequential,+Nto jump forward,-Nto jump back. Loops and conditionals implemented naturally. - Debuggability. Harder than traditional bytecode — you can't dump the program as a byte stream. Better than AST interpretation — the program is a linear slice.
- Hot-path inlining. Each closure is a separate function the Go compiler can inline or not independently; no giant-function register spillage.
Relation to [static¶
type specialization](<./static-type-specialization.md>)
The callback-slice design composes beautifully with static
specialization: each specialized opcode is a separate
emit*_<type> function that emits a closure already wired for
the specific operand types. No runtime type dispatching inside
the closure; no bytecode-level type annotations; just typed
closures.
Relation to deoptimization¶
A callback-slice closure can bail out by setting
vm.err = errDeoptimize and returning 1; the VM loop
notices the error and passes control to the AST interpreter.
func (c *compiler) emitNeg_i() {
c.emit(func(vm *VirtualMachine) int {
arg := vm.stack[env.vm.sp-1].(*evalInt64)
if arg.i == math.MinInt64 {
vm.err = errDeoptimize
} else {
arg.i = -arg.i
}
return 1
})
}
Seen in¶
- sources/2025-04-05-planetscale-faster-interpreters-in-go-catching-up-with-cpp — canonical wiki introduction of the technique. Vitess's evalengine VM uses callback-slice plus static type specialization plus VM→AST deoptimization to catch up with MySQL's C++ expression engine on a Go runtime.
Related¶
- concepts/bytecode-virtual-machine
- concepts/ast-interpreter
- concepts/tail-call-continuation-interpreter
- concepts/static-type-specialization
- concepts/instruction-dispatch-cost
- concepts/go-compiler-optimization-gap
- concepts/vm-deoptimization
- concepts/jit-compilation
- systems/vitess-evalengine
- patterns/callback-slice-vm-go