共享内存优化:从原理到实战

共享内存,说白了就是GPU上的一块“手搓缓存”。它位于每个线程块内部,所有线程都能访问。我刚开始接触CUDA时,总觉得全局内存够用就行,直到被一次性能瓶颈狠狠教育了一回——那次我写了个矩阵转置,全局内存版本跑得比乌龟还慢。后来换成共享内存,速度直接翻了十几倍。嗯,从那以后我再也不敢小看它了。

共享内存的工作原理

共享内存是片上内存,延迟极低——大概比全局内存快100倍。但它容量有限,每个SM(流式多处理器)通常只有几十到一百多KB。你想想看,这就像你办公桌上的小抽屉,放的是最常用的工具,而不是仓库里的大箱子。

共享内存的生命周期和线程块绑定。线程块启动时分配,线程块结束时释放。同一个块内的线程可以互相读写共享内存,实现数据共享和协作。我个人习惯把共享内存当作“手动缓存”来用——把全局内存中会被多次访问的数据先搬进来,再慢慢处理。

核心要点:共享内存是程序员可控的、低延迟的片上存储。用好它,性能起飞;用不好,反而可能拖慢速度。

Bank Conflict:共享内存的“隐形杀手”

共享内存被划分成32个Bank(存储体),每个Bank宽度为4字节。当多个线程同时访问同一个Bank的不同地址时,就会发生Bank Conflict。这会导致访问串行化,性能骤降。

为什么会这样?因为硬件设计上,每个Bank每个周期只能响应一个请求。如果两个线程撞到同一个Bank,就得排队。我曾在项目中遇到过这样一个案例:一个简单的直方图计算,因为Bank Conflict,性能比预期差了3倍。排查了半天才发现是地址映射的问题。

访问模式 是否冲突 性能影响
所有线程访问同一个地址 不冲突(广播) 极快
每个线程访问不同Bank 不冲突 理想
多个线程访问同一Bank的不同地址 冲突 串行化,性能下降
多个线程访问同一Bank的同一地址 不冲突(多播) 较快

避坑指南:我曾经写过一个2D卷积核,因为没注意列方向上的步长,导致每个warp内的线程都踩到了同一个Bank。解决办法很简单——在共享内存声明时加一个padding列,比如 __shared__ float s_data[32][33] 而不是 [32][32]。多出来的那一列就是用来错开Bank的。

共享内存的分配方式

共享内存有两种分配方式:静态分配和动态分配。静态分配在编译时就确定大小,动态分配则在运行时指定。

静态共享内存:

__global__ void kernel() {
    __shared__ float s_data[256];  // 编译时确定大小
    // ...
}

动态共享内存:

__global__ void kernel() {
    extern __shared__ float s_data[];  // 大小由启动参数指定
    // ...
}

// 启动时指定大小
kernel<<<grid, block, 256 * sizeof(float)>>>();

我个人更推荐动态分配,尤其是在写通用库函数时。因为不同场景下需要的共享内存大小可能不同,硬编码会限制灵活性。但要注意,动态共享内存只能声明一个未指定大小的数组,而且所有动态共享内存变量共享同一块内存区域。

注意:动态共享内存的大小是在启动内核时通过第三个配置参数指定的。如果忘记传参,默认是0,那你的 extern __shared__ 数组就什么都存不了。我曾经因为这个bug调试了整整一个下午。

动态共享内存的实战技巧

动态共享内存的一个常见用法是处理可变大小的数据块。比如在矩阵乘法中,每个线程块处理的子矩阵大小可能不同。这时候动态分配就派上用场了。

__global__ void matmul(float* A, float* B, float* C, int N) {
    extern __shared__ float s_data[];
    float* s_A = s_data;                    // 前半部分存A
    float* s_B = &s_data[BLOCK_SIZE * BLOCK_SIZE];  // 后半部分存B

    int tx = threadIdx.x, ty = threadIdx.y;
    int row = blockIdx.y * BLOCK_SIZE + ty;
    int col = blockIdx.x * BLOCK_SIZE + tx;

    float sum = 0.0f;
    for (int t = 0; t < N / BLOCK_SIZE; ++t) {
        // 加载数据到共享内存
        s_A[ty * BLOCK_SIZE + tx] = A[row * N + t * BLOCK_SIZE + tx];
        s_B[ty * BLOCK_SIZE + tx] = B[(t * BLOCK_SIZE + ty) * N + col];
        __syncthreads();

        for (int k = 0; k < BLOCK_SIZE; ++k) {
            sum += s_A[ty * BLOCK_SIZE + k] * s_B[k * BLOCK_SIZE + tx];
        }
        __syncthreads();
    }
    C[row * N + col] = sum;
}

// 启动内核
int shared_mem_size = 2 * BLOCK_SIZE * BLOCK_SIZE * sizeof(float);
matmul<<<grid, block, shared_mem_size>>>(d_A, d_B, d_C, N);

这段代码里,我用一个 extern __shared__ 数组同时存放了A和B两个子矩阵。通过指针偏移来划分内存区域。这样做的好处是,你只需要修改 BLOCK_SIZE 就能适应不同的硬件配置,而不需要重新编译。

知识体系结构图

下面这张图展示了共享内存优化的核心逻辑和关键路径:

共享内存优化知识体系 共享内存 工作原理 Bank Conflict 分配方式 片上内存,低延迟 线程块生命周期绑定 32个Bank,4字节宽度 Padding法:加一列错开Bank 静态分配:编译时确定 动态分配:运行时指定 核心目标:减少全局内存访问,避免Bank Conflict 手动管理缓存,最大化数据复用

总结与个人心得

共享内存优化,说白了就是两件事:一是把数据从全局内存搬进来,二是确保搬进来的数据访问时没有Bank Conflict。第一件事靠的是对数据复用模式的理解,第二件事靠的是对地址映射的精细控制。

我个人的经验是,写共享内存代码时,先画个数据流图,搞清楚每个线程需要访问哪些数据,再考虑怎么布局共享内存。别一上来就写代码,那样很容易踩坑。另外,__syncthreads() 这个同步函数一定要用对——少了它数据不一致,多了它性能下降。我见过太多人因为同步放错位置而debug到崩溃。

最后说一句:共享内存虽好,但别贪多。每个SM的资源是有限的,共享内存用多了,能同时运行的线程块就少了。这是一个trade-off,需要根据具体场景来权衡。

小技巧:用 cudaOccupancyMaxPotentialBlockSize 这个API可以帮你找到共享内存使用量和线程块数量的最佳平衡点。我每次写新内核都会跑一下这个函数,省了不少手动调参的时间。

可可精品资料库