Skip to content

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: multiply
# Args: ['a', 'b']
# Binary operation: Mult

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: Evaluate expression
x = 5
result = eval("x + 10")
print(result)  # 15

# exec: Execute statements
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: Never eval untrusted input!
user_input = "__import__('os').system('rm -rf /')"  # DANGER!
# eval(user_input)  # Would execute!

# SAFE: Use restricted namespace
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: x + y
# Python translates to: x.__add__(y)
# 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