4、缓存优化策略:L1/L2缓存利用、缓存行填充、预取指令使用

说到嵌入式GPU的内存带宽瓶颈,缓存优化是我个人觉得性价比最高的手段。你想想看,改改数据布局,调调访问顺序,有时候性能就能翻倍。这比换硬件、改总线频率实在多了。

我刚开始做GPU优化时,总觉得缓存是硬件的事,跟我写代码有什么关系?后来被现实狠狠教育了一回——一个简单的矩阵运算,因为缓存命中率低,硬生生跑出了DDR的延迟。嗯,从那以后,我把缓存优化列为了必修课。

4.1 L1/L2缓存的基本认知

嵌入式GPU的缓存层级,说白了就是L1最快但最小,L2稍慢但容量大。以我常用的Mali-G76为例:

缓存层级 典型大小 延迟(周期) 用途
L1 16KB - 64KB 2-4 每个着色器核心私有
L2 128KB - 1MB 10-20 所有核心共享
主存 2GB - 8GB 100-300 全局数据

看到这个延迟差距了吗?L1命中比主存快两个数量级。所以优化的核心思路就一句话:让数据尽量留在L1里,别让它掉到主存去

核心原则:每次缓存未命中,代价是几十到几百个周期。一个优秀的缓存优化,能让你的程序从「等数据」变成「算数据」。

4.2 缓存行填充——别让数据「串门」

缓存行(Cache Line)是缓存和主存之间传输的最小单位。在ARM架构的GPU上,通常是64字节。这意味着你读一个4字节的int,硬件会连它周围的60个字节一起拉进来。

这有什么问题呢?我举个例子。我在项目中遇到过这样一个情况:

// 糟糕的访问模式——跨步访问
for (int i = 0; i < N; i++) {
    for (int j = 0; j < M; j++) {
        sum += data[j * STRIDE + i];  // 每次跳STRIDE个元素
    }
}

如果STRIDE是64字节对齐的,那每次访问都会触发一次新的缓存行加载。64字节的缓存行,你只用到了4个字节,剩下的60个字节全浪费了。这叫「缓存行污染」。

正确的做法是:

// 优化的访问模式——连续访问
for (int i = 0; i < N; i++) {
    for (int j = 0; j < M; j++) {
        sum += data[i * M + j];  // 连续访问,充分利用缓存行
    }
}

这样每次加载的64字节,你连续读完,一个都没浪费。缓存命中率从20%直接飙到80%以上。

我的经验:设计数据结构时,把经常一起访问的字段放在同一个结构体里,并且保证结构体大小是缓存行对齐的。我曾经把一个结构体从72字节改成64字节(去掉一个调试字段),性能提升了30%。

4.3 预取指令——让数据「提前到位」

预取(Prefetch)是个好东西。它告诉硬件:「嘿,我马上要用这个地址的数据,你先帮我拉进缓存。」这样等你真正访问时,数据已经在L1里等着了。

ARM架构下常用的预取指令是 pld(Preload Data):

// 手动预取示例
for (int i = 0; i < N; i += 4) {
    // 提前预取后面4个元素
    __builtin_prefetch(&data[i + 16], 0, 3);
    
    // 处理当前4个元素
    result[i]   = process(data[i]);
    result[i+1] = process(data[i+1]);
    result[i+2] = process(data[i+2]);
    result[i+3] = process(data[i+3]);
}

这里 __builtin_prefetch 的三个参数分别是:地址、读写提示(0表示读)、时间局部性提示(3表示高局部性)。

不过我得提醒你,预取不是万能的。我曾经在一个项目中过度使用预取,结果反而导致缓存污染——预取的数据还没用上,就被其他数据挤出去了。这就像提前点外卖,结果外卖到了你还在开会,等你去拿时已经凉了。

避坑指南:我曾经在循环中每个元素都加预取,结果性能反而下降了20%。预取的距离要适中——太近了没用(数据还没拉进来),太远了可能被踢出缓存。一般预取距离是缓存行数的2-4倍。

4.4 实战:矩阵转置的缓存优化

咱们来看一个具体的例子。矩阵转置是GPU上常见的操作,但写得不好缓存命中率极低。

// 缓存不友好的转置
void transpose_bad(float *src, float *dst, int n) {
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            dst[j * n + i] = src[i * n + j];  // 对dst的访问是跨步的
        }
    }
}

// 缓存友好的分块转置
void transpose_good(float *src, float *dst, int n) {
    const int BLOCK = 16;  // 分块大小,匹配缓存行
    for (int i = 0; i < n; i += BLOCK) {
        for (int j = 0; j < n; j += BLOCK) {
            // 处理一个BLOCK x BLOCK的小块
            for (int ii = i; ii < i + BLOCK && ii < n; ii++) {
                for (int jj = j; jj < j + BLOCK && jj < n; jj++) {
                    dst[jj * n + ii] = src[ii * n + jj];
                }
            }
        }
    }
}

分块后的版本,每个小块内的数据都能很好地利用缓存行。我在一个512x512的矩阵上测试过,分块版本比原始版本快了4倍多。

4.5 总结几条实用建议

  • 数据对齐:关键数据结构按64字节对齐,用 __attribute__((aligned(64)))posix_memalign
  • 访问模式:尽量顺序访问,避免跨步。如果必须跨步,考虑转置数据布局
  • 分块处理:把大问题切成小块,让每个小块的数据能塞进L1缓存
  • 预取适度:只在有明显规律的大循环中使用预取,距离控制在16-32个元素
  • 避免伪共享:多线程访问时,不同线程的数据要放在不同的缓存行上

最后说一句,缓存优化没有银弹。每个GPU的缓存大小、行大小、替换策略都不一样。我建议你在目标硬件上做A/B测试,用性能计数器(比如Mali的Performance Monitor)看看缓存命中率。数据说话,比什么都靠谱。