Skip to content

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:

  1. 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.
  2. 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 large switch.
  3. No bytecode encoding to maintain. The compiler-VM-in-sync discipline of traditional bytecode VMs evaporates. "Developing the compiler means developing the VM simultaneously."
  4. 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: 1 for sequential, +N to jump forward, -N to 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

Last updated · 319 distilled / 1,201 read