4、L1缓存(L1 Cache)设计:L1缓存的组织方式(数据/纹理/指令)、缓存行大小与关联度、命中率优化策略

好,咱们今天聊聊L1缓存。这是GPU内存子系统中离计算单元最近的一级存储,也是决定性能的关键一环。我个人习惯把L1缓存比作「芯片的短期记忆」——它必须快,也必须准。

4.1 L1缓存的三种组织方式

GPU的L1缓存不像CPU那么单纯。它通常要服务三种不同的访存需求:数据、纹理、指令。这三种访问模式差异很大,所以组织方式也不一样。

4.1.1 数据L1缓存(Data L1)

数据L1主要服务于通用计算指令的读写操作。比如你在Shader里写了个float a = b + c,b和c的读取就可能走数据L1。

它的特点是:

  • 读写都需要:不像指令缓存只读,数据缓存既要读也要写
  • 随机性较强:数据访问地址不像纹理那样有空间局部性规律
  • 一致性要求高:多个线程可能读写同一地址

关键设计点:数据L1通常采用写回(Write-Back)策略,而不是写通(Write-Through)。为什么?因为GPU的线程数量巨大,写通策略会让L2和显存带宽瞬间爆炸。我在项目中遇到过,某次为了简化设计用了写通,结果带宽直接被打满,帧率掉了30%。

4.1.2 纹理L1缓存(Texture L1)

纹理缓存是GPU独有的。它专门为纹理采样操作优化。纹理访问有个特点:空间局部性极强。你采样一个像素,大概率会采样它旁边的像素。

纹理L1的设计要点:

  • 只读缓存:纹理数据不会被Shader修改
  • 支持多种滤波模式:双线性、三线性、各向异性滤波需要同时读取多个纹素
  • 地址映射特殊:纹理坐标到内存地址的映射是二维/三维的,不是线性地址

我记得有一次调试一个纹理闪烁的问题,查了两天才发现是纹理L1的缓存行大小设置不合理。纹理采样器一次要读2x2或4x4个纹素,如果缓存行太小,就会反复miss。

4.1.3 指令L1缓存(Instruction L1)

指令缓存最简单,但也最容易出问题。它只存Shader指令,不存数据。

指令L1的特点:

  • 只读:不存在写操作
  • 顺序访问为主:虽然有分支跳转,但大部分指令是顺序执行的
  • 指令密度高:一条指令可能只有几个字节

我的经验:指令L1的容量不需要太大,16KB到32KB通常就够了。因为GPU的指令执行是流水线式的,指令缓存miss的代价可以通过预取来隐藏。但要注意分支密集的Shader,比如包含大量if-else的代码,指令缓存可能会频繁miss。

4.2 缓存行大小与关联度

这两个参数直接决定了L1缓存的性能和面积。选大了浪费芯片面积,选小了命中率上不去。

4.2.1 缓存行大小(Cache Line Size)

缓存行是缓存和下级存储之间交换数据的最小单位。GPU中常见的缓存行大小有64字节、128字节,甚至256字节。

缓存行大小 优点 缺点 适用场景
64B 带宽利用率高,浪费少 空间局部性利用不足 数据L1(随机访问多)
128B 空间局部性好 可能读取无用数据 纹理L1(连续访问多)
256B 预取效果好 面积大,带宽浪费 指令L1(顺序执行)

你想想看,纹理采样时一次要读4个纹素,每个纹素4字节,那就是16字节。如果缓存行只有64字节,一次miss只能带回来4个纹素。但如果缓存行是128字节,一次就能带回来8个纹素,后续的采样很可能就hit了。

注意:缓存行不是越大越好。我见过一个设计把纹理L1的缓存行设成512字节,结果面积暴涨,但命中率只提升了2%。因为很多纹素根本用不上,白白浪费了带宽和功耗。

4.2.2 关联度(Associativity)

关联度决定了同一个内存地址可以映射到缓存中的几个位置。常见的有直接映射(1路)、4路、8路、16路组相联。

关联度越高,冲突miss越少,但硬件复杂度也越高。为什么?因为每次访问都要比较所有路,比较器数量随关联度线性增长。

我个人的经验是:

  • 数据L1:8路或16路。因为数据访问随机性强,冲突miss会很致命
  • 纹理L1:4路或8路。纹理访问有规律,适当降低关联度可以省面积
  • 指令L1:4路就够了。指令访问顺序性强,冲突概率低

避坑指南:我曾经在一个项目中把数据L1的关联度从16路降到8路,想着省点面积。结果跑某个深度学习推理模型时,命中率从92%掉到了78%。原因是那个模型的权重访问模式刚好产生了大量冲突miss。后来我加了个伪随机映射(XOR-based indexing),才把命中率拉回到88%。

4.3 命中率优化策略

命中率是L1缓存设计的核心指标。90%和95%的命中率,性能差距可能超过30%。下面是我在实践中总结的几个有效策略。

4.3.1 预取(Prefetching)

预取就是在数据被实际使用之前,提前把它加载到缓存里。GPU的预取比CPU更容易做,因为GPU的访问模式更规律。

常见的预取策略:

  • 顺序预取:访问地址A后,预取A+1、A+2... 适合纹理和指令
  • 步长预取:检测到访问步长后,按步长预取。适合数组遍历
  • 自适应预取:根据miss模式动态调整预取距离

说白了,预取就是在赌——赌你接下来会访问什么。赌对了,性能起飞;赌错了,浪费带宽和功耗。

4.3.2 写合并(Write Combining)

GPU的线程会频繁写数据。如果每个写操作都直接访问L1,效率很低。写合并缓冲区可以把多个写操作合并成一个,再一次性写入L1。

我建议写合并缓冲区的大小至少是缓存行大小的2倍。比如缓存行128字节,写合并缓冲区就设256字节。这样即使写操作跨缓存行边界,也能被合并。

4.3.3 伪LRU替换策略

缓存满了之后,要踢掉哪一行?最理想的是LRU(最近最少使用),但硬件实现太贵。实际工程中常用伪LRU(Pseudo-LRU)。

伪LRU的实现方式:

  • Tree-based PLRU:用二叉树记录访问顺序,每个节点一个bit
  • NRU(Not Recently Used):周期性重置所有行的使用位
  • RRIP(Re-Reference Interval Prediction):预测每行被再次访问的间隔

我的选择:对于8路组相联,Tree-based PLRU效果最好。硬件开销只有7个bit(2^3-1),命中率接近真LRU的95%以上。NRU虽然更简单,但在某些访问模式下表现很差。

4.3.4 分区缓存(Partitioned Cache)

有些GPU把L1缓存分成多个独立的分区,每个分区服务不同的功能单元。比如一个分区给ALU,一个分区给纹理单元。

这样做的好处是:

  • 避免干扰:纹理访问不会冲掉ALU的数据
  • 并行访问:多个分区可以同时读写
  • 功耗优化:不用的分区可以关闭

但缺点也很明显:静态分区可能导致利用率不均。比如纹理密集的场景,纹理分区满了,但ALU分区还空着。

注意:动态分区可以解决这个问题,但硬件复杂度高。我参与的一个项目尝试了动态分区,结果验证周期多了3个月,最后性能提升只有5%。性价比不高。

4.4 小结

L1缓存设计没有银弹。数据、纹理、指令三种缓存各有各的脾气。缓存行大小和关联度需要根据访问模式仔细权衡。命中率优化也不是堆容量就能解决的,预取、写合并、替换策略这些细节往往才是决定成败的关键。

嗯,最后说一句:如果你在做L1缓存设计,一定要先跑真实 workload 的 trace。纸上谈兵算出来的命中率,和实际跑起来的结果,可能差得很远。我吃过这个亏,希望你不用再吃一次。