Execution Model & Compilation¶
Overview¶
Understanding Python's execution model reveals how your code runs:
- Parsing: Source code → Parse tree
- AST: Abstract Syntax Tree representation
- Compilation: AST → Bytecode
- Execution: Bytecode → Machine instructions
- Optimization: How Python optimizes at each stage
-
The Python Execution Pipeline¶
Complete Pipeline¶
.py file
↓ (Read)
Source code (text)
↓ (Parse)
Parse tree
↓ (Transform)
AST (Abstract Syntax Tree)
↓ (Compile)
Code object (bytecode + metadata)
↓ (Cache to.pyc)
Bytecode file
↓ (Load)
Python VM (interpreter)
↓ (Execute)
Machine instructions (CPU/GPU)
Parsing: Text to AST¶
Using the ast Module¶
import ast
import inspect
source_code = """
def add(a, b):
return a + b
"""
# Parse to AST
tree = ast.parse(source_code)
# Print AST structure
print(ast.dump(tree, indent=2))
# Output structure:
# Module(
# body=[
# FunctionDef(
# name='add',
# args=arguments(posonlyargs=[], args=[arg(arg='a'), arg(arg='b')]),
# body=[
# Return(value=BinOp(left=Name(id='a'), op=Add(), right=Name(id='b')))
#]
#)
#]
#)
Walking the AST¶
import ast
class Visitor(ast.NodeVisitor):
"""Custom AST visitor."""
def visit_FunctionDef(self, node):
print(f"Function: {node.name}")
print(f"Args: {[arg.arg for arg in node.args.args]}")
self.generic_visit(node)
def visit_BinOp(self, node):
print(f"Binary operation: {node.op.__class__.__name__}")
self.generic_visit(node)
code = """
def multiply(a, b):
return a * b
"""
tree = ast.parse(code)
Visitor().visit(tree)
# Output:
# Function
# Args
# Binary operation
AST Transformation¶
Modifying Code Before Compilation¶
import ast
import inspect
class DebugTransformer(ast.NodeTransformer):
"""Add print statements for debugging."""
def visit_FunctionDef(self, node):
# Add debug print at start of function
debug_print = ast.Expr(
value=ast.Call(
func=ast.Name(id='print', ctx=ast.Load()),
args=[ast.Constant(value=f'Entering {node.name}')],
keywords=[]
)
)
# Insert at beginning of function body
node.body.insert(0, debug_print)
return node
# Original function
def add(a, b):
return a + b
# Transform AST
tree = ast.parse(inspect.getsource(add))
transformer = DebugTransformer()
new_tree = transformer.visit(tree)
# Compile and execute
code = compile(new_tree, filename='<ast>', mode='exec')
exec(code)
# Now calling add() will print debug message
add(3, 5) # Output: "Entering add" then result
Frame Objects and Execution Context¶
Understanding Frames¶
import sys
import inspect
def show_frame_info():
"""Inspect current execution frame."""
frame = sys._getframe()
print(f"Frame info:")
print(f" Function: {frame.f_code.co_name}")
print(f" Line: {frame.f_lineno}")
print(f" Locals: {frame.f_locals}")
print(f" File: {frame.f_code.co_filename}")
# Walk up the call stack
print(f"\nCall stack:")
i = 0
f = frame
while f:
print(f" {i}: {f.f_code.co_name} (line {f.f_lineno})")
f = f.f_back
i += 1
def outer():
x = 10
def inner():
y = 20
show_frame_info()
inner()
outer()
Frame Locals vs Globals¶
import sys
global_var = "global"
def show_scope():
local_var = "local"
frame = sys._getframe()
print(f"Locals: {frame.f_locals}")
# {'local_var': 'local', 'frame': <frame object>}
print(f"Globals (sample): {list(frame.f_globals.keys())[:5]}")
# Keys like '__name__', '__doc__', 'global_var', etc.
show_scope()
-
Exception Handling in Bytecode¶
How Exceptions Flow¶
import dis
def with_exception():
"""Show exception handling bytecode."""
try:
x = 1 / 0 # Raises ZeroDivisionError
except ZeroDivisionError:
return "error"
finally:
print("cleanup")
dis.dis(with_exception)
# Key instructions:
# SETUP_FINALLY # Set up exception handler
# LOAD_CONST 1 (1) # Load 1
# LOAD_CONST 2 (0) # Load 0
# BINARY_TRUE_DIVIDE # Divide (raises exception)
#
# If exception:
# POP_TOP # Pop exception
# LOAD_CONST 3 ("error") # Load return value
# RETURN_VALUE
#
# Finally:
# SETUP_CLEANUP # Ensure cleanup runs
# LOAD_GLOBAL print
# LOAD_CONST 4 ("cleanup")
# CALL_FUNCTION 1
Code Compilation Phases¶
Phase 1: AST Optimization (Python 3.8+)¶
import ast
import sys
def optimize_ast(tree):
"""Python optimizes AST before bytecode."""
# Example optimizations:
# - Constant folding: 1 + 2 → 3
# - Dead code elimination
# - Jump threading
source = "x = 1 + 2"
tree = ast.parse(source)
print("Before optimization:")
print(ast.dump(tree))
# Python optimizes during compilation
code = compile(tree, '<string>', 'exec')
print(f"\nAfter compilation:")
print(f"Constants: {code.co_consts}") # Should be (None, 3) - optimized!
Phase 2: Bytecode Generation¶
import dis
def example():
# Constant folding: 1 + 2 is optimized to 3
x = 1 + 2
return x
dis.dis(example)
# Result:
# LOAD_CONST 1 (3) # Optimized! Not 1 + 2
# STORE_FAST 0 (x)
# LOAD_FAST 0 (x)
# RETURN_VALUE
-
Dynamic Code Execution¶
Using eval and exec¶
# eval
x = 5
result = eval("x + 10")
print(result) # 15
# exec
code = """
def greet(name):
return f"Hello, {name}!"
"""
exec(code)
print(greet("World")) # "Hello, World!"
# Providing custom namespace
namespace = {}
exec("x = 100; y = 200", namespace)
print(namespace['x']) # 100
Dangers of eval/exec¶
# DANGEROUS
user_input = "__import__('os').system('rm -rf /')" # DANGER!
# eval(user_input) # Would execute!
# SAFE
safe_namespace = {
'__builtins__': {}, # Remove dangerous builtins
}
user_input = "2 + 2"
result = eval(user_input, {"__builtins__": {}}, {})
print(result) # 4
Real-World: How PyTorch Uses Bytecode¶
PyTorch Operator Overloading¶
import torch
# PyTorch intercepts bytecode operators
class Tensor:
def __init__(self, data):
self.data = data
def __add__(self, other):
"""Intercepts BINARY_ADD bytecode."""
print(f"Custom add called!")
return Tensor(self.data + other.data)
def __mul__(self, other):
"""Intercepts BINARY_MULTIPLY bytecode."""
print(f"Custom mul called!")
return Tensor(self.data * other.data)
# When you write
# Python translates to
# PyTorch can run CUDA kernels inside __add__
t1 = Tensor([1, 2, 3])
t2 = Tensor([4, 5, 6])
result = t1 + t2 # Calls __add__, runs CUDA kernel!
Performance Implications¶
Execution Speed by Operation¶
import timeit
# Local variable access (fastest)
code1 = """
x = 5
y = 10
z = x + y
"""
# Global variable access (slower)
code2 = """
y = 10
z = x + y # x is global, requires lookup
"""
# Function call (slower still)
code3 = """
z = sum([1, 2, 3]) # Function call overhead
"""
t1 = timeit.timeit(code1, repeat=1000)
t2 = timeit.timeit(code2, globals={'x': 5}, repeat=1000)
t3 = timeit.timeit(code3, repeat=1000)
print(f"Local access: {t1:.3f}")
print(f"Global access: {t2:.3f}")
print(f"Function call: {t3:.3f}")
Summary: Execution Pipeline¶
| Stage | Input | Output | Optimization |
|---|---|---|---|
| Parse | Source text | Parse tree | Structure |
| AST Transform | Parse tree | AST | Visitor pattern |
| Optimize AST | AST | Optimized AST | Constant folding |
| Compile | AST | Bytecode | Instruction selection |
| Load | Bytecode | Code object | .pyc caching |
| Execute | Bytecode | Results | JIT, caching |
-
Related Topics¶
- 01 Python Bytecode Fundamentals - Understanding bytecode
- [03 Jit Compilation & Optimization](/05-py3/09-bytecode-and-execution/(03-jit-compilation-optimization/) - Making execution faster
- 02 Decorators - AST-based decorators
- 00 Readme - Memory during execution