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.plflamegraph.pl是Brendan Gregg提供的脚本,可以从GitHub下载。

火焰图怎么看?记住三点:

  • 看宽的地方:矩形越宽,说明这个函数占用的CPU时间越多。这就是瓶颈。
  • 看顶部:顶部的函数是实际执行代码的地方。底部是调用者。
  • 看颜色:颜色没有特殊含义,只是为了区分不同函数。红色通常表示热点。

避坑指南:我曾经遇到一个情况,火焰图上显示malloc占了很大一块。我以为是内存分配太频繁,优化了半天没效果。后来仔细一看,调用链是main -> process -> malloc,但malloc下面还有一层mmap。原来问题不是malloc本身,而是它触发了系统调用。嗯,火焰图要结合调用链一起看,不能只看表面。

SVG火焰图示例

下面是我用SVG画的一个简化版火焰图,展示了一个网络服务程序的CPU热点分布:

CPU热点火焰图(简化版) main (100%) process_packet (100%) parse_header (40%) handle_payload (60%) memcpy (17%) strcmp (11%) atoi (10%) compress_data (29%) encrypt (17%) send (12%) lz77 (14%) huffman (15%) 图例: 热点 中等 冷门 说明:矩形宽度代表CPU占用比例,从上到下为调用链。 可以看到 compress_data 和 encrypt 是主要瓶颈。

从这张火焰图可以清楚看到: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