4、纹理缓存(Texture Cache):缓存策略、命中率优化、缓存行设计

纹理缓存,说白了就是GPU里那个专门伺候纹理数据的“小仓库”。

我刚开始接触GPU架构时,总觉得纹理缓存不就是个普通缓存吗?后来被现实狠狠教育了一顿。纹理访问模式跟CPU的数据访问完全不是一回事——CPU喜欢顺序访问,纹理则是各种奇怪的UV坐标满天飞。你想想看,一个像素可能访问周围四个纹素,下一个像素又跳到完全不同的区域。这种访问模式,传统缓存根本扛不住。

4.1 纹理缓存的特殊性

纹理缓存跟CPU的L1/L2缓存有几个关键区别:

  • 访问模式不同:纹理访问具有空间局部性,但缺乏时间局部性。一个纹素被访问后,短时间内再次被访问的概率很低。
  • 数据格式多样:从RGBA8888到BC7压缩格式,纹理缓存需要支持各种像素格式的读取。
  • 过滤需求:双线性、三线性过滤需要同时读取多个纹素,缓存必须支持多端口访问。
  • Mipmap层级:不同LOD级别的纹理数据可能同时被访问,缓存需要处理跨层级的数据。

核心差异:CPU缓存优化的是“最近使用过的数据”,纹理缓存优化的是“附近位置的数据”。这是本质区别。

4.2 缓存策略:从直接映射到全相联

纹理缓存常用的映射策略有三种。我一个个说。

4.2.1 直接映射缓存

最简单粗暴的方式。每个内存地址只能映射到缓存中的一个固定位置。

// 直接映射示例
cache_line_index = (address >> LINE_OFFSET) & (CACHE_SIZE - 1)

优点:硬件实现简单,访问延迟低。
缺点:容易发生冲突失效。两个经常一起访问的纹素如果映射到同一行,就会互相踢来踢去。

我在项目中遇到过这种情况:一个地形渲染的纹理,刚好因为纹理尺寸是缓存大小的整数倍,导致相邻的纹素全部映射到同一组缓存行里。性能直接腰斩。后来我学乖了,纹理分配时都会加一个小的偏移量。

4.2.2 组相联缓存

这是目前最主流的方案。NVIDIA从Fermi架构开始就用16路组相联,AMD的GCN也是类似设计。

// 4路组相联示例
set_index = (address >> LINE_OFFSET) & (NUM_SETS - 1)
// 在set_index对应的4个缓存行中查找匹配的tag

为什么是16路? 我个人的经验是:8路在某些极端场景下冲突率还是偏高,32路面积又太大。16路是个不错的平衡点。

相联度 冲突率 面积开销 延迟 典型应用
直接映射 1周期 早期GPU
4路 2周期 移动GPU
16路 较高 3周期 桌面GPU
全相联 极低 极高 4+周期 特殊用途

4.2.3 全相联缓存

理论上命中率最高,但面积和功耗都吃不消。一般只在纹理缓存的TLB(页表缓存)里用用。

我的建议:别在纹理数据缓存上用全相联。我曾经在一个原型芯片上试过,面积直接翻了三倍,性能提升不到5%。不值当。

4.3 命中率优化:实战技巧

纹理缓存的命中率直接决定了纹理单元的吞吐量。我总结了几个关键优化点。

4.3.1 空间局部性优化

纹理访问最怕的就是“跳来跳去”。优化思路就是让相邻像素尽量访问相邻纹素。

  • 纹理排列优化:使用Z-order曲线(也叫Morton顺序)代替行优先排列。这样2D空间上相邻的纹素,在内存中也相邻。
  • 瓦片化(Tiling):把纹理分成32x32或64x64的瓦片。渲染时按瓦片顺序处理,而不是按扫描线顺序。
// Z-order编码示例
uint32_t morton_encode(uint32_t x, uint32_t y) {
    // 将x和y的位交错排列
    uint32_t result = 0;
    for (int i = 0; i < 16; i++) {
        result |= (x & (1 << i)) << i;
        result |= (y & (1 << i)) << (i + 1);
    }
    return result;
}

4.3.2 预取策略

纹理缓存可以提前预测接下来要访问的纹素,提前加载到缓存里。

我记得在优化一个粒子系统时,粒子位置随机分布,纹理缓存命中率只有30%左右。后来加了基于运动向量的预取,命中率直接飙到70%。

预取原则:只预取有90%以上把握会用到的数据。乱预取反而会污染缓存,把有用的数据踢出去。

4.3.3 压缩纹理的缓存友好性

BC(Block Compression)系列压缩格式对缓存特别友好。一个4x4的纹素块压缩到128位或64位,缓存行一次就能装下好几个块。

压缩格式 压缩比 块大小 缓存行利用率
BC1 6:1 64位/4x4块
BC3 4:1 128位/4x4块
BC7 3:1 128位/4x4块
ASTC 可变 128位/可变块 取决于块大小

4.4 缓存行设计:细节决定成败

缓存行大小是纹理缓存最关键的参数之一。太小了空间局部性利用不足,太大了又浪费带宽。

4.4.1 行大小选择

主流GPU的纹理缓存行大小通常是128字节或256字节。

  • 128字节:适合32位RGBA纹理,正好装下8x8个纹素。移动GPU常用。
  • 256字节:适合高分辨率纹理和压缩纹理。桌面GPU更常见。

为什么会这样?你想想看,双线性过滤需要读取2x2个纹素。如果缓存行是128字节,一次就能把周围一圈纹素都拉进来。三线性过滤需要两个LOD级别的数据,256字节就更从容。

4.4.2 扇区设计

现代纹理缓存普遍采用扇区(Sector)设计。一个缓存行分成多个扇区,每个扇区可以独立失效和填充。

// 扇区化缓存行结构示例
struct CacheLine {
    uint64_t tag;           // 标签
    uint8_t valid_bits;     // 每个扇区的有效位
    uint8_t dirty_bits;     // 每个扇区的脏位
    Sector sectors[4];      // 4个扇区,每个32字节
};

扇区的好处

  • 减少带宽浪费:只需要加载实际用到的扇区,不用把整个缓存行都填满
  • 提高利用率:不同扇区可以来自不同的纹理
  • 降低延迟:小数据量传输更快

注意:扇区设计会增加标签比较的复杂度。每个扇区都需要独立的有效位和脏位。我见过一个设计,扇区数量从4个增加到8个,面积涨了15%,但命中率只提升了2%。

4.4.3 写策略

纹理缓存通常是只读的——纹理数据从显存读到缓存,然后被纹理单元消费。但有些场景需要写回,比如渲染到纹理(RTT)。

我建议的做法是:

  • 写分配(Write-Allocate):写入时如果缓存未命中,先把数据加载到缓存再写。适合频繁读写的场景。
  • 写穿透(Write-Through):同时写入缓存和下一级存储。实现简单,但带宽消耗大。
  • 写回(Write-Back):只写缓存,标记脏位,被替换时才写回。性能最好,但控制逻辑复杂。

实际项目中,纹理缓存大多用写回策略。我曾经在一个项目里试过写穿透,结果纹理带宽直接翻倍,帧率掉了20%。

4.5 实际案例分析

拿NVIDIA的Turing架构举个例子。它的纹理缓存是分层设计的:

  • L0纹理缓存:每个SM独立,16KB,16路组相联,128字节缓存行,4个扇区
  • L1纹理缓存:每个SM独立,64KB,16路组相联,256字节缓存行,8个扇区
  • L2纹理缓存:所有SM共享,大小取决于具体型号

这种分层设计的好处是:L0负责快速响应,L1负责大容量缓存,L2负责全局共享。纹理请求先在L0查找,未命中再到L1,最后到L2。

经验之谈:纹理缓存的命中率目标应该是L0 > 80%,L1 > 95%。如果L0命中率低于70%,说明纹理访问模式有问题,需要从应用层优化。

好了,纹理缓存这块就聊这么多。记住一句话:纹理缓存的设计,本质上是在面积、延迟、带宽之间做权衡。没有完美的方案,只有最适合你应用场景的方案。