CPU Cache性能优化:缓存行、伪共享与访存模式实战
第一次被 Cache 真正"教育",是在一段看起来毫无问题的双重循环上。同样的数据量、同样的运算次数,只是把内层和外层循环换了个顺序,耗时差了将近六倍。当时第一反应是编译器没优化好,反汇编、改编译选项、换标准库,折腾一圈发现生成的指令几乎一样,唯一的区别就是访存地址的跳跃方式。从那以后我形成了一个习惯:凡是涉及数组、结构体、多线程共享变量的代码,先不看算法复杂度,先看它的访存模式对缓存友不友好。
CPU Cache 这玩意儿,教科书上讲得都很"正确"——局部性原理、映射方式、替换算法,考试能拿满分。但真到了工程里,你要面对的问题完全是另一套:为什么我这个循环只快了 15%,隔壁同事的改了数组布局就快了 4 倍?为什么两个线程各写各的变量,性能反而比单线程还差?为什么加了alignas(64)之后 A 机器变快了、B 机器反而慢了一点?这些问题没有标准答案,但有一套可以复用的分析路径和一批踩过的坑。
这篇内容面向的是已经写过一段时间代码、被性能问题折磨过、但还没系统梳理过缓存行为的开发者。我会从一组延迟数字讲起,把 Cache line、组相联、伪共享、写策略、内存屏障这些概念,全部放到"你会在什么场景下遇到它、遇到之后怎么测、测完怎么改"这条线上讲。所有数字都来自常见平台的公开资料和我自己的实测,不同硬件会有差异,但数量级和趋势是稳定的。
1. 用一组延迟数字建立直觉:设计取舍的源头
1.1 一次内存访问相当于几百个时钟周期
理解缓存的第一步不是看缓存本身,而是看它要掩盖的那个差距。现代处理器的主频大概在 3GHz 到 5GHz 之间,取 3GHz 算,一个时钟周期约 0.33 纳秒。而一次真正落到内存的访问,延迟在 60 到 100 纳秒这个区间,换算下来就是 200 到 300 个时钟周期。这意味着如果 CPU 老老实实等内存返回数据,它在这段时间里能执行几百条不相关的指令,全浪费了。
把各级存储的典型数字摆在一起看,差距会更直观:
| 存储层级 | 典型容量 | 典型访问延迟 | 换算成时钟周期(3GHz) |
|---|---|---|---|
| 寄存器 | 几百字节 | 0.3ns 以内 | 不到 1 |
| L1 数据缓存 | 32KB - 48KB | 4 - 5 个周期 | 4 - 5 |
| L2 缓存 | 256KB - 2MB | 12 - 20 个周期 | 12 - 20 |
| L3 缓存 | 8MB - 64MB | 40 - 70 个周期 | 40 - 70 |
| 本地内存 | 几十 GB | 60 - 100ns | 200 - 300 |
| 跨节点内存 | 同上 | 120 - 200ns | 400 - 600 |
这张表里最关键的不是绝对值,而是相邻两级之间的比值。L1 到 L2 大约 3 到 4 倍,L2 到 L3 大约 3 到 4 倍,L3 到内存大约 4 到 6 倍。每一级都在用容量换延迟,用"命中率高一点点"去抵消"容量小很多"。这个比例不是随便定的,它背后是成本、功耗、面积三者妥协出来的结果。
我在实际排查中经常用这张表做粗略估算:如果一段代码的 LLC(最后一级缓存)miss 率从 5% 涨到 15%,而它每秒要访问一亿次缓存行,那多出来的 miss 就是每秒一千万次,每次按 80 纳秒算,光等待就是 0.8 秒。这个量级的差异,改什么算法都救不回来,只能改访存模式。
1.2 为什么是三级结构而不是一级或五级
有人会问,既然缓存这么有用,为什么不干脆做一级超大缓存?答案很朴素:做得越大越慢。缓存本质上是 SRAM,容量上去之后,寻址译码的复杂度、连线延迟、功耗都会上升,访问时间会从 4 个周期涨到十几个周期。你做一个 8MB 的"L1",它可能就要 20 个周期才能命中,那它就不再是 L1 了,只是把 L2 挪了个位置,中间那层快速缓冲反而没了。
所以实际的做法是分层次:最快的那层小到能塞进核心旁边,专门伺候最近用过的几 KB 数据;中间那层给单个核心提供几 MB 的工作集;最外面那层由所有核心共享,用大容量兜住跨核心的数据复用。三级是长期演化出来的平衡点,不是理论上推导出来的最优解。
这里有个工程上很容易忽略的点:L3 是共享的,而且通常是被切成多个 slice 的。这意味着 L3 的访问延迟不是一个固定值,而是和你访问的地址落在哪个 slice、离当前核心多远有关系。在双路甚至多路机器上,访问"本地 L3"和"远端 L3"的延迟能差一倍以上。这就是为什么同一段多线程代码,在单路机器上跑得好好的,上了双路反而出现负载不均——线程和它要访问的数据被调度到了不同的 NUMA 节点上。
我处理过的一个案例是:一个内存数据库的查询线程池,在单路机器上扩展性接近线性,换成双路之后 8 线程以上的吞吐几乎不涨。原因就是数据分片是按哈希分布到各个线程的,但线程又被操作系统随机调度到两个节点上,跨节点访问 L3 和内存的比例大幅上升。后来在启动时做了线程绑核,并且让每个线程优先分配本地内存,吞吐立刻恢复了。这件事跟算法一点关系都没有,纯粹是缓存的物理拓扑决定的。
2. 64 字节的 Cache line 决定了你的数据布局
2.1 line 大小背后的硬件与成本约束
缓存和内存之间不是按字节交换数据的,而是按固定大小的块,这个块就叫缓存行(cache line)。主流平台的 line 大小是 64 字节,少数平台是 128 字节。这个数字不是拍脑袋定的,它和内存总线的突发传输长度有关:常见的 DRAM 一次突发传输是 8 个数据单元,每个单元 8 字节,合起来正好 64 字节。缓存行的粒度和内存传输的粒度对齐,一次搬运就是一次完整的突发,效率最高。
这个设计带来的直接后果是:你哪怕只读一个字节,硬件也会把包含它的 64 字节全部搬到缓存里。如果程序是顺序访问的,这 64 字节里剩下的 63 个字节很快就会被用到,这笔"预付款"赚了;如果程序是跳跃访问的,每次只用一个字节,那另外 63 个字节就是纯浪费,内存带宽被白白消耗掉。
所以判断一段代码对缓存友好不友好,有一个非常实用的标准:看它在一次缓存行加载之后,实际用掉了这 64 字节中的多少。用掉得越多,有效带宽越高。这个指标比抽象的"局部性"好理解得多,也更容易在代码审查时看出来。
顺带提一句,64 字节同时也是很多硬件层面的对齐要求:DMA 描述符、原子操作的目标、某些平台上的自旋锁,都要求按缓存行对齐。如果你的结构体跨了两个缓存行,一次读改写操作就要牵扯两条 line,代价翻倍。
2.2 二维数组的遍历顺序能差出数倍
这是最经典也最容易被低估的例子。C 语言的二维数组是按行连续存储的,a[i][j]和a[i][j+1]在内存里相邻,而a[i][j]和a[i+1][j]之间隔了整整一行。所以用行优先遍历时,内层循环是在连续地址上走;用列优先遍历时,内层循环每走一步就跳一整行。
#define N 4096 static double a[N][N]; // 行优先:内层连续访问 double sum_row_major(void) { double s = 0.0; for (int i = 0; i < N; i++) for (int j = 0; j < N; j++) s += a[i][j]; return s; } // 列优先:内层跨行跳跃 double sum_col_major(void) { double s = 0.0; for (int j = 0; j < N; j++) for (int i = 0; i < N; i++) s += a[i][j]; return s; }按理论算一下:a是 4096×4096 的 double,每个元素 8 字节,一行就是 32768 字节,正好 512 个缓存行。行优先版本每加载一个缓存行,能用满其中的 8 个 double,利用率 100%。列优先版本每次访问一个新元素,就要加载一个新的缓存行,用完 8 字节就跳走,利用率只有 12.5%。
我在一台普通桌面平台上实测,N=4096 时两个版本的耗时比大约在 5 到 8 倍之间,具体数字随编译器优化和硬件预取能力浮动。有意思的是,如果把 N 从 4096 改成 512,差距会缩小很多——因为 512×512×8 字节只有 2MB,整个数组能塞进 L2 或者 L3,跳跃访问的代价被缓存容量吸收了。这也说明一件事:同一个"坏"写法,在不同的工作集大小下,性能表现可能完全不同,所以线上出问题、本地复现不出来,是很常见的情况。
2.3 结构体布局与 line 利用率
数组之外,结构体布局是第二个高频踩坑点。假设你要做一个粒子系统,每个粒子有位置、速度、质量、颜色、id 等字段,最自然的写法是把所有字段塞进一个结构体。但如果某个热循环只用到位置和速度,那么每次加载一个粒子,就会把用不到的颜色、id 一起拉进缓存,line 利用率直接掉一半。
工程上常见的处理是拆成"热数据"和"冷数据"两个数组,或者用结构体数组(AoS)和数组结构体(SoA)的取舍:
| 布局方式 | 典型写法 | 适用场景 |
|---|---|---|
| AoS | struct P { float x,y,z; float mass; ... }; struct P arr[N]; | 每次都要访问同一对象的全部字段 |
| SoA | float xs[N], ys[N], zs[N], ms[N]; | 不同循环只用到部分字段,或需要向量化 |
| 拆分热冷 | 热字段单独一个数组,冷字段单独一个 | 热路径只用少数几个字段 |
我做过一个简单的验证:一个每帧遍历十万个粒子的物理更新,原本用 AoS,每个粒子 64 字节(正好一个缓存行)。改成 SoA,把位置和速度拆成两个各 24 字节的数组,更新循环的耗时下降了大约 35%,同时编译器也终于能对这个循环做向量化了。注意这里两个收益是叠加的:缓存 line 利用率提升是一个,向量化是另一个,它们经常同时出现,因为向量化本身也要求连续访存。
还有一个更隐蔽的问题:结构体跨缓存行。如果一个结构体大小是 60 字节,但起始地址没有对齐,那么它可能横跨两个缓存行,一次访问要拉两条 line。解决方式是加对齐声明,把常用的、会被并发访问的结构体按 64 字节对齐:
typedef struct alignas(64) { _Atomic long long head; _Atomic long long tail; /* ... */ } ring_queue_t;这类对齐对单线程性能影响不大,但对多线程共享的数据结构影响很大,下一节会详细说。
3. 组相联与替换策略:冲突 miss 不是靠扩容能解决的
3.1 直接映射的抖动问题
缓存要把内存地址映射到有限的槽位里,最简单的做法是取模:地址除以缓存行大小,再对槽位数取模,得到它唯一能去的位置。这叫直接映射,硬件实现最简单,一次比较就能判断命中还是未命中。
但直接映射有个致命问题:两个地址只要模数相同,就会互相踢。假设缓存是 32KB、64 字节一行,一共 512 个槽位。那么地址 0x0、0x8000、0x10000、0x18000 全都映射到槽位 0。如果你的程序恰好在一个循环里交替访问这几个地址,那每次访问都会把上一个踢掉,命中率是 0,不管缓存有多大。
这就是所谓的冲突 miss(conflict miss)。它跟容量 miss 不一样:容量 miss 是因为工作集真的放不下,再加缓存就能解决;冲突 miss 是工作集明明不大,只是地址恰好对撞,加缓存完全没用,因为对撞的位置永远对撞。
实际编程中,步长为 2 的幂的访问模式最容易触发这种对撞。比如按 4KB 或 8KB 步长遍历一个大数组,或者一个大矩阵的维度刚好是 1024、2048 这种数字。我见过一个图像处理的 bug,处理宽度为 2048 的图像时性能明显低于宽度 2000 的图像,就是因为行步长和缓存的组数形成了整数倍关系。
3.2 组相联如何把冲突摊薄
为了解决对撞,硬件把每个槽位扩展成一组(set),组内可以有多个"路"(way),每个地址可以放到它对应组内的任意一路。主流桌面和服务器的 L1 一般是 8 路或 12 路组相联,L2 是 8 到 16 路,L3 有时是非包含式的、路数更多。
组相联本质上是"用多余的比较器换灵活性"。8 路组相联,意味着查找时要同时比较 8 个 tag,硬件成本上去了,但冲突 miss 大幅下降。极端情况是全相联,任何地址可以放任何位置,冲突 miss 降为零,但硬件代价高到无法接受,一般只在小容量的 TLB 里用。
组相联带来的新问题是替换策略:组满了之后该踢谁?理论上最优是 LRU(最近最少使用),但真正实现精确 LRU 需要为每一路维护时间戳,8 路就要维护 8 个计数器的相对顺序,代价太高。所以实际硬件用的是伪 LRU(PLRU)、随机替换、或者两者的混合。这意味着缓存的替换行为并不完全可预测,你在代码里精心安排的访问顺序,未必能换来预期的保留效果。
这一点在工程上的意义是:不要试图去"操控"缓存内容。你能做的是让热数据集中、让访问连续,剩下的交给硬件的启发式策略。如果你发现必须精确控制某些数据常驻缓存,那通常不是写业务代码该解决的问题,而是应该考虑用小容量的显式管理内存、或者改变数据流让它天然适合缓存。
3.3 步长访问与 bank 冲突的工程经验
除了组冲突,还有一个相关但不同的现象:访问落在同一个存储体(bank)上。现代缓存的 SRAM 阵列会分成多个 bank,可以并行访问;如果连续的访问全部落在同一个 bank,就会串行化,吞吐下降。这类问题一般在库层面被掩盖,但当你写高性能内核、或者做手工向量化的时候会遇到。
我总结了几条在实践中验证过的经验:
- 遍历大数组时,尽量让内层循环的步长为 1。如果必须跳步,考虑用分块把跳跃变成块内连续。
- 二维或多维数据的维度,尽量避免是 2 的整数次幂。加一个 padding 列(比如把 1024 改成 1024+8),能显著降低组冲突。这个技巧在数值计算里非常常见,通常能带来 10% 到 30% 的提升。
- 多个数组同时参与运算时,注意它们的基地址关系。如果两个数组的起始地址相差恰好是缓存容量的整数倍,它们在组索引上会对齐,冲突会更严重。
- 哈希表的桶数组大小如果取质数或奇数,除了减少哈希聚集,对缓存组冲突也有好处。
这些技巧听起来有点"玄学",但每一条背后都有明确的硬件原因:它们都是在破坏地址与组索引之间的规律性对齐。理解了这个本质,你就能自己判断哪些场景需要处理、哪些没必要。
4. 伪共享:多核时代最贵的一行代码
4.1 一行缓存被两个核争抢时发生了什么
伪共享(false sharing)是我认为最值得单独拎出来讲的缓存问题。它的定义很简单:两个线程分别写两个不同的变量,但这两个变量恰好落在同一个缓存行里。逻辑上它们毫无关系,物理上却共享一个缓存行,于是缓存一致性协议就会不停地把这个行在两个核心之间搬来搬去。
具体过程是这样的:核心 A 写了变量 x,把这个缓存行标记为"已修改"状态的私有副本;核心 B 要写同一行的变量 y,必须先让核心 A 把行写回并失效,才能获得独占权;然后核心 A 再写 x 时,又要反过来抢一次。每一次写入都触发一次跨核通信,延迟从几个周期暴涨到几十甚至上百个周期。
这个问题最阴险的地方在于:代码看起来完全是线程安全的。没有共享变量,没有锁竞争,两个线程各写各的计数器,结果性能比单线程还差。我见过一个统计模块,四个线程各自累加自己的请求计数,跑在四核上吞吐只有单线程的 1.2 倍,加上填充之后立刻恢复到 3.8 倍。
判断是否存在伪共享,有一个简单的心算方法:把并发写入的变量地址列出来,除以 64 取整,如果落在同一个区间,就是伪共享。这个方法粗糙但极其有效,我在代码审查时经常直接用它做第一轮筛查。
4.2 从复现到修复的完整链路
先说复现。构造一个最小案例,两个线程各跑一亿次自增,变量相邻:
#include <pthread.h> #include <stdio.h> struct { volatile long long a; volatile long long b; } counter; void *worker_a(void *arg) { for (long long i = 0; i < 100000000LL; i++) counter.a++; return NULL; } void *worker_b(void *arg) { for (long long i = 0; i < 100000000LL; i++) counter.b++; return NULL; }这段代码在两个核心上跑,通常会比串行版本慢,因为每次自增都要抢一次缓存行的独占权。修复方式是让两个变量各自独占一个缓存行:
struct alignas(64) padded_counter { volatile long long v; char pad[64 - sizeof(long long)]; }; static struct padded_counter ca; static struct padded_counter cb;改完之后,两个变量分别落在不同的缓存行,互不干扰,吞吐应该能接近线性扩展。注意alignas(64)是关键的,只加 padding 不保证对齐,编译器可能把两个结构体紧挨着放,填充就白加了。
这里有一个值得注意的细节:部分较新的处理器带有相邻行预取机制,会把 64 字节的缓存行成对加载成 128 字节的单元。在这种平台上,仅按 64 字节对齐可能还不够,需要考虑按 128 字节对齐,或者手动把填充加大到 128 字节。我自己的做法是默认用 128 字节对齐,因为在 64 字节平台上多出来的 64 字节填充几乎没有成本,而在 128 字节平台上能避免二次踩坑。
改完之后怎么验证?不能只看总耗时,因为总耗时受调度、频率、内存带宽多重因素影响。我的做法是固定线程绑核、固定 CPU 频率(如果平台允许)、跑多轮取中位数,然后对比"修复前"和"修复后"的加速比曲线。如果修复前 4 线程相对 1 线程的加速比明显低于核心数,修复后接近核心数,基本可以确认是伪共享。
4.3 那些不容易一眼看出来的伪共享场景
最容易发现的伪共享是显式的全局计数器,最难发现的是藏在数据结构和内存分配器里的。
场景一:线程本地对象被分配器放在一起。很多线程池实现会给每个工作线程分配一个本地结构体,如果分配器是连续分配的,这些结构体很可能挤在同一批缓存行里。每个线程更新自己的任务计数、状态标志,就形成了伪共享。解决办法是在分配时按 128 字节对齐,或者给每个线程的结构体预留足够大的填充区。
场景二:无锁队列的头尾指针。队列的 head 由消费者更新、tail 由生产者更新,如果两者在同一缓存行,生产者和消费者会持续互相踢缓存。所以高性能无锁队列几乎都会把头尾指针分开到不同的缓存行,并在中间加填充。这一点在很多实现里都能看到,是标准做法。
场景三:批量任务的原子累加。多个线程对同一个原子变量做fetch_add,这不是伪共享,是真共享,代价更高。解决思路是"分片累加":每个线程累加到自己的本地变量,最后统一汇总。这个模式在统计、计数、限流这些场景里非常好用,我以前做过一个请求计数,从全局原子改成 16 个分片之后再汇总,QPS 提升了将近一倍。
场景四:只读数据被误判。如果多个线程读同一份数据,本身没问题是共享状态,不会触发一致性流量。但如果某个线程偶尔写一下(比如打点、更新时间戳),整行就会被反复失效。这类"读多写极少"的场景,往往要用副本或者批量更新来规避。
5. 写策略与一致性协议:内存屏障为什么存在
5.1 写回、写直达与写分配的取舍
当 CPU 要写一个不在缓存里的地址时,硬件面临两个选择:一是把数据直接写到内存(写直达,write-through),二是先把这个缓存行读进来再改(写分配,write-allocate)。同时还要决定修改是同步写回内存(写直达),还是先标记为脏、等被替换时再写回(写回,write-back)。
主流处理器的 L1、L2 基本都采用写回 + 写分配。理由是:写回把多次写合并成一次内存事务,大幅降低带宽压力;写分配则让后续对同一行的读写能命中缓存。代价是内存中的数据可能短暂过期,需要一致性协议来兜底。
但写分配不是万能的。如果一个程序对大块内存做一次性写入、之后再也不读,那么写分配就变成了纯浪费:它先把整行读进来(一次内存读),改完之后再写回去(一次内存写),读写各一遍。这种场景就是所谓的"非临时写"(non-temporal store),硬件提供了绕过缓存的指令来优化它:
#include <immintrin.h> void fill_large(double *dst, size_t n, double v) { __m128d val = _mm_set1_pd(v); for (size_t i = 0; i + 1 < n; i += 2) { _mm_stream_pd(&dst[i], val); } _mm_sfence(); }这段代码用流式存储指令写入,绕过了缓存层级,直接走向内存,避免了对目标区域的预读。我在处理几百 MB 的输出缓冲区时用过这个技巧,相比普通写,内存带宽占用能降低 30% 到 40%。但要注意,流式存储之后,这块数据短期内再读会很慢(因为不在缓存里),所以它只适合"写完就不管"的场景。而且要记得加sfence,保证写入的顺序可见性。
5.2 从 MESI 状态迁移看缓存行迁移的代价
多核一致性最经典的模型是 MESI,每个缓存行处于四个状态之一:修改(M)、独占(E)、共享(S)、无效(I)。理解它的关键不是背状态表,而是理解状态迁移背后的通信成本。
- 核心 A 读一个未缓存的行:发出读请求,其他核心都没有,从内存加载,状态变成 E(独占,因为只有它一份)。
- 核心 B 也读这一行:A 检测到冲突,把行降级为 S,B 也拿到 S 状态的副本。这一步有一次跨核通信,但之后两边都能自由读,不再通信。
- 核心 A 要写:必须先把所有其他核心的副本失效掉,自己才能升到 M。这一步要发无效化消息,并等待所有核心确认。
- 核心 B 要写同一行:A 必须先写回数据、把自己的副本降为 I,B 才能拿到 M。又是一次完整的行迁移。
可以看到,纯读共享几乎没有持续成本,一旦有写就必然出现独占权的转移。这解释了为什么"读多写少"的数据结构容易优化,"多写"的数据结构天生难扩展。
工程上的推论很直接:想让多核扩展性好,就要尽量减少共享行的写入频率;如果无法避免,就降低写入粒度(比如把频繁更新的字段单独拆到一行),或者用批量、异步的方式合并写操作。
5.3 Store buffer 与屏障指令的真实用途
CPU 为了不让写操作阻塞,在缓存和核心之间加了一层写缓冲(store buffer)。写操作先进入缓冲区就返回,等缓存行拿到独占权之后再真正落地。这样做极大提升了流水线效率,但也带来了一个新问题:其他核心可能先看到写操作之后发生的读操作结果,而不是写操作本身。
这就是内存重排序的来源。所谓的内存屏障指令,本质上是在告诉硬件:在屏障之前的写操作必须真正完成(对其他核心可见),屏障之后的读写才能开始。它解决的不是缓存一致性问题——一致性由协议保证——而是可见性与顺序性问题。
在实际工程里,绝大多数场景你不需要直接写屏障指令,而是用语言层面的原子类型和内存序:
// 生产端 prepare_data(&buf); atomic_store_explicit(&ready, 1, memory_order_release); // 消费端 while (atomic_load_explicit(&ready, memory_order_acquire) == 0) { } consume_data(&buf);release保证它之前的所有写在ready变为 1 之前对其他核可见,acquire保证读到 1 之后的所有读能看到那些写。这两个配对使用,语义是"数据准备好之后再发信号,收到信号之后再读数据",和缓存的写回、独占权获取在硬件层面是对应的。
我的经验是:能用语言提供的原子和内存序,就不要手写汇编屏障。手写屏障极难验证,而且不同平台的语义差异很大,一旦出错就是那种一年才偶发一次、日志里什么都看不到的 bug。
6. 把 Cache 行为量出来:测量方法与常见误判
6.1 用性能计数器读 miss 率的正确姿势
改代码之前先测,这是底线。Linux 上最直接的工具是 perf,它能读出硬件性能计数器:
perf stat -e cycles,instructions,\ cache-references,cache-misses,\ L1-dcache-loads,L1-dcache-load-misses,\ LLC-loads,LLC-load-misses \ ./your_program几个指标的读法要说清楚,不然很容易误判:
cache-references和cache-misses在多数平台上对应的是最后一级缓存的行为,不是 L1。LLC-load-misses高,说明数据真的要从内存取了,这是最贵的。L1-dcache-load-misses高,但 LLC miss 低,说明数据在 L2/L3 里能找到,代价中等,优化空间在于提升 L1 命中率。- 单看 miss 数没意义,要看miss 率和每次 miss 的代价。十万次 miss 打在一亿次访问上是 0.1%,打在二十万次访问上就是 50%。
还有一个常见误区:把instructions和cycles的比值当成唯一指标。IPC 下降可能来自缓存 miss,也可能来自分支预测失败、指令依赖链太长、向量化单元空闲。要配合branch-misses和stalled-cycles-backend一起看,才能定位到真正的原因。
我通常的流程是:先跑一遍拿到基线,记录 IPC、L1 miss 率、LLC miss 率三个数;然后做一次改动,再跑一遍;如果 LLC miss 率没变但耗时变了,说明问题不在缓存,要往别处找;如果 LLC miss 率下降了但耗时没降,说明这段代码不是瓶颈,或者瓶颈转移到了别的地方(比如变成了计算密集)。
6.2 硬件预取器带来的"假好转"
现代处理器都有硬件预取器,能识别出固定步长的地址序列,提前把后续的缓存行拉进来。这对顺序访问帮助巨大,但也给测量带来麻烦:有些"坏"代码在预取器的帮助下测出来不慢,换到别的平台或者换一个访问步长就原形毕露。
预取器一般能识别几种模式:顺序递增、固定步长、跨页的连续流。它识别不了的是随机跳转和步长频繁变化的模式。所以在测试缓存敏感代码时,我会有意识地做几组对照:
| 访问模式 | 预取器效果 | 说明 |
|---|---|---|
| 顺序步长 1 | 极强 | 基本能完全隐藏延迟 |
| 固定步长 8、16 | 强 | 步长固定就能学出来 |
| 步长交替变化 | 弱 | 学不出来,暴露真实延迟 |
| 随机跳转 | 几乎无效 | 用于测最坏情况 |
如果你的代码在顺序访问下性能很好,别急着高兴,用随机顺序再测一遍。如果随机顺序下慢了十倍,说明你的性能完全依赖预取器,那么一旦数据分布变成非规律性的(这在真实业务里非常常见),性能就会塌方。
还有一个反向的技巧:如果你知道数据是顺序访问的,可以手工用__builtin_prefetch提前几轮把数据拉进来,比等硬件预取器学习更快进入状态。参数上,读预取用 0,写预取用 1,局部性提示用 3(表示数据会被反复用)。经验上提前 8 到 16 个缓存行比较合适,太早会把有用的数据踢出去,太晚来不及。
6.3 自测时的几个陷阱
自己写 benchmark 测缓存行为,很容易掉进几个坑,我基本都踩过:
第一个坑是数据太小。测试数组只有 1KB,怎么测都命中 L1,看不出任何差异。测缓存行为,工作集至少要比目标缓存的容量大一个数量级,才有区分度。
第二个坑是没有防止编译器优化。循环里累加一个变量,编译器可能直接算成闭式解。要么用volatile,要么把结果作为返回值输出,要么用内联汇编加一个依赖。
第三个坑是热身不充分。第一次跑的时候缓存是冷的、频率还没升上去、分支预测器还没训练好,结果会明显偏离稳态。至少要跑几十轮取后面的中位数。
第四个坑是频率漂移。笔记本或者共享服务器上的频率会随温度和负载变化,测出来的时间差可能来自频率而不是代码。有条件的话固定频率,没条件就多跑几轮看方差。
第五个坑是把延迟和带宽混着测。缓存优化有时候是降低延迟(命中率提升),有时候是提高带宽(利用率提升),这两者的优化手段完全不同。测的时候要分开设计,比如指针追逐测延迟,大数据量流式读写测带宽。
7. 可以直接抄进项目的几条规则
把上面这些内容压缩成可以直接执行的清单,我在新项目或者代码审查时基本就按这个顺序过一遍。
关于数据布局:热数据连续放、冷数据挪走;会被并发写的变量,按 128 字节对齐并隔离;多线程各自的本地状态,分配时按缓存行对齐;结构体大小尽量控制在缓存行以内,避免跨行。这几条改起来成本很低,收益往往最直接。
关于访问顺序:多维数组按内存布局顺序遍历;大矩阵运算做分块,让三个工作块能同时装进 L2;避免 2 的整数次幂的步长和维度,加一点 padding 打破对齐规律;随机访问的数据结构,考虑把索引和数据分开,让索引遍历连续。
关于共享:能拆成本地累加就不要用全局原子;必须共享的计数器做分片;无锁结构的头和尾分离到不同缓存行;只读数据尽量在初始化阶段就确定,避免运行期零星写入。
关于测量:改之前先测 LLC miss 率和 IPC,改之后再测;不要只看总耗时,要看加速比随线程数的变化曲线;顺序和随机两种访问模式都要测,避免被预取器骗;固定频率、充分热身、多轮取中位数。
最后分享一个我自己的判断习惯:当你发现一段代码的性能无法用指令数解释时,先怀疑缓存。反汇编看看访存模式,用 perf 看看 miss 率,基本十分钟内就能确认方向。反过来,如果一段代码的访存模式很糟糕,你花再多时间调指令顺序、换编译器选项,收益都不会超过把数组布局改一下。缓存的代价是按数量级算的,而指令级的优化通常在百分比这个量级上打转,两者的投入产出比差得太多。