缓存未命中:三种 Cache Miss 详解
聊算子融合,绕不开缓存未命中。
说白了,你费劲心思做融合,最终目的就是减少这些 Miss。我见过不少同学,一上来就猛调循环分块,结果性能纹丝不动。为什么?因为他根本不知道自己的程序到底踩了哪种坑。
今天我们就把这三种 Miss 掰开揉碎讲清楚。
强制未命中(Compulsory Miss)
也叫冷启动未命中。这是程序第一次访问某个数据块时必然发生的。
你想想看,缓存里啥都没有,CPU 一伸手,肯定扑空。这种 Miss 没法避免——除非你提前预取。但预取也不是万能的,预取错了反而污染缓存。
我在做图像处理算子时遇到过这种情况。一个 4K 分辨率的图像,第一次遍历像素时,几乎全是强制未命中。嗯,这时候你只能接受它,因为这是第一次访问的代价。
容量未命中(Capacity Miss)
这个就好理解了。缓存太小,装不下你的工作集。
举个例子,你的 L1 缓存只有 32KB,但你处理的数据是 1MB。数据在缓存里进进出出,刚加载进来又被踢出去,下次再用时又得重新加载。这就是容量未命中。
我优化过一个矩阵乘法的算子。原始实现里,三个大矩阵同时参与计算,工作集远超 L2 缓存大小。结果呢?性能惨不忍睹。后来我用了分块(tiling),把计算切小,让每个小块的数据都能塞进缓存。容量未命中一下子就降下来了。
冲突未命中(Conflict Miss)
这是最隐蔽的一种。缓存明明还有空间,但数据偏偏被映射到同一个缓存组里,互相踢来踢去。
为什么会这样?因为缓存是组相联的。比如一个 4 路组相联缓存,每个组只能放 4 个数据块。如果程序访问的地址恰好映射到同一个组,第 5 个数据就会把前面的踢出去。
我曾经踩过一个坑。在做卷积算子时,输入特征图和权重矩阵的地址刚好相差 2 的幂次。结果它们疯狂竞争同一个缓存组,性能直接腰斩。我当时排查了好久,最后用地址偏移(padding)解决了问题。
三种 Miss 的对比
| 类型 | 根本原因 | 优化手段 | 我常用的方法 |
|---|---|---|---|
| 强制未命中 | 第一次访问 | 预取 | 软件预取指令 |
| 容量未命中 | 工作集太大 | 分块、循环分块 | 分块大小 = 缓存大小 / 3 |
| 冲突未命中 | 地址映射冲突 | 地址偏移、数组 padding | padding 4-8 个元素 |
实战中的判断方法
你可能会问:我怎么知道当前是哪种 Miss?
我一般用 perf 工具。看这几个指标:
- L1-dcache-load-misses:总未命中次数
- L1-dcache-loads:总访问次数
- cache-references 和 cache-misses:LLC 级别的数据
如果 LLC miss 很高,大概率是容量未命中。如果 L1 miss 高但 LLC miss 低,可能是冲突未命中。强制未命中一般只在程序启动阶段出现。
知识体系图
下面这张图总结了三种 Miss 的核心逻辑和优化方向:
嗯,这张图把三种 Miss 的关系理清楚了。你写算子时,脑子里要时刻想着:我当前的操作,到底在制造哪种 Miss?
想要更多资料,联系可可