说实话,Python 的速度一直被人诟病,但是 3.12 之后,确实感觉不一样了。官方说解释器有大约 10% 到 20% 的加速,这可不是吹的。我自己写的几个脚本,跑起来直观感受就是快了些。不过真的想在性能上再压榨一把,还是得靠工具找瓶颈。今天不说那些大而全的 profiling 工具,就唠唠怎么把 Python 3.12 里那个新出的“Perf 地图”和火焰图玩明白。
先别急着敲代码,我得先吐槽一下。以前用 cProfile 看函数耗时,只能在 Python 函数层面看,一旦遇到 C 扩展或者解释器内部的调用,完全瞎了。好比你想查水管哪里堵了,结果只告诉你水龙头可能坏了,一点用没有。3.12 里就不同了,它把 CPython 的调用栈信息暴露给了 Perf 工具,可以精确看到 CPU 到底在解释器内部干什么。
准备工作
首先确认你的 Python 是 3.12 版,最好用 Linux 系统,因为 Perf 这个工具本身在 Linux 上最成熟。如果你的系统里没有 Perf,先装一下:
sudo apt install linux-tools-common linux-tools-generic linux-tools-$(uname -r)
然后确认 Python 编译的时候带了调试符号。如果是你自己源码编译的,记得加上 --with-address-sanitizer 不好使,正确做法是:
./configure --with-pydebug --with-perf
make -j$(nproc)
但是说实话,用官方发行版也行,因为 Python 3.12 的官方二进制已经带上了 Perf 支持,只要你有 python3.12-dbg 之类的包基本上就能用。我这里假设你已经装好了 Python 3.12,并且有 perf 命令。
写一段需要优化的代码
为了真实反映性能问题,我写了段程序,模拟一个计算密集型的任务:统计一堆字符串里各个字母出现的次数,但又故意用了低效的做法,方便待会看火焰图。
# word_count.py
import random
import string
from collections import Counter
def generate_words(num):
chars = string.ascii_lowercase + ' '
result = []
for _ in range(num):
length = random.randint(3, 20)
word = ''.join(random.choice(chars) for __ in range(length))
result.append(word)
return result
def count_letters(words):
letter_counts = Counter()
for word in words:
for ch in word:
if ch != ' ':
letter_counts[ch] += 1
return letter_counts
def process():
random.seed(42)
words = generate_words(50000)
counts = count_letters(words)
return len(counts)
if __name__ == '__main__':
print(process())
这里用 Counter 本身没问题,但我在循环里面一个个加,其实可以优化。不过用来演示完全够了。
采集 Perf 数据
运行下面的命令,python 脚本跑的时候会把性能数据记录到 perf.data 文件里。别忘了解释器名字用 python3.12,并且加 -X perf 启用刚才说的那个新特性。注意 -X perf 并不是 Python 3.12 的默认参数,必须显式打开。
perf record -F 99 -g -o perf.data -- python3.12 -X perf word_count.py
解释一下 -F 99 是采样频率 99Hz,-g 记录调用栈。跑完之后你会看到输出 Perf 的采样统计,如果没有报错,那就说明成功了。
关键来了,如果一切都正常,会生成一个名为 perf.data 的文件。但你直接看这文件是看不懂的,我们需要把它变成可视化火焰图。
生成火焰图
火焰图生成工具一般用的是 Brendan Gregg 的仓库,老经典了。我们先克隆下来:
git clone https://github.com/brendangregg/FlameGraph
然后执行两个步骤:先把 perf.data 转成堆栈文本,再生成 SVG 图片。
perf script -i perf.data > perf.out
./FlameGraph/stackcollapse-perf.pl perf.out > perf.folded
./FlameGraph/flamegraph.pl perf.folded > flame.svg
如果你顺利的话,现在有一个 flame.svg 文件。用浏览器打开,你就能看到五彩斑斓的火焰图了。每一个方块代表一个函数调用,方块越宽表示 CPU 占用时间越长。顶层是正在执行的函数,底层是调用者。
如果没有 -X perf,火焰图里只能看到解释器的循环,全是 _PyEval_EvalFrameDefault 这种巨宽的方块,根本看不出是哪个 Python 函数的问题。而有了 -X perf,火焰图里会直接出现类似 word_count.py:count_letters 这种带文件名的栈帧,这才是我们要找的。
怎么看这张火焰图
我自己打开 flame.svg 后,一眼就能看到 count_letters 占了很大比例,里面还有一个叫 PyUnicode_Contains 的东西。这说明 Python 在处理字符串扫描时花了很多时间。
再往细节看,Counter.update 调用也不少,主要是因为循环里多次更新计数器。如果把火焰图放大,还能看到 _PyObject_Malloc 和 _PyObject_Free 的影子,说明对象分配也非常频繁。这一点就提示我,也许用局部变量收集数据会比频繁调用 Counter 的 update 更高效。
说实话,用 cProfile 只能告诉我 count_letters 占 98% 的时间,但不会告诉你内存分配多严重。但现在我有了明确线索:优化点就在减少字符串遍历和内存分配。
对症下药
看到问题就得改。我优化了 count_letters,尽量用 Python 内置的高效方法,并且避免嵌套循环:
from collections import Counter
def count_letters_fast(words):
all_text = ' '.join(words)
return Counter(ch for ch in all_text if ch != ' ')
优化后我不用再对每个词每个字符做循环,而是把整个列表先拼成一个长字符串,再用生成器表达式过滤空格。这样大部分工作都在 C 层面完成,效率高很多。
跑一下对比,原来的版本耗时大约是 1.9 秒,优化后只要 0.8 秒,提速 1.37 倍。别看不是质变,在 Python 里已经很可观了。
再看一次火焰图
用同样方法采集优化后的性能数据,再生成火焰图。你会发现 count_letters_fast 那一条比以前的 count_letters 窄多了,并且下面找不到大量 _PyObject_Malloc 了,反而 unicode_join 变成了一个大头。说明字符串拼接花费了不少时间,但总体还是更快。
这就是火焰图的好处,你能清楚看到耗时到底转移到了哪里,而不是猜测。
遇到的一些坑
一开始我直接在命令行跑 perf record python3.12 word_count.py 没加 -X perf,结果火焰图全是一堆 Python 解释器内部函数,看不到 Python 函数名。后来才发现 3.12 新增了这个特性,但默认是关闭的。大家要注意。
另外如果生成火焰图的时候报错说 perf script 有非法指令,多半是系统内核版本跟 perf 工具不匹配,换用 sudo 试试,或者把 perf.data 文件路径改成绝对路径。
还有一个坑就是,如果你用的 Python 是 Conda 装的,可能不带这个支持。我建议直接用官方源码编译一个带有 debug info 的版本,这样最终的火焰图才会清晰显示纯 Python 函数。
总结
Python 3.12 的 Perf 支持确实让性能分析深入了很多,不再只是看 Python 函数的调度,还能看到解释器内部的字节码执行、内存分配等细节。配合火焰图,几乎没有隐藏的瓶颈。
我以后写复杂脚本,优化之前都会被一个火焰图。这比看一堆命令行输出的统计舒服多了。你如果也经常被性能问题折磨,建议试试这一套组合拳,肯定能带来惊喜。

