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映射图。把每个线程要访问的地址算出来,看看有没有冲突。虽然有点麻烦,但能避免后期调试的头痛。
4.6 实战建议
最后,给你几个实战中的建议:
- 先测后优化:用profiler看看实际的Bank冲突率。别凭感觉优化。
- 填充是首选:加几个无用元素,简单有效。我90%的场景都用这个。
- 注意数据类型:double类型占8字节,Bank冲突的计算方式不同。double的Bank索引是
(byte address / 8) % 32。 - 别过度优化:如果Bank冲突率已经低于10%,再优化收益不大。把精力放在其他地方。
我曾经犯过的错:有一次为了消除Bank冲突,我把共享内存数组从32x32改成了32x33。结果因为没处理好边界条件,数据读错了。调试了两天才发现是填充导致的数据错位。所以,填充后一定要仔细检查索引计算。
共享内存的Bank冲突,说白了就是「多个线程抢同一个通道」。理解了这一点,优化思路就清晰了——要么让线程访问不同的Bank,要么让Bank能同时处理多个请求。填充、改布局、向量化,都是围绕这个核心思路展开的。