CPU Cache工程实践:缓存行、伪共享与多核性能优化

发布时间:2026/11/6 4:23:38
CPU Cache工程实践:缓存行、伪共享与多核性能优化

1. 先建立工程直觉:CPU Cache 到底快在哪、慢在哪

性能排查里最让人挠头的一类情况是:代码逻辑看着没问题,算法复杂度也挑不出毛病,但压测数字就是上不去。我碰到过一次典型的多线程服务,单核跑得飞快,线程一开多,总吞吐反而掉了将近一半。采样图上看热点函数平平无奇,没有任何一行是肉眼可见的瓶颈。后来用缓存分析工具一测,两个统计计数器挤在同一条 CPU Cache 缓存行里,几个核心为了这一行的所有权来回倒手,光把这两个变量拆开,吞吐就回去了。这类问题,你不了解 CPU Cache 的脾气,基本只能靠猜。

CPU Cache 不是只在面试题里出现的名词,它实打实地决定着你那些看起来差不多的代码究竟跑多快。缓存行多大、组相联多少路、写回还是写直达、多核之间怎么同步状态,这些硬件细节最终都会落到你的循环怎么写、结构体怎么排、线程间的数据怎么摆上。这篇东西我想从工程落地角度把 CPU Cache 拆开:不只是讲它是什么,更讲你在写代码时哪些地方会踩坑、参数怎么算、改动怎么验证。刚学完体系结构课的同学能看懂,已经在做性能调优的人也能直接抄走一部分。先把几个硬数字摆出来,后面所有讨论都建立在这些数字上。

1.1 一次内存访问的真实代价

很多性能问题之所以被低估,是因为我们对“内存很慢”这件事缺乏体感。现代处理器主频在 3GHz 上下,一个时钟周期大约 0.33 纳秒。而访问一次主存,典型延迟在 80 到 100 纳秒这个量级,换算下来是两三百个周期。也就是说,如果 CPU 真的停下来等数据,这段时间它本可以执行几百条指令。这个差距在过去二十多年里越拉越大:处理器频率和流水线宽度涨得飞快,内存的访问延迟改善却很有限,两者之间的沟越来越深。CPU Cache 存在的全部意义,就是把这个沟填上——把最近用过的、以及大概率马上要用的数据搬到离核心更近的地方。

下面这张表是我平时用来跟人解释的典型数值,不同平台会有出入,但数量级关系是稳定的:

存储层级典型容量典型访问延迟换算成周期(约 3GHz)
寄存器几百字节小于 1ns1
L1 数据缓存32KB 至 48KB约 1ns4 至 5
L2 缓存256KB 至 2MB4 至 15ns12 至 45
L3 缓存(多核共享)8MB 至 64MB15 至 40ns45 至 120
主存GB 级80 至 100ns240 至 300

这张表真正的信息量在于比值。L1 命中大约 4 个周期,L2 命中十几到几十个周期,走到主存就是两三百个周期,相差几十倍到上百倍。你代码里一个命中率从 95% 掉到 80% 的改动,看起来只损失了 15 个百分点,实际耗时可能翻倍。所以做性能优化时,我第一件事往往不是去数指令,而是判断这个循环的工作集能不能装进某级缓存、访问顺序对缓存友不友好。这两个问题答不上来,后面的优化大概率是白忙。

还有一个容易忽略的点:延迟和带宽是两回事。顺序读一大片内存,硬件预取器会提前把后面的行拉进来,带宽能跑得很满,你感受不到高延迟;但如果你在做指针追逐,比如遍历链表或者树,下一次访问的地址要等上一次的结果才能算出来,预取器完全没法插手,这时候你承受的就是完整的、串行的高延迟。同样是“读内存”,顺序和随机的实际代价能差一个数量级以上,这也是为什么数组常常比链表快得多,哪怕两者的算法复杂度一样。

1.2 存储层级结构与工程取舍

各级缓存的容量、速度、成本构成一个金字塔,越往上越快越贵越小,越往下越慢越便宜越大。L1 离核心最近,通常拆成指令缓存和数据缓存两份,各自 32KB 左右,访问延迟跟核心流水线深度绑定;L2 是每个核心私有的第二道防线,容量几百 KB 到几 MB;L3 一般是整个芯片内所有核心共享的一大块,容量可以到几十 MB,代价是延迟更高、而且多个核心会争用它。再往下才是主存和存储。

理解这个结构的关键,是明白“私有”和“共享”意味着什么。L1 和 L2 是核心私有的,你的线程跑在哪个核上,它热的数据就在那个核的私有缓存里。线程一旦被调度器挪到别的核,那些数据带不走,得重新从 L3 甚至主存搬。L3 是共享的,好处是一个核心算出来的东西另一个核心可能直接从 L3 拿到,坏处是所有核心抢同一块资源,某个吃内存带宽的任务会把别人的 L3 挤掉。

这个结构直接决定了几个工程上的取舍。第一,多线程程序不要指望“把线程数开到核数就线性加速”,缓存是共享资源,线程越多争用越厉害,超过某个点收益会掉头往下。第二,线程绑定是有意义的,把线程固定在某个核上,能让它私有缓存里的数据持续发挥作用,减少迁移带来的冷启动。第三,要区分“计算密集”和“访存密集”,前者优化的是指令和分支,后者优化的是局部性,两者的手段完全不同。

我见过不少人一上来就加线程、加机器,但真正的问题在于单个线程的工作集已经远大于 L3,每次循环都在打主存,加多少核都是瓶颈在内存总线上。这种时候正确的动作是改数据布局、做分块,把工作集压进缓存。反过来,如果工作集本来就很小、完全装得下 L1,那再怎么折腾缓存也没多少收益,该去看分支预测和指令依赖了。判断标准很朴素:把你的核心数据结构的字节数量级算出来,跟各级缓存容量对一对,心里就有数了。

1.3 缓存行:你优化时的最小粒度

CPU Cache 跟主存之间不是一个字节一个字节地搬数据的,而是按固定大小的块整体搬运,这个块就叫缓存行,主流平台上通常是 64 字节。这一条足以改变很多代码的写法。首先,哪怕你只读一个字节,硬件也会把包含它的 64 个字节全拉进来;其次,你改一个字节,最终写回主存时也是整行写回;最关键的第三点,多核之间判断“这块数据有没有被别人动过”,也是以行为单位来判断的。

第一点带来的是好处:如果你接下来访问的相邻数据在同一个行里,那就是免费命中,等于零成本。这就是为什么顺序遍历数组比按列遍历二维数组快,因为前者把行内 64 字节全用上了,后者每次只用 8 字节就跳到很远的地方去了。第二点带来的是代价:如果你只改行里的一个字节,写回时整行都要走一遍总线,实际写带宽被浪费了十几倍。第三点带来的是最隐蔽的问题——两个线程分别改同一行里两个不同的变量,逻辑上毫无关系,硬件却认为它们在共享同一份数据,会不断地把这一行在核心之间搬来搬去,这就是伪共享。

关于缓存行大小,有两个工程细节值得记住。一是它不是所有平台都一样,某些平台的 L1 是 64 字节,取指路径或者特定架构上可能是 128 字节,写跨平台代码时不要硬编码 64,用编译器的对齐属性去表达意图,让编译器决定具体数值。二是硬件有个“相邻行预取”的行为,你在顺序读一个行的时候,它可能顺手把相邻的那一行也拉进来,等于实际有效粒度是 128 字节。这对顺序访问是纯赚,但对伪共享来说意味着风险范围可能翻倍,我做线程间隔离时习惯把关键变量按 128 字节对齐,宁可多浪费点内存。

提示:判断两个变量是不是在同一缓存行,最直接的办法是打印它们的地址,把地址对 64 取模。差值小于 64 就有共享风险。这个手法在排查阶段特别管用,不需要任何工具。

2. 地址映射:三种组织方式与手算练习

理解了缓存行是搬运单位,下一个问题就是:主存里那么多行,缓存里位置有限,一个具体的地址该放到缓存的哪一格?这就是地址映射要解决的问题。三种经典方案——直接映射、全相联、组相联——本质上是“查找灵活度”与“硬件成本”之间的三档取舍。实际处理器几乎都选了组相联,因为它在这两者之间取得了比较好的平衡。但你要真正理解为什么会踩到索引冲突的坑,就必须把三种方案的机制过一遍,尤其是会算具体的组数、会看一个地址落在哪一组。

2.1 直接映射、全相联、组相联的本质区别

直接映射最简单:缓存被切成若干个槽位,主存里的每一个行地址,用它的某几位做索引,硬性规定只能放到唯一对应的那个槽。好处是查找极快,给个地址直接算位置,一个周期就能判断命中与否;坏处是冲突很死板,如果程序恰好在两个步长等于缓存总容量的地址之间来回跳,它们会映射到同一个槽,每次访问都把对方踢出去,缓存命中率直接归零,哪怕缓存里其它槽位全空着也没用。这就像一排储物柜,每人只能开自己那一个,两个同事共用一个柜子,就会不停地互相扔东西。

全相联是另一个极端:任何一行可以放到缓存的任意位置,查找的时候要把所有行都对比一遍。灵活性拉满,冲突最少,但硬件代价高得吓人——比较器数量随行数线性增长,做大容量基本不现实。它一般只用在极小容量的结构上,比如 TLB 或者某些小缓冲。

组相联是两者的折中:缓存先按索引分成若干组,每组里有 N 个槽位,这就是所谓“N 路组相联”。一个地址先算索引定位到组,然后在这一组的 N 个槽位里找有没有匹配的 tag。N=1 就退化回直接映射,N 等于全部槽位数就是全相联。主流 L1 一般是 8 路或 12 路,L2 是 4 到 16 路,L3 在 16 路以上。N 越大冲突越少,但比较器也越多、访问延迟越高,所以厂商按层级做差异化,靠近核心的追求快,靠外的追求命中率。

映射方式查找速度冲突概率硬件成本典型用途
直接映射最快,单次比较最高最低早期缓存、小型缓冲
全相联最慢,多路并行比较最低最高TLB、小容量结构
组相联(N 路)中等,N 路并行比较中等中等现代各级缓存的主流选择

这里有个容易被忽略的推论:组相联的“N 路”其实给了一个容错额度。你的程序即使有冲突,只要同时在同一个组里活跃的地址不超过 N 个,就不会互相驱逐。所以冲突优化的目标不是“完全避免映射到同一组”,而是“把同一组内的活跃地址数量压到 N 以下”。这个视角比笼统地说“注意冲突”可操作得多,后面算 4KB 步长的例子时你会看到它的用处。

2.2 手算一个 32KB L1 的结构

光看概念没用,会算才算真理解。拿一个常见的组合做练习:32KB 容量、8 路组相联、缓存行 64 字节。求组数,然后拆地址。

组数等于总容量除以(行大小乘以路数):

  • 32KB = 32768 字节
  • 每组容量 = 64 字节 × 8 路 = 512 字节
  • 组数 = 32768 ÷ 512 = 64 组

64 组意味着索引需要 6 位(2^6 = 64),64 字节的行意味着行内偏移需要 6 位(2^6 = 64)。假设是 48 位物理地址,那么 tag 位宽就是 48 - 6 - 6 = 36 位。一个地址的拆法从低位往高位是:低 6 位是行内偏移,接着 6 位是组索引,剩下的高位全是 tag。

这个拆法有一个非常重要的工程含义。行内偏移加组索引一共 12 位,正好等于 4KB 页的大小。换句话说,在一个 4KB 页的内部,所有地址映射到不同的组,不产生索引冲突;而页号不同但页内偏移相同的两个地址,必然落在同一组。这就解释了一个流传很广的经验:步长恰好是 4KB 的访问模式特别容易出问题。如果你的程序按 4096 字节的间隔访问一个数组,每个被访问的元素都落在同一组里,8 路就只容得下 8 个,第 9 个开始就必然把前面的踢掉。

同一套算法可以套到别的层级。假设 L2 是 512KB、16 路、64 字节行,那么组数是 524288 ÷ (64 × 16) = 512 组,索引 9 位;如果 L3 是 32MB、16 路、64 字节行,组数是 33554432 ÷ 1024 = 32768 组,索引 15 位。你看,越往下索引位越多,4KB 步长在 L2 和 L3 上就不一定会冲突,因为同一组能容纳的地址跨度变大了。这也是为什么有些冲突只在 L1 上表现出来,换个层级测就看不到了。

还有一层是 TLB。虚拟地址到物理地址的翻译本身也要缓存,页表项缓存项数有限,4KB 小页的情况下覆盖范围可能只有几 MB。工作集一旦超出这个范围,每次访问都要走页表遍历,代价同样可观。对大数组做顺序扫描时,用大页能把 TLB 覆盖范围扩大几百倍,这是我做大规模数据处理时经常顺手打开的一个开关,收益往往比微调循环还明显。

2.3 索引冲突:4KB 步长的经典陷阱

理论说完,看一个能直接复现的现象。考虑一个二维数组,按行存储,行长度刚好是 4096 字节(比如 512 个 8 字节元素)。如果代码按列访问,每隔 4096 字节取一个元素:

#define N 512 #define ROWS 4096 double a[ROWS][N]; // 按列遍历:每次跳 4096 字节 for (int j = 0; j < N; j++) { for (int i = 0; i < ROWS; i++) { sum += a[i][j]; } }

每次访问跨度正好 4096 字节,按前面算出来的结果,所有这些地址在 32KB 的 L1 上全落在同一组。8 路组相联只能扛住 8 个并发,第 9 个开始每次都是驱逐加加载,L1 命中率趋近零。而如果换成按行遍历,同样的数据量,相邻元素都在同一行内,命中率高得离谱。同一份数据、同样的循环次数,只改遍历顺序,实测差距可以到五倍以上。

解决这种冲突有几种常见手法。第一种是换访问顺序,把内层循环改成最内层连续的方向,这是首选,不增加任何成本。第二种是数组填充,在每行末尾多补几个元素,让行长度不再是 2 的整数次幂,步长就散了:

#define N 512 #define PAD 8 // 补 8 个元素,行跨度变成 520*8 = 4160 字节,不再是 2 的幂 double a[ROWS][N + PAD];

填充的代价是内存占用上升和索引计算变复杂,所以只在对性能敏感、且确认存在冲突时用。补多少合适?我一般从 8 个元素起步试,实际测下来 4 到 16 之间挑一个就行,没必要精细调优。第三种是分块,把大数组切成能装进缓存的小块逐块处理,这在矩阵乘法里几乎是标配,后面第 4 章会展开。

注意:填充不是万能的。如果代码已经做过分块,工作集本来就小,填充反而可能因为打乱了原有的对齐关系带来新的冲突。先量再改,别凭感觉补。

3. 写策略与多核一致性:真正踩坑的地方

读的问题相对好理解,写的问题才是工程上翻车最集中的地方。写操作牵扯到两件事:改完的数据什么时候同步到下级存储,以及多个核心同时持有同一份数据时谁说了算。前者叫写策略,后者叫缓存一致性协议。这两块内容教科书上讲得很形式化,但工程上你只需要抓住几个关键结论,就能解释绝大多数多线程性能异常。

3.1 写回、写直达与写分配

写策略有两组选择。第一个维度是写命中时怎么处理:写直达是指每次写都同时更新缓存和下级存储,保证两边永远一致,代价是写带宽被大量消耗;写回是指只改缓存,把这一行标记为“脏”,等它被驱逐的时候才一次性写回去。现代处理器的各级缓存基本都用写回,因为程序对同一块内存的写通常是连续的,攒着一起写效率高得多。

第二个维度是写未命中时怎么处理:写分配是指先把这一行读进缓存,再在缓存里改;非写分配是指直接写到下级存储,不占缓存。写回策略通常和写分配搭配,因为写回意味着你预期后续还会改这一行,那就值得把它拉进来。反过来说,如果你确定一块数据只会被写一次、写完再也不读,那么把它读进缓存纯属浪费带宽——这就是流式写(非临时存储)的适用场景。

这个判断在工程上很有用。大数组的初始化、图像缓冲区的一次性填充、日志的顺序追加,这些都是典型的“写一次不重用”。对这类写操作,用普通赋值会把每一行都读进来再改,实际占用带宽是两倍;改成流式写指令,直接绕过缓存往下推,带宽立省一半。代价是这块数据短期内不会被缓存命中,所以只在你确认不重读的时候用。

还有一个隐含成本值得说:写未命中触发的那次读取,如果对应的是多核共享状态,还要先跟其他核心打招呼把所有权要过来,这个过程可能比单纯读一次内存还慢。所以“写”在多核环境下从来都不便宜,能减少写次数就减少。

3.2 MESI 状态机用大白话讲清楚

多核一致性协议里最常见的一套是四状态模型,四个状态分别是:已修改、独占、共享、无效。我用一个类比来说:想象一份文件有多个副本放在不同人的桌上。

已修改:我这份是最新的,而且只有我一个人有,别人的副本都不作数。这种情况下我改它不用通知任何人,最自由,但如果别人要读,我得先把最新内容交出去。

独占:我这份是干净的、跟主存储一致,而且只有我一个人持有。我想改的时候不用打招呼,直接改就行,升级成本最低。

共享:我这份是干净的,但可能还有别人也持有同样的副本。这时候我想改,必须先广播通知所有持有者作废他们的副本,然后我才能独占。

无效:我这份作废了,不能用,要访问得重新去拿。

这四种状态之间的迁移,构成了多核程序里所有“看不见的开销”。两个线程读同一块数据,双方都是共享态,相安无事。可一旦其中一个要写,它必须把对方从共享态打成无效态,这个“通知-确认”的过程发生在总线或者互联总线上,耗时几十纳秒,比一次 L1 命中慢了两个数量级。如果两个线程反复交替写同一块数据,这一行就在两个核心之间来回弹,每一次写都要打一轮招呼,这就是所谓的缓存行弹跳。

协议还引出了两个重要机制,写缓冲和失效队列。写操作先进入写缓冲,核心不等确认就继续执行,这是性能的关键,但也意味着别的核心看到这次写的时机被延迟了。失效通知先进入失效队列,确认动作延后处理,同样是为了不阻塞。这两个缓冲的存在,就是内存序问题的硬件根源,也是各种内存屏障指令存在的原因。你写无锁队列、双检锁之类的东西时踩到的诡异重排,追根究底都能追到这两个缓冲上。

状态数据是否最新是否独占写操作是否需要通知别人
已修改是是不需要
独占是(与主存一致)是不需要
共享是(与主存一致)否需要,先作废他人副本
无效不可用不适用需要重新加载

工程上记住一句就够:读共享,写独占,读写交替最贵。任何能让多个线程少写同一行、少交替读写同一行的改动,几乎都是净赚。

3.3 伪共享:多核性能的第一杀手

伪共享指的是两个线程各自修改不同的变量,但这两个变量恰好落在同一条缓存行上。逻辑上完全不相关,硬件层面却把它们当成一份共享数据来维护。前面讲的机制在这里全部生效:线程 A 要写,得先把线程 B 的那一份作废;线程 B 要写,又得把 A 的作废。既没有真正的数据依赖,也没有真正的竞争,却付出了完整的缓存行迁移成本。

一个最小的复现例子:

#include <pthread.h> #include <stdio.h> struct counters { long a; long b; // 与 a 相邻,极可能在同一缓存行 }; struct counters g; void *worker_a(void *arg) { for (long i = 0; i < 100000000L; i++) g.a++; return NULL; } void *worker_b(void *arg) { for (long i = 0; i < 100000000L; i++) g.b++; return NULL; }

这段代码跑起来,两个线程分别加自己的变量,理论上应该接近线性加速。实测往往只有单线程的一小部分。原因就是a和b距离 8 字节,同处一行,两核每次自增都在争这一行的所有权。注意g.a++本身还不是原子操作,但即便不用原子指令,光是缓存行迁移就足以把性能打穿。

修法是把两个变量拆到不同的缓存行:

#include <stdalign.h> struct counters { alignas(64) long a; alignas(64) long b; };

用对齐属性表达意图,让编译器去处理具体的填充。老代码里常见的手法是塞一个char pad[64],效果一样但不够优雅,而且容易算错。为什么要 64 而不是随便一个数?因为对齐到 64 字节边界能保证一个变量独占一整行。前面提过相邻行预取的存在,我在核心路径上会直接对齐到 128,多花 64 字节换确定性,很划算。

注意:别把所有结构体都对齐到 64。对齐会让结构体变大,数组元素之间出现空洞,反而降低缓存利用率。只在“多线程高频写”的变量上用,其余地方老老实实紧凑排布。

排查伪共享有个很好用的工具,能直接报出哪个地址的行发生了跨核争用,比盲猜高效得多。没有工具的情况下,也可以做一个简单对照实验:把可疑结构体加填充再跑一遍,如果性能明显改善,基本就坐实了。

4. 落到代码:可复现的优化与验证手法

前面讲的是机理,这一章讲怎么用。CPU Cache 相关的优化有一个共同特点:改动小、效果大、但不做验证就完全不知道有没有用。所以我把内容分成三块——数据结构层面怎么排、循环层面怎么走、以及最重要的,怎么把效果量出来。

4.1 结构体布局与缓存行对齐

结构体是缓存优化最直接的抓手。一个原则:热字段聚在一起,冷字段踢出去,跨线程高频写的字段分开。假设有一个任务描述结构体,包含任务 ID、状态、结果指针、一段诊断用的日志字符串,前三个字段在调度循环里被高频访问,日志只在出错时读。如果按声明顺序紧凑排布,一个结构体可能横跨两三行,每次访问都要多拉无用的数据,缓存利用率被浪费掉。

改法是把热字段提到一起,让它压缩在一行内;冷字段单独放一个结构体,用指针关联。这样调度循环每次只需要触碰一行,缓存能装下更多任务。这个改动在任务量大的情况下效果非常明显,尤其当任务队列是数组形式、遍历时逐项访问时。

/* 改造前:热字段与冷字段混在一起 */ struct task_bad { uint64_t id; int state; void *result; char diag_log[256]; /* 冷数据,撑爆了缓存行 */ }; /* 改造后:热字段内聚在一行,冷数据分离 */ struct task { uint64_t id; int state; void *result; struct task_diag *diag; /* 冷数据走指针,按需加载 */ };

另一个高频场景是计数器数组。多线程环境下如果每个线程更新数组里的一个元素,相邻元素必然同处一行,伪共享无法避免。解法是让每个元素独占一行,或者干脆改成每个线程一份私有计数,最后汇总。前者内存开销大但实现简单,后者内存效率高但要注意汇总时机。选哪个看你的线程数和计数更新频率,线程数少、更新频率极高的情况,独占一行更稳妥。

还有一种情况是数组元素本身太小。比如你有一个几百万个元素的状态数组,每个元素只有 1 个字节,那么一行里有 64 个元素。顺序遍历没问题,非常好;但如果多个线程分区段并发更新,相邻区段的边界处必然共享行。处理办法很简单,让每个线程负责的区段按缓存行对齐,段首段尾留出一点余量。

4.2 循环分块与顺序访问

循环层面的缓存优化,核心只有一句话:让内层循环访问连续的内存。这一条能解决大部分问题,而且不需要任何额外结构。

以前面提过的矩阵乘法为例。朴素的三重循环如果写成了最内层跳着走,缓存表现会非常糟。经典的做法是调整循环顺序,让最内层对应连续维度,把一次读取的数据尽可能多用几次。再进一步是分块,把大矩阵切成小块,让参与计算的三块数据同时装进 L1 或 L2。

分块尺寸怎么定?这是一个可算的问题。假设做双精度浮点矩阵乘,分块大小是 B,那么三个块的工作集大约是 3 × B × B × 8 字节。要求这个值小于目标缓存容量。以 32KB 的 L1 为例,留出余量按 24KB 算:3 × B² × 8 ≤ 24576,解出 B² ≤ 1024,B ≤ 32。所以 32 是一个合理的起点。实践中考虑预取和关联度,通常会在 16 到 64 之间试,我用 32 和 48 居多。块再大就会溢出到 L2,虽然也能跑,但收益会下降。

#define BS 32 /* 分块大小,按 L1 容量推算得出 */ for (int ii = 0; ii < N; ii += BS) for (int jj = 0; jj < N; jj += BS) for (int kk = 0; kk < N; kk += BS) for (int i = ii; i < ii + BS && i < N; i++) for (int k = kk; k < kk + BS && k < N; k++) { double a_ik = A[i][k]; for (int j = jj; j < jj + BS && j < N; j++) C[i][j] += a_ik * B[k][j]; }

注意内层把A[i][k]提到了循环外。这是一个很小的改动,但作用不小:原写法每次内层迭代都要读一次A[i][k],虽然大概率命中,但仍然是多余的一次加载;提出来之后只用寄存器保存,编译器也更容易做向量化。

除了分块,还有几个实操上的小手法。预取:对于可以提前算出地址的访问,用预取指令在需要之前把行拉进来,掩盖掉一部分延迟。这对顺序扫描有帮助,但对指针追逐基本无效,因为地址本身要等上一次结果。避免不必要的清零:大块内存直接分配后立刻全量写入时,不需要先清零,省掉一次全量读。结构体数组改成数组结构体:当循环只用到结构体里的少数字段时,把字段拆成独立的数组,连续访问的密度会高得多,缓存利用率能提升好几倍。这个改动在数据量大的时候效果很直观,代价是代码可读性下降,需要权衡。

4.3 用工具把效果量出来

所有优化都必须量。没有测量的优化等于瞎改,而且很容易把别处的性能吃掉。下面是我常用的几个手段,按上手难度排序。

最轻量的方法是计数器加时间戳。在循环前后读一次高精度计时,重复多轮取中位数,能快速判断改动方向对不对。缺点是噪声大,需要控制变量,且无法告诉你“为什么变快”。

第二层是硬件性能计数器。通过系统的性能分析工具,能直接拿到缓存命中率、各级缓存的缺失次数、分支预测失败次数等指标。一次典型的用法是跑前后两个版本,对比 L1 缺失率和每指令周期数。我通常关注三个数:L1 数据缺失率、末级缓存缺失率、以及缓存未命中导致的停顿占比。前两个反映局部性好不好,第三个直接反映对性能的影响有多大。

# 采集缓存相关指标,跑三遍取稳定值 perf stat -e cache-references,cache-misses,\ L1-dcache-loads,L1-dcache-load-misses,cycles,instructions \ ./your_program

第三层是缓存模拟器。它能给出精确的模拟命中率,而且可以任意调整缓存参数,非常适合做原理验证。缺点是完全脱离真实硬件,绝对数值没有参考价值,看的是相对趋势。用它验证“4KB 步长导致冲突”这类结论特别合适,因为你可以把组数改大改小,观察命中率怎么变,因果关系一目了然。

第四层是跨核争用检测。专门用来找伪共享,能直接报出发生缓存行跨核迁移的地址和次数。伪共享这种东西,性能计数器只能告诉你“缺失很多”,但定位不到具体是哪一行,这个工具能补上最后一块拼图。如果你怀疑某个结构体有问题,先在压测下跑一遍这个,比反复改代码试要快得多。

手段能回答什么问题上手成本适用阶段
时间戳打点改完到底快没快低任何阶段
性能计数器命中率、缺失率、停顿占比中定位瓶颈
缓存模拟器参数变化对命中率的影响中原理验证、方案预判
跨核争用检测哪一行在核间弹跳中高多线程调优

我自己的习惯是先用时间戳确认真有收益,再用性能计数器确认收益来自缓存而不是别的地方,最后才做精细调整。很多次“看起来变快了”的改动,一测发现是别的因素,比如分支预测变好了或者编译器换了优化策略,跟缓存一点关系没有。搞清楚因果,下次才能复用经验。

5. 排查实录:常见现象与对应解法

原理和手法讲完,最后这部分是我觉得最有价值的内容——把现象和解法对应起来。性能问题难的地方从来不是不知道原理,而是看到一堆数字之后不知道该往哪查。下面这张表是我这些年积累下来的对照关系,基本上拿到一个现象就能缩小到两三个可疑方向。

5.1 症状速查表

现象高概率原因第一步怎么验证
单线程快,多线程吞吐反而下降伪共享或共享计数器争用加填充做对照实验,或用跨核争用工具
数据量翻倍后耗时涨了好几倍工作集超出某级缓存容量算工作集字节数,对比各级缓存容量
二维数组换遍历顺序差异巨大索引冲突或跨行访问计算步长与组数的关系,或直接换顺序测
顺序读快、随机读极慢预取器失效,承受完整延迟对比两种访问模式的每元素耗时
线程绑定核之后性能上升线程迁移导致私有缓存失效对比绑定前后的缺失率
代码没改但性能周期性波动缓存被其他进程挤占观察共享缓存占用,隔离运行环境
小数组很快,大数组突然变慢超出末级缓存或页表缓存覆盖从小到大扫一遍数据量,画曲线找拐点

这张表里我最想强调最后一条:从小到大扫一遍数据量、画出耗时曲线,是判断缓存效应最快的手段。因为容量是硬边界,一旦工作集跨过某个层级,曲线会出现明显的台阶。台阶的位置就能反推出是哪个层级在起作用。这个方法不需要任何工具,只需要循环几次改参数,特别适合在没有性能分析环境的机器上做初步判断。

5.2 我踩过的几个坑

第一个坑是只按单元素大小算内存,忘了缓存行这个粒度。早年我做稀疏矩阵相关的东西,用了一堆小结构存边信息,算下来总字节数完全装得下 L2,结果实测慢得离谱。后来才想明白,每个小结构体十几字节,跨行存放,实际占用的缓存行数量是按 64 字节算的,等于实际占用翻了四五倍,早就溢出到主存去了。自那以后我算工作集都按缓存行向上取整来估,宁可多算一点。

第二个坑是把只读的共享数据当成了安全的。只读确实不会有一致性开销,但多个核心同时读同一块数据,L3 的带宽是被共同消耗的。如果这块数据特别大,比如几十 MB 的模型参数,所有核心同时扫,L3 会被反复冲刷,效果跟没有缓存差不多。这种场景的正确做法是让每个核心处理数据的不同片段,或者调整访问时序让各核心错开,而不是把同一份数据复制到所有线程里各扫一遍。

第三个坑是对齐过头。有一阵子我迷上了把所有热点结构体都对齐到 64,结果数组元素之间出现大量空洞,原本连续的数据变得稀疏,顺序访问的密度下降,总缓存行占用反而上升。对齐是一种隔离手段,只在需要隔离的地方用,普通的数据容器老老实实紧凑排布才对。

第四个坑是没考虑运行环境的干扰。同一份代码在同一台机器上两次跑出不同的结果,一开始我以为是测量误差,后来发现是共享缓存的争用——同一台机器上还跑着别人的任务,末级缓存被抢走了。做性能对比实验一定要控制环境,尽量独占机器,或者至少保证前后两次运行的背景负载一致,否则测出来的差异可能全是噪声。

第五个坑是优化了不该优化的地方。有一次我花了两天做数据布局调整,收益 3%,后来随手把一段频繁调用的函数里的内存分配挪到循环外,收益 40%。教训是先用工具找到真正的瓶颈,再动手。缓存优化只是众多手段之一,当瓶颈根本不在访存上时,再怎么调布局都是徒劳。

心得:判断一个优化值不值得做,我有个粗略的标准——先用最小改动验证方向,能拿到 10% 以上的收益才值得投入精力做精细打磨。低于这个量级的改动,噪声可能就把它盖住了,而且增加了代码复杂度,维护成本是长期的。

最后再分享一个我常用的做法。做任何跟缓存相关的改动之前,我会先在纸上把数据流画一遍:这块数据有多大、被谁访问、访问顺序是什么、同时活跃的数据有几份、分给几个线程。这五个问题答完,优化的方向基本自己就浮出来了,很多时候根本不需要上工具。反过来,如果这五个问题里有答不上来的,那说明我对这段代码的理解还不够,贸然改就是在赌。