0%

我的程序卡在哪里?—— 用火焰图精确定位性能瓶颈

本次环境为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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <stdio.h>
#include <stdlib.h>

long long long_test() {
int i;
// 注意!这里的 volatile 很关键,我们后面会讲为什么
volatile long long j = 0;
for (i = 0; i < 1000000; i++) {
j = i;
}
return j;
}

long long foo2() {
int i;
long long total = 0;
for (i = 0; i < 10; i++) {
total += long_test();
}
return total;
}

long long foo1() {
int i;
long long total = 0;
for (i = 0; i < 100; i++) {
total += long_test();
}
return total;
}

int main(void) {
long long result1 = foo1();
long long result2 = foo2();

// 同样关键,我们必须“使用”结果
printf("Result: %lld\n", result1 + result2);
return 0;
}

探案之旅:三起谜案,揭开火焰图的真相

理论是枯燥的,让我们直接动手,然后一头撞上那些新手必踩的坑。我把我的整个踩坑和破案过程记录了下来。

谜案一:函数的集体“蒸发”

满怀信心,我写了最初版的代码(没有 volatileprintf),然后用 -O2 优化编译,兴冲冲地执行了全套火焰图生成命令:

1
2
3
4
5
6
# 编译
g++ -O2 -g -o test-flame test.cpp
# 采样
sudo perf record -F 99 -g -- ./test-flame
# 生成火焰图
perf script | stackcollapse-perf.pl | flamegraph.pl > flame.svg

然后,我得到了这样一张图:

“空”火焰图

一片荒芜!我的 foo1, foo2, long_test 全都不见了!perf 只记录到了几个程序启动初期的动态链接器的样本。

破案:这是我遇到的第一个老师——编译器优化。编译器实在太聪明了,它检查了我的代码,发现 long_test 里的循环除了改变一个没人用的局部变量 j 之外,什么都没干。于是,它大笔一挥,执行了“死代码消除”(Dead Code Elimination),把我的所有核心函数全都优化掉了。程序变成了一个空壳,自然瞬间执行完毕。

解决方案:我必须“欺骗”编译器,让它相信我的代码是有用的。

  1. long_test 中使用 volatile 关键字,这个关键字会告诉编译器:“别动这个变量,我另有他用”,从而阻止循环被优化。
  2. 让函数返回计算结果,并在 main 函数中用 printf 打印出来。只要结果被使用,编译器就不敢轻易删除计算过程。

谜案二:调用栈的“破碎”

解决了第一个问题后,我重新生成了火焰图,得到了一张稍微好点,但依然奇怪的图:

调用栈残缺的火焰图

foo1 出现了,但它的“孩子” long_test 却不见了。整个调用栈看起来像被拦腰斩断,充满了 [unknown]

破案:第二个老师出场了——还是编译器优化。这次是“函数内联”(Function Inlining)。编译器觉得 long_test 函数太简单了,每次调用它都走一遍压栈、跳转的流程太麻烦。于是,它把 long_test 的代码直接“复制粘贴”进了 foo1foo2 的循环里。

所以,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 信息)来绘制调用栈。这种方式虽然慢一点,但无比准确。

胜利的果实:一张会说话的火焰图

在使用了“黄金组合”之后,我终于得到了这张梦寐以求的、完美的火焰图:

完美的火焰图

现在,让我们来解读它:

  1. Y 轴 - 调用栈深度:底部是 _startmain,顶部是 long_test,完美地展示了 main -> foo1 -> long_test 的调用关系。
  2. X 轴 - CPU 占用时间
    • long_test 构成了最宽的“平顶山”,说明它就是程序的绝对热点。
    • 它下面的 foo1 几乎和它一样宽,而 foo2 则窄到几乎看不见。这精确地反映了 foo1 的循环次数(100 次)远大于 foo2(10 次),因此绝大部分对 long_test 的调用都发生于 foo1 内部。

这张图用一种无比直观的方式,将我们程序的性能瓶颈暴露无遗。

结论:不只是工具,更是思想

这次火焰图的探案之旅,让我学到的远超几个命令:

  1. 永远不要低估编译器:它既是你最好的朋友,也是性能分析时最大的“捣蛋鬼”。
  2. 基准测试代码本身必须可靠:确保你的测试代码不会被优化器“作弊”干掉。
  3. 观测工具需要精调:默认参数通常不够用。理解工具(如 perf)的高级选项,才能应对复杂的现实世界场景。

我们已经学会了如何找到 CPU 的瓶颈,但在真实的软件世界里,程序还会因为崩溃、内存泄漏等问题而倒下。在下一篇文章中,我们将拿起新的武器——GDB, ASAN 和 Valgrind,成为一名合格的“程序法医”。