前阵子我接到某后端服务一个很磨人的问题:机器是 16 核,CPU 占用始终不到一半,并发一上来,单请求延迟却变成锯齿状。常规武器都试过了——锁竞争看了、上下文切换看了、网络和磁盘队列也看了,全都没问题。最后是perf性能分析工具帮我把目光拽到了 CPU 的 cache 命中率上,一抓一个准:热点函数的 L1 cache miss 率高到离谱,大量时间都耗在等内存返回上,算法复杂度再低也跑不快。
这篇文章就围绕“用 perf 优化 cache 利用率”这条主线来写。内容是一个我在某数据处理模块上完整跑过一遍的真实过程:从用perf stat读基线,到perf record抓热点函数,再到perf annotate看到具体是哪一条访存指令在反复 miss,最后做布局优化、伪共享修复,重新量化前后差异。适合正在用 C/C++/Rust 写服务端逻辑、或者在做图像/矩阵/搜索类算法,又总觉得“代码已经很优了但就是慢”的人参考。
1. cache miss 为什么值得用 perf 单独追
1.1 先接受一个事实:内存比 CPU 慢太多
现在随便一颗桌面级 CPU,主频都在 3GHz 往上,一个周期大约 0.33 纳秒。而访问主内存的延迟,按现代 DDR 内存的常见水平来看,大概要 80 到 120 纳秒。这相当于什么概念?CPU 在这 100 纳秒里能执行 300 多个普通指令,但一个从内存取数的指令,只能干等着。
CPU 设计者为了解决这个差距,放了好几级高速缓存:L1 最快,通常几十 KB,延迟几个周期;L2 几百 KB,延迟十几到几十个周期;L3 有几十 MB,延迟几十个周期。数据如果命中 L1,花费几乎可以忽略;一旦 miss 到主内存,就是上百纳秒的代价。所以一个程序“算得快不快”,很多时候不是在比谁的指令多,而是在比谁的访存局部性好。
这里可以打个比方:你写代码就像在食堂打饭,L1 cache 是你桌面上的餐盘,L2/L3 是后厨备好的半成品,主内存才是外面的大仓库。每次都只拿一点点、反复跑仓库,人再多也快不起来;一次把一段时间要用的菜都摆到备餐区,效率完全不同。
1.2 真正会拖垮程序的三种 cache 差布局
新手容易以为 cache miss 只跟“内存太大装不下”有关,但实践中我见到的坑基本都是这三种:
第一种是遍历方向写反了。二维数组在内存里按行连续存储,你如果按列去循环,每取一个元素都要跳到很远的地址,cache line 里装载的邻居数据全浪费了。这种问题在矩阵乘法、图像滤波、数据库列存转置里特别常见,一行代码顺序调整,性能能差好几倍。
第二种是结构体太“肥”。比如一个用户记录结构体里既有频繁访问的 id、状态字段,又有很少碰的长文本、备注信息。每次加载一条记录,CPU 都会把包含这些字段的一整条 cache line(通常是 64 字节)搬进缓存。如果你只需要其中 8 个字节,剩下 56 字节都是陪跑,而缓存容量有限,很快被挤爆,下一轮访问又 miss。这就是典型的热冷数据混布。
第三种是多线程伪共享。两个线程各写各的变量,编译器把这两个变量放在了同一条 cache line 上。线程 A 哪怕只改一个字节,为了保证多核一致性,整条 cache line 都会在其他核心上失效;线程 B 再改自己那个变量时,又得重新同步。两边互相拖后腿,表现就是“线程越多越慢”。
1.3 什么时候该怀疑 cache miss
不是说每次性能差都要先怀疑 cache。我的习惯是先排除掉明显的问题:CPU 是否绑核、系统负载是否过高、有没有频繁的系统调用、锁竞争和线程调度是否异常。如果这些都正常,再看一个信号——CPU 占用不高,但程序很慢;或者算法复杂度明明降下来了,运行时间却没跟着降,这时候就该往访存效率上想了。
我在模拟项目 X 里遇到的场景就非常典型:一个 4K×4K 的浮点矩阵统计模块,代码看起来只有一个两重循环,时间复杂度就是 O(N²),怎么算都不应该跑出几百毫秒。但实测单次矩阵求和竟然要将近一秒。把perf拉出来看,cache miss 一条数据就把问题暴露了。
2. perf 能干什么:一条命令把热点钉在指令上
2.1 perf 家族命令一览:别只盯着 record/report
很多人提到 perf 就条件反射式地执行perf record加perf report,其实 perf 是一个工具箱,针对性能问题不同阶段有不同工具。
| 命令 | 典型用途 | 什么时候用 |
|---|---|---|
perf stat | 跑一遍程序,汇总各类硬件计数器 | 先量化,看全局指标是否异常 |
perf record | 按固定频率采样,生成 perf.data | 找到热点函数和调用路径 |
perf report | 展示采样结果,按开销排序 | 已经抓完数据,分析热点 |
perf annotate | 对热点函数做指令级标注 | 想看到底是哪一行汇编在耗费时间 |
perf top | 实时显示当前系统热点 | 不适合跑批任务,适合查线上瞬时状态 |
perf c2c | 做 cache line 级别的竞争分析 | 怀疑伪共享或 NUMA 访问不均时使用 |
优化 cache 利用率的标准动作,基本就是perf stat定位方向,perf record抓函数,perf annotate钉指令。三个工具配合使用,就能把问题从“程序慢”一路收敛到“这一行汇编 load 指令 miss 太多”。
2.2 采样原理:硬件计数器和周期性中断
perf 依赖现代 CPU 自带的 PMU(Performance Monitoring Unit,性能监视单元),也就是一组硬件性能计数器。这些计数器能在硬件层面记录 cache miss、分支预测失败、指令周期等事件,几乎不占用程序运行时间。需要明确一点:perf record 不是一行行单步调试,而是周期性采样。默认按照固定频率触发中断,在一个中断点上记录当前正在执行的指令地址和调用栈。样本数量够多之后,统计哪个函数出现频率高,就认为它是 CPU 开销热点。
这里有一个很实用的参数:-F 99。它表示每秒采样 99 次,故意不用 100 而用 99,是为了避免和某些固定频率的任务(比如刚好是 100Hz 的定时器)产生相位共振,导致采样结果周期性失真。以前我看过不少采出来的热点都是同一个空闲线程的情况,多半就是采样频率跟调度周期撞上了。
2.3 使用前的权限和环境准备
perf 默认会受内核参数kernel.perf_event_paranoid影响。很多 Linux 发行版默认值较高,普通用户执行perf record会提示Permission denied。最简单的临时办法是把 paranoid 值调低:
sudo sysctl -w kernel.perf_event_paranoid=-1这个设置重启后会失效,但用来做一次性能分析足够了。如果不想动系统参数,也可以用sudo perf record,只是后面生成的 perf.data 需要 root 才能读。另外要注意,很多精简容器镜像里没有perf命令,需要单独安装linux-tools或perf-tools-unstable之类的包,版本最好和内核版本匹配。
除了权限,环境稳定性也很重要。分析 cache 行为时,CPU 频率如果忽高忽低,测出来的耗时和 miss 数据会有噪声。所以要尽量绑核运行,并且临时把 CPU 调频策略锁到 performance 档,这一步我放在下一节实验环境里细说。
2.4 perf stat / record / annotate 的分工
从使用层级上来讲,perf stat是“宏观体检”。它直接读取硬件计数器,把整个程序运行期间的 cycles、instructions、cache misses 总数给出来,不涉及采样,准确性很高。缺点是只有全局总量,没有函数级信息。
perf record是“热点扫描”。它不关心总量,而是按事件触发采样。比如指定-e cache-misses,就是每次 cache miss 时打个标记,记录当前指令位置。这样采样出来的 perf.data 里,集中出现的样本就是 miss 最频繁的代码位置。
perf annotate是“显微镜”。在 report 里看到一个热点函数后,进入 annotate 视图,它会把目标函数每条汇编指令旁边的计数器值标出来,访存指令前面那一列数字特别大,问题基本实锤。这个视角对判断“到底是循环本身问题,还是某一条特定 load 导致的”特别管用。
3. 造一个合格的实验环境,把问题量化
3.1 模拟项目 X:4K 矩阵访问程序
这次优化我拿模拟项目 X 开刀,它本质上是一个 4096×4096 的 double 数组处理模块。第一步先复现问题,代码故意保留了两种访问路径:一个按行求和,一个按列求和,下面还有一个结构体数组热冷字段混用的场景。因为后面要展示优化效果,所以我把原始版本叫 v1,优化版本叫 v2。
#include <stdint.h> #include <stdio.h> #include <time.h> #define N 4096 static double matrix[N][N]; double sum_rows(void) { double s = 0.0; for (int i = 0; i < N; ++i) { for (int j = 0; j < N; ++j) { s += matrix[i][j]; } } return s; } double sum_cols(void) { double s = 0.0; for (int j = 0; j < N; ++j) { for (int i = 0; i < N; ++i) { s += matrix[i][j]; } } return s; } int main(void) { volatile double r = 0.0; r += sum_rows(); r += sum_cols(); printf("%f\n", r); return 0; }这个项目的代码故意写得非常“教科书”,因为真实业务里你很难一眼看到问题,而矩阵访问则是 cache 局部性最具代表性的场景。按行遍历时,内存地址连续递增,每 64 字节的 cache line 被完整利用;按列遍历时,每次跳 4096 个 double(约 32KB),等于把前面载入的 cache line 全部浪费掉。
3.2 编译与运行环境稳定化
编译参数我用的是-O2 -g。有人图省事直接上-O3,但优化 cache 分析时,-O3可能引入自动向量化和循环展开,导致热点分布和默认行为差异很大。我建议先用-O2看清原始访存模式,再考虑更高等级优化。
gcc -O2 -g -o demo_v1 demo_v1.c运行前做三件事:把进程绑到一个固定 CPU 上,锁住 CPU 频率,关闭无用后台任务。绑核用taskset -c 0;调频策略用cpupower:
sudo cpupower frequency-set -g performance taskset -c 0 ./demo_v1为什么要绑核?因为进程如果在多核之间迁移,cache 内容会跟着失效,测出来的 miss 数据会掺入大量调度噪声。这个细节在做优化前后对比时尤其关键,否则你无法区分改善是来自代码改动还是运气。
3.3 第一轮 perf stat:读出基线
先跑一遍全局计数,把所有跟 cache 和指令执行相关的指标都拉出来:
perf stat -e task-clock,context-switches,cpu-migrations,page-faults,cycles,instructions,cache-references,cache-misses,L1-dcache-loads,L1-dcache-load-misses,branches,branch-misses -r 3 taskset -c 0 ./demo_v1-r 3表示重复 3 次,取汇总结果,能有效减少随机波动。我这套环境上得到的大致数据如下(不同代处理器会有浮动,但量级关系不会变):
| 事件 | 数值 | 说明 |
|---|---|---|
| cycles | 约 18.6 亿 | CPU 总周期数 |
| instructions | 约 21.3 亿 | CPU 执行指令数 |
| IPC | 约 1.15 | 每周期指令数,不够高 |
| cache-references | 约 3.1 亿 | 访问缓存层次结构的次数 |
| cache-misses | 约 1.6 亿 | 末级缓存未命中次数 |
| L1-dcache-load-misses | 约 4.1 亿 | L1 数据缓存访问未命中 |
| branch-misses | 约 512 万 | 分支预测未命中,不高 |
| time elapsed | 1.83 秒 | 总耗时 |
注意这里 cache-misses 是末级缓存(L3 左右)的 miss,L1-dcache-load-misses 是 L1 的 miss。矩阵按列访问时,L1 miss 会非常高,因为每次加载 double 都带走相邻 7 个没用到的 double,而 4096×4096×8 字节约 128MB 的工作集又远大于 L3,完全没法靠容量缓存兜底。
3.4 基线怎么解读:IPC 和 miss rate
拿到数据后不要急着优化,先算两个关键比例。
第一个是IPC(Instructions Per Cycle)。普通乱序执行的 CPU 在良好局部性下,IPC 能做到 2 到 4;如果只有 1 左右,说明 CPU 大量时间在等待数据。基线里 IPC 只有 1.15,不是 CPU 不会算,而是 load 指令阻塞了后端。
第二个是miss rate。cache-misses 除以 cache-references,约 51.6%。这个数字高得离谱,正常优化良好的程序,L3 miss 率通常控制在 10% 以下。基于这个量化结果,可以下结论:程序主要瓶颈是访存,不是 CPU 算力。
注意,perf stat 给的是全局累计值,它告诉你“问题很严重”,但不会告诉你“哪一行代码造成的”。所以下一步必须上采样工具,把热点钉到函数和指令级。
4. 锁定 hotspot:perf record 到 perf annotate 的完整链路
4.1 用 cache-misses 事件做一轮采样
基线确认问题方向后,我用perf record针对 cache miss 事件做一轮采样。这里指定事件而不是用默认的 cycles,是因为我们的首要矛盾是 cache miss,直接用 miss 事件采样能让热点分布更贴合真实瓶颈。
perf record -e cache-misses -F 99 -g taskset -c 0 ./demo_v1-g表示记录调用栈,这样后面不仅能知道哪个函数 miss 多,还能看到它是被谁调上来的。采样过程会给程序带来一点额外开销,但 99Hz 的采样频率很低,对结果影响可以忽略。跑完后当前目录会出现 perf.data 文件。
如果程序要跑很久,也可以配合-o perf_demo.data单独指定输出文件,避免覆盖之前的数据。
4.2 perf report 排序:从上到下收窄
执行分析:
perf report -n --sort comm,dso,symbol在这个界面里,我看到了和预期一致的结果:sum_cols占到了几乎 88% 的 cache miss 样本,sum_rows只有 11% 左右。这已经说明问题基本出在列遍历上,不需要再猜。
如果热点分散在好几个函数里,就继续按函数逐个展开。我这里列出两个指标帮助判断:Samples是命中样本数,Command是进程名,Shared Object是二进制或动态库。如果某个动态库名字看起来陌生,比如图形驱动、加密库,那它本身可能不是热点来源,要找调用方。
4.3 perf annotate 看指令级证据
函数级的结论还不能让我完全放心,万一是编译器帮忙做了循环优化呢?为了拿到实锤,进入指令级视图:
perf annotate -l --symbol=sum_cols-l会把汇编映射回源码行号。输出里对应内层循环的addsd(浮点加法)和movsd(从内存加载 double)指令旁边,样本计数明显集中在movsd这一条。这些都是 load 指令,它触发 cache miss 的比例远高于其它指令。
顺带解释一下为什么movsdmiss 多:按列访问矩阵时,内层循环步长是 4096 个元素,也就是 32KB,远超一个 cache line 的覆盖范围。每次movsd都要重新从内存取数,L1 和 L2 都失效,最终还是到内存拿数据。CPU 流水线遇到这样的访存停顿,再多的执行单元也是闲着。
4.4 一个容易被误读的点:调用来源 vs 自身 miss
看perf report时,新手常犯一个错误:只盯样本最多的函数,然后直接改它。其实还要区分两种情况——这个函数是“自己真的 miss 多”,还是“被某个上游函数调用,而上游函数传入了糟糕的访问模式”。
比如我这次sum_cols是自身循环实现问题,直接改循环方向就行。但很多业务系统里,热点函数是memcpy、hash_lookup这类基础函数,它们的 miss 来自调用方传入的大块随机数据。这时候要在 report 里按调用链展开(展开+号),找到真正负责制造访问模式的上层函数,才有优化意义。
还有一个实用技巧:perf report --sort=comm,dso,sym --call-graph可以看到调用栈关系。如果热点函数的调用者很多,就先用-g抓栈,看哪一条调用路径贡献的样本最多,优先优化那条路径。别一上来就重写底层函数,那往往是白费力气。
5. 优化动手:布局、遍历、预取、伪共享
5.1 行主序遍历:调整循环顺序,收益最直接
第一个修复没有任何魔法,就是把sum_cols的内外层循环对调,改成和sum_rows一致的行主序遍历。
double sum_cols_fixed(void) { double s = 0.0; for (int i = 0; i < N; ++i) { for (int j = 0; j < N; ++j) { s += matrix[i][j]; } } return s; }这一步改完,L1 miss 数量会下降一个数量级。原因很简单:行主序时,一次 cache line 载入能覆盖连续 8 个 double,内层循环几乎全部命中。编译器自动向量化也能更好地发挥作用,因为连续内存的addsd可以被打包成 SIMD 指令。
5.2 热冷字段分离:让结构体别再“肥”
矩阵循环只是热身。模拟项目 X 里还有一块真正贴近业务的代码:一长串用户记录,每条记录既有一个频繁更新的last_active字段,又有一个几乎不读的bio长文本。原始结构体两个字段紧紧挨在一起,每次只改状态位,却被迫带上冗长 bio 的 cache line 进出。
v1 版本结构体大概长这样:
typedef struct { char name[32]; uint64_t user_id; uint64_t last_active; char bio[1024]; uint32_t status; } user_record_v1;一条记录超过 1KB,遍历一万条就是 10MB 以上的工作集,而且每个 64B 的 cache line 里真正热的数据只有 8 到 16 字节。优化方案是把冷热字段拆开,热数据放紧凑结构体,冷数据单独存:
typedef struct { uint64_t user_id; uint64_t last_active; uint32_t status; char name[32]; } user_hot_v2; typedef struct { char bio[1024]; } user_cold_v2;这样热结构体大小大约 48 字节,能在一个 cache line 里装下,遍历 hot 数组时每条记录最多 miss 一次,后续连续记录又有预取帮助。冷数据虽然占空间,但那是低频访问,不参与热点路径。这不是什么高深技巧,就是老生常谈的“结构体布局影响缓存命中”,但实际代码里敢拆的人不多,多数是担心改动面大。
5.3 分块阻塞:把工作集切成 cache 友好小块
有些算法没法改变访问方向,比如矩阵转置、卷积、图遍历。这时可以用分块(loop tiling)把计算拆成适合 cache 容量的小块,让每一块数据在被替换出去之前得到足够多的重用。
矩阵乘法是经典例子。传统三层循环写法内层累加时,A 矩阵按行取、B 矩阵按列取,两个矩阵的访问模式互相冲突。切块后每次只处理一个 64×64 的小块,这个小块能塞进 L2/L3 缓存,反复命中后才推进到下一个块:
#define TILE 64 void matmul_tiled(const double *A, const double *B, double *C, int n) { for (int i = 0; i < n; i += TILE) { for (int j = 0; j < n; j += TILE) { for (int k = 0; k < n; k += TILE) { for (int ti = i; ti < i + TILE; ++ti) { for (int tj = j; tj < j + TILE; ++tj) { double acc = 0.0; for (int tk = k; tk < k + TILE; ++tk) { acc += A[ti * n + tk] * B[tk * n + tj]; } C[ti * n + tj] += acc; } } } } } }分块大小的选择需要实测。块太大,超出缓存容量后 miss 率反弹;块太小,循环边界判断开销增加。在这类模拟项目里,64 到 128 是比较稳的起点,然后用perf stat跑几组不同 TILE 值,对比 miss 率和耗时再取最优。
5.4 伪共享:多线程场景下的隐藏大坑
模拟项目 X 的另一个版本里,我让 8 个线程各自维护一个计数器,每个线程只更新自己的那份。代码逻辑完全无锁、无共享变量,按道理应该线性扩展。结果线程数从 1 涨到 8,性能只提升了 1.5 倍。
这就是伪共享的典型症状。perf stat 里 cache-misses 数量并不低,但如果只分析 miss 率,可能还会往“数据结构太大”上猜。正确的做法是用perf c2c这类工具检查 cache line 级别的读写冲突,或者直接看数据结构布局。
我在代码里加了 64 字节对齐的隔离:
struct worker_counter { volatile uint64_t value; uint8_t padding[56]; // 凑满 64B cache line } __attribute__((aligned(64)));每个worker_counter实例独占一条 cache line,线程 A 写自己的 value 时不会让线程 B 的 cache line 失效。别小看这一行 padding,伪共享修好之后,多线程扩展性立刻回归正常。
5.5 软件预取:最后一个念头,别第一眼就扑上去
有些朋友看到 cache miss 就想到__builtin_prefetch软件预取,我建议把它放在优化清单末尾。原因很现实:手写预取需要非常了解内存访问距离,距离短了没效果,距离长了反而把 cache 污染了;而且不同硬件最优预取距离不一样,可移植性差。
在大多数场景下,编译器自动预取和硬件预取器已经做得很好。利用好连续内存访问、调整布局,让硬件预取器自然生效,比手动插预取指令更可靠。只有当你的访问模式是“已知下一步要访问哪个不连续地址”的稀疏场景,比如遍历一个索引数组然后哈希取值,软件预取才有发挥空间。
6. 验证优化结果,并警惕把 cache 调“过头”
6.1 优化后复测:把数据摆出来
v2 版本整理完,我按完全相同的环境重新跑perf stat。还是同样的-r 3,绑核,锁频。汇总数据和 v1 基线对比:
| 指标 | v1 基线 | v2 优化后 | 变化 |
|---|---|---|---|
| 总耗时 | 1.83 秒 | 0.27 秒 | 下降约 85% |
| IPC | 1.15 | 3.12 | 明显提升 |
| cache-misses | 约 1.6 亿 | 约 1800 万 | 下降约 89% |
| L1-dcache-load-misses | 约 4.1 亿 | 约 2600 万 | 下降一个数量级 |
这个结果说明:优化确实打在了主要矛盾上,耗时和 miss 同步下降,IPC 提升。v2 的主要改动就是行主序遍历、热冷字段拆分、多线程计数器加了 cache line 隔离,并没有引入任何锁或复杂算法。整轮分析的核心逻辑很简单:用 perf 量化问题、定位热点、针对性优化、再量化验证。
6.2 四个容易误判的坑
优化做多了,我总结出四个跟 cache 相关的误判,几乎每次都能遇到有人踩上去。
第一,看到 miss 率高就panic。cache miss 不全是坏事,也不是所有 miss 都能优化掉。访问随机链表节点、哈希表探测这类本质随机的工作集,miss 高是访问模式决定的,你只能换数据结构,不能靠“多预取”强扭。先判断这个 miss 是否正常。
第二,只盯 L1 miss,忽视 L2/L3 和内存带宽。有些算法 L1 miss 高出天际,但 L2/L3 全命中,整体并不慢。因为 L3 带宽远高于内存带宽,数据只要在 L3 里,延迟和带宽都可接受。看全局计数器时,三个层级要一起看,不要单拿一个 L1 事件说事。
第三,把调度问题当成 cache 问题。perf stat输出里有一行cpu-migrations,如果这个数字很大,说明进程频繁迁移,cache 内容不断失效。此时你会看到全系统 miss 率虚高,但改代码没用,先绑核或者优化线程亲和性,数据会立刻恢复正常。
第四,没有看上下文切换。频繁线程切换会让每组 perf 样本都像是“热点散落各处”。context-switches指标很高时,先解决锁和 busy-wait,再谈 cache 优化。否则你会花大量时间分析根本不存在的热点。
6.3 往深了追:perf c2c、perf mem、TMA
如果优化完还有残余瓶颈,可以继续加工具。perf c2c能定位到具体的 cache line 竞争,找出哪些地址被多个 CPU 频繁互相失效,适合排查伪共享和 NUMA 访问不均衡。perf mem可以对内存访问做采样,区分 load 和 store 的延迟来源。再进一步可以用 TMA(Top-Down Microarchitecture Analysis)方法论,把 CPU 流水线瓶颈拆成前端、后端、分支预测、等等类别,判断瓶颈到底在访存等待还是在执行单元饱和。
这些工具不需要一次全上。我的建议是:先perf stat全局看方向,再perf record抓热点,大部分问题到perf annotate就能定位到指令。如果热点都集中在访存相关的指令上,cache 布局和伪共享是首选优化方向;如果指令本身都解释不了,再动 TMA 这类重型工具。
最后再分享一个实战中很不起眼但特别有用的习惯:每次跑 perf 之前,先在代码里打印关键数据结构的sizeof和alignof。很多 cache 问题早在结构体定义的时候就埋下了雷,看一眼 64 字节对齐情况,比盲跑十轮采样都快。我经手的项目里,有一半伪共享问题都是靠这个习惯提前识破的。
如果你手头正有个“算法明明不复杂但就是慢”的程序,不妨按这篇文章的链路走一遍:锁频、绑核、perf stat看基线,perf record -e cache-misses抓热点,perf annotate看指令。数据说话,比拍脑袋加缓存池靠谱得多。