CPython VM, Bytecode, and Code Execution
CPython VM, Bytecode, and Code Execution
After reading this chapter, you will master CPython’s internal compilation pipeline, disassemble Python functions into bytecode using the dis module, analyze frame evaluation stacks, understand modern specialization opcodes (PEP 659 adaptive interpreter), and trace how CPython executes instructions.
Mental model
Python is neither a pure interpreter nor a pure machine-code compiler. It is a bytecode-compiled stack virtual machine:
CPython Execution Pipeline:
Python Source Code ("result = a + b")
│
▼ 1. Tokenizer & Parser
Abstract Syntax Tree (AST)
│
▼ 2. Bytecode Compiler
Code Object (PyCodeObject: co_code, co_consts, co_varnames)
│
▼ 3. Evaluation Loop (ceval.c / bytecodes.c)
Allocates PyFrameObject on C call stack:
┌───────────────────────────┐
│ Value Stack (LIFO stack) │ ──▶ PUSH a ──▶ PUSH b ──▶ BINARY_OP (+) ──▶ STORE result
│ Fast Local Array (locals) │
└───────────────────────────┘
The CPython virtual machine evaluates bytecode instructions by pushing operands onto and popping operands off an in-memory evaluation value stack.
Minimal example
Save as disassemble_bytecode.py:
# disassemble_bytecode.py
import dis
def calculate_tax(amount: float, rate: float = 0.08) -> float:
total = amount * (1.0 + rate)
return total
def main() -> None:
print("--- Disassembling calculate_tax function ---")
dis.dis(calculate_tax)
code_obj = calculate_tax.__code__
print("\n--- Code Object Introspection ---")
print(f"Argument count : {code_obj.co_argcount}")
print(f"Local variables : {code_obj.co_varnames}")
print(f"Constants table : {code_obj.co_consts}")
print(f"Stack size required: {code_obj.co_stacksize}")
if __name__ == "__main__":
main()Run via uv run python disassemble_bytecode.py:
--- Disassembling calculate_tax function ---
... LOAD_FAST 0 (amount)
... LOAD_CONST 1 (1.0)
... LOAD_FAST 1 (rate)
... BINARY_OP 0 (+)
... BINARY_OP 5 (*)
... STORE_FAST 2 (total)
... LOAD_FAST 2 (total)
... RETURN_VALUE
--- Code Object Introspection ---
Argument count : 2
Local variables : ('amount', 'rate', 'total')
Constants table : (None, 1.0)
Stack size required: 3
Worked examples
Case 1: The Adaptive Specializing Interpreter (PEP 659)
Modern CPython versions (3.11+) feature a specializing, adaptive interpreter. When an instruction (like BINARY_OP or LOAD_ATTR) executes repeatedly with the exact same types, CPython dynamically swaps the generic opcode for a type-specialized opcode (e.g. BINARY_OP_ADD_INT):
# adaptive_specialization.py
import dis
def add_integers(a: int, b: int) -> int:
return a + b
def main() -> None:
# 1. Warm up the function to trigger adaptive specialization in the VM
for i in range(10_000):
add_integers(i, 1)
print("Disassembly after adaptive specialization:")
# adaptive=True reveals specialized opcodes
dis.dis(add_integers, adaptive=True)
if __name__ == "__main__":
main()Run:
uv run python adaptive_specialization.pyOutput:
Disassembly after adaptive specialization:
... LOAD_FAST 0 (a)
... LOAD_FAST 1 (b)
... BINARY_OP_ADD_INT 0 (+)
... RETURN_VALUE
Notice that the generic BINARY_OP was dynamically replaced by the CPython VM with BINARY_OP_ADD_INT, skipping generic type checks and achieving near-C integer addition speeds.
Case 2: Inspecting Call Stack Frames with sys._getframe()
Each function call allocates an in-memory frame (PyFrameObject) tracking execution state:
# frame_inspector.py
import sys
def deep_worker(level: int) -> None:
current_frame = sys._getframe(0)
caller_frame = sys._getframe(1)
print(f"Level {level} Frame Info:")
print(f" Current function : {current_frame.f_code.co_name}")
print(f" Current line : {current_frame.f_lineno}")
print(f" Local variables : {current_frame.f_locals}")
print(f" Caller function : {caller_frame.f_code.co_name}")
def orchestrator() -> None:
deep_worker(42)
if __name__ == "__main__":
orchestrator()Run:
uv run python frame_inspector.pyOutput:
Level 42 Frame Info:
Current function : deep_worker
Current line : 6
Local variables : {'level': 42}
Caller function : orchestrator
Pitfalls
Pitfall 1: Modifying Code Objects at Runtime
PyCodeObject instances are immutable C-level structs. Attempting to assign to func.__code__.co_code raises AttributeError: readonly attribute. To modify bytecode dynamically, you must instantiate a new CodeType object via types.CodeType.
Exercises
- Disassemble a list comprehension and compare its bytecode against an equivalent explicit
forloop. - Use
dis.get_instructions()to programmatically scan a function’s bytecode and count the number ofLOAD_GLOBALinstructions. - Compare the bytecode of string concatenation (
a + b) versus an f-string (f"{a}{b}"). - Inspect
__code__.co_flagsand determine whether a function is a standard function, a generator, or a coroutine.
Further reading
- PEP 659: Specializing Adaptive Interpreter.
- Python Standard Library:
dismodule documentation. - CPython Source:
Python/ceval.candPython/bytecodes.c.