本节目标:说清 CPython 三种剖析钩子的触发机制与真实开销,让你能根据「调用密度」而不是「工具名气」选剖析器。
适用版本:Python 3.12+(实测 3.14.6)
10.1 剖析器内部与采样原理
站内专题 Python 调试与日志
已经教过 cProfile 怎么用、py-spy 怎么附着进程,性能剖析与优化入门
也演示过 pstats 排序。这些是「怎么用」。本节换一个角度:剖析器本身是怎么接进解释器的,以及为什么它的开销有时可以忽略、有时会把程序拖慢好几倍。理解了钩子机制,你就能预判某个剖析器在你的负载上到底贵不贵。
10.1.1 三条钩子:settrace / setprofile / sys.monitoring
CPython 提供三个层次的「执行事件订阅」入口,语义与粒度完全不同:
| 钩子 | 引入版本 | 事件粒度 | 典型用途 |
|---|---|---|---|
sys.settrace | 2.0 | 逐行(含 call/line/return) | pdb、coverage 行覆盖 |
sys.setprofile | 2.0 | 调用级(call/return/c_call) | cProfile、pyinstrument 栈跟踪 |
sys.monitoring | 3.12 | 指令/事件级,可精确选事件 | 新一代调试器与轻量剖析 |
一个容易踩的坑:sys.monitoring 是挂在 sys 模块上的属性,不是子模块,所以 import sys.monitoring 会失败:
import sys
# import sys.monitoring # ❌ ModuleNotFoundError: No module named 'sys.monitoring'
print(type(sys.monitoring)) # <class 'module'>
print([e for e in dir(sys.monitoring.events) if e.isupper()][:6])
真实输出:
<class 'module'>
['BRANCH', 'BRANCH_LEFT', 'BRANCH_RIGHT', 'CALL', 'C_RAISE', 'C_RETURN']
sys.monitoring 用「工具 ID」隔离多个订阅者,内置四个保留 ID:DEBUGGER_ID=0、COVERAGE_ID=1、PROFILER_ID=2、OPTIMIZER_ID=3。每个 ID 可以独立注册回调、独立开关事件:
import sys
mon = sys.monitoring
mon.use_tool_id(mon.PROFILER_ID, "demo")
seen = []
def on_py_start(code, offset):
seen.append(code.co_name)
return mon.DISABLE # 该代码位置的 PY_START 以后不再回调
mon.register_callback(mon.PROFILER_ID, mon.events.PY_START, on_py_start)
mon.set_events(mon.PROFILER_ID, mon.events.PY_START)
# ... 被测代码 ...
mon.set_events(mon.PROFILER_ID, mon.events.NO_EVENTS)
mon.free_tool_id(mon.PROFILER_ID)
回调返回 sys.monitoring.DISABLE 是这套 API 的关键设计:它让某个代码位置的事件只触发一次,解释器随即就地关掉该事件的位,不再进入回调。实测在 10 万次调用的负载上,返回 DISABLE 的 PY_START 总共只触发了 2 次事件,整段负载开销降到 x1.02——几乎与无剖析等价。这正是新一代工具(如轻量覆盖/调试器)相比 settrace 能做到低开销的核心原因:按需订阅、一次性触发、可随时关停。
10.1.2 各钩子到底触发哪些事件(实测)
用同一段代码分别挂 setprofile 与 settrace,打印每次回调的 event 与函数名:
import sys
def g():
x = 1
return x
def f():
return g()
def pc(frame, event, arg):
print(f" {event:<10} {frame.f_code.co_name}")
sys.setprofile(pc)
f()
sys.setprofile(None)
真实输出(sys.setprofile):
call f
call g
return g
return f
c_call <module>
换成 sys.settrace(回调需返回自身,否则后续行事件不再触发):
call f
line f
call g
line g
line g
return g
return f
对比很清楚:setprofile 看不到 line 事件,只在函数进出(以及 C 函数调用)时回调;settrace 额外为每一行可执行代码触发一次 line。这正是「确定性行剖析」很贵、而「调用计数」相对便宜的根源。
10.1.3 开销实测:正比于调用次数,而非循环次数
这是本节最反直觉、也最有用的一条结论。构造一个「调用密集」负载(循环里调用 10 万次 leaf),在同一进程内依次测量基线、cProfile、setprofile、settrace、sys.monitoring:
import sys, time, cProfile
mon = sys.monitoring
def leaf(x): return x + 1
def work(n):
s = 0
for i in range(n):
s = leaf(s)
return s
def bench(fn, n=100_000, repeat=7):
best = 1e9
for _ in range(repeat):
t0 = time.perf_counter(); fn(n)
best = min(best, time.perf_counter() - t0)
return best
真实结果(每次 work(100_000),取 7 次最小值):
| 方式 | 耗时 | 相对基线 | hook 触发(实测) |
|---|---|---|---|
| 基线 | 3.72 ms | x1.00 | — |
sys.monitoring(仅 PY_START) | 9.04 ms | x2.43 | 100 001 |
sys.monitoring(PY_START|PY_RETURN) | 13.68 ms | x3.67 | 200 002 |
cProfile | 17.02 ms | x4.57 | 每次 call/return 记账 |
sys.setprofile(Python 回调) | 18.87 ms | x5.07 | 200 003 |
sys.settrace(Python 回调) | 24.47 ms | x6.57 | 逐行,远超调用数 |
关键对照:把同一个循环换成单帧、无函数调用的纯计算(5 百万次 s += i*i),cProfile 与 pyinstrument 的开销几乎消失:
| 负载 | 基线 | cProfile | pyinstrument |
|---|---|---|---|
| 调用密集(10 万次调用) | 3.72 ms | x4.57 | x4.08 |
| 单帧纯计算(500 万次循环) | 222.7 ms | x0.96 | x0.98 |
同一个剖析器,在两种负载上一个贵 4 倍、一个几乎免费。原因只有一个:这些剖析器的开销正比于「事件触发次数」(即函数调用次数),而不是循环迭代次数。一个只在最内层循环里算数的热点函数,用调用级剖析器几乎测不到开销;而一个被调用几百万次的小函数,会让任何基于回调的剖析器都付出代价。
10.1.4 cProfile 为什么用 C 回调
cProfile 底层是 _lsprof.Profiler——一个用 C 写的回调。它在每次 call/return 时更新一张调用关系表,不做任何 Python 层函数调用,因此比 sys.setprofile(一个 Python 函数) 便宜约 10%。实测中 cProfile(x4.57)确实快于 setprofile 的 Python 回调(x5.07)。
它的统计口径是**确定性(deterministic)**的:每一次调用都被精确计数,不丢样。用 pstats 看:
import cProfile, pstats, io
pr = cProfile.Profile()
pr.enable(); work(100_000); pr.disable()
buf = io.StringIO()
pstats.Stats(pr, stream=buf).sort_stats("tottime").print_stats(4)
print(buf.getvalue())
真实输出节选:
100002 function calls in 0.016 seconds
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.011 0.011 0.016 0.016 pyinstr_overhead.py:6(work)
100000 0.005 0.000 0.005 0.000 pyinstr_overhead.py:4(leaf)
ncalls=100000——一个不多、一个不少。确定性剖析的代价就是这个「精确」:每次调用都要记账,调用越密越贵。
10.1.5 采样式剖析:pyinstrument 实测
采样式剖析器不记每一次调用,而是每隔一个时间片抓一次当前调用栈,用采样频率近似时间占比。理论上开销与调用次数无关,只与采样率有关。本机装了 pyinstrument 5.1.3,可以直接验证。
但有一个内部实现细节很多人不知道:pyinstrument 并不是纯采样器,它是混合式的。启动剖析时它会安装一个 C 级的 sys.setprofile 钩子(本机实测 sys.getprofile() 返回一个 ProfilerState 对象),再用一个定时线程按 interval 触发栈抓取。C 扩展 stat_profile.cpython-314-darwin.so 就是这套机制的实现。所以它的开销依然与调用次数相关——上面的表里,调用密集负载下它是 x4.08。
验证「开销与采样间隔无关」:
interval | 调用密集负载开销 |
|---|---|
| 1 ms | x4.08 |
| 5 ms | x4.04 |
| 10 ms | x3.96 |
间隔从 1ms 调到 10ms(采样点减少 10 倍),开销几乎不变——因为开销的大头是每次调用的钩子记账,不是采样本身。
它的输出则完全是采样风味:用 -m pyinstrument 跑一段有调用层次的三层函数:
python -m pyinstrument -r text samp_demo.py
真实输出(节选):
_ ._ __/__ _ _ _ _ _/_ Recorded: 12:04:01 Samples: 7
/_//_/// /_\ / //_// / //_'/ // Duration: 0.187 CPU time: 0.180
/ _/ v5.1.3
0.181 <module> samp_demo.py:1
└─ 0.181 main samp_demo.py:9
└─ 0.181 medium samp_demo.py:7
└─ 0.181 slow_sum samp_demo.py:2
Samples: 7——整段程序只抓到 7 个采样点,时间按栈展开逐层归属。这就是采样式剖析的本质:它统计的是「栈被看到的次数」,不是「调用发生的次数」。好处是输出天然是一棵「火焰树」,坏处是极短或极快的调用可能一次都没被采到。
py-spy与scalene本机未安装,本节不提供它们的实测数据。 二者原理与 pyinstrument 不同:py-spy用操作系统的ptrace直接读取目标进程的内存,不需要被剖析进程配合、也不改一行代码,因此可以附着到已经在跑的线上进程;scalene则在采样之外还追踪内存分配。这些机制本机无法验证,只能讲原理。
10.1.6 确定性 vs 采样:怎么选
| 维度 | 确定性(cProfile) | 采样(pyinstrument / py-spy) |
|---|---|---|
| 计数精度 | 每次调用精确计数 | 按采样点估计,有误差 |
| 开销来源 | 每次调用 | 每次调用(钩子)或近似为零(ptrace) |
| 能附着运行中进程 | 否,需改代码 | py-spy 可以 |
| 适合场景 | 调用次数不多、要精确调用图 | 长跑服务、要低侵入火焰图 |
选择判据不是「哪个工具更高级」,而是你的热点是「调用多」还是「耗时长」:热点函数被调用上百万次,用确定性剖析会把被测程序拖慢数倍;热点是少量函数各跑很久,用采样式就足够且更轻。若热点在 C 扩展里(如 NumPy),两种 Python 层剖析器都看不到内部,得靠 py-spy 这类跨语言采样器或系统级 perf。
小结
- 三层钩子语义不同:
sys.settrace逐行、sys.setprofile调用级、sys.monitoring(3.12+)事件级;sys.monitoring是属性不是子模块,import sys.monitoring会失败。 setprofile只见 call/return/c_call,看不到line;settrace才逐行回调——这是行剖析昂贵的根源。- 实测:调用密集负载下
sys.monitoring(PY_START)x2.43、cProfilex4.57、setprofilex5.07、settracex6.57;但单帧纯计算负载下cProfile/pyinstrument都约 x1.0。 - 核心结论:回调式剖析器的开销正比于函数调用次数,与循环迭代次数无关;
cProfile用 C 回调(_lsprof)比 Python 回调便宜约 10%。 pyinstrument 5.1.3是混合式:安装 C 级setprofile钩子跟踪调用栈 + 定时线程采样;间隔 1/5/10 ms 开销几乎不变(x3.96–x4.08)。py-spy/scalene本机未装,仅讲原理:ptrace方案可零侵入附着运行中进程。
知道了「贵在哪」,下一步是把「省内存」也量化——下一节用 getsizeof、tracemalloc 和 objgraph 实测对象与容器的真实占用。
阅读导航:上一节:9.3 PyO3:用 Rust 写扩展模块 · 下一节:10.2 内存优化与数据结构选型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。