16. 性能分析工具(下):perf使用、硬件计数器、火焰图生成与分析
上一讲我们聊了perf的基本用法,这次咱们深入一点。说实话,perf这工具我用了快十年,每次遇到棘手的性能问题,第一个想到的就是它。它不像gprof那样需要重新编译,也不像Valgrind那样慢得让人抓狂。perf直接跟硬件打交道,开销极低,这才是真正的生产环境利器。
perf stat:看一眼你的程序在硬件层面干了什么
先从一个最简单的场景说起。你写了个程序,感觉跑得慢,但不知道瓶颈在哪。这时候我最先跑的就是perf stat:
perf stat ./my_program
输出大概长这样:
Performance counter stats for './my_program':
1,234,567,890 cycles # 3.40 GHz
987,654,321 instructions # 0.80 insn per cycle
123,456,789 branch-misses # 5.67% of all branches
45,678,901 cache-misses # 12.34% of all cache refs
0.364234987 seconds time elapsed
这里有几个关键指标,我一个个说。
cycles:CPU周期数。说白了就是CPU跑了多少下心跳。这个数字越大,程序执行时间越长。
instructions:执行的指令条数。注意,这不是代码行数,是CPU指令。一个C语句可能编译成好几条指令。
insn per cycle(IPC):每个周期执行了多少条指令。这个值很关键。现代CPU每个周期理论上能执行4条甚至更多指令。如果你的IPC只有0.5,说明CPU大部分时间在等什么——等内存、等分支预测、等数据依赖。
branch-misses:分支预测失败的次数。这个高了,流水线就要冲刷,性能损失很大。
cache-misses:缓存未命中。这个高了,CPU就得去主存拿数据,慢几十倍甚至上百倍。
我的经验:有一次我优化一个网络包处理程序,perf stat显示IPC只有0.3。我一看就知道问题出在内存访问上。后来把数据结构从链表改成数组,IPC直接跳到1.8,吞吐量翻了三倍。perf stat就像医生的听诊器,一听就知道哪里不对劲。
perf record & perf report:找到热点函数
perf stat只能看全局,想知道具体哪个函数在拖后腿,就得用perf record:
perf record -g ./my_program
perf report
-g参数表示记录调用栈。这样你不仅能看到哪个函数最热,还能看到是谁调了它。
perf report的输出是一个交互式界面。最上面是热点函数列表,按CPU占用率排序。你可以按Enter展开,看到调用链。
举个例子,假设你看到memcpy占了30%的CPU时间。点进去一看,调用链是process_packet -> parse_header -> memcpy。这时候你就知道,问题出在parse_header里频繁拷贝数据。
小技巧:perf record默认采样频率是4000Hz,也就是每秒采样4000次。对于短时间运行的程序,可以调高采样频率:perf record -F 10000 -g ./my_program。但注意,频率太高会产生大量数据,小心磁盘空间。
硬件计数器:CPU自带的性能监控单元
perf之所以强大,是因为它直接读取CPU内部的性能监控单元(PMU)。每个现代CPU都内置了一组硬件计数器,可以统计各种底层事件。
列出当前CPU支持的所有硬件事件:
perf list hardware
perf list cache
perf list pmu
常见的硬件事件包括:
| 事件名称 | 含义 | 典型问题 |
|---|---|---|
| cycles | CPU周期数 | 程序整体耗时 |
| instructions | 指令数 | 代码量是否过大 |
| cache-references | 缓存访问次数 | 内存访问模式 |
| cache-misses | 缓存未命中次数 | 数据局部性差 |
| branch-misses | 分支预测失败次数 | 分支模式混乱 |
| stalled-cycles-frontend | 前端停顿周期 | 指令获取瓶颈 |
| stalled-cycles-backend | 后端停顿周期 | 执行单元或内存瓶颈 |
你可以同时监控多个事件:
perf stat -e cycles,instructions,cache-misses,branch-misses ./my_program
注意:硬件计数器数量有限。Intel CPU通常只有4-8个通用计数器。如果你一次指定太多事件,perf会分时复用计数器,导致数据不准确。我一般一次不超过4个事件。
火焰图:一眼看穿性能瓶颈
perf report虽然好用,但面对复杂的调用栈,文字界面还是不够直观。这时候火焰图就派上用场了。
火焰图是Brendan Gregg发明的可视化工具。它把采样到的调用栈堆叠起来,每个矩形代表一个函数,宽度代表CPU占用比例。从上往下看,就是调用链。
生成火焰图的步骤:
# 第一步:采样
perf record -g -F 99 ./my_program
# 第二步:生成火焰图数据
perf script > out.perf
# 第三步:折叠调用栈
stackcollapse-perf.pl out.perf > out.folded
# 第四步:生成SVG火焰图
flamegraph.pl out.folded > flame.svg
这里stackcollapse-perf.pl和flamegraph.pl是Brendan Gregg提供的脚本,可以从GitHub下载。
火焰图怎么看?记住三点:
- 看宽的地方:矩形越宽,说明这个函数占用的CPU时间越多。这就是瓶颈。
- 看顶部:顶部的函数是实际执行代码的地方。底部是调用者。
- 看颜色:颜色没有特殊含义,只是为了区分不同函数。红色通常表示热点。
避坑指南:我曾经遇到一个情况,火焰图上显示malloc占了很大一块。我以为是内存分配太频繁,优化了半天没效果。后来仔细一看,调用链是main -> process -> malloc,但malloc下面还有一层mmap。原来问题不是malloc本身,而是它触发了系统调用。嗯,火焰图要结合调用链一起看,不能只看表面。
SVG火焰图示例
下面是我用SVG画的一个简化版火焰图,展示了一个网络服务程序的CPU热点分布:
从这张火焰图可以清楚看到:compress_data占了29%的CPU时间,encrypt占了17%。这两个函数就是优化的重点。
perf top:实时监控热点
有时候你想看看正在运行的程序在干什么,或者不想停掉服务。这时候用perf top:
perf top -p PID
它会实时显示当前进程的热点函数,每秒刷新一次。有点像Linux的top命令,但显示的是函数级别的CPU占用。
我经常在性能压测的时候开一个终端跑perf top,一边加压一边看热点变化。这样能直观地看到,随着压力增大,瓶颈是怎么转移的。
我的习惯:perf top默认显示内核和用户态的所有函数。如果只想看用户态,加--user参数。如果只想看某个特定模块,用--sym-filter过滤。比如perf top --sym-filter='my_module_*'。
实战案例:优化一个字符串处理函数
光说不练假把式。我拿一个真实案例来说明。
几年前我优化过一个日志解析程序。perf stat显示IPC只有0.6,cache-miss率高达20%。perf report显示热点是find_delimiter函数,占了35%的CPU时间。
原代码是这样的:
char* find_delimiter(char* str, char delim) {
while (*str) {
if (*str == delim) return str;
str++;
}
return NULL;
}
看起来很简单对吧?但问题在于,这个函数被调用了上亿次,每次处理的数据都在不同的内存页上。缓存根本来不及加载。
优化方案:把输入数据按块处理,一次加载一个缓存行(64字节),然后在寄存器里搜索:
char* find_delimiter_fast(char* str, char delim) {
// 对齐到16字节边界
while ((uintptr_t)str & 0xF) {
if (*str == delim) return str;
if (*str == '\0') return NULL;
str++;
}
// 一次处理16字节
uint64_t delim_pattern = delim;
delim_pattern |= delim_pattern << 8;
delim_pattern |= delim_pattern << 16;
delim_pattern |= delim_pattern << 32;
while (1) {
uint64_t* chunk = (uint64_t*)str;
uint64_t data = *chunk;
// 检查是否包含分隔符
uint64_t match = (data ^ delim_pattern);
uint64_t low = match & 0x7F7F7F7F7F7F7F7FULL;
uint64_t high = ~data & 0x8080808080808080ULL;
uint64_t result = (low + 0x0101010101010101ULL) & high;
if (result) {
// 找到分隔符
int pos = __builtin_ctzll(result) >> 3;
return str + pos;
}
// 检查是否遇到字符串结束
uint64_t null_check = (data - 0x0101010101010101ULL) & ~data & 0x8080808080808080ULL;
if (null_check) {
// 在剩余字节中查找
for (int i = 0; i < 8; i++) {
if (str[i] == '\0') return NULL;
if (str[i] == delim) return str + i;
}
}
str += 8;
}
}
优化后,perf stat显示IPC从0.6提升到了1.8,cache-miss率从20%降到了3%。整个程序的吞吐量提升了2.5倍。
关键点:这个优化利用了SIMD思想,但用的是纯C代码。核心思路是减少内存访问次数,一次加载8字节,在寄存器里做比较。你想想看,原来每字节都要访问一次内存,现在8字节才访问一次,缓存压力自然小多了。
总结
perf这套工具链,说白了就是让你能「看到」CPU内部发生了什么。硬件计数器告诉你宏观指标,perf record告诉你热点函数,火焰图让你一眼看穿调用链。三者配合,大部分性能问题都能定位。
我个人觉得,性能优化最重要的不是技巧,而是「测量」。没有数据支撑的优化都是瞎猜。perf就是那个给你数据的人。用好它,你的优化工作会事半功倍。
最后提醒:perf需要root权限才能访问硬件计数器。如果你在容器里跑,记得加--privileged参数。另外,虚拟化环境下硬件计数器可能不准确,这点要注意。
好了,这一讲就到这里。记住,工具只是手段,理解硬件原理才是根本。下次遇到性能问题,先跑个perf stat看看,别急着改代码。
公众号:蓝海资料掘金营,微信deep3321