很多人跑perf stat,第一眼看的往往就是cache-misses这个计数器。数字一高就觉得缓存没用明白,可问起这个数到底是怎么来的、高在哪里、改哪个函数能降下来,又说不清楚。这篇我不会去复述 perf 的手册页,而是以一次真实做过的 cache 优化过程为线索,把 perf 从统计计数、采样热点、定位到源码行的完整链路走一遍。你可以把它当成一份操作笔记,也可以直接抄里面的命令和思路去排查自己的程序。
这个内容适合两类人:一是已经会用gcc -O2编译程序、但还没系统用过 perf 的性能分析初学者;二是写过一些性能代码、知道 cache miss 是瓶颈却不知道怎么定位到具体代码行的开发者。读完你应该能回答三个问题:Cache 优化到底在优化什么、perf 哪几个命令能告诉你 miss 在哪、以及改完代码后怎么验证是真的变快了。
1. 为什么优化Cache利用率是性能调优的关键
先别急着敲命令,我和很多开发者的经验一致:如果对 Cache 的工作原理没有基本概念,perf 输出的那一串计数器只会让你更迷茫。你要优化一个东西,前提是知道它为什么慢。
1.1 Cache失效的代价远比你想的更贵
现代 CPU 的算力早就不是瓶颈了,瓶颈往往在数据搬运上。CPU 访问 L1 Cache 大概 1 纳秒,访问 L2 大概 3-5 纳秒,访问主内存则是 100 纳秒上下。这中间差了两个数量级,换算成 CPU 周期更直观:L1 命中大约 4 个周期,L2 命中大约 12 到 15 个周期,最后一级 Cache(LLC)命中 40 个周期左右,而真正落到内存是一次 200 到 300 周期的访问。也就是说,一次 cache miss 可能需要等上几百个周期,期间 CPU 流水线只能停在那里。
我在实际项目里经常遇到一个现象:某个核心函数明明 CPU 占用率很高,但你用perf stat看instructions和cycles的比例,会发现 IPC 只有 0.5 左右。说明 CPU 有一大半时间在等数据,而不是在执行指令。这种情况再去抠指令数没有意义,真正要解决的是把数据放到离 CPU 更近的地方,也就是提高 cache 利用率。
所以 Cache 优化不是"锦上添花",而是很多服务延迟和吞吐问题的根因。比如同样一个 hash 查找逻辑,如果 key 分散在内存各处,每次访问都是 miss,性能可能差 3 到 5 倍。这个差距远比你在代码里省几条指令来得大。
1.2 Cache Miss怎么分类,perf里对应什么事件
理解 miss 的类型对后续用 perf 定位问题非常有帮助。通常我们按产生原因把 cache miss 分三类:
- 强制失效(compulsory miss):数据第一次被访问时缓存里必然没有,这种 miss 没法完全消除,只能靠预取提前把数据拉进 cache。
- 容量失效(capacity miss):工作集超过了 cache 容量,前面的数据被挤出去了,后面再次访问时又要从内存拿。这对应的是程序的内存占用相对于 cache 太大。
- 冲突失效(conflict miss):数据在 cache 中映射到了同一个组里,明明 cache 还有空位,但因为映射关系互相挤占。这种情况在路数(associativity)较少的 cache 上更容易出现。
对应到 perf 的事件上,cache-misses和cache-references是通用硬件事件,很多架构上cache-misses统计的其实就是最后一级 Cache 的 miss。如果你用的是 Intel 平台,还可以进一步拆:LLC-load-misses、LLC-store-misses、LLC-loads、LLC-stores。这一步非常关键,因为我见过不少人拿cache-misses数值去推 L1 命中率,完全牛头不对马嘴。perf 里 L1 的 miss 事件通常叫l1d-load-misses,和cache-misses含义不同。
实操中,我一般先看整体:cache-misses / cache-references得到 miss 率;再细化到LLC-load-misses / LLC-loads看最后一级的命中情况。如果 LLC miss 率很高,基本可以断定瓶颈在内存访问。如果 LLC miss 率不高但总时间很长,那问题可能在锁竞争、分支预测或其他地方,别死磕 cache。
2. 用perf定位Cache性能问题的具体方法
perf 是个工具集,不是单一命令。针对 cache 优化,最常用的三个命令是perf stat、perf record+perf report、perf annotate。我带过不少同事,发现最容易犯的错是上来就perf record -g抓栈,抓完发现热点函数全是编译器生成的 memset 或 memcpy,完全看不出问题。正确的做法应该是先 stat 后 record,先看计数器再采样。
2.1 先用perf stat建立基线:看什么指标,怎么算命中率
我把建立基线这步看得很重。没有基线,后面任何优化都无法量化。先跑一遍:
perf stat -e task-clock,cycles,instructions,cache-references,cache-misses ./your_program如果是在支持具体事件细分的平台,可以再补一条:
perf stat -e LLC-loads,LLC-load-misses,LLC-stores,LLC-store-misses ./your_program输出里你会看到类似这样的内容:
1,234,567,890 cycles 2,345,678,901 instructions 456,789,012 cache-references 67,890,123 cache-misses那么:
- IPC(每周期指令数)= instructions / cycles,一般大于 1.0 算健康,低于 0.7 就说明大概率在等内存或锁。
- 通用 cache miss 率 = cache-misses / cache-references,这个数值是最后一级 cache 视角。
- LLC miss 率 = LLC-load-misses / LLC-loads,这个更能反映真实内存访问压力。
建立基线的时候,建议关闭 CPU 频率 scaling 带来的干扰。我用得比较多的做法是taskset绑核,配合 cpupower 把核固定在最高频。否则同一份代码,前一分钟跑在 1.5GHz,后一分钟睿频到 4GHz,task-clock和cycles都会失真。
另外一个要点是要跑足够的次数。我自己的标准是至少 5 次,取中位数,而不是平均值。perf 的硬件计数器本身精度很高,但系统的调度噪声、其他进程的中断都会干扰单次测量。取中位数可以剔除异常跳动。
2.2 用perf record和annotate找到"哪一行代码在制造miss"
perf stat告诉你是高是低,perf record则告诉你是哪个函数、哪一行。这里很多人会直接采样cycles或instructions,我更推荐针对事件采样:
perf record -g -F 999 -e cache-misses ./your_program perf report -n --stdio-F 999是采样频率,单位 Hz。-g是记录调用栈。设 999 而不是 1000,是因为有些内核版本对整数采样频率有锁相位问题,1999、999 这类奇数频率能有效避免周期性采样和程序相位的巧合,让热点分布更真实。
perf report出来后,先看Samples列,让 cache-misses 占比最高的函数排在最前面。这个函数就是你要开刀的地方。
下一步用perf annotate定位到具体源码行:
perf annotate --stdio --symbol=你的热点函数名它会同时显示汇编和对应的源码行,并且用百分比标出每一行指令的 cache miss 占比。我自己看 annotate 输出有个经验:重点看访存指令,也就是mov、load这一类操作数在内存里的指令。如果某个mov指令的 miss 比例异常高,说明它频繁访问的内存区域不在 cache 里,上下文中的地址往往是某个数组或结构体字段。
这一步不需要你在脑子里跑模拟器,perf 已经帮你把证据链补全了。真正难的是解读:miss 高的指令到底在访问什么数据结构、为什么总是 miss。这就进入下一步,也是最核心的代码分析环节。
2.3 常用perf模块与适用场景
除了stat、record、annotate三板斧,还有两个值得在 cache 优化中使用的扩展:perf mem和perf c2c。
perf mem record能够记录内存访问的延迟分布,再通过perf mem report查看哪些内存访问延迟最高。它在定位 NUMA 访问远端内存、TLB miss、页表开销等问题时很有用。cache 优化做到后期经常会碰到这些交叉问题。
perf c2c是专门分析 cacheline 伪共享的工具。如果你发现cache-misses集中在被多个线程修改的共享变量上,可以用它看是哪些不同的 CPU 在打架。多线程程序的 cache 优化,c2c 基本是首选工具。
需要注意,这两个模块在部分虚拟化环境或老内核上不可用。碰到提示not supported的时候不要死磕,改用perf stat -e细分事件逐个排查。工具永远是辅助,理解数据到代码的映射才是核心能力。
3. 一个完整的Cache优化实例:某日志处理程序从FP到优化落地
为了不牵扯具体业务,我用「某日志处理程序」这个虚构项目来说明完整流程。背景是一个后端服务里的解析模块,每天处理大量结构化日志,每个日志条目有几十个字段。优化前,单条日志的平均处理时间大约是 8 微妙。压测时发现服务整体吞吐上不去,CPU 占用居高不下,但负载就是不高不低卡在 70% 左右。
3.1 项目背景与基线数据
核心数据结构是一个结构体数组,里面有时间戳、级别、来源 ID、一段最长 64 字节的消息文本、若干业务字段。为了表示清楚,代码大致长这样:
struct log_entry { uint64_t ts; uint32_t level; uint32_t src_id; char message[64]; double metric; // 其他业务字段若干 }; struct log_entry entries[MAX_ENTRIES];程序的主流程是遍历这条数组,对metric字段求和,按src_id统计数量,还要按level过滤。听起来就是一个简单的扫描,但实测数据很不理想。
基线数据用perf stat跑出来是这样的:
| 指标 | 数值 |
|---|---|
| 任务运行时间 | 约 2.1 秒 |
| CPU cycles | 约 51 亿 |
| 指令数 | 约 28 亿 |
| IPC | 0.55 |
| cache-references | 约 3.5 亿 |
| cache-misses | 约 1.02 亿 |
| cache miss 率 | 约 29% |
| LLC-load-misses | 约 9000 万 |
这个 miss 率对顺序扫描程序来说太高了。理论上顺序访问内存,cache 命中率应该很高才对。于是我用perf record -e cache-misses采样,perf report显示热点集中在遍历结构体的循环里,perf annotate进一步把问题定位到访问metric和src_id的那几条访存指令上。
接下来要思考的是:为什么顺序访问结构体数组还会大量 miss?
原因在于结构体数组的布局。log_entry里包含一个 64 字节的message,加上其他字段,单个条目打包下来轻松超过 128 字节。每次循环访问metric时,虽然访问下一个条目的metric地址只往后挪了一个结构体的距离,但中间夹着大量用不到的数据。一个 64 字节的 cacheline 里,能装下的有用字段没几个,大部分空间都被白占。更麻烦的是message内容通常很大,把后续条目的数据从 cache 里挤出去。最终的结果就是:你读的字段很稀疏,cache 利用率极低。
3.2 第一次优化:结构体数组改成并行数组
想通之后,解决方向就清晰了:把log_entry改成结构数组(SoA,Structure of Arrays)。按字段拆分成多个连续数组,遍历哪个字段,哪个字段的数据就是紧挨着的:
double *metrics; uint32_t *src_ids; uint64_t *timestamps; char (*messages)[64];改成这样之后,遍历metrics数组时,每次 cacheline 拉进来的 64 字节里几乎全部都是下一个要用的metric数据。连续内存访问的 cache 利用率理论上可以接近 100%。
代码改动不大,前后台接口都不用动,只是把内部的存储布局换了。重新编译,再跑同样的压测:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 运行时间 | 2.1 秒 | 1.4 秒 |
| cache miss 率 | 29% | 11% |
| IPC | 0.55 | 0.91 |
这一趟改动,唯一的成本是把存储布局重构成 SoA,收益是 33% 的耗时下降。没有任何算法层面的花活,就是数据局部性的胜利。这也是为什么我一直强调,cache 优化首先是数据结构优化,不是指令优化。
3.3 第二次优化:二维数组遍历顺序的调整
同一份程序里还有另一个典型问题:一个二维数组的统计逻辑。原始代码写了双重循环,但顺序是i在外、j在内,j表示列方向。问题在于 C 语言的多维数组按行优先存储,内层循环遍历列意味着每个元素的地址跨了整行的距离,访问间隔远大于 cacheline。
for (int i = 0; i < ROWS; i++) for (int j = 0; j < COLS; j++) sum += matrix[i][j];改成内层循环遍历行:
for (int j = 0; j < COLS; j++) for (int i = 0; i < ROWS; i++) sum += matrix[i][j];看起来只是交换循环顺序,但对 cache 来说是质的改变。按行遍历时,相邻元素在内存中相邻,CPU 拉进 cacheline 的数据马上能用。按列遍历时,每读一个元素都要跳到很远的地址,每次基本都是一次 miss。两种写法的运行时间差距在数组变大后可以达到 5 倍以上。
修改后二次跑 perf,数据如下:
| 指标 | SoA 之后 | 循环交换之后 |
|---|---|---|
| 运行时间 | 1.4 秒 | 1.1 秒 |
| cache miss 率 | 11% | 7% |
到这里,原先的 2.1 秒变成了 1.1 秒,程序吞吐压测刚好过了阈值。这个案例能说明问题:perf 不只是给你波形图看热闹的,它能清楚告诉你 miss 出现在哪,而分析之后发现大部分问题的本质都是"数据布局和访问顺序不匹配硬件特性"。
3.4 第三次优化:prefetch指令,什么时候值得用
做完前两步,我还想尝试预取。代码里有一段分支密集的逻辑,根据不同的level走不同处理路径,提前判断之后必然要访问某个数组的特定下标。这个下标从业务逻辑上能提前知道,于是可以用__builtin_prefetch手动把数据拉进 cache:
__builtin_prefetch(&table[next_index].key, 0, 3);第三个参数是局部性等级,取值 0 到 3,3 代表希望数据留在所有层级的 cache 中。这里要说实话,手动 prefetch 的效果远没有大多数人想象中那么明显,尤其在现代 CPU 的硬件预取器已经很强的情况下。在这个项目里,手动加 prefetch 后运行时间从 1.1 秒变成 1.08 秒,基本在噪声范围内。
我的经验是:能靠改数据结构布局解决的,不要优先上 prefetch。硬件预取器对规则访问模式的识别已经相当好,手动 prefetch 只有在访存模式不规则、且你能提前足够周期知道地址时才有效。如果 prefetch 距离太近,数据还没拉进来就要用,照样 miss;距离太远,又可能把有用的 cacheline 挤出去,得不偿失。对多核缓存一致性的干扰也要考虑进去。
4. 常见问题与排查技巧实录
perf 用多了自然会碰到各种坑。这里整理几个我实际踩过的,给第一次用的人排排雷。
4.1 采样不到热点/权限问题
刚接触 perf 的人大概率会遇到三件事:提示permission denied、采样出来全是unknown、或者事件计数全是 0。
权限问题通常是/proc/sys/kernel/perf_event_paranoid设置限制导致的。临时查看一下:
cat /proc/sys/kernel/perf_event_paranoid如果值为 3 或更高,普通用户基本什么都干不了。调试机上可以临时调低,注意这只是开发环境做法,生产环境不要乱动权限配置。另一种方式是以足够的权限运行 perf,但你要提前确认所在环境是否允许这样做。
采样全是 unknown 或者函数名解析不出来,多数是因为程序被 strip 了符号,或者 perf 没找到 debug 符号。编译时用-g,不要用-s等去符号选项。对动态库,可以先跑perf buildid-list看看能否拿到 build-id,再用perf archive归档符号。
事件计数为 0 还有一种可能是写错了事件名。perf 的 PMU 事件名依赖具体 CPU 架构,比如cache-misses在大多数 x86 上能用,但如果是 ARM 平台,能用的事件名可能不同。先跑perf list看当前支持的硬件事件列表,再挑事件。
4.2 短程序测不准:循环次数与采样频率
如果一个程序整体运行时间只有几十毫秒,perf stat测出来的数据往往波动很大。不要在这么短的时间窗口里纠结单个指标,我对短程序的做法是:把核心操作放大到至少 1 到 2 秒的运行时长。具体手段是在测试程序里加足够多的重复次数,或者固定在一个数据规模上反复跑多轮。
采样频率也要匹配程序的运行时长。-F 999的意思是每秒采 999 个样本,跑 1 秒大概采到 1000 个样本。样本太少的话,perf report的统计结果不稳定。如果你看到热点函数的样本数量只有几十个,数据基本不可信,要么加长运行时间,要么提高采样频率,但频率过高会增加性能开销,反过来影响测量。
我自己的标准是热点函数的 sample 数量不低于 500 个,整体样本在 2000 以上才敢下结论。
4.3 编译优化、超线程与绑定策略带来的假象
编译器优化级别会影响 cache miss 的分布。-O0编译的代码经常因为栈上局部变量的反复存取制造假性 miss,-O2之后这些访问会被优化进寄存器,miss 分布才接近真实热点。所以性能分析请用和线上发布一致的编译选项,不要去分析一个从未上线的调试版本。
超线程对 cache 测量的影响也很隐蔽。同一个物理核上的两个逻辑核共享 L1 和 L2,如果系统调度把你的程序在两个逻辑核之间来回迁移,你看到的 cache-misses 会被人为放大。用taskset -c 0之类的把程序固定在一个核上,可以减少这种调度噪声。固定核之后,还要确认 CPU 频率没有被省电策略压低,否则 IPC 和周期数据同样失真。
另外,容器或虚拟机里跑 perf 需要额外小心。很多虚拟化平台无法完全暴露 PMU 计数器,perf 只能看到虚拟化层模拟的事件,数据参考价值有限。真碰上这种情况,可以考虑在宿主机上做基准,或者在容器内用软件事件(比如cpu-clock)做相对对比。
5. 把perf固化进日常优化工作流
单次 perf 分析只能算一次体检,真正让团队受益的是让 perf 的测量成为日常开发的一部分。
5.1 一个可复用的perf对比脚本
我习惯在项目里维护一个简单的脚本,输入是程序路径和绑核参数,输出是一个时间戳标记的 perf stat 结果文件。脚本本身不复杂,思路是每次改动前后都自动留底,方便回溯。
#!/bin/bash set -e PROG=$1 PERF_DATA="perf_$(date +%Y%m%d_%H%M%S)" taskset -c 2 perf stat -x, -e task-clock,cycles,instructions,cache-references,cache-misses,LLC-loads,LLC-load-misses \ -o "${PERF_DATA}.stat" "$PROG" perf record -g -F 999 -e cache-misses -o "${PERF_DATA}.perfdata" -- "$PROG"-x,让输出变成 CSV 格式,后续可以用脚本批量对比。每次改动前后,把perf_*.stat放到同一个目录,跑一个简单的 diff 就能看到 cache miss 率、IPC 的变化趋势。别再靠记忆里的"好像快了一点",所有优化都从这份数据里找证据。
5.2 数据如何纳入版本管理
在代码仓库里,我不会直接存放 perf 原始数据,因为二进制文件体积大且没法有效 diff。我推荐把关键指标沉淀为文本摘要,也就是每次优化后,把perf stat的 CSV 结果、perf report的热点函数 top 10、运行时间压测结果一起放进一个benchmarks/目录,以日期和 commit 号命名。
有了历史基线,后续任何一次重构,只要跑一遍同样的脚本,马上能判断有没有引入 cache 回退。我踩过这样的坑:某次重构后功能测试全过,但一周后才发现关键路径变慢了 15%,因为当时没有在 merge 前重跑 perf 基线。现在我们把 perf 对比作为合入前的检查项之一,推送代码时如果 cache miss 率明显上升,CI 会直接拦截并提醒人工确认。
这个流程的成本很低,但避免了大量"上线后发现变慢再回滚"的尴尬局面。
最后再分享一个个人习惯:改完代码后,不要只盯着cache-misses的绝对数值。先跑一次perf stat确认 miss 率确实降了,再跑完整 benchmark 看总耗时。如果 miss 率降了但耗时没变,说明瓶颈可能不在 cache,而在别的地方,比如内存带宽、锁、或者是系统调用。如果 miss 率没降但耗时涨了,那更要警惕,可能只是改动把 cache 压力转移到了别处。
perf 最大的价值,是让你把"感觉程序慢"变成可验证的数据判断。优化 cache 利用率这件事,前期看起来像是内存布局的书本知识,但真正操作一遍会发现,它就是在排查数据访问和硬件特性之间的错位。先找到错位的地方,再调整数据组织方式,性能提升往往比你想的顺利得多。