性能优化:profiling工具(gprof/perf)、热点函数识别、缓存友好代码
性能优化这事儿,说简单也简单,说难也难。我见过太多人一上来就凭感觉改代码,结果改了半天,瓶颈压根不在这儿。你想想看,要是连问题在哪儿都不知道,怎么优化?
所以今天咱们聊的,就是怎么用工具说话。用数据告诉你,程序到底慢在哪儿。
为什么需要profiling?
我刚开始写C程序那会儿,也犯过傻。觉得某个函数写得不够优雅,就重写一遍。结果性能没提升,反而多了几个bug。后来带我的老工程师跟我说了一句话,我一直记着:「不要猜,要测。」
Profiling就是干这个的。它帮你回答三个问题:
- 程序把时间花在哪儿了?
- 哪些函数被调用了最多次?
- 缓存命中率怎么样?
没有这些数据,你就是在黑暗中开枪。
gprof:入门级profiling工具
gprof是GNU工具链自带的,用起来非常方便。我个人习惯用它做第一轮分析,因为上手快,结果直观。
用法很简单,三步走:
- 编译时加上
-pg标志 - 运行程序,会生成
gmon.out文件 - 执行
gprof ./a.out gmon.out查看结果
来看个例子:
// 编译:gcc -pg -o myapp main.c
// 运行:./myapp
// 分析:gprof ./myapp gmon.out
// 示例代码
#include <stdio.h>
void slow_function() {
for (int i = 0; i < 100000000; i++) {
// 模拟耗时操作
volatile int x = i * i;
}
}
void fast_function() {
for (int i = 0; i < 1000; i++) {
volatile int x = i + 1;
}
}
int main() {
slow_function();
fast_function();
return 0;
}
运行gprof后,你会看到类似这样的输出:
Flat profile:
Each sample counts as 0.01 seconds.
% cumulative self self total
time seconds seconds calls ms/call ms/call name
99.8 0.45 0.45 1 450.00 450.00 slow_function
0.2 0.45 0.00 1 0.00 0.00 fast_function
看到没?slow_function 占了99.8%的时间。这就是热点函数。
核心要点:gprof适合做「函数级」分析。它能告诉你每个函数花了多少时间,被调用了多少次。但注意,它只能分析单线程程序,而且对短时间运行的函数精度不够。
我的小技巧:用gprof之前,先确保程序运行时间在几秒以上。太短的话采样点不够,结果不准。我曾经为了测试一个微秒级的函数,特意在循环里跑了100万次——嗯,这样gprof才能抓到它。
perf:更强大的性能分析工具
gprof虽然好用,但局限性也很明显。它只能做函数级分析,而且需要重新编译。perf就不一样了——它是Linux内核自带的工具,可以分析整个系统,包括内核代码。
perf能做的事情太多了,我挑几个最常用的:
| 命令 | 作用 |
|---|---|
perf stat ./myapp |
统计程序运行时的硬件事件(CPU周期、缓存未命中、分支预测失败等) |
perf record ./myapp |
采样记录性能数据 |
perf report |
分析采样结果,显示热点函数 |
perf top |
实时查看系统热点 |
举个例子,我想知道程序缓存命中率怎么样:
perf stat -e cache-references,cache-misses ./myapp
Performance counter stats for './myapp':
123,456,789 cache-references
12,345,678 cache-misses # 10.00% of all cache refs
10%的缓存未命中率,说实话有点高了。一般低于5%才算健康。
避坑指南:我曾经在虚拟机里用perf,结果发现数据完全不对。后来才意识到,虚拟机里的硬件计数器是模拟的,不准。所以——perf最好在物理机上跑,或者至少确认你的虚拟化环境支持硬件性能计数器透传。
热点函数识别:从数据到行动
拿到profiling数据之后,怎么判断哪些函数值得优化?我一般看三个指标:
- 自耗时(self time):函数本身执行的时间,不包括子函数。这个值高,说明函数内部有瓶颈。
- 调用次数:一个函数被调了100万次,哪怕每次只花1微秒,加起来也很可观。
- 缓存未命中率:这个容易被忽略,但往往是性能杀手。
举个例子,假设你看到这样一个结果:
% cumulative self self total
time seconds seconds calls ms/call ms/call name
40.0 0.20 0.20 100000 0.002 0.005 process_item
30.0 0.35 0.15 1 150.00 200.00 load_data
20.0 0.45 0.10 5000 0.020 0.025 sort_data
process_item 被调了10万次,自耗时40%。虽然每次只有0.002毫秒,但架不住次数多。这种函数就值得优化——哪怕每次省0.001毫秒,整体也能省100秒。
而 load_data 虽然只调了一次,但花了150毫秒。如果数据加载是瓶颈,可以考虑异步加载或者内存映射。
缓存友好代码:让数据靠近CPU
说到缓存,我得先讲个故事。有一次我优化一个图像处理程序,把算法复杂度从O(n²)降到了O(n log n),结果性能只提升了20%。我当时就懵了——理论上应该快好几倍才对。
后来用perf一看,发现缓存未命中率高达30%。原来我的数据是随机访问的,CPU每次都要去内存里取数据,慢得不行。算法再快也没用,CPU在等数据呢。
这就是缓存友好的重要性。说白了,就是让CPU尽量在缓存里找到它要的数据,别老去内存里翻。
几个实用技巧:
- 顺序访问:遍历数组时,按内存顺序访问。比如二维数组,先按行遍历,别按列。
- 数据对齐:把结构体里的成员按大小从大到小排列,减少填充字节。
- 局部性原理:把经常一起访问的数据放在一起。比如一个结构体里,把热点字段放在前面。
来看个对比:
// 缓存不友好:按列遍历
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]; // 顺序访问,缓存命中率高
}
}
按行遍历比按列遍历快多少?我在x86机器上测过,大概快3-5倍。原因很简单:按行遍历时,CPU预取机制能正常工作,把下一行数据提前加载到缓存里。
记住:现代CPU的缓存行通常是64字节。也就是说,你访问一个int(4字节),CPU会把相邻的60字节也一起加载进来。如果你能利用好这60字节,性能就能起飞。
实战:用perf定位缓存问题
咱们来一个完整的例子。假设你写了一个链表遍历的程序,感觉慢:
#include <stdio.h>
#include <stdlib.h>
typedef struct node {
int data;
struct node *next;
} Node;
int main() {
// 创建100万个节点,随机分配内存
Node *head = NULL;
for (int i = 0; i < 1000000; i++) {
Node *n = malloc(sizeof(Node));
n->data = i;
n->next = head;
head = n;
}
// 遍历链表
long sum = 0;
for (Node *p = head; p != NULL; p = p->next) {
sum += p->data;
}
printf("sum = %ld\n", sum);
return 0;
}
用perf分析:
perf stat -e cache-misses,cache-references,instructions,cycles ./linkedlist
Performance counter stats for './linkedlist':
45,678,901 cache-misses
123,456,789 cache-references # 37.0% miss rate
1,234,567,890 instructions
2,345,678,901 cycles
0.85 seconds time elapsed
37%的缓存未命中率!这就是问题所在。链表节点是随机分配的,遍历时CPU得不停地去内存里取数据。
怎么优化?换成数组:
// 用数组代替链表
int *arr = malloc(1000000 * sizeof(int));
for (int i = 0; i < 1000000; i++) {
arr[i] = i;
}
long sum = 0;
for (int i = 0; i < 1000000; i++) {
sum += arr[i];
}
再跑一次perf,缓存未命中率降到5%以下,时间从0.85秒降到0.12秒。这就是缓存友好的威力。
我的建议:写代码时,优先考虑数组而不是链表。除非你确实需要频繁插入删除,否则数组的缓存友好性完胜链表。我在项目中用这个原则优化过一个消息队列,性能提升了6倍。
知识体系总览
下面这张图总结了本章的核心内容:
总结
性能优化不是玄学,是科学。工具给你数据,你根据数据做决策。gprof适合快速定位热点函数,perf能深入分析硬件层面的问题,而缓存友好代码则是把理论落到实处的关键。
我最后再啰嗦一句:别优化你还没测量的代码。你以为的瓶颈,往往不是真正的瓶颈。让工具告诉你答案。
公众号:蓝海资料掘金营,微信deep3321