18、性能分析工具(下):perf的使用,采样与计数,火焰图生成与解读
上一讲我们聊了perf的基本用法,说白了就是能告诉你程序里哪些函数最耗CPU。但perf的能力远不止于此。今天咱们深入聊聊perf的两种核心工作模式——采样与计数,以及我个人非常喜欢的火焰图。这东西一旦用上,你排查性能问题的效率能翻好几倍。
perf的两种工作模式:采样 vs 计数
perf有两种干活的方式,我习惯叫它们“抽样调查”和“精确统计”。
采样模式(Sampling)
采样模式是perf最常用的方式。它每隔一段时间(比如每1ms)打断一次CPU,看看当前在跑什么代码。跑的次数多了,就能统计出每个函数被采样到的次数。次数越多,说明这个函数占用的CPU时间越多。
我举个例子。你写了个程序,跑起来很慢。用perf采样一下:
perf record -g ./my_program
perf report
-g参数很关键,它让perf记录调用栈。这样你不仅知道哪个函数慢,还能看到是谁调了它。我在项目中遇到过好几次,优化了半天发现是上层调用太频繁,而不是底层函数本身的问题。
采样模式的好处是开销小,对程序运行影响微乎其微。缺点是不够精确——它只是估算,不是精确计数。
计数模式(Counting)
计数模式就不同了。它直接读取CPU内部的性能计数器,精确统计发生了多少次特定事件。比如:
perf stat ./my_program
输出会告诉你程序跑了多少条指令、发生了多少次缓存未命中、分支预测失败了多少次。这些都是硬件级别的精确数据。
我个人习惯是先用采样模式定位热点函数,再用计数模式深挖具体原因。比如采样发现某个函数很慢,计数模式一看,哦,原来是L1缓存未命中率高达30%。那优化方向就很明确了——改善数据局部性。
核心区别一句话总结:采样告诉你“哪里慢”,计数告诉你“为什么慢”。两者配合使用,效果最佳。
perf采样实战:定位热点函数
咱们来点实际的。假设你有这么一段代码:
// test.c
#include <stdio.h>
void slow_func() {
for (int i = 0; i < 100000000; i++) {
// 模拟耗时操作
volatile int x = i * i;
}
}
void fast_func() {
for (int i = 0; i < 1000; i++) {
volatile int x = i + i;
}
}
int main() {
slow_func();
fast_func();
return 0;
}
编译并采样:
gcc -O0 -g -o test test.c
perf record -g ./test
perf report
你会看到类似这样的输出:
Samples: 1K of event 'cycles'
Event count (approx.): 850000000
Overhead Command Shared Object Symbol
99.50% test test [.] slow_func
0.50% test test [.] fast_func
看到没?slow_func占了99.5%的开销。优化它就行了。但如果你没有加-g参数,你就看不到调用链,万一slow_func是被多个地方调用的,你就没法知道是哪个调用路径最热。
小技巧:采样时加上--call-graph dwarf参数,可以获取更完整的调用栈信息。特别是对于编译优化后的代码,默认的栈回溯可能不够准确。
perf计数实战:深挖性能瓶颈
采样找到了热点函数,接下来用计数模式看看它为什么慢:
perf stat -e cycles,instructions,cache-misses,branch-misses ./test
输出示例:
Performance counter stats for './test':
850,000,000 cycles
1,200,000,000 instructions
500,000 cache-misses
10,000 branch-misses
0.850 sec time elapsed
这里有个关键指标:IPC(Instructions Per Cycle)。计算一下:1.2B / 0.85B ≈ 1.41。现代CPU每个周期可以执行3-4条指令,1.41说明效率偏低。再看cache-misses有50万次,说明内存访问模式可能有问题。
我曾经优化过一个网络协议栈,采样发现某个解析函数很慢。计数一看,cache-misses高达200万次。后来我把数据结构从链表改成了数组,cache-misses降到了20万次,性能提升了3倍。嗯,数据局部性就是这么重要。
火焰图:让性能问题一目了然
perf report的输出是文本形式的,看多了眼睛疼。火焰图就好多了,它是可视化的调用栈采样结果。每个色块代表一个函数,宽度代表它占用的CPU时间比例。从下往上看,底层是调用者,上层是被调用者。
生成火焰图需要几个工具:
- perf script:把perf.data转换成可读的文本格式
- FlameGraph工具集:把文本转换成SVG图片
具体步骤:
# 1. 采样(记得加-g)
perf record -g ./test
# 2. 生成脚本输出
perf script > out.perf
# 3. 折叠调用栈
./stackcollapse-perf.pl out.perf > out.folded
# 4. 生成火焰图
./flamegraph.pl out.folded > flame.svg
打开flame.svg,你会看到一张彩色图片。宽大的色块就是性能热点。鼠标悬停上去,会显示函数名和占比。
注意:火焰图只显示CPU时间,不显示I/O等待、锁等待等非CPU开销。如果你的程序在等网络或磁盘,火焰图上看不到。这时候需要用其他工具,比如strace或iostat。
如何解读火焰图
解读火焰图有几个要点:
- 看顶部:最顶层的函数是实际在CPU上执行的代码。如果顶层很宽,说明这个函数本身很耗时。
- 看宽度:色块越宽,占用的CPU时间越多。优先优化最宽的。
- 看颜色:颜色是随机的,没有特殊含义。别被颜色误导。
- 看“平顶”:如果某个函数顶部很平,说明它内部没有调用其他函数,就是自己在算。这种优化起来比较直接——改进算法或减少计算量。
- 看“尖塔”:如果某个函数顶部很尖,说明它调用了很多子函数。这时候要往下看,找到最宽的子函数。
我曾经接手过一个项目,火焰图看起来像一座“平顶山”——一个函数占了80%的CPU。点进去一看,里面是个三层嵌套循环,复杂度O(n³)。改成O(n²)后,性能直接起飞。
知识体系总览
下面这张图总结了perf的核心用法和火焰图的关系:
避坑指南
用perf和火焰图时,有几个坑我踩过,分享给你:
- 采样频率别太高:默认的1ms采样间隔就够用。设得太高(比如0.1ms)反而会引入大量采样开销,影响结果准确性。
- 注意编译器优化:用
-O2编译的代码,函数可能被内联,调用栈会变短。这时候火焰图看起来“矮”了,但实际性能可能更好。我建议调试时用-O0 -g,优化时用-O2再测一次。 - 多线程程序要小心:perf默认只采样当前进程。多线程程序要用
-t指定线程ID,或者用-a全系统采样。 - 火焰图不是万能的:它只显示CPU时间。如果你的程序在等锁、等I/O,火焰图上看不到。这时候需要结合其他工具。
我的习惯:每次优化前,先跑一次perf stat记录基线数据。优化后再跑一次,对比IPC、cache-misses等指标。这样能量化优化效果,而不是凭感觉说“好像快了”。
好了,perf和火焰图的核心用法就这些。工具再好,也得动手用。找个你手头的项目,跑一遍perf,生成一张火焰图,看看能不能找到性能瓶颈。相信我,第一次看到火焰图的时候,你会觉得——原来我的程序是这样跑的!