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.
Highlight = just executed, before dispatch to the next PC.
00 PUSH 001 LOAD n02 JUMP_IF_ZERO 1003 LOAD n04 ADD05 LOAD n06 PUSH 107 SUB08 STORE n09 JUMP 110 RETURNHighlighted line is active
- Just executed
- none
- Next PC
- 0
- Local n
- 3
- Result
- pending
Operand stack · top at right
Cases inside one evaluator function
- From: VM entry
- Loop back to the shared switch
- To: PC 0 · PUSH 0
C dispatch pseudocode
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:
# 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:
// 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
| State | What it stores | What moving to another opcode means |
|---|---|---|
| Native C activation | A running evaluator or handler's native execution state | Switch and computed goto stay within the evaluator; guaranteed tail calls replace the dispatch handler's activation |
| Interpreter frame | One interpreted function call's execution state, including locals | An ordinary opcode transition does not itself create another Python function call |
| Operand stack | Temporary values used by instructions within a frame | A 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.
Related concepts
Follow direct-style and CPS factorial through the same calculation. See how closures carry pending work, why Python still grows call depth, and where trampolines fit.
See how tail calls reuse stack frames, when compilers can eliminate them, and how CPython 3.14 applies tail calls to C opcode dispatch.
Step through mutual recursion and a Call/Done trampoline to see deferred work, bounded worker depth, base cases, and the allocation tradeoff.
Deep dive into Python's asyncio library, understanding event loops, coroutines, tasks, and async/await patterns with interactive visualizations.
Explore CPython bytecode compilation from source to .pyc files. Learn the dis module, PVM stack operations, and Python 3.11+ adaptive specialization.
Understand CPython garbage collection: reference counting, generational GC for circular references, weak references, and gc module tuning strategies.
