缓存未命中:三种 Cache Miss 详解

聊算子融合,绕不开缓存未命中。

说白了,你费劲心思做融合,最终目的就是减少这些 Miss。我见过不少同学,一上来就猛调循环分块,结果性能纹丝不动。为什么?因为他根本不知道自己的程序到底踩了哪种坑。

今天我们就把这三种 Miss 掰开揉碎讲清楚。

强制未命中(Compulsory Miss)

也叫冷启动未命中。这是程序第一次访问某个数据块时必然发生的。

你想想看,缓存里啥都没有,CPU 一伸手,肯定扑空。这种 Miss 没法避免——除非你提前预取。但预取也不是万能的,预取错了反而污染缓存。

我在做图像处理算子时遇到过这种情况。一个 4K 分辨率的图像,第一次遍历像素时,几乎全是强制未命中。嗯,这时候你只能接受它,因为这是第一次访问的代价。

关键点:强制未命中无法通过算法优化消除,只能通过预取技术缓解。

容量未命中(Capacity Miss)

这个就好理解了。缓存太小,装不下你的工作集。

举个例子,你的 L1 缓存只有 32KB,但你处理的数据是 1MB。数据在缓存里进进出出,刚加载进来又被踢出去,下次再用时又得重新加载。这就是容量未命中。

我优化过一个矩阵乘法的算子。原始实现里,三个大矩阵同时参与计算,工作集远超 L2 缓存大小。结果呢?性能惨不忍睹。后来我用了分块(tiling),把计算切小,让每个小块的数据都能塞进缓存。容量未命中一下子就降下来了。

我的经验:遇到容量未命中,优先考虑分块策略。分块大小要适配缓存行大小,通常是 64 字节的整数倍。

冲突未命中(Conflict Miss)

这是最隐蔽的一种。缓存明明还有空间,但数据偏偏被映射到同一个缓存组里,互相踢来踢去。

为什么会这样?因为缓存是组相联的。比如一个 4 路组相联缓存,每个组只能放 4 个数据块。如果程序访问的地址恰好映射到同一个组,第 5 个数据就会把前面的踢出去。

我曾经踩过一个坑。在做卷积算子时,输入特征图和权重矩阵的地址刚好相差 2 的幂次。结果它们疯狂竞争同一个缓存组,性能直接腰斩。我当时排查了好久,最后用地址偏移(padding)解决了问题。

避坑指南:我曾经因为数组大小是 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 低,可能是冲突未命中。强制未命中一般只在程序启动阶段出现。

一个小技巧:用 perf stat -e cache-misses,cache-references,L1-dcache-load-misses,L1-dcache-loads 跑一遍你的程序,看比例。

知识体系图

下面这张图总结了三种 Miss 的核心逻辑和优化方向:

三种 Cache Miss 知识体系 强制未命中 第一次访问 原因:冷启动 优化:预取 无法完全消除 容量未命中 工作集太大 原因:缓存装不下 优化:分块 最常见的问题 冲突未命中 地址映射冲突 原因:组相联竞争 优化:padding 最隐蔽的坑 算子融合的核心目标:减少这三种 Miss 的总和

嗯,这张图把三种 Miss 的关系理清楚了。你写算子时,脑子里要时刻想着:我当前的操作,到底在制造哪种 Miss?

总结一下:强制未命中是命,容量未命中是病,冲突未命中是妖。命要认,病要治,妖要防。

想要更多资料,联系可可