4、perf实战:CPU热点分析:使用perf定位CPU密集型函数,优化循环和算法。
说到性能调优,我最常被问到的一个问题就是:「我的程序跑得慢,到底慢在哪?」
嗯,这个问题其实挺难回答的。没有数据,全靠猜,那跟算命没啥区别。我个人习惯,第一步就是上 perf 这个工具。它就像程序的CT机,能直接告诉你CPU的时间都花在哪些函数上了。
今天我们就来实战一把,看看怎么用perf定位CPU热点,然后优化那些拖后腿的循环和算法。
4.1 什么是CPU热点?
说白了,CPU热点就是程序里那些「吃CPU大户」的函数或代码段。它们占用了大量的CPU时间片,导致整体性能上不去。
你想想看,如果一个函数只占1%的CPU时间,你花三天去优化它,效果微乎其微。但如果一个函数占了80%的CPU时间,你稍微动一动,性能可能就翻倍了。这就是「二八定律」在性能优化里的体现。
4.2 perf的基本用法:采样与报告
perf的工作原理很简单——它每隔一段时间就打断CPU一次,记录下当前正在执行的函数。采样次数多了,就能统计出每个函数被命中的次数。命中率越高,说明这个函数越「热」。
4.2.1 准备一个「慢」程序
我们先写一个简单的C程序,里面有个明显的CPU密集型循环。我在项目中遇到过类似的情况,当时是一个图像处理算法,循环里做了大量无意义的计算。
// hot_loop.c
#include <stdio.h>
void slow_function() {
int sum = 0;
// 一个愚蠢的循环,纯属浪费CPU
for (int i = 0; i < 100000000; i++) {
sum += i * i / 3;
}
printf("sum = %d\n", sum);
}
void fast_function() {
int sum = 0;
// 稍微聪明一点的循环
for (int i = 0; i < 100000000; i++) {
sum += i;
}
printf("sum = %d\n", sum);
}
int main() {
slow_function();
fast_function();
return 0;
}
编译时记得加 -g 选项,保留调试信息,这样perf才能定位到具体的代码行。
gcc -g -o hot_loop hot_loop.c
4.2.2 运行perf采样
执行以下命令,perf会以99Hz的频率采样(即每秒采样99次),持续到程序结束。
perf record -F 99 ./hot_loop
采样完成后,会生成一个 perf.data 文件。然后我们用 perf report 查看报告。
perf report
你会看到一个类似这样的界面:
| Overhead | Command | Shared Object | Symbol |
|---|---|---|---|
| 72.34% | hot_loop | hot_loop | slow_function |
| 27.66% | hot_loop | hot_loop | fast_function |
看到了吗?slow_function 占了72%的CPU时间,而 fast_function 只占28%。这就是热点!
-a 参数对所有CPU进行采样,或者用 perf stat 先看看整体性能指标。
4.3 深入热点:perf annotate 查看汇编
光知道哪个函数慢还不够,我们得知道函数里哪一行代码最慢。perf提供了 annotate 功能,可以直接把采样结果映射到源代码和汇编指令上。
perf annotate slow_function
输出会显示每一行代码的采样百分比。比如:
│ for (int i = 0; i < 100000000; i++) {
0.02 │ mov -0x4(%rbp),%eax
│ cmp $0x5f5e100,%eax
12.34 │ ↓ jl 10
│ sum += i * i / 3;
87.64 │ imul %eax,%eax
│ mov %eax,%edx
│ mov $0x55555556,%eax
│ imul %edx
│ mov %edx,%eax
│ shr $0x1f,%eax
│ add %eax,%edx
│ add %edx,-0x8(%rbp)
│ addl $0x1,-0x4(%rbp)
│ ↓ jmp 10
看到 87.64% 那行了吗?就是 sum += i * i / 3 这条语句。其中 imul(整数乘法)和后续的除法操作占了绝大部分开销。
为什么会这样?因为 i * i / 3 涉及乘法和除法,而除法是CPU里最慢的指令之一。相比之下,fast_function 里只有 sum += i,一个加法指令,快得多。
i * i * 0.333 可能反而更慢,因为涉及浮点运算。
4.4 优化实战:从算法层面下手
找到热点之后,怎么优化?我建议从两个角度考虑:
- 减少计算量:能不能用更简单的公式?
- 改变算法:能不能用空间换时间?
4.4.1 优化循环体
对于上面的例子,i * i / 3 其实可以改写。如果我们知道 i 的范围,可以预先计算好结果,用查表代替实时计算。
void optimized_function() {
int sum = 0;
// 查表法:预先计算 i*i/3 的结果
static int table[100000000]; // 注意:实际中不要这样写,会爆栈
for (int i = 0; i < 100000000; i++) {
table[i] = i * i / 3;
}
for (int i = 0; i < 100000000; i++) {
sum += table[i];
}
printf("sum = %d\n", sum);
}
当然,这个例子有点极端。实际项目中,查表法常用于三角函数、开方等复杂运算。我在做嵌入式音频处理时,就用查表法优化过 sin 函数,性能提升了5倍。
4.4.2 减少循环次数
有时候,循环本身的开销也不小。比如循环里有个 if 判断,能不能提到循环外面?
// 优化前
for (int i = 0; i < N; i++) {
if (flag) {
do_something(i);
} else {
do_other(i);
}
}
// 优化后
if (flag) {
for (int i = 0; i < N; i++) {
do_something(i);
}
} else {
for (int i = 0; i < N; i++) {
do_other(i);
}
}
这样避免了每次循环都判断 flag,减少了分支预测失败的概率。
4.5 用perf验证优化效果
优化完,别急着高兴。我们得用数据说话。再次运行perf,看看优化后的热点分布。
perf record -F 99 ./hot_loop_optimized
perf report
如果优化有效,你会发现 slow_function 的占比大幅下降,甚至可能被 fast_function 反超。我曾经优化过一个网络协议栈,优化前一个函数占85%的CPU,优化后降到了30%。那种成就感,嗯,比写新功能还爽。
4.6 避坑指南
- 不要优化非热点:我曾经花了一整天优化一个只占2% CPU的函数,结果整体性能几乎没变化。后来才意识到,那2%根本就不是瓶颈。
- 注意编译器优化:有时候你写的代码,编译器在
-O2下已经帮你优化掉了。用perf分析时,最好用-O0编译,或者对比不同优化级别的结果。 - 采样频率别太高:
-F 99是常用值。如果设成-F 9999,采样本身就会消耗大量CPU,影响结果准确性。 - 多线程程序注意:perf默认只采样当前进程。对于多线程程序,可以用
-a或--per-thread参数。
4.7 本章小结
perf的核心价值,就是帮你从「盲人摸象」变成「精准打击」。找到热点,分析热点,优化热点——这三步走下来,大部分性能问题都能解决。
我个人习惯,每次写完代码都会跑一遍perf,看看有没有「意外之喜」。有时候你会发现,自己以为很高效的代码,其实藏着不少坑。嗯,这就是工具的价值。