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时间比例。从下往上看,底层是调用者,上层是被调用者。

生成火焰图需要几个工具:

  1. perf script:把perf.data转换成可读的文本格式
  2. 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性能分析工具核心用法 perf 采样模式 (Sampling) 计数模式 (Counting) 定位热点函数 perf record / perf report 分析硬件事件 perf stat / 缓存未命中 火焰图生成与解读 perf script → FlameGraph → SVG 采样找热点 → 计数找原因 → 火焰图可视化

避坑指南

用perf和火焰图时,有几个坑我踩过,分享给你:

  • 采样频率别太高:默认的1ms采样间隔就够用。设得太高(比如0.1ms)反而会引入大量采样开销,影响结果准确性。
  • 注意编译器优化:用-O2编译的代码,函数可能被内联,调用栈会变短。这时候火焰图看起来“矮”了,但实际性能可能更好。我建议调试时用-O0 -g,优化时用-O2再测一次。
  • 多线程程序要小心:perf默认只采样当前进程。多线程程序要用-t指定线程ID,或者用-a全系统采样。
  • 火焰图不是万能的:它只显示CPU时间。如果你的程序在等锁、等I/O,火焰图上看不到。这时候需要结合其他工具。

我的习惯:每次优化前,先跑一次perf stat记录基线数据。优化后再跑一次,对比IPC、cache-misses等指标。这样能量化优化效果,而不是凭感觉说“好像快了”。

好了,perf和火焰图的核心用法就这些。工具再好,也得动手用。找个你手头的项目,跑一遍perf,生成一张火焰图,看看能不能找到性能瓶颈。相信我,第一次看到火焰图的时候,你会觉得——原来我的程序是这样跑的!


公众号:蓝海资料掘金营,微信deep3321