pyinstrument 是 Python 的采样式性能分析器,特点是输出一棵按耗时排序的调用树,而不是一张函数耗时平铺表。它的宣传点是「开销低」,这篇就是去量这个开销到底有多低,以及低在什么条件下。

两行接入

from pyinstrument import Profiler

p = Profiler(interval=0.001)
p.start()
your_code()
p.stop()
print(p.output_text())

也可以用命令行直接包一层:pyinstrument -m your_module,不用改代码。interval 是采样间隔,默认 1 毫秒。

同一个任务,装与不装

任务是三段计算(字符串解析、数字转换、排序)循环 50 轮,各跑 5 次取中位:

方式耗时倍数
不装 profiler381.5 毫秒基准
pyinstrument 采样1980.6 毫秒5.19 倍

慢了 5 倍,这跟「采样分析器几乎无开销」的常见说法差得远。继续查才发现,开销不跟耗时挂钩,跟 Python 调用的次数挂钩。

开销跟着调用次数走

换成三种完全不同的负载,同样测装与不装:

负载类型不装装了倍数
大量 Python 调用(20 万次)11.5 毫秒39.8 毫秒3.46 倍
少量调用 + 重 C 计算263.0 毫秒262.5 毫秒1.00 倍
纯 sleep 0.5 秒500.5 毫秒501.1 毫秒1.00 倍

后两行几乎没有额外开销。重 C 计算那 263 毫秒全在 sum(range(30000000)) 里,Python 层只调了一次;sleep 更是完全不占 CPU。

第一行 20 万次 Python 调用,多花了 28 毫秒,折算下来每次调用多 0.14 微秒。前面那个 5.19 倍的例子,50 轮里跑了几百万次生成器迭代,按这个单价累加出来就是 1.6 秒。

结论很明确:Python 代码越密集,它越贵;瓶颈在 C 扩展或者 IO 等待上,它几乎免费。

interval 调大也没变快

interval=0.0005   1991.4 ms   5.22 倍
interval=0.001    1971.8 ms   5.17 倍
interval=0.005    1940.7 ms   5.09 倍
interval=0.01     1958.3 ms   5.13 倍
interval=0.05     1946.0 ms   5.10 倍

从 0.5 毫秒调到 50 毫秒,耗时纹丝不动。真正的采样分析器应该靠降低采样频率来减少开销,这里没有,说明这份开销花在每个函数调用的钩子上,而不是花在采样动作本身。

所以别指望把 interval 调大来降低开销,它买到的只是更粗的采样粒度。

采样太少会得到空报告

>>> p = Profiler(interval=0.5)
>>> p.start(); sum(range(100000)); p.stop()
>>> print(p.output_text())
...  Samples:  0
...  Duration: 0.001

任务只跑了 1 毫秒,采样间隔 0.5 秒,一帧都没采到,报告是空的。它不会提示「样本不足」,只给你一张没有内容的树。

所以分析短函数必须把工作量放大,或者把 interval 调小。反过来,跑一个几十毫秒就结束的脚本然后抱怨「看不出来热点」,多半就是这个原因。

报告怎么读

输出的是一棵树,每行左边的数字是这一层花掉的时间,[self] 表示时间花在函数自己身上、没有再往下调:

1.946 task
└─ 1.946 aggregate
   ├─ 1.343 parse
   │  ├─ 1.009 <genexpr>
   │  │  ├─ 0.702 [self]
   │  │  └─ 0.307 ord
   │  └─ 0.329 [self]
   ├─ 0.356 <genexpr>
   └─ 0.187 [self]

从这张图能直接读出两件事:1.343 秒在 parse 里,占总量七成;parse 内部又有一半在 ord 这个内置函数上。要优化就改这里,别的地方加起来不到三分之一。

什么时候用它

怀疑某段代码慢,但不知道慢在哪一层。调用树能直接指出是哪一层的哪个函数吃掉了时间,比自己插 time.perf_counter() 猜位置快得多。

服务已经跑起来了要抓现场。它支持挂到运行中的进程上采样,不用重启,也不用改代码。

纯计算密集、调用层级深的代码,别用它。5 倍的开销会把本来就慢的地方放得更大,测出来的绝对耗时没有参考价值。这种场景用 cProfile 或者直接打点更合适。

没测的部分列一下,3 项:挂到运行中进程的实际成功率 / 输出 HTML 报告的体积 / 与 cProfile 在同一段代码上的结果差异。