3. 数据复用与局部性优化:时间局部性与空间局部性、Tiling技术(分块)、Shared Memory手动缓存、Register重用来减少显存访问

大家好,我是你们的老朋友。今天我们来聊聊显存优化的核心——数据复用与局部性。说实话,很多同学在写GPU程序时,一上来就想着怎么并行,怎么把线程堆满。但往往忽略了最关键的一点:数据到底是怎么在芯片里流动的。

我见过太多项目,明明计算量不大,却因为显存访问太频繁,导致性能直接腰斩。嗯,这其实是个很常见的坑。今天我们就把它彻底讲透。

3.1 时间局部性与空间局部性:两个基本概念

先问大家一个问题:为什么CPU和GPU都要搞缓存?说白了,就是访存太慢了。从显存读一次数据,可能要几百个时钟周期。而计算一个浮点乘加,只需要几个周期。这中间的差距,就是我们需要优化的空间。

时间局部性:如果一个数据被访问过,那么它很可能在不久的将来再次被访问。举个例子,你在循环里反复读同一个变量,这就是时间局部性。

空间局部性:如果一个地址被访问了,那么它附近的地址很可能也会被访问。比如你遍历一个数组,相邻元素就是空间局部性。

核心观点:GPU的优化,本质上就是利用这两种局部性,把数据尽量往靠近计算单元的地方搬。越靠近计算单元,访问速度越快,功耗也越低。

我在项目中遇到过这样一个案例:一个矩阵乘法算子,原始版本直接读写全局显存,带宽利用率只有20%。后来我们做了分块和共享内存缓存,带宽利用率直接飙到85%以上。你想想看,这差距有多大?

3.2 Tiling技术(分块):把大问题切成小问题

Tiling,说白了就是分块。把一个大矩阵切成若干个小块,每次只处理一小块。这样,这个小块的数据就可以完全放到共享内存里,反复使用。

为什么要分块?因为共享内存太小了。以NVIDIA A100为例,每个SM只有192KB的共享内存。你不可能把整个矩阵都塞进去。但你可以把矩阵切成16x16、32x32的小块,每个块刚好能放进共享内存。

来看一个简单的例子:

// 没有分块的矩阵乘法(直接访问全局显存)
__global__ void matmul_naive(float *A, float *B, float *C, int N) {
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    
    float sum = 0.0f;
    for (int k = 0; k < N; k++) {
        sum += A[row * N + k] * B[k * N + col];
    }
    C[row * N + col] = sum;
}

// 使用Tiling的矩阵乘法(利用共享内存)
#define TILE_SIZE 16
__global__ void matmul_tiled(float *A, float *B, float *C, int N) {
    __shared__ float As[TILE_SIZE][TILE_SIZE];
    __shared__ float Bs[TILE_SIZE][TILE_SIZE];
    
    int row = blockIdx.y * TILE_SIZE + threadIdx.y;
    int col = blockIdx.x * TILE_SIZE + threadIdx.x;
    
    float sum = 0.0f;
    for (int tile = 0; tile < N / TILE_SIZE; tile++) {
        // 加载数据到共享内存
        As[threadIdx.y][threadIdx.x] = A[row * N + tile * TILE_SIZE + threadIdx.x];
        Bs[threadIdx.y][threadIdx.x] = B[(tile * TILE_SIZE + threadIdx.y) * N + col];
        __syncthreads();
        
        // 计算当前tile
        for (int k = 0; k < TILE_SIZE; k++) {
            sum += As[threadIdx.y][k] * Bs[k][threadIdx.x];
        }
        __syncthreads();
    }
    C[row * N + col] = sum;
}

个人经验:分块大小不是越大越好。我建议你根据共享内存大小和寄存器数量来调。一般来说,16x16或32x32是比较稳妥的选择。太大容易导致共享内存溢出,太小又无法充分利用局部性。

3.3 Shared Memory手动缓存:把控制权握在自己手里

共享内存,你可以把它理解成程序员手动管理的缓存。L1缓存是硬件自动管理的,你没法控制。但共享内存是你可以显式控制的。这意味着什么?意味着你可以精确地决定哪些数据要缓存,缓存多久,什么时候替换。

为什么要手动管理?因为硬件缓存有时候不够聪明。举个例子,如果你的数据访问模式是跨步的(stride很大),L1缓存的预取机制可能完全失效。这时候,手动用共享内存来缓存,效果会好得多。

我曾经优化过一个3D卷积算子。原始版本直接读全局显存,性能惨不忍睹。后来我们用共享内存缓存了输入特征图的多个通道,每次计算时直接从共享内存读,避免了重复的全局访存。最终性能提升了3倍多。

避坑指南:我曾经犯过一个错误——在共享内存里放了太多数据,导致每个SM能同时运行的block数量减少。共享内存是有限的,你用得越多,SM上能同时跑的block就越少。这反而会降低吞吐。所以,共享内存不是越多越好,要平衡使用。

3.4 Register重用:最极致的优化

寄存器是GPU里最快的存储单元。没有之一。访问寄存器只需要1个时钟周期,而访问共享内存需要几十个周期,访问全局显存需要几百个周期。所以,能用寄存器就别用共享内存,能用共享内存就别用全局显存。

寄存器重用,说白了就是把反复使用的数据尽量放在寄存器里。比如在矩阵乘法中,每个线程计算一个输出元素时,会累加很多次。这个累加变量就应该放在寄存器里,而不是每次从显存读。

来看一个更极致的例子:

// 寄存器重用的卷积计算
__global__ void conv_register_reuse(float *input, float *weight, float *output, 
                                     int H, int W, int K) {
    int x = blockIdx.x * blockDim.x + threadIdx.x;
    int y = blockIdx.y * blockDim.y + threadIdx.y;
    
    // 把权重加载到寄存器(每个线程只加载自己需要的部分)
    float w[9];  // 假设3x3卷积
    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 3; j++) {
            w[i * 3 + j] = weight[i * 3 + j];
        }
    }
    
    // 计算输出,累加变量也在寄存器
    float sum = 0.0f;
    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 3; j++) {
            int in_x = x + j - 1;
            int in_y = y + i - 1;
            if (in_x >= 0 && in_x < W && in_y >= 0 && in_y < H) {
                sum += input[in_y * W + in_x] * w[i * 3 + j];
            }
        }
    }
    output[y * W + x] = sum;
}

关键点:寄存器数量是有限的。每个线程最多能用255个寄存器(NVIDIA架构)。如果你用太多,编译器会 spill 到 local memory(实际上就是全局显存),那就得不偿失了。所以,寄存器重用要适度,别贪心。

3.5 三种技术的对比与选择

技术 存储位置 访问延迟 容量 适用场景
Register重用 寄存器 1 cycle ~255个/线程 频繁使用的标量或小数组
Shared Memory缓存 共享内存 ~30 cycles ~48-192KB/SM 线程间共享的数据块
Tiling分块 共享内存+全局显存 取决于实现 无限制 大矩阵、大张量操作

我个人习惯是:先做Tiling,再用Shared Memory缓存,最后用Register优化热点。这个顺序能让你在大多数场景下获得不错的收益。

3.6 知识体系总览

下面这张图,是我自己总结的数据复用优化框架。你可以把它当作一个检查清单,每次优化时对照着看:

数据复用与局部性优化框架 显存访问优化 时间局部性 空间局部性 手动控制 Register重用 循环内变量复用 Tiling分块 连续内存访问 Shared Memory缓存 Bank Conflict避免 目标:减少全局显存访问,提升带宽利用率

这张图里,我把三种技术按照它们利用的局部性类型做了分类。你可能会问:为什么Tiling属于空间局部性?因为分块后,每个块内的数据在内存中是连续的,访问时自然就利用了空间局部性。而Register重用更多是利用时间局部性——同一个数据反复使用。

我的建议:刚开始做优化时,别想着一步到位。先做Tiling,把大问题变小。然后加Shared Memory缓存,把热点数据放进去。最后再看哪些数据可以放寄存器。每一步都能带来可观的收益,而且每一步的改动都不大。

好了,这一章的内容就到这里。数据复用和局部性优化,说白了就是让数据离计算更近。你想想看,如果每次计算都要去显存读数据,那再多的计算单元也是白搭。记住这个核心思想,后面的优化技巧都是围绕它展开的。

想要更多资料,联系可可