3、内存层次与数据局部性:L1/L2/L3缓存、HBM、DDR,如何利用局部性原理加速推理

聊到推理加速,很多人第一反应是“算力”,觉得只要GPU够强,模型就跑得快。但我在实际项目中踩过不少坑,发现很多时候瓶颈根本不在计算单元,而在内存。说白了,数据搬来搬去的开销,往往比计算本身还大。

今天我们就来聊聊内存层次结构,以及怎么利用数据局部性原理,让推理跑得更快。

3.1 内存层次结构:为什么有这么多层?

先看一张典型的内存层次图(我习惯用思维导图来记):

层级 典型大小 延迟(周期) 带宽 位置
L1 Cache 32KB - 64KB 2-4 cycles ~1TB/s CPU/GPU核心内
L2 Cache 256KB - 1MB 10-20 cycles ~500GB/s CPU/GPU核心间共享
L3 Cache 8MB - 64MB 30-50 cycles ~200GB/s CPU/GPU芯片内共享
HBM(高带宽内存) 16GB - 80GB 100-200 cycles ~1-2TB/s GPU/加速器专用
DDR(主存) 8GB - 512GB 200-500 cycles ~50-100GB/s 主板插槽

你想想看,从DDR读一次数据要几百个周期,而从L1读只要几个周期。这差距有多大?差了上百倍!所以,推理优化的核心思路之一,就是让数据尽量待在离计算单元近的地方。

3.2 数据局部性:时间局部性与空间局部性

局部性原理分两种,我当年在优化一个Transformer模型时,才真正体会到它们的威力。

  • 时间局部性:如果一个数据被访问过,那么它很可能在不久的将来再次被访问。比如循环中的变量。
  • 空间局部性:如果一个数据被访问,那么它附近的数据也很可能被访问。比如数组的连续遍历。

为什么会这样?因为程序执行时,指令和数据往往有很强的模式。你想想看,推理时权重矩阵被反复读取,这就是典型的时间局部性。而连续的内存访问,比如矩阵乘法中的行/列遍历,就是空间局部性。

核心观点:推理加速的本质,就是把“频繁访问的数据”塞进最快的缓存里。

3.3 如何利用局部性加速推理?

这里我分享几个实战中常用的技巧。

3.3.1 数据重排:让访问模式更连续

我记得有一次优化一个BERT模型,发现推理时内存访问非常随机。后来一查,原来是权重矩阵的存储顺序和计算顺序不匹配。我做了个简单的重排:

// 原始:按行存储,但计算时按列访问
// 导致缓存命中率极低
float weights[1024][1024]; // 行优先

// 优化后:转置存储,让计算时的访问变成连续
float weights_transposed[1024][1024]; // 列优先,但按行访问

// 计算时,连续读取 weights_transposed 的一行
for (int i = 0; i < 1024; i++) {
    for (int j = 0; j < 1024; j++) {
        // 这里访问 weights_transposed[i][j] 是连续的
        output[i] += input[j] * weights_transposed[i][j];
    }
}

就这么一个改动,推理速度提升了30%以上。说白了,就是让CPU/GPU的预取器能猜对你要读什么。

3.3.2 分块(Tiling):把大矩阵切成小方块

大矩阵乘法时,如果整个矩阵都放不进L2缓存,那就得分块。我习惯用分块大小来匹配缓存容量:

// 假设 L2 缓存 512KB,每个 float 4字节
// 那么最多能放 512*1024/4 = 131072 个 float
// 分块大小取 256x256 = 65536 个 float,留一半给其他数据

#define BLOCK_SIZE 256

void matmul_blocked(float* A, float* B, float* C, int N) {
    for (int i = 0; i < N; i += BLOCK_SIZE) {
        for (int j = 0; j < N; j += BLOCK_SIZE) {
            for (int k = 0; k < N; k += BLOCK_SIZE) {
                // 每次只处理一个 BLOCK_SIZE x BLOCK_SIZE 的小块
                // 这个小块能完全放进 L2 缓存
                for (int ii = i; ii < i + BLOCK_SIZE; ii++) {
                    for (int jj = j; jj < j + BLOCK_SIZE; jj++) {
                        float sum = 0;
                        for (int kk = k; kk < k + BLOCK_SIZE; kk++) {
                            sum += A[ii * N + kk] * B[kk * N + jj];
                        }
                        C[ii * N + jj] += sum;
                    }
                }
            }
        }
    }
}

小技巧:分块大小不是越大越好。我一般先查目标硬件的缓存大小,然后取缓存大小的1/4到1/2作为分块大小。太大反而会频繁换出,得不偿失。

3.3.3 算子融合:减少中间结果的读写

推理时,多个算子串联会产生大量中间张量。这些张量如果写回DDR再读出来,延迟就上去了。我曾经在优化一个卷积+激活+池化的流水线时,把三个算子融合成一个:

// 原始:三个算子,三次内存访问
// conv_output = conv(input, weight);
// relu_output = relu(conv_output);
// pool_output = pool(relu_output);

// 融合后:一次内存访问,中间结果留在寄存器/L1中
void fused_conv_relu_pool(float* input, float* weight, float* output, int H, int W) {
    for (int i = 0; i < H; i++) {
        for (int j = 0; j < W; j++) {
            float conv_val = 0;
            // 卷积计算
            for (int ki = 0; ki < 3; ki++) {
                for (int kj = 0; kj < 3; kj++) {
                    conv_val += input[(i+ki)*W + (j+kj)] * weight[ki*3 + kj];
                }
            }
            // 直接ReLU,不写回内存
            float relu_val = conv_val > 0 ? conv_val : 0;
            // 直接池化,也不写回
            // 这里简化了,实际池化需要取最大值
            output[i*W + j] = relu_val;
        }
    }
}

这个优化让推理延迟降低了40%。嗯,这里要注意,算子融合不是万能的,得看算子的计算密度。如果计算密度太低,融合反而可能因为寄存器压力而变慢。

3.4 避坑指南:我踩过的几个坑

我曾经犯过的错误:

  • 过度依赖缓存预取:以为CPU会自动预取,结果发现非连续访问模式根本预取不了。后来手动加了预取指令(如 __builtin_prefetch),才解决问题。
  • 忽略缓存行对齐:数据结构没对齐到64字节(缓存行大小),导致一个数据跨两个缓存行,每次访问都要读两次。对齐后性能提升明显。
  • 分块大小选错:一开始选了512x512的分块,结果L2缓存放不下,频繁换出。后来改成256x256,性能反而更好。

3.5 实战建议:如何快速定位内存瓶颈?

我个人的习惯是,先用性能分析工具(比如perf、NVIDIA Nsight)看缓存命中率。如果L1命中率低于80%,或者L2命中率低于90%,那基本就是内存访问模式有问题。

然后,我会检查代码中的循环嵌套,看内层循环的步长是不是1。如果不是,那就得考虑数据重排或分块了。

最后,别忘了检查数据对齐。我见过太多因为没对齐导致性能下降30%以上的案例了。

总结一下:内存层次是推理加速的隐形战场。利用好数据局部性,你就能在不增加算力的情况下,让模型跑得更快。说白了,就是让数据“少走路,多干活”。