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 - 48KB4 - 5 个周期4 - 5
L2 缓存256KB - 2MB12 - 20 个周期12 - 20
L3 缓存8MB - 64MB40 - 70 个周期40 - 70
本地内存几十 GB60 - 100ns200 - 300
跨节点内存同上120 - 200ns400 - 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)的取舍:

布局方式典型写法适用场景
AoSstruct P { float x,y,z; float mass; ... }; struct P arr[N];每次都要访问同一对象的全部字段
SoAfloat 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 率,基本十分钟内就能确认方向。反过来,如果一段代码的访存模式很糟糕,你花再多时间调指令顺序、换编译器选项,收益都不会超过把数组布局改一下。缓存的代价是按数量级算的,而指令级的优化通常在百分比这个量级上打转,两者的投入产出比差得太多。