Mapping Linux Perf to Python Bytecode Frames
Profiling Python applications with Linux perf presents a
unique challenge: perf monitors kernel and native CPU
instructions, while Python executes dynamically compiled bytecode within
a virtual machine. This article explains how Linux perf
tracepoints and sampling interrupts can be bridged to dynamic CPython
execution states. It details how to leverage native perf trampolines
introduced in CPython 3.12, User Statically Defined Tracing (USDT)
probes, and eBPF-driven frame inspection to associate low-level events
directly with Python functions, filenames, and bytecode offsets.
The Challenge: The Native-to-Bytecode Gap
When running standard perf record on a CPython binary,
stack unwinding only resolves native C symbols:
_PyEval_EvalFrameDefault (in libpython3.x.so)
└── PyObject_Vectorcall
└── ...
Because CPython executes Python bytecode inside an evaluation loop
(historically eval_frame or
_PyEval_EvalFrameDefault), the native CPU instruction
pointer (RIP/PC) points to the machine code of
the interpreter rather than the Python source code. To map kernel
tracepoints (e.g., sched:sched_switch, page faults, system
calls) to Python bytecode, you must map the active
PyFrameObject (or _PyInterpreterFrame in
Python 3.11+) to the hardware instruction pointer at the moment the
tracepoint fires.
Method 1: Python 3.12+ Native Perf Support (Perf Maps and Trampolines)
CPython 3.12 introduced native support for Linux perf
via runtime trampoline generation. Instead of calling the evaluation
loop generically, Python generates tiny JIT-like native trampolines for
each Python code object.
1. How It Works
When a Python function is executed:
- CPython writes machine code stubs (trampolines) that call into the frame evaluator.
- The runtime registers these addresses and writes entries to
/tmp/perf-<PID>.mapor emits a binaryjitdumpfile. - Linux
perfparses/tmp/perf-<PID>.mapduring symbol resolution to map instruction addresses directly to Python function names, files, and line numbers.
2. Activation
Enable trampolines via an environment variable or command-line flag:
# Via environment variable
export PYTHONPERFSUPPORT=1
perf record -F 999 -g -- python your_script.py
# Or via the CLI switch
perf record -F 999 -g -- python -X perf your_script.py3. Analyzing Tracepoints
To trace a specific kernel event (e.g., system calls) correlated with Python execution:
perf record -e raw_syscalls:sys_enter -g -- python -X perf your_script.py
perf report --stdioThe call tree will display the Python function name and source file interleaved with native C library calls and kernel functions.
Method 2: USDT Tracepoints in CPython
If CPython is compiled with DTrace/USDT support
(--with-dtrace), the binary contains static tracepoints
compiled directly into the interpreter.
1. Available USDT Probes
Common probes embedded in the Python runtime include:
python:function__entry: Fires when entering a Python frame. Arguments provide the filename, function name, and line number.python:function__return: Fires when exiting a Python frame.python:line: Fires on each bytecode line execution (disabled by default due to high overhead).
2. Registering Probes with Linux Perf
You can inspect and register these probes using
perf:
# Check for USDT markers in the python binary
perf list sdt
# Add a USDT marker to perf dynamic tracing
perf probe -x /usr/bin/python3.11 python:function__entryOnce registered, tracepoint events can be recorded alongside hardware performance counters or kernel scheduler events to reconstruct frame lifecycles.
Method 3: Dynamic Bytecode Introspection via eBPF
For environments running Python versions prior to 3.12 without trampolines, eBPF (extended Berkeley Packet Filter) allows out-of-band unwinding of the CPython frame structures.
1. Reading CPython Thread State
In CPython, each OS thread maintains a pointer to a
PyThreadState struct, which contains a pointer to the
active frame:
- Python 3.10 and earlier:
PyThreadState -> frame(PyFrameObject*) - Python 3.11+:
PyThreadState -> current_frame(_PyInterpreterFrame*)
Using an eBPF program attached to a tracepoint (e.g.,
tracepoint/sched/sched_switch), you can:
- Locate the base address of
libpythonin user space. - Read the thread-local
_PyThreadState_Currentsymbol usingbpf_probe_read_user(). - Traverse the frame linked list back to find:
co_filename:frame->f_code->co_filenameco_name:frame->f_code->co_namef_lasti: Current bytecode instruction index insidef_code->co_code.
2. Resolving Bytecode Offsets
The f_lasti attribute represents the exact offset within
the bytecode payload. By extracting the bytecode mapping table
(co_linetable or co_lnotab), user-space
consumers of the eBPF perf buffer match the exact f_lasti
integer to the Python source line.
Comparison of Methods
| Feature | Python 3.12+ -X perf |
USDT Probes | eBPF Custom Unwinding |
|---|---|---|---|
| Overhead | Low to Medium (~1–3%) | High (if tracing every call) | Minimal |
| Setup Complexity | Zero (Built-in) | Recompile with
--with-dtrace |
High (requires DWARF knowledge) |
| Line-level Precision | Yes (in call tree) | Requires python:line |
Yes (via f_lasti) |
| System Event Correlation | Native (via perf record) |
Via perf /
bpftrace |
Full kernel correlation |
Recommended Workflow
- Use Python 3.12+ with
-X perfas the default mechanism for profiling CPU hotspots, page faults, and context switches alongside Python frames. - Use eBPF (or tools like
py-spy) when you need non-invasive observation in production environments without restarting the target process or modifying runtime flags.