全局内存合并访问:Coalescing 原理与实战

说到 GPU 显存优化,全局内存合并访问绝对是个绕不开的话题。我刚开始写 CUDA 程序那会儿,总觉得显存带宽不够用,后来才发现——不是带宽不够,是我没用好。

说白了,全局内存合并访问就是让 GPU 的一次内存请求,能尽可能多地加载有用的数据。你想想看,GPU 访问显存是以 128 字节为单位的,如果你只用了其中 4 个字节,那剩下的 124 个字节就白白浪费了。这就是带宽利用率低下的根源。

合并访问的原理

先说说硬件是怎么工作的。当一个 warp(32 个线程)执行一条加载指令时,GPU 会把这些线程的访存请求合并成尽可能少的内存事务。理想情况下,32 个线程访问连续的 128 字节,只需要一次事务就能搞定。

我遇到过最典型的反面教材:一个 warp 里的线程访问地址是 0, 32, 64, 96... 这种跨步访问。结果呢?一次请求被拆成了 32 次独立的事务,带宽利用率直接掉到 3%。

核心原则: 同一个 warp 中的线程,访问的地址要连续且对齐到 128 字节边界。

对齐与步长的影响

对齐是什么意思?举个例子:

// 对齐访问 - 好
int idx = blockIdx.x * blockDim.x + threadIdx.x;
float val = data[idx];  // 每个线程访问连续地址

// 未对齐访问 - 差
int idx = blockIdx.x * blockDim.x + threadIdx.x + 1;
float val = data[idx];  // 偏移了 4 字节,导致跨边界

步长的影响更隐蔽。我做过一个实验,用不同的步长访问同一个数组:

步长(元素数) 带宽利用率 说明
1 100% 完美合并
2 50% 浪费一半带宽
4 25% 越来越差
随机 <5% 基本没救

为什么会这样?因为步长 2 意味着每个线程只用到 8 字节,但 GPU 还是得加载 128 字节。剩下的 120 字节?全浪费了。

矩阵转置的合并访问优化

矩阵转置是个经典案例。先看一个 naive 的实现:

__global__ void transpose_naive(float *in, float *out, int n) {
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;
    
    if (row < n && col < n) {
        out[col * n + row] = in[row * n + col];
    }
}

这个写法有个大问题:读的时候是合并的(行连续),但写的时候是跨步的(列连续)。我实测过,对于 4096x4096 的矩阵,带宽利用率只有 15% 左右。

怎么优化?用共享内存做中转:

__global__ void transpose_coalesced(float *in, float *out, int n) {
    __shared__ float tile[TILE_DIM][TILE_DIM];
    
    int row = blockIdx.y * TILE_DIM + threadIdx.y;
    int col = blockIdx.x * TILE_DIM + threadIdx.x;
    
    if (row < n && col < n) {
        // 合并读
        tile[threadIdx.y][threadIdx.x] = in[row * n + col];
    }
    
    __syncthreads();
    
    // 交换行列索引
    int new_row = blockIdx.x * TILE_DIM + threadIdx.y;
    int new_col = blockIdx.y * TILE_DIM + threadIdx.x;
    
    if (new_row < n && new_col < n) {
        // 合并写
        out[new_row * n + new_col] = tile[threadIdx.x][threadIdx.y];
    }
}

这个版本的关键在于:读和写都是合并的。读的时候按行连续,写的时候也按行连续。中间通过共享内存做了一次转置。

我的经验: 共享内存的 bank conflict 也要注意。TILE_DIM 最好选 32 的倍数,比如 32 或 64。我习惯用 32,因为一个 warp 正好处理一行。

知识体系结构

下面这张图展示了全局内存合并访问的核心逻辑:

全局内存合并访问知识体系 合并访问原理 warp 级访存合并 影响因素 对齐 & 步长 优化方法 共享内存中转 128 字节事务单元 连续地址合并 跨步访问拆分 地址对齐到 128B 步长 = 1 最优 步长增大 → 带宽下降 共享内存做中转 读/写都合并 避免 bank conflict 目标:最大化带宽利用率 一次内存事务 = 128 字节有效数据
避坑指南: 我曾经在一个项目里用了二维线程块,但忘记调整 blockDim 和 gridDim 的对应关系。结果矩阵转置跑出来全是错的。调试了两天才发现是索引计算出了问题。记住:共享内存的读写索引和全局内存的索引要分开算。

最后说个实战技巧。如果你不确定自己的访存模式是否合并,可以用 NVIDIA Nsight Compute 的 Memory Workload Analysis 看一下。它会告诉你每个 warp 产生了多少次内存事务。理想情况是 1 次,如果看到 4 次或更多,那就得优化了。

嗯,全局内存合并访问就讲这么多。记住一句话:让同一个 warp 的线程访问连续地址,这是 GPU 编程的基本功。

想要更多资料,联系可可