24、性能优化的重构:性能分析工具(gprof/perf)、热点代码优化、缓存友好设计
性能优化这件事,我做了快二十年,最大的体会是:别猜,要测。
很多程序员拿到一段慢代码,第一反应是「这里该加缓存」、「那里该用位运算」。结果呢?优化了半天,发现瓶颈根本不在那儿。我见过最夸张的一次,团队花了两周优化一个排序算法,最后用 perf 一跑,发现 80% 的时间花在 malloc 上——排序本身只占了 5%。
所以这一章,咱们就聊聊怎么用工具说话。
24.1 性能分析工具:gprof 与 perf
工欲善其事,必先利其器。C 语言性能分析,我主要用两把刀:gprof 和 perf。前者轻量,适合快速定位;后者深入,适合内核级分析。
24.1.1 gprof:入门级性能分析
gprof 是 GNU 工具链自带的。用法很简单:编译时加 -pg 选项,运行程序,然后执行 gprof ./a.out gmon.out。
它会告诉你两件事:
- 每个函数花了多少时间(flat profile)
- 函数调用关系(call graph)
我个人习惯先看 flat profile。如果某个函数占了超过 20% 的 CPU 时间,那它就是「热点」。举个例子:
// 编译:gcc -pg -o test test.c
// 运行:./test
// 分析:gprof ./test gmon.out
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls ms/call ms/call name
45.2 0.19 0.19 100000 0.002 0.002 compute_hash
30.1 0.32 0.13 50000 0.003 0.005 parse_packet
15.0 0.38 0.06 20000 0.003 0.008 process_data
9.7 0.42 0.04 1 40.00 42.00 main
看到没?compute_hash 占了 45% 的时间。这就是我们要优化的目标。
24.1.2 perf:现代性能分析利器
perf 是 Linux 内核自带的工具,比 gprof 强大得多。它能分析到 CPU 缓存命中率、分支预测错误、甚至内核态调用。
我最常用的命令是:
# 采样 CPU 周期
perf record -e cycles -g ./my_program
perf report
# 查看缓存未命中
perf stat -e cache-misses,cache-references ./my_program
有一次,我用 perf 发现一个函数缓存未命中率高达 40%。优化后降到了 5%,性能直接翻倍。这就是工具的价值。
-g 选项会记录调用栈。配合 perf report -g graph,你能看到热点函数是被谁调用的——有时候问题不在函数本身,而在调用者传了糟糕的参数。
24.2 热点代码优化
找到热点之后,怎么优化?我总结了三个方向:
24.2.1 减少不必要的计算
这是最直接的。看这段代码:
// 优化前
for (int i = 0; i < n; i++) {
double factor = sqrt(2.0 * PI) / exp(i * 0.5);
result[i] = data[i] * factor;
}
// 优化后
double factor_base = sqrt(2.0 * PI);
for (int i = 0; i < n; i++) {
double factor = factor_base / exp(i * 0.5);
result[i] = data[i] * factor;
}
把 sqrt(2.0 * PI) 提到循环外面,性能提升可能不大,但积少成多。我在项目中遇到过类似情况,一个循环里提了三个常量计算,整体快了 15%。
24.2.2 用查表代替计算
对于三角函数、指数函数这类耗时操作,查表往往更快:
// 优化前
double sin_val = sin(angle);
// 优化后(预计算 1024 个点)
double sin_val = sin_table[(int)(angle * 1024.0 / (2 * PI)) & 0x3FF];
嗯,这里要注意:查表会牺牲精度。如果要求高精度,别这么干。
24.2.3 减少函数调用开销
小函数频繁调用,可以考虑内联:
// 优化前
static inline int min(int a, int b) {
return a < b ? a : b;
}
// 优化后:直接用宏或手动内联
#define MIN(a, b) ((a) < (b) ? (a) : (b))
不过,现代编译器(如 GCC 的 -O2)会自动内联小函数。我建议先看汇编,再决定要不要手动内联。
24.3 缓存友好设计
这是性能优化的「高阶玩法」。CPU 缓存的速度比内存快两个数量级。如果你的代码频繁缓存未命中,性能会惨不忍睹。
24.3.1 空间局部性:按顺序访问内存
看这两个循环:
// 缓存不友好:跳跃访问
for (int j = 0; j < COLS; j++) {
for (int i = 0; i < ROWS; i++) {
sum += matrix[i][j]; // 按列访问
}
}
// 缓存友好:顺序访问
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
sum += matrix[i][j]; // 按行访问
}
}
第一个版本,每次访问 matrix[i][j] 都会跳到不同行,缓存行(通常 64 字节)只用了 8 字节。第二个版本,连续访问同一行,缓存利用率高得多。我曾经把一个矩阵转置操作从按列改成按行,快了 5 倍。
24.3.2 结构体对齐与填充
结构体成员顺序会影响缓存效率:
// 缓存不友好
struct bad {
char a; // 1 字节
int b; // 4 字节,需要对齐到 4 字节边界
char c; // 1 字节
// 实际占用:1 + 3(填充) + 4 + 1 + 3(填充) = 12 字节
};
// 缓存友好
struct good {
int b; // 4 字节
char a; // 1 字节
char c; // 1 字节
// 实际占用:4 + 1 + 1 + 2(填充) = 8 字节
};
把大成员放前面,小成员放后面,能减少填充,提高缓存利用率。
24.3.3 预取(Prefetch)
对于大数据集,可以手动提示 CPU 预取数据:
for (int i = 0; i < n; i++) {
__builtin_prefetch(&data[i + 8], 0, 1); // 预取 8 个元素后的数据
process(data[i]);
}
不过,预取是一把双刃剑。用错了反而会降低性能。我建议只在明确知道访问模式时使用。
24.4 知识体系图
下面这张图总结了本章的核心逻辑:
24.5 避坑指南
最后,分享几个我踩过的坑:
- 别优化未验证的代码:先跑 perf,再动手。我曾经凭直觉优化了一个函数,结果 perf 显示它只占 2% 的时间——白忙活。
- 缓存友好不等于代码可读:为了缓存对齐,把结构体拆得七零八落,后期维护成本很高。我建议只在热点路径上做缓存优化。
- 编译器优化可能覆盖你的手写优化:GCC 的
-O3会做循环展开、向量化。有时候你手动优化的代码,反而干扰了编译器。记得对比汇编。 - perf 需要 root 权限:有些事件(如缓存未命中)需要
sudo。我在生产环境上吃过亏——没权限,跑不了。
核心原则:性能优化是「测量-分析-修改-验证」的循环。不要跳过任何一步。工具是你的眼睛,没有工具,你就是在黑暗中摸索。
好了,这一章就到这里。记住:别猜,要测。下一章咱们聊聊多线程并发中的重构技巧——那又是另一个世界了。
公众号:蓝海资料掘金营,微信deep3321