缓存层级:L1/L2/L3缓存结构,缓存行与局部性原理
大家好,我是你们的老朋友。今天我们来聊聊缓存层级这个事。
说实话,很多做算子融合的同学,一开始都栽在缓存上。我自己刚入行那会儿,写了个自以为很牛的矩阵乘法,结果跑起来比单线程还慢。后来一查,原来是缓存没用好。嗯,今天就把这些坑给大家捋清楚。
一、L1/L2/L3 缓存结构
现代CPU的缓存,说白了就是三级金字塔结构。我习惯把它想象成一个「快递分拣中心」:
- L1 缓存:离核心最近,速度最快(1-2个时钟周期),但容量最小(32KB-64KB)。每个核心独享。
- L2 缓存:速度稍慢(10-20个时钟周期),容量256KB-1MB。也是每个核心独享。
- L3 缓存:所有核心共享,速度最慢(30-50个时钟周期),容量8MB-32MB。
你想想看,为什么要有这么多层?
因为「快」和「大」是矛盾的。SRAM做L1可以很快,但贵得要命。DRAM做内存便宜,但慢得让人抓狂。所以折中方案就是:把最常用的数据放在L1,次常用的放L2,再次的放L3,剩下的去内存里找。
核心要点:算子融合的目标,就是让数据尽可能留在L1/L2里,别老往内存跑。
二、缓存行——你根本躲不开的64字节
这里有个概念我必须强调:缓存行(Cache Line)。
CPU从内存读数据,不是按字节读的,而是按「行」读。一行通常是64字节。什么意思呢?
比如你只想读一个int(4字节),CPU会连它旁边的60个字节一起搬进缓存。这就是所谓的「空间局部性」——你读了一个,很可能马上要读旁边的。
我在项目中遇到过一个问题:两个线程各自修改相邻的变量,结果性能暴跌。为什么?因为这两个变量在同一个缓存行里。线程A改了变量,导致整个缓存行失效,线程B不得不重新加载。这就是著名的伪共享(False Sharing)。
避坑指南:我曾经在一个多线程算子里,把两个热点变量放在同一个结构体里,结果性能只有预期的1/3。后来用__attribute__((aligned(64)))把它们分开,速度直接翻倍。
三、局部性原理——算子融合的理论根基
局部性分两种:
- 时间局部性:刚用过的数据,很快还会再用。
- 空间局部性:刚用过的数据旁边的数据,很快也会用到。
算子融合为什么能提升缓存命中率?说白了就是利用局部性。
举个例子,两个独立的算子:
// 算子A:对数组每个元素加1
for (i = 0; i < N; i++) {
a[i] = a[i] + 1;
}
// 算子B:对数组每个元素乘2
for (i = 0; i < N; i++) {
a[i] = a[i] * 2;
}
分开执行时,第一个循环把整个数组从内存搬到缓存,处理完又写回内存。第二个循环再重复一遍。两次全量搬运,缓存利用率极低。
融合之后:
// 融合算子:一次循环搞定
for (i = 0; i < N; i++) {
a[i] = (a[i] + 1) * 2;
}
每个元素只从内存加载一次,在缓存里完成两次操作,然后写回。缓存命中率从50%左右提升到接近100%。
我的经验:实际项目中,融合后的算子往往能减少30%-50%的内存访问量。但要注意,融合不能无脑做——如果两个算子访问的数据集完全不同,融合反而会污染缓存。
四、一张图看懂缓存层级与局部性
下面这张SVG图,是我自己画的一个缓存层级与算子融合的关系图。你可以看到数据是怎么从内存一层层往上走的:
五、实战中的缓存优化策略
基于上面的原理,我总结了几条实战经验:
| 策略 | 说明 | 效果 |
|---|---|---|
| 循环融合 | 将多个遍历同一数据集的循环合并 | 减少内存访问次数,提升时间局部性 |
| 数据对齐 | 确保热点数据按64字节对齐 | 避免跨缓存行访问,减少加载次数 |
| 分块处理 | 将大数组切分成适合L1缓存的小块 | 让数据在L1中完成全部操作,避免频繁换入换出 |
| 避免伪共享 | 多线程中,将不同线程的数据分散到不同缓存行 | 防止缓存行无效导致的性能抖动 |
一句话总结:算子融合的本质,就是通过合并计算步骤,让数据在缓存里「一次加载,多次使用」。你想想看,这比来回倒腾内存不知道高到哪里去了。
好了,今天的内容就到这里。记住这些缓存层级和局部性的原理,后面讲具体融合策略时,你会理解得更深。