第四章 共享内存优化:从入门到精通

共享内存,说白了就是GPU上的一块“手写板”。每个线程块里的线程都能读写它,速度比全局内存快一个数量级。我刚开始做CUDA优化时,总觉得共享内存可有可无——直到有一次,一个简单的矩阵转置,全局内存版本跑了半天,换成共享内存后直接起飞。嗯,从那以后我再也不敢小看它了。

4.1 共享内存的分配与使用

共享内存的分配分两种:静态分配和动态分配。静态分配最简单,直接在核函数里声明就行:

__global__ void kernel() {
    __shared__ float cache[256];  // 静态分配,编译时就定好了
    // 剩下的逻辑...
}

动态分配呢?你可以在调用核函数时指定大小。我个人习惯用动态分配,因为灵活。比如处理不同尺寸的输入数据时,你不需要为每种尺寸都写一个核函数。

__global__ void dynamic_kernel() {
    extern __shared__ float cache[];  // 动态分配,大小由调用时决定
    // 使用 cache[0..size-1]
}

// 调用时指定共享内存大小
dynamic_kernel<<<grid, block, 256 * sizeof(float)>>>();
小提示:动态共享内存声明时,必须用 extern 关键字,而且不能指定大小。大小在调用时通过第三个配置参数传入。

这里有个坑:共享内存的总大小是有限制的。每个线程块最多能用多少?取决于你的GPU架构。以V100为例,每个SM有96KB共享内存,但每个线程块最多能用48KB(默认配置)。我曾经在项目里贪心了一把,一个块用了64KB,结果核函数直接罢工——嗯,后来查文档才知道上限。

4.2 动态共享内存的实战技巧

动态共享内存最常用的场景是:输入数据大小在运行时才能确定。比如做图像处理时,图片尺寸千变万化,你不可能为每种尺寸都写一个核函数。

来看一个实际例子:向量求和,但每个线程块处理的数据量不固定。

__global__ void flexible_sum(float* input, float* output, int n) {
    extern __shared__ float sdata[];
    
    int tid = threadIdx.x;
    int bid = blockIdx.x;
    int block_size = blockDim.x;
    
    // 每个线程加载一个元素到共享内存
    int idx = bid * block_size + tid;
    sdata[tid] = (idx < n) ? input[idx] : 0.0f;
    __syncthreads();
    
    // 规约求和
    for (int s = 1; s < block_size; s *= 2) {
        if (tid % (2 * s) == 0) {
            sdata[tid] += sdata[tid + s];
        }
        __syncthreads();
    }
    
    if (tid == 0) output[bid] = sdata[0];
}

// 调用时动态分配
int block_size = 256;
int grid_size = (n + block_size - 1) / block_size;
flexible_sum<<<grid_size, block_size, block_size * sizeof(float)>>>(d_in, d_out, n);
关键点:动态共享内存的大小必须能被所有线程块共享。如果你有多个线程块,每个块都会获得一份独立的共享内存副本。总共享内存使用量 = 每个块的大小 × 同时运行的块数。

为什么用 __syncthreads()?因为共享内存是线程块内共享的,但线程执行顺序不确定。不加同步的话,一个线程读到的数据可能是另一个线程还没写好的——这就是典型的“数据竞争”。我刚开始写CUDA时,就因为这个bug调试了整整两天。

4.3 共享内存与寄存器的权衡

这是个经典问题:用共享内存还是用寄存器?

寄存器更快,但数量有限。每个线程最多能用255个寄存器(不同架构略有差异)。一旦超过这个数,编译器会把多余的数据“溢出”到本地内存——而本地内存其实在全局内存里,速度慢得可怜。

共享内存呢?速度比寄存器慢一点,但容量大得多。而且共享内存是线程块内所有线程共享的,适合做数据交换。

我一般遵循这个原则:

  • 线程私有的、频繁访问的数据 → 放寄存器
  • 线程间需要共享的数据 → 放共享内存
  • 数据量太大,寄存器放不下 → 放共享内存

来看一个矩阵乘法的例子,感受一下权衡:

// 版本1:全部用寄存器(容易溢出)
__global__ void matmul_reg(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;
}

// 版本2:使用共享内存做分块
__global__ void matmul_shared(float* A, float* B, float* C, int N) {
    __shared__ float As[BLOCK_SIZE][BLOCK_SIZE];
    __shared__ float Bs[BLOCK_SIZE][BLOCK_SIZE];
    
    int row = blockIdx.y * BLOCK_SIZE + threadIdx.y;
    int col = blockIdx.x * BLOCK_SIZE + threadIdx.x;
    
    float sum = 0.0f;
    for (int tile = 0; tile < N / BLOCK_SIZE; tile++) {
        // 加载数据到共享内存
        As[threadIdx.y][threadIdx.x] = A[row * N + tile * BLOCK_SIZE + threadIdx.x];
        Bs[threadIdx.y][threadIdx.x] = B[(tile * BLOCK_SIZE + threadIdx.y) * N + col];
        __syncthreads();
        
        // 从共享内存计算
        for (int k = 0; k < BLOCK_SIZE; k++) {
            sum += As[threadIdx.y][k] * Bs[k][threadIdx.x];
        }
        __syncthreads();
    }
    C[row * N + col] = sum;
}
注意:共享内存不是万能的。如果每个线程只访问自己的数据,用寄存器更好。共享内存的优势在于“复用”——多个线程反复读取同一块数据时,才值得把数据搬进去。

我曾经在一个项目里,把所有的中间变量都塞进共享内存,结果性能反而下降了。为什么?因为共享内存的访问延迟虽然比全局内存低,但比寄存器高。而且共享内存还有bank conflict的问题——这个我们后面会详细讲。

4.4 知识体系总览

下面这张图总结了共享内存优化的核心脉络:

共享内存优化知识体系 分配与使用 静态分配:__shared__ 动态分配:extern __shared__ 大小限制:每个块≤48KB 同步:__syncthreads() 动态共享内存 运行时确定大小 灵活适配不同数据 调用时传参指定 典型:规约、分块 与寄存器权衡 寄存器:更快但有限 共享内存:更大但稍慢 私有数据→寄存器 共享数据→共享内存 核心原则 1. 数据复用度高 → 用共享内存 2. 线程私有且频繁访问 → 用寄存器 3. 避免寄存器溢出 → 考虑共享内存 4. 别忘了同步和bank conflict

4.5 避坑指南

最后分享几个我踩过的坑:

  • 忘记同步:共享内存的数据写完后,一定要加 __syncthreads()。我曾经在一个规约核函数里忘了同步,结果结果时对时错,排查了三天才发现。
  • 动态共享内存大小计算错误:调用核函数时,第三个参数是每个块的总共享内存大小(字节)。如果你有多个数组,记得把它们的尺寸加起来。
  • 过度使用共享内存:不是所有数据都适合放共享内存。如果每个线程只读一次,直接读全局内存反而更快——因为省去了加载到共享内存的开销。
  • 忽略bank conflict:共享内存有32个bank,连续访问同一bank会导致冲突。这个我们下一章会详细讲,但你现在要知道:sdata[threadIdx.x] 这种访问模式是没问题的,但 sdata[threadIdx.x * 32] 就会出问题。
我的习惯:写共享内存代码时,先画个数据流图。哪个线程写、哪个线程读、什么时候同步,画清楚了再写代码。这样能避免80%的bug。

共享内存优化,说白了就是“用空间换时间”。你多花一点心思在数据布局上,GPU就能少等几百个时钟周期。这个投入,绝对值。

想要更多资料,联系可可