Frame Objects
Each call to a Python function has an activation record called a frame. Frames contain the dynamic state needed to execute a function.Frame Structure
A frame consists of three conceptual sections:- Specials - Per-frame VM state (globals, code, instruction pointer, etc.)
- Local variables - Arguments, locals, cells, free variables
- Evaluation stack - Operand stack for bytecode execution
Internal Structure
The internal frame structure is_PyInterpreterFrame, defined in Include/internal/pycore_interpframe_structs.h.
Allocation
Python semantics allow frames to outlive the function call, so they cannot be allocated on the C call stack.Per-Thread Stack
Most frames are allocated contiguously in a per-thread stack:- Function:
_PyThreadState_PushFrame()in Python/pystate.c - Fast path:
_PyFrame_PushUnchecked()when space is available - Benefits: Reduced overhead, improved cache locality
Generator/Coroutine Frames
Frames for generators and coroutines are embedded in the generator object:The Specials Section
The specials section contains pointers to:- Globals dict - Module globals
- Builtins dict - Built-in namespace
- Locals dict - For eval/class (not “fast” locals)
- Code object - The
PyCodeObjectbeing executed - Frame object - Heap-allocated
PyFrameObject(if exposed to Python) - Function - The
PyFunctionObject(holds strong refs to globals/builtins)
Storing a reference to the function is cheaper than storing strong references to both globals and builtins separately.
Frame Objects vs. Interpreter Frames
Interpreter Frames
_PyInterpreterFrame is a lightweight internal structure:
- Stack-allocated or embedded in generators
- Not directly visible to Python code
- Used during execution
Frame Objects
PyFrameObject is a full Python object:
- Heap-allocated only when needed
- Exposed via
sys._getframe()or tracebacks - Created lazily from
_PyInterpreterFrame
Lazy Creation
When Python code accesses a frame:- Check if
frame_objfield isNULL - If so, allocate
PyFrameObjectand link it - Store strong reference in
frame_objfield
Frame Object Lifetime
If aPyFrameObject outlives its _PyInterpreterFrame:
- The
_PyInterpreterFrameis copied into thePyFrameObject - Previous frame link is updated
- Evaluation stack must be empty at this point
Generator and Coroutine Frames
Generators (PyGen_Type, PyCoro_Type, PyAsyncGen_Type) have embedded frames:
Frame Ownership
If a frame object for a generator outlives the generator:- Detected by
refcount > 1onframe_obj take_ownership()copies_PyInterpreterFrametoPyFrameObject- Ownership transfers from generator to frame object
Field Names
Many_PyInterpreterFrame fields were copied from Python 3.10’s PyFrameObject, so they retain the f_ prefix:
f_globals- Globals dictf_builtins- Builtins dictf_locals- Locals dict (for eval/class)f_code- Code object
These names may be rationalized in a future version to remove the
f_ prefix.Shim Frames
On entry to_PyEval_EvalFrameDefault(), a shim frame is pushed on the C stack:
- Temporary frame for interpreter entry
- Points to special code with
INTERPRETER_EXITinstruction - Popped when
_PyEval_EvalFrameDefault()returns - Eliminates need for
RETURN_VALUE/YIELD_VALUEto check for entry frame
Base Frame
Each thread state has an embedded base frame that serves as a sentinel:- Located in
_PyThreadStateImpl(internal thread state) ownerfield set toFRAME_OWNED_BY_INTERPRETER- Accessible via
PyThreadState.base_framepointer - Initialized in
new_threadstate()(Python/pystate.c)
Purpose
External profilers can validate complete stack unwinding:- Read
tstate->base_framebefore walking stack - Walk from
tstate->current_framefollowingframe->previous - Stop when
owner == FRAME_OWNED_BY_INTERPRETER - Verify last frame address matches
base_frame - Discard sample if mismatch (incomplete due to race)
Remote Profiling Frame Cache
Thelast_profiled_frame field optimizes remote profilers:
How It Works
- Remote profiler samples call stack
- Writes current frame address to
last_profiled_frame - Eval loop updates pointer to parent when frame returns
- Next sample walks from
current_frameuntil reachinglast_profiled_frame - Frames below are unchanged and retrieved from cache
Update Guard
In_PyEval_FrameClearAndPop():
The Instruction Pointer
Two fields maintain the instruction pointer:instr_ptr
Points to the current/next instruction:
- Executing frame: Current instruction
- Suspended frame: Next instruction on resume
- After
f_linenoset: Next instruction to execute - During call: Call instruction (for tracebacks)
return_offset
Where to return in the caller:
- Offset relative to caller’s
instr_ptr - Set by call instructions (
CALL,SEND,BINARY_OP_SUBSCR_GETITEM) - Necessary because:
- Applying offset during
RETURNis too early SENDneeds two offsets (one forRETURN, one forYIELD)
- Applying offset during
Example: Accessing Frame Information
Performance Considerations
Pre-3.11 vs. 3.11+
Pre-3.11:- Full
PyFrameObjectallocated for each call - Heap allocation overhead
- Poor cache locality
- Lightweight
_PyInterpreterFramestack-allocated - Contiguous per-thread stack
PyFrameObjectonly when needed- Significant performance improvement
Benchmark Results
Python 3.11 function call overhead reduced by ~15-20% compared to 3.10.Related Topics
- Bytecode Interpreter - How frames are executed
- Code Objects - Code executed by frames
- Generators - Generator frame lifecycle
- Exception Handling - Frame unwinding
