Skip to main content

Interpreter Dispatch: Switch, Computed Goto, and Tail Calls

Trace a small virtual machine through branches and loops, then compare switch, computed goto, and tail-call dispatch without changing its bytecode semantics.

A virtual machine has finished an addition. How does it reach the code that handles the next instruction? Step through this loop, then change Dispatch at the same position. Try Initial n = 0 to take the exit immediately.

One program, three dispatch paths
Dispatch
Initial n
Step 1 / 32
0 / 31

Highlight = just executed, before dispatch to the next PC.

sum_program.bytecode
00  PUSH 001  LOAD n02  JUMP_IF_ZERO 1003  LOAD n04  ADD05  LOAD n06  PUSH 107  SUB08  STORE n09  JUMP 110  RETURN

Highlighted line is active

Just executed
none
Next PC
0
Local n
3
Result
pending

Operand stack · top at right

empty

Cases inside one evaluator function

  1. From: VM entry
  2. Loop back to the shared switch
  3. To: PC 0 · PUSH 0
C dispatch pseudocode
switch_dispatch.c
for (;;) {  switch (code[pc].tag) {    case PUSH: execute_and_advance(); break;    /* other opcode cases omitted */  }}

Highlighted line is active

Ready. No opcode has executed. Entry dispatch selects PUSH at PC 0.

Authored VM trace and schematic C, not CPython bytecode or compiled measurements. A step counts one VM opcode, not time or native calls.

What does dispatch select?

An opcode identifies an operation in the VM's instruction set. An opcode handler is the native code that implements that operation. Dispatch selects a handler for the next opcode and transfers control to it.

The program counter, or PC, identifies an instruction in the bytecode program. The figure uses instruction indices, rather than byte offsets. A handler updates the operand stack or local variables and determines the next PC. Dispatch then uses the opcode at that PC to select native code.

The teaching program computes this sum for a small nonnegative integer:

python
# examples/dispatch_sum.py
def sum_down(n):
    total = 0
    while n != 0:
        total += n
        n -= 1
    return total

The figure's bytecode keeps total on the operand stack between iterations. LOAD n pushes a copy of the local. ADD and SUB pop two values and push one result; subtraction uses the lower operand minus the top operand. STORE n pops a value into the local. JUMP_IF_ZERO pops its condition whether the branch is taken or not. RETURN pops the answer and halts.

These are authored VM instructions, not the output of Python's dis module. Real CPython bytecode includes version-specific details such as specialization and inline caches. Python's bytecode documentation

Two control-flow decisions

The bytecode program decides which instruction comes next. Dispatch decides which native handler implements that instruction.

At PC 2, JUMP_IF_ZERO 10 chooses PC 10 when the popped condition is zero and PC 3 otherwise. At PC 9, JUMP 1 returns to the loop test. Neither decision depends on whether handlers are connected by a switch, a computed goto, or tail calls.

Watch the two PC fields in the explorer. Just executed names the highlighted instruction whose effects are already visible on the stack. Next PC names the instruction selected afterward. For n = 3, all views execute 31 VM opcodes and return 6. For n = 0, all execute 4 and return 0. Equal opcode counts establish equal work in this model; they provide no timing comparison.

Three ways to connect handlers

Switch dispatch

A portable C evaluator can put the handlers in a loop around a switch. A case performs an operation, sets the next PC, and returns control to the common selection point. The evaluator stays active across the run; dispatch does not require a recursive C call for every opcode.

A compiler may lower a dense switch to a jump table. Writing switch therefore does not imply a chain of comparisons. The resulting branches and layout depend on the compiler and target. CPython keeps a switch dispatch path in its evaluator macros. CPython 3.14 dispatch definitions

Computed goto

With the GNU labels-as-values extension, handlers can be labels inside one evaluator function. A table maps opcodes to label addresses; goto *targets[opcode] jumps to the selected label. The table contains code addresses, not operand-stack values. GCC restricts this mechanism to labels in the same function. GCC's labels-as-values documentation

Each handler can end with its own dispatch site. This gives the compiler a different control-flow graph from a common switch selection point. Whether the final machine code keeps distinct sites, and whether that helps branch prediction on a workload, must be checked in the generated code and measurements. The source spelling alone cannot settle it.

Tail-called handlers

Another design makes each handler a separate C function with a compatible signature. A handler forwards the VM state to the next handler in tail position:

C++
// examples/tail_dispatch.c — schematic handler boundary
Result HandleAdd(State state) {
    ExecuteAddAndAdvance(&state);
    [[clang::musttail]] return handlers[code[state.pc].tag](state);
}

The handler has no work left after the transfer. A guaranteed tail call allows the next handler to replace its native activation instead of retaining a growing chain of waiting callers. A terminal handler returns the result to the VM's caller. Ordinary function calls between every handler would need an equivalent guarantee or a different execution strategy to run indefinitely with bounded dispatch-stack usage. Clang's musttail contract

Computed goto already avoids a native call chain per opcode. Tail-called handlers change how the compiler organizes the evaluator into functions and passes state between them. They are not the first way to keep opcode dispatch from building a native stack tower.

Keep three kinds of state separate

StateWhat it storesWhat moving to another opcode means
Native C activationA running evaluator or handler's native execution stateSwitch and computed goto stay within the evaluator; guaranteed tail calls replace the dispatch handler's activation
Interpreter frameOne interpreted function call's execution state, including localsAn ordinary opcode transition does not itself create another Python function call
Operand stackTemporary values used by instructions within a frameA handler may push or pop values without adding a function call

In the explorer, three operand values can be live during subtraction. They are three numbers inside one VM invocation. The figure does not count native activations, and it does not model calls between Python functions.

This separation also explains why changing dispatch cannot, by itself, eliminate Python recursion. Python calls and returns still have their own frame semantics. CPython's release documentation explicitly distinguishes its tail-call interpreter from tail-call optimization of Python functions. Python 3.14's interpreter distinction

How CPython 3.14 uses this design

CPython 3.14 introduced an opt-in interpreter with separate C opcode functions. Its TARGET macro defines handlers with the preserve_none calling convention, and its dispatch macro uses musttail when calling the next handler. The common arguments include the interpreter frame, operand-stack pointer, thread state, instruction pointer, and instruction argument. Versioned handler and dispatch macros

The two compiler features solve different problems. musttail requires a tail transfer at the return statement, including when ordinary optimization is disabled. It has signature, calling-convention and lifetime restrictions. preserve_none adjusts the calling convention to preserve fewer general registers and use more for arguments. Its ABI is compiler-specific and unstable; it is not a Python extension interface. Clang's musttail restrictions, Clang's preserve_none documentation

For Python 3.14, --with-tail-call-interp selects this implementation while building CPython. The documented requirements include appropriate tail-call and preserve_none support; the release notes describe Clang 19 or newer on x86-64 and AArch64. This is a source-build choice, not an option passed to a Python script. CPython configure option, 3.14 release notes

What would establish a real improvement?

Start with the generated code: did the compiler emit the intended indirect branches or guaranteed tail transfers, and how did it arrange the handlers? Then compare representative workloads using the same VM revision, toolchain, optimization settings and profiling setup. Measure wall time and relevant hardware counters across repeated runs.

The explorer can establish its next-PC sequence, stack effects, termination and result. It cannot establish native instruction count, branch misses, register spills or execution time. Specialization, fused instructions, error handling and tracing are deliberately outside its model.

Dispatch is one application of explicit control transfer. Continuation-passing style makes the next computation explicit in function arguments. Trampolines let an outer loop run explicitly returned next steps. Those pages examine where the remaining work lives; this page examines how a VM reaches its next opcode handler.

If you found this explanation helpful, consider sharing it with others.

Mastodon