GPU L2 Cache逆向分析:用软件实验测出容量、相联度与Hash映射 1. 从一块显卡说起为什么要逆向分析 L2 Cache很多人拿到 GPU 之后第一反应是跑分、跑模型、看帧率很少有人会去关心 L2 Cache 到底长什么样。但如果你做过 CUDA 性能调优尤其是那种明明算力够、带宽也够kernel 就是跑不快的场景最后十有八九会怀疑到缓存头上。L2 Cache 是 GPU 上所有 SM 共享的最后一级片上缓存它决定了 DRAM 访问的合并效率、原子操作的吞吐、以及跨 SM 数据交换的延迟。厂商在文档里只会告诉你我们有 XX MB L2至于它的组相联度、替换策略、sector 划分、hash 函数基本只字不提。我这次做的事情就是拿一块 Ampere 架构的消费级显卡用纯软件的手段把 L2 的结构参数一点点测出来。所谓逆向不是去拆芯片、看版图而是通过精心设计的访存实验从时序和命中率的行为反推出硬件参数。这套方法在 CPU 领域已经非常成熟经典的 cache timing attack 论文早就把 L1/L2/L3 的测量套路写透了但 GPU 上做同样的事情要麻烦得多——因为 GPU 的访存模型是 SIMT 的一个 warp 里 32 个线程的访问会被合并、会被调度器重排你很难像 CPU 那样精确控制单次访问。这篇文章适合谁看如果你正在做 GPU 性能工程、体系结构研究、或者单纯对硬件黑盒怎么被软件撬开这件事感兴趣那接下来的内容应该对你有用。我会把整个实验的设计思路、踩过的坑、以及最后测出来的参数都摊开讲代码片段和参数计算过程都会给全你可以直接照着复现。需要说明的是我用的是一块公开零售的 Ampere 显卡所有实验都在正常的使用范围内进行不涉及任何固件修改或非公开接口。先说结论方便你判断要不要往下读这块卡的 L2 总容量 6 MB物理上分成 2 个 partition每个 partition 32 个 slice每个 slice 16 路组相联line size 128 字节但实际以 32 字节的 sector 为粒度做填充和替换。替换策略接近伪 LRU但在高冲突场景下会退化成近似随机。这些数字后面我都会给出测量方法和原始数据。2. 逆向 L2 的整体思路与方案选型2.1 为什么不用现成工具而要自己写市面上确实有一些 GPU profiling 工具比如厂商自带的 profiler 能告诉你 L2 的命中率、吞吐、甚至部分结构信息。但这些工具给的是聚合后的统计量你没法从中反推出第 17 路和第 18 路是不是同一个 set这种细粒度结构。而且 profiler 本身会干扰测量采样开销、计数器复用都会让时序失真。要做结构逆向必须自己控制访存模式用最原始的 clock64() 或者全局计时来测延迟。另一个选择是直接用微基准测试框架比如一些学术界的 cache benchmark。我试过几个问题是它们大多针对 CPU 的缓存层次设计搬到 GPU 上要么编译不过要么因为 warp 调度导致测量结果完全不可读。最后还是决定从零写一套虽然费时间但每个参数都可控出了问题也知道去哪查。2.2 核心测量原理指针追逐与冲突构造逆向缓存结构最核心的两个手段一个是指针追逐pointer chasing一个是冲突集构造conflict set construction。指针追逐用来测延迟。你构造一个链表每个节点指向下一个节点节点之间故意拉开到不同的缓存行。然后让一个线程沿着链表走每次访问都依赖上一次的结果这样硬件没法乱序执行、没法预取测出来的就是真实的访存延迟。如果链表的所有节点都能装进 L2那延迟就是 L2 命中延迟如果装不下就会周期性掉到 DRAM延迟曲线会出现明显的台阶。冲突集构造用来测组相联度和 set 索引。原理是这样的如果你能找到一组地址它们全部映射到同一个 cache set那么当你访问的地址数量超过相联度时就会开始互相驱逐命中率骤降。通过扫描地址步长找到那个访问 N1 个地址就开始抖动的 N就是相联度。而映射到同一个 set 的地址之间的步长关系就暴露了 set 索引的 hash 函数。这两个手段在 CPU 上都很成熟GPU 上的难点在于第一你需要保证一个 warp 内的线程访问模式一致否则合并逻辑会打乱你的地址序列第二GPU 的 L2 有 sector 机制一个 128 字节的 line 分成 4 个 32 字节的 sector部分填充是常态这会干扰命中判断第三Ampere 的 L2 是物理分布式partitioned的地址到 partition 的映射本身就是一个 hash你得先把这层解开。2.3 实验平台与工具链选择我用的平台是一块 Ampere 架构的消费级显卡CUDA 版本 11.x驱动是当时较新的稳定版。操作系统是常见的 Linux 发行版。工具链就是 nvcc 加标准 CUDA runtime没有用任何特殊库。计时用的是 clock64()它在 SM 上是一个递增的周期计数器精度足够测出 L2 和 DRAM 之间几十个周期的差异。这里有个细节要注意clock64() 在不同 SM 上的基准值可能不同跨 SM 比较没有意义。所以所有延迟测量都必须在同一个线程内完成或者至少保证测量和被测量在同一个 SM 上。我一开始没注意这点跨 SM 测出来的数据全是噪声白白浪费了两天。提示做 GPU 缓存逆向第一件事是确认你的计时器行为。建议先写一个空 kernel里面连续调用两次 clock64()看看差值分布确认计时精度和稳定性再开始正式实验。3. 核心参数测量容量、相联度、Line Size3.1 用延迟台阶测总容量测容量的思路很直接构造一个大小为 S 的指针追逐链表让所有节点尽量均匀地分布在地址空间里然后测平均访问延迟。当 S 小于 L2 容量时所有节点都在 L2 里延迟低且平稳当 S 超过 L2 容量时开始有节点被驱逐到 DRAM平均延迟上升。延迟曲线拐点对应的 S就是 L2 的有效容量。实际操作中链表节点的大小要设成 line size 的整数倍避免一个节点跨两个 line 导致额外访问。我一开始用 64 字节节点结果发现延迟曲线很糊后来改成 128 字节也就是一个完整 line才清晰起来。链表节点的地址要随机化不能是连续的否则硬件预取器会介入把 DRAM 延迟藏起来。测量结果如下表这是多次运行取中位数的结果链表大小平均延迟周期说明1 MB约 210全部命中 L22 MB约 215全部命中 L24 MB约 220基本命中 L25 MB约 235开始出现驱逐6 MB约 280明显抖动8 MB约 420大量访问 DRAM16 MB约 430基本全在 DRAM从 5 MB 开始延迟上升6 MB 时已经很明显8 MB 时基本饱和到 DRAM 延迟。考虑到 sector 机制和替换策略的开销有效容量略小于标称的 6 MB这是正常的。标称 6 MB 是物理容量实际可用会打折扣。3.2 冲突构造测组相联度测相联度需要先找到一组映射到同一个 set 的地址。Ampere 的 L2 set 索引不是简单的地址位切片而是经过了一个 hash 函数目的是把访问打散到各个 partition 和 slice避免热点。这个 hash 函数的具体形式厂商不会公开但可以通过实验反推。我的做法是固定一个基地址然后以不同的步长 stride 去访问一组地址观察什么时候开始出现冲突。如果 stride 正好等于set 数量 × line size那么所有访问都会落到同一个 set。但因为有 hash这个关系不是线性的需要扫描。具体实验分配一块缓冲区用 stride 从 128 字节开始每次增加 128 字节对每个 stride 访问 64 个地址测命中率。当 stride 使得这 64 个地址大量冲突时命中率会掉。记录下所有命中率骤降的 stride它们之间应该存在某种倍数关系。实测下来在 4 KB 到 64 KB 的 stride 范围内出现了几个明显的冲突点。结合后续的 set 索引分析最终确定每个 slice 是 16 路组相联。验证方法是构造 17 个映射到同一 set 的地址访问它们如果第 17 个地址的访问导致前 16 个中的某一个被驱逐说明相联度就是 16。这里有个坑GPU 的 L2 替换不是严格的 LRU而是伪 LRU 或者带随机性的策略。所以你不能指望访问第 17 个一定驱逐第 1 个只能看统计规律。我重复了上千次统计驱逐分布才确认相联度是 16。3.3 Line Size 与 Sector 机制的区分Line size 和 sector size 是两回事这点很容易搞混。Line 是缓存管理的最小单位sector 是填充和传输的最小单位。Ampere 的 L2 line 是 128 字节但分成 4 个 32 字节的 sector。当你只访问 line 里的一个字节时硬件只填充那一个 sector而不是整个 line。这个机制对带宽利用率影响很大。测 line size 的方法是访问一对地址它们之间的偏移从 0 增加到 256 字节看什么时候从同一个 line变成不同 line。如果两个地址在同一个 line 里第二次访问应该命中如果跨 line就可能 miss。实测发现偏移在 128 字节以内时行为一致超过 128 字节后行为改变。所以 line size 是 128 字节。测 sector size 的方法类似但要看的是部分填充行为。访问一个地址后再访问同一 line 内偏移 32 字节的地址如果命中说明这个 sector 已经被填充如果 miss说明只填充了第一个 sector。实测确认 sector 是 32 字节。参数测量值测量方法Line size128 字节偏移扫描观察命中行为变化Sector size32 字节部分填充实验相联度16 路冲突集构造统计驱逐总容量6 MB延迟台阶Partition 数2地址 hash 分析Slice 数每 partition 32冲突点间距分析4. 地址映射与 Hash 函数的反推4.1 物理分布式带来的复杂性Ampere 的 L2 不是一块统一的缓存而是物理上分成多个 partition每个 partition 又分成多个 slice。地址到 partition 和 slice 的映射决定了访问会落到哪里。如果映射不均匀就会出现某些 slice 过热、某些闲置的情况这是性能调优的大敌。反推映射函数的基本思路是找到两个地址它们除了某几位不同之外其他都相同然后看这两位的变化是否导致它们映射到不同的 slice。通过系统地翻转地址的每一位观察冲突行为的变化可以逐步确定哪些位参与了 slice 索引。这个过程很繁琐因为 hash 函数通常是多轮异或和移位不是简单的位切片。我用了大约一周时间通过大量的对照实验才把映射关系大致摸清楚。核心发现是地址的低位line offset 以下不参与索引中间若干位经过异或后决定 slice高位决定 partition。4.2 实测映射规律具体来说对于这块卡地址的第 7 到第 12 位对应 128 字节到 4 KB 的范围经过一个异或网络后产生 slice 索引。第 13 位及以上参与 partition 选择。这个规律不是猜的是通过改变某一位看冲突模式是否改变一步步试出来的。举个例子我构造两组地址第一组只在第 8 位上有差异第二组只在第 9 位上有差异。如果第 8 位参与 slice 索引那么第一组地址会分散到不同 slice冲突减少如果第 9 位不参与第二组地址会集中在同一 slice冲突增加。通过对比这两组的命中率就能判断哪些位是关键位。实测数据表明第 7 到 12 位确实参与索引而且不是简单的线性组合存在异或。这意味着你不能通过地址加上某个固定值来保证落到同一个 slice必须精确控制这些位的值。注意不同型号、不同显存的 Ampere 显卡这个映射函数可能不同。我测的是特定型号你如果换一块卡需要重新测。不要直接套用我的结论。4.3 对性能调优的实际意义知道映射函数有什么用最直接的用途是避免 bank conflict 的升级版——slice conflict。如果你有两个线程块它们的数据恰好映射到同一个 slice那么这两个块会互相争抢 L2 带宽性能下降。通过调整数据布局把热点数据打散到不同 slice可以显著提升吞吐。我做过一个实验一个矩阵乘法的 kernel原本的数据布局导致某些 slice 过热L2 吞吐只有峰值的 60%。调整了数据的 padding 之后吞吐提升到 85% 以上。这个提升不是靠改算法纯粹是靠让地址映射更均匀。另一个用途是设计缓存友好的数据结构。比如你要做一个哈希表如果哈希函数的输出恰好和 L2 的 slice 索引相关就会导致冲突。知道映射规律后可以在哈希函数里加一层扰动打破这种相关性。5. 替换策略的实测与行为分析5.1 伪 LRU 的验证方法替换策略决定了当 set 满了之后哪个 line 被踢出去。如果是最理想的 LRU那么最久没被访问的 line 会被驱逐。但硬件为了省面积和功耗通常用伪 LRU 或者带随机性的策略。Ampere 的 L2 用的是什么厂商没说只能测。测的方法是在一个 set 里填满 16 个 line然后按特定顺序重复访问其中一部分观察哪一部分被保留、哪一部分被驱逐。如果是严格 LRU你重复访问的那部分应该一直保留如果是伪 LRU可能会有意外驱逐如果是随机驱逐分布应该均匀。实测结果是在低冲突场景下行为接近 LRU重复访问的 line 保留率很高但在高冲突场景下比如 16 路全部被反复访问驱逐变得不那么可预测有随机性。这说明硬件用的是某种伪 LRU可能在树形 LRU 的基础上加了随机扰动。5.2 高冲突下的退化行为高冲突场景下的行为很有意思。我构造了一个实验16 个地址映射到同一个 set然后以轮询的方式反复访问它们。如果是完美 LRU每次访问都应该命中因为所有 line 都在缓存里。但实测发现命中率只有 70% 左右说明有 30% 的访问发生了驱逐。进一步分析发现驱逐不是均匀的某些 line 更容易被踢。这可能是因为伪 LRU 的树形结构在某些访问模式下会做出次优决策。这个发现对性能调优有实际意义如果你的 kernel 有高冲突的访问模式不要指望缓存能完美保留数据要么减少冲突要么接受一定的 miss 率。5.3 对实际 kernel 的影响替换策略的行为直接影响 kernel 的性能稳定性。我测过一个流式处理的 kernel它的访问模式是顺序扫描一个大数组。理论上顺序扫描的缓存行为很好预测但实际上因为 L2 是共享的其他 SM 的访问会干扰导致命中率波动。理解替换策略后我调整了每个线程块处理的数据块大小让每个块的工作集刚好能放进 L2 的一个子集减少了跨块干扰性能波动从 ±15% 降到 ±5%。另一个例子是原子操作。原子操作在 L2 里执行如果目标地址频繁被驱逐原子操作的延迟会飙升。通过把原子操作的目标地址分散到不同的 set避免冲突可以显著降低延迟。我测过一个计数器更新的场景优化后延迟降低了 40%。6. 常见问题与排查技巧实录6.1 测量结果不稳定怎么办这是最常见的问题。GPU 上有太多干扰因素其他进程、驱动后台任务、温度导致的频率变化、甚至显示输出都会影响。我的经验是关闭所有不必要的后台进程尤其是图形相关的。固定 GPU 频率如果驱动支持避免动态调频干扰。多次运行取中位数不要取平均值平均值容易被异常值拉偏。测量时间要足够长但也不能太长避免温度累积导致降频。如果做完这些还是不稳定检查你的计时方法。clock64() 在有些情况下会被编译器重排需要用 volatile 或者内存屏障约束。6.2 指针追逐链表被预取怎么办预取器是缓存测量的天敌。如果硬件发现你在顺序访问它会提前把数据拉进来你测到的延迟就不是真实的。避免预取的方法是链表节点的地址要随机化不要连续。节点之间要有足够的间隔至少跨多个 line。访问模式要看起来无规律可以每隔几次访问插入一个随机跳转。我一开始的链表是顺序分配的结果测出来的延迟比预期低很多后来改成随机分配才正常。6.3 如何区分 L2 命中和 DRAM 命中L2 命中延迟和 DRAM 命中延迟差距很大通常 L2 是 200 周期左右DRAM 是 400 周期以上。但如果你测出来的延迟在中间地带可能是 sector 部分命中、或者 TLB miss 导致的。区分方法是改变工作集大小看延迟是否随容量变化。L2 命中延迟对容量不敏感DRAM 延迟对容量敏感。用不同的访问模式比如随机 vs 顺序。DRAM 对顺序访问有优化L2 不太在乎。检查 TLB 行为大页和小页的 TLB 覆盖范围不同会影响地址翻译延迟。6.4 常见问题速查表问题现象可能原因排查方法延迟数据全是噪声计时器跨 SM 使用确保测量在同一线程内延迟比预期低预取器介入随机化地址打乱访问顺序命中率波动大其他 SM 干扰减少并发块数或固定 SM冲突点找不到hash 函数非线性扩大扫描范围用统计方法容量测出来偏小sector 机制开销考虑有效容量而非物理容量7. 这些参数对写 kernel 到底有什么用测完这些参数最终还是要落到实际优化上。我总结几个直接能用的点。第一数据布局要避开 slice 冲突。如果你知道自己的数据会映射到哪些 slice可以通过 padding 把热点数据打散。具体做法是在数组的行之间插入几个字节的填充改变地址的低位从而改变 slice 索引。填充多少需要根据实测的映射函数来定不能瞎填。第二工作集要控制在 L2 容量以内。6 MB 听起来不小但分到每个 SM 就没多少了。如果你的 kernel 每个块的工作集超过几百 KB就要考虑分块处理让每个块的数据能留在 L2 里。我一般会把块的工作集控制在 256 KB 以内这样即使有干扰也能保证较高的命中率。第三原子操作要分散。原子操作在 L2 里串行执行如果多个线程操作同一个地址会排队。把原子操作的目标地址分散到不同的 set可以减少排队。具体做法是给每个线程分配一个独立的计数器最后再汇总而不是所有线程抢一个计数器。第四注意 sector 利用率。因为填充是 32 字节为单位的如果你的访问模式是每个 line 只用一个字节那带宽利用率只有 1/4。尽量让访问连续把整个 sector 用满。我测过一个 kernel改成连续访问后有效带宽提升了 3 倍。提示这些优化不是孤立的实际调优时要一起考虑。比如你为了避开 slice 冲突做了 padding可能会破坏访问的连续性导致 sector 利用率下降。需要权衡。8. 我个人在实际操作中的几点体会这套逆向方法我前后折腾了大概一个月中间走了不少弯路。最大的体会是不要迷信理论模型硬件的实际行为往往比教科书复杂。比如教科书上说 LRU 是最久未使用但实际硬件为了省成本用的是近似算法行为会有偏差。你只能通过大量实验去逼近真实行为不能指望一次测量就得到完美结果。另一个体会是测量工具本身也会影响测量结果。clock64() 虽然精度高但调用它本身有开销如果测量间隔太短开销会淹没信号。我后来改成测量一批访问的总时间再除以访问次数这样开销被摊薄了。还有就是不同批次的芯片可能有细微差异。我测的这块卡是一个批次换一块同型号的卡某些参数可能有几个百分点的偏差。所以如果你要精确调优最好在自己的目标硬件上重新测一遍不要直接套用别人的数据。最后分享一个小技巧如果你觉得完整的逆向太费时间可以只测最关键的几个参数——容量、相联度、line size。这三个参数决定了大部分缓存行为测出来就能指导大部分优化。slice 映射和替换策略属于进阶内容等你确实遇到相关性能问题时再深入也不迟。