第一次用CUDA写矩阵乘法时,我兴冲冲地跑了个4096x4096的测试,结果发现GPU性能还不如CPU。当时盯着nsight compute的报告看了半天,直到发现"DRAM Burst"这个关键词才恍然大悟——原来问题出在内存访问上。
现代GPU的显存(DRAM)就像个批发市场,最讨厌零散购物。当你需要读取一个32字节的数据时,显存控制器会一次性抓取相邻的256字节(具体大小取决于架构),这就是所谓的DRAM Burst特性。如果后续线程刚好需要相邻数据,就能直接从缓存读取,省去了反复访问显存的开销。
举个例子,假设线程0要读取地址0x1000的数据,DRAM会顺带把0x1000-0x10FF的数据都加载到缓存。如果线程1-7接着要读0x1004-0x101C,这些数据已经在缓存里等着了。但要是线程1偏要跳着访问0x2000,那就得再发起一次DRAM访问,性能直接腰斩。
理解了DRAM Burst的特性后,Memory Coalescing(内存合并)的概念就很好理解了——让相邻的线程访问相邻的内存地址,就像跳集体舞时大家保持队形一样整齐。
这里有个实战中的经典错误案例。假设我们要处理一个MxN的矩阵,常见两种访问模式:
c++复制// 模式A:按列访问(推荐)
int idx = row * N + col; // 相邻threadIdx.x访问连续地址
// 模式B:按行访问(不推荐)
int idx = col * M + row; // 相邻threadIdx.x访问间隔M的地址
在我的RTX 3090上实测,处理4096x4096矩阵时,模式A耗时16ms,模式B却要19ms。看起来只差3ms?但放大到工业级规模的迭代计算中,这个差距会被放大成小时级的等待。
让我们拆解两个具体的核函数实现,看看内存合并如何影响实际性能:
c++复制__global__ void optimized_kernel(float *input, float *output, int width) {
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x < width && y < width) {
// 关键点:x连续变化保证内存合并
float val = input[y * width + x];
output[y * width + x] = val * 2;
}
}
这个版本中,threadIdx.x连续变化的特性保证了当线程块中的32个线程(一个warp)执行时,访问的input地址是连续的32个float(正好256字节,匹配DRAM Burst大小)。
c++复制__global__ void unoptimized_kernel(float *input, float *output, int width) {
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
if (x < width && y < width) {
// 灾难性访问模式:相邻线程访问间隔width的地址
float val = input[x * width + y];
output[x * width + y] = val * 2;
}
}
这个版本在Ampere架构上实测会有约40%的性能损失。更糟的是,随着矩阵尺寸增大,性能差距会进一步拉大,因为缓存命中率会急剧下降。
当数据复用率高时,使用共享内存是常识。但很多人忽略了从全局内存加载到共享内存时的合并访问问题。来看个矩阵乘法的经典分块实现:
c++复制__global__ void matrixMul(float *A, float *B, float *C, int N) {
__shared__ float As[TILE][TILE];
__shared__ float Bs[TILE][TILE];
int bx = blockIdx.x, by = blockIdx.y;
int tx = threadIdx.x, ty = threadIdx.y;
// 关键技巧:让相邻线程加载连续地址的数据
As[ty][tx] = A[(by * TILE + ty) * N + (bx * TILE + tx)];
Bs[ty][tx] = B[(by * TILE + ty) * N + (bx * TILE + tx)];
__syncthreads();
// ...后续计算逻辑...
}
这里有两个优化点值得注意:
As[ty][tx]的加载模式保证了全局内存的合并访问在我的项目中,经过这种优化后,矩阵乘法的性能从15 TFLOPS提升到了27 TFLOPS,几乎翻倍。
从Kepler到Ampere架构,NVIDIA对内存合并的要求其实在逐渐放宽。比如Volta架构引入的L2缓存大幅缓解了非合并访问的惩罚。但别高兴太早——这绝不意味着我们可以不关注访问模式了。
在Turing架构上测试发现:
特别提醒使用A100的开发者:虽然其巨大的L2缓存能掩盖一些问题,但在处理超大规模数据时,良好的合并访问模式仍然是必须的。我最近优化的一个气象模拟项目里,仅仅调整了内存访问模式就让迭代速度从每小时5次提升到8次。
理论说再多不如实际验证。NVIDIA Nsight Compute工具可以直观显示内存合并效果。以下是使用步骤:
bash复制ncu --set full -o profile ./your_kernel
dram__bytes.sum:显存访问总量l1tex__t_bytes.sum:L1缓存访问量dram__throughput.avg.pct_of_peak_sustained:带宽利用率健康的内核应该满足:
最近帮同事优化一个图像处理内核时,发现其带宽利用率只有35%。通过调整为合并访问模式后,不仅带宽利用率提升到78%,整个流程耗时也从23ms降到了11ms。
不是所有算法都能天然满足合并访问。比如在稀疏矩阵运算中,我常用以下技巧:
间接访问的优化方案:
c++复制__global__ void sparse_kernel(float *data, int *indices, float *output) {
int tid = blockIdx.x * blockDim.x + threadIdx.x;
// 先合并加载索引到共享内存
__shared__ int shared_indices[BLOCK_SIZE];
shared_indices[threadIdx.x] = indices[tid];
__syncthreads();
// 再根据索引加载数据(可能有非合并访问)
float val = data[shared_indices[threadIdx.x]];
// ...后续处理...
}
虽然最终的数据加载仍可能非合并,但至少保证了索引数据的合并加载。在GTC 2022的一个案例中,这种"分阶段加载"策略让稀疏矩阵向量乘法的性能提升了3倍。
另一个技巧是数据重排预处理。对于某些无法避免随机访问的场景,可以先用一个内核将数据按照访问频率重新排列。这就像先把厨房的调料按使用频率摆放,做菜时就能减少来回走动的时间。