本次环境为Arch Linux,内核版本6.12.46-3-cachyos-lts,perf版本6.16-3
前言:从“健康报告”到“外科手术”
在上一篇文章《我的程序为什么慢?—— Perf CPU 性能剖析》中,我们学会了使用 perf stat
。它就像一份体检报告,能告诉我们程序的整体健康状况——IPC 高不高、分支预测准不准。
但这还不够。如果报告说“指标异常”,我们并不知道问题出在哪个“器官”。perf stat
告诉我们程序慢了,但它没告诉我们慢在哪里。
要回答这个问题,我们需要进行“外科手术”,精确定位到消耗 CPU 最多的“病灶”——也就是热点函数。今天的主角,就是性能分析领域的“核磁共振仪”:perf record
与火焰图(Flame Graph)。
实验准备:一个“意图明显”的性能靶子
为了让火焰图的效果一目了然,我们需要一个性能瓶颈足够明显的程序。下面这个 test.cpp
就是我们的靶子,它的意图很明显:foo1
会调用 long_test
100 次,foo2
调用 10 次,因此 foo1
的 CPU 开销应该是 foo2
的 10 倍左右。
1 |
|
探案之旅:三起谜案,揭开火焰图的真相
理论是枯燥的,让我们直接动手,然后一头撞上那些新手必踩的坑。我把我的整个踩坑和破案过程记录了下来。
谜案一:函数的集体“蒸发”
满怀信心,我写了最初版的代码(没有 volatile
和 printf
),然后用 -O2
优化编译,兴冲冲地执行了全套火焰图生成命令:
1 | # 编译 |
然后,我得到了这样一张图:
一片荒芜!我的 foo1
, foo2
, long_test
全都不见了!perf
只记录到了几个程序启动初期的动态链接器的样本。
破案:这是我遇到的第一个老师——编译器优化。编译器实在太聪明了,它检查了我的代码,发现 long_test
里的循环除了改变一个没人用的局部变量 j
之外,什么都没干。于是,它大笔一挥,执行了“死代码消除”(Dead Code Elimination),把我的所有核心函数全都优化掉了。程序变成了一个空壳,自然瞬间执行完毕。
解决方案:我必须“欺骗”编译器,让它相信我的代码是有用的。
- 在
long_test
中使用volatile
关键字,这个关键字会告诉编译器:“别动这个变量,我另有他用”,从而阻止循环被优化。 - 让函数返回计算结果,并在
main
函数中用printf
打印出来。只要结果被使用,编译器就不敢轻易删除计算过程。
谜案二:调用栈的“破碎”
解决了第一个问题后,我重新生成了火焰图,得到了一张稍微好点,但依然奇怪的图:
foo1
出现了,但它的“孩子” long_test
却不见了。整个调用栈看起来像被拦腰斩断,充满了 [unknown]
。
破案:第二个老师出场了——还是编译器优化。这次是“函数内联”(Function Inlining)。编译器觉得 long_test
函数太简单了,每次调用它都走一遍压栈、跳转的流程太麻烦。于是,它把 long_test
的代码直接“复制粘贴”进了 foo1
和 foo2
的循环里。
所以,perf
采样时,CPU 确实在执行 long_test
的代码,但从调用栈的角度看,程序一直都“停留”在 foo1
函数内部,从未“进入”过 long_test
。
谜案三:永不妥协的优化器
我怒了,我决定正面硬刚编译器。我查到了 __attribute__((noinline))
这个可以建议编译器不要内联的属性,还找到了 -fno-omit-frame-pointer
这个可以强制保留栈帧信息的编译选项。
然而,在使用 -O2
优化时,生成的火焰图依然不尽人意,调用栈还是残缺的。
破案:我终于明白了,和全力开火的优化器“搏斗”,试图去限制它的行为,是一条很艰难的路。真正的专业思路应该是反过来:让编译器尽情优化,然后用更强大的探查器(Profiler)去适应它。
最终的“黄金组合”:让优化与观测和谐共存
在经历了九九八十一难后,我终于找到了生成完美火焰图的“黄金组合”:
1. 专业级的编译指令:
我们依然要开启优化,但强制保留堆栈回溯的线索。
1 | g++ -O2 -g -fno-omit-frame-pointer -o test-flame test.cpp |
-O2
: 开启优化,模拟生产环境。-g
: 保留调试符号,让火焰图显示函数名。-fno-omit-frame-pointer
: 强制保留帧指针。这是给perf
堆栈回溯提供的最可靠的“路标”。
2. 专业级的 perf
指令:
我们明确告诉 perf
,使用它最强大的、基于 DWARF
调试信息的回溯引擎。
1 | sudo perf record -F 99 --call-graph dwarf -g -- ./test-flame |
--call-graph dwarf
: 这才是真正的魔法。它命令perf
不再依赖可能被优化掉的帧指针,而是严格根据-g
生成的详细“地图”(DWARF 信息)来绘制调用栈。这种方式虽然慢一点,但无比准确。
胜利的果实:一张会说话的火焰图
在使用了“黄金组合”之后,我终于得到了这张梦寐以求的、完美的火焰图:
现在,让我们来解读它:
- Y 轴 - 调用栈深度:底部是
_start
和main
,顶部是long_test
,完美地展示了main
->foo1
->long_test
的调用关系。 - X 轴 - CPU 占用时间:
long_test
构成了最宽的“平顶山”,说明它就是程序的绝对热点。- 它下面的
foo1
几乎和它一样宽,而foo2
则窄到几乎看不见。这精确地反映了foo1
的循环次数(100 次)远大于foo2
(10 次),因此绝大部分对long_test
的调用都发生于foo1
内部。
这张图用一种无比直观的方式,将我们程序的性能瓶颈暴露无遗。
结论:不只是工具,更是思想
这次火焰图的探案之旅,让我学到的远超几个命令:
- 永远不要低估编译器:它既是你最好的朋友,也是性能分析时最大的“捣蛋鬼”。
- 基准测试代码本身必须可靠:确保你的测试代码不会被优化器“作弊”干掉。
- 观测工具需要精调:默认参数通常不够用。理解工具(如
perf
)的高级选项,才能应对复杂的现实世界场景。
我们已经学会了如何找到 CPU 的瓶颈,但在真实的软件世界里,程序还会因为崩溃、内存泄漏等问题而倒下。在下一篇文章中,我们将拿起新的武器——GDB, ASAN 和 Valgrind,成为一名合格的“程序法医”。