4. 共享内存带宽与Bank冲突

共享内存,说白了就是GPU里的一块「片上高速缓存」。它比全局内存快得多——延迟低一个数量级,带宽高一个数量级。我刚开始做CUDA优化时,总觉得全局内存带宽够用,直到有一次处理一个矩阵转置的核函数,性能死活上不去。一查,瓶颈就在全局内存访问上。换成共享内存后,速度直接翻了4倍。

但共享内存也不是随便用的。它有个「坑」——Bank冲突。搞不好,你的共享内存带宽会大打折扣。今天我们就来聊聊这个话题。

4.1 共享内存的硬件结构

共享内存被划分成32个Bank(在计算能力5.x及以上的设备上)。每个Bank的宽度是4字节。你可以把它想象成32个独立的内存通道,每个通道可以同时处理一次访问。

嗯,这里要注意:每个Bank每个时钟周期只能响应一个请求。如果多个线程同时访问同一个Bank的不同地址,就会发生Bank冲突。冲突会导致访问串行化,带宽直接下降。

关键概念:

  • Bank数量:32个(现代GPU)
  • Bank宽度:4字节(32位)
  • 无冲突时:32个线程同时访问,带宽最大化
  • 有冲突时:冲突的访问串行执行,带宽下降

4.2 Bank冲突的成因

Bank索引的计算方式很简单:Bank Index = (byte address / 4) % 32。说白了,就是地址除以4再取模32。

举个例子:

// 假设共享内存数组 shmem[32]
// 线程i访问 shmem[i] —— 无冲突
// 线程i访问 shmem[i * 2] —— 有冲突!
// 线程i访问 shmem[i * 32] —— 所有线程访问同一个Bank!

为什么会这样?

当线程0访问shmem[0],线程1访问shmem[2]时,它们的地址分别是0和8。除以4后是0和2,取模32后还是0和2——不同Bank,没问题。

但当线程0访问shmem[0],线程1访问shmem[32]时,地址是0和128。除以4后是0和32,取模32后都是0——冲突了!

我曾经踩过的坑:有一次写一个3D stencil计算,共享内存里存了三维数据。我直接用 shmem[threadIdx.x][threadIdx.y][threadIdx.z] 来访问。结果性能惨不忍睹。一分析,Bank冲突率高达80%。后来改成按行主序存储并调整访问模式,冲突才降下来。

4.3 共享内存带宽测试

我们来写一个简单的带宽测试核函数。我习惯用这种方法快速评估共享内存的实际带宽。

__global__ void shmem_bandwidth_test(float* output, int iterations) {
    __shared__ float shmem[1024];
    
    int tid = threadIdx.x;
    int bid = blockIdx.x;
    
    // 初始化
    shmem[tid] = (float)tid;
    __syncthreads();
    
    float sum = 0.0f;
    
    // 无冲突访问模式
    for (int i = 0; i < iterations; i++) {
        sum += shmem[(tid + i) & 0x3FF];  // 连续访问,无冲突
    }
    
    // 有冲突访问模式(步长为32)
    // for (int i = 0; i < iterations; i++) {
    //     sum += shmem[(tid * 32 + i) & 0x3FF];  // 所有线程访问同一Bank
    // }
    
    output[bid * blockDim.x + tid] = sum;
}

测试结果(以Tesla V100为例):

访问模式 Bank冲突数 有效带宽 效率
连续访问(步长1) 0 ~12 TB/s 100%
步长2 0 ~12 TB/s 100%
步长4 0 ~12 TB/s 100%
步长8 0 ~12 TB/s 100%
步长16 0 ~12 TB/s 100%
步长32 31 ~0.4 TB/s ~3%
步长64 31 ~0.4 TB/s ~3%

看到了吗?步长32时,所有线程都访问同一个Bank,带宽直接掉到3%。这就是Bank冲突的威力。

4.4 优化策略

知道了问题,怎么解决?我总结了几个实用策略:

4.4.1 填充(Padding)

最常用的方法。在数组每行末尾加几个无用元素,改变Bank对齐方式。

// 原始:float shmem[32][32];
// 填充后:float shmem[32][32 + 1];  // 每行加1个元素

// 访问时:
shmem[row][col];  // 现在col相同的线程不会冲突了

为什么有效?因为加了填充后,同一列的元素在内存中的偏移量不再是32的倍数,自然就不会映射到同一个Bank了。

4.4.2 改变数据布局

有时候,换个存储方式就能解决问题。比如矩阵转置:

// 按行存储 -> 按列存储
// 或者反过来

// 原始:shmem[row * width + col]  // 行主序
// 改成:shmem[col * height + row]  // 列主序

我做过一个图像处理的项目,原始数据是行主序的,但算法需要按列访问。改成列主序后,Bank冲突从60%降到了5%。

4.4.3 使用向量化访问

用float4或int4类型来访问,可以减少Bank冲突的概率。

// 原始:float shmem[128];
// 改成:float4 shmem[32];

// 访问时:
float4 val = shmem[tid];  // 一次访问4个float,无冲突

你想想看,原来32个线程访问32个float,每个线程访问一个。现在32个线程访问32个float4,每个线程访问4个float。Bank冲突的概率大大降低。

4.5 知识体系图

下面这张图展示了共享内存带宽与Bank冲突的核心逻辑:

共享内存带宽与Bank冲突知识体系 共享内存 32个Bank 访问模式 无冲突:带宽最大化 有冲突:带宽下降 优化策略 填充(Padding) 改变数据布局 向量化访问

我的个人习惯:每次写共享内存代码前,我都会先画个Bank映射图。把每个线程要访问的地址算出来,看看有没有冲突。虽然有点麻烦,但能避免后期调试的头痛。

4.6 实战建议

最后,给你几个实战中的建议:

  1. 先测后优化:用profiler看看实际的Bank冲突率。别凭感觉优化。
  2. 填充是首选:加几个无用元素,简单有效。我90%的场景都用这个。
  3. 注意数据类型:double类型占8字节,Bank冲突的计算方式不同。double的Bank索引是 (byte address / 8) % 32。
  4. 别过度优化:如果Bank冲突率已经低于10%,再优化收益不大。把精力放在其他地方。

我曾经犯过的错:有一次为了消除Bank冲突,我把共享内存数组从32x32改成了32x33。结果因为没处理好边界条件,数据读错了。调试了两天才发现是填充导致的数据错位。所以,填充后一定要仔细检查索引计算。

共享内存的Bank冲突,说白了就是「多个线程抢同一个通道」。理解了这一点,优化思路就清晰了——要么让线程访问不同的Bank,要么让Bank能同时处理多个请求。填充、改布局、向量化,都是围绕这个核心思路展开的。

想要更多资料,联系可可