阶段 2 · 执行模型与内存06 / 15约 22 分钟cuda/07_transpose.cu
内存合并:同一段代码,5 倍性能差距
warp 如何发起访存事务、什么是 stride 访问、AoS 与 SoA 的抉择
学完这一课你会
- 理解一个 warp 的访存是如何被硬件合并成 32B sector 的
- 识别代码中破坏合并的常见写法
- 掌握 AoS → SoA 这个最有效的数据布局改造
- 会用 shared memory 修复无法避免的非合并访问
GPU 访问显存不是按字节,而是按 32 字节的 sector 为单位。当一个 warp 的 32 个线程同时发起访存,硬件会把它们的地址合并成尽可能少的 sector 请求。理想情况下,32 个线程读连续的 32 个 float(128 字节)= 4 个 sector,一次搞定,零浪费。最坏情况下,32 个线程各读一个相距很远的 float = 32 个 sector = 1024 字节被搬进来,而你只用了其中 128 字节,87.5% 的带宽被扔掉了。
合并访问 c[i] = a[i] stride-8 访问 c[i] = a[i * 8] 地址: 0 4 8 ... 124 地址: 0 32 64 ... 992 线程: t0 t1 t2 ... t31 线程: t0 t1 t2 ... t31 ┌────┬────┬────┬────┐ ┌────┐ ┌────┐ ┌────┐ │sec0│sec1│sec2│sec3│ │sec0│... │sec8│... │sec31│ └────┴────┴────┴────┘ └────┘ └────┘ └────┘ 全部命中,无浪费 32 个 sector,每个只用 4/32 字节 搬运 128 B / 使用 128 B = 100% 搬运 1024 B / 使用 128 B = 12.5%
破坏合并的四种典型写法
CUDA C++
1// ❌ 每个线程跳着取,warp 覆盖 32 * 8 * 4 = 1024 字节2__global__ void strided(const float* in, float* out, int stride) {3 int i = blockIdx.x * blockDim.x + threadIdx.x;4 out[i] = in[i * stride];5}6 7// ✅ 若 stride 来自「多通道数据」,改成让通道走 blockIdx,元素走 threadIdx8__global__ void byChannel(const float* in, float* out, int n) {9 int i = blockIdx.x * blockDim.x + threadIdx.x; // 元素维,连续10 int c = blockIdx.y; // 通道维,warp 内不变11 out[c * n + i] = in[c * n + i];12}CUDA C++
1// ❌ threadIdx.x 对应行:warp 内 32 个线程各跨一整行2int row = blockIdx.x * blockDim.x + threadIdx.x;3int col = blockIdx.y * blockDim.y + threadIdx.y;4out[row * cols + col] = in[row * cols + col];5 6// ✅ threadIdx.x 对应列:warp 覆盖连续的 128 字节7int col = blockIdx.x * blockDim.x + threadIdx.x;8int row = blockIdx.y * blockDim.y + threadIdx.y;9out[row * cols + col] = in[row * cols + col];3. AoS vs SoA:粒子系统的经典案例
CUDA C++
1struct Particle { float x, y, z, vx, vy, vz; }; // 24 字节2 3__global__ void stepAoS(Particle* p, float dt, int n) {4 int i = blockIdx.x * blockDim.x + threadIdx.x;5 if (i >= n) return;6 p[i].x += p[i].vx * dt; // 相邻线程访问的 x 相距 24 字节7 p[i].y += p[i].vy * dt;8 p[i].z += p[i].vz * dt;9}10// 一个 warp 读 x 时跨越 32 * 24 = 768 字节,11// 且 24 不是 32 的因数,sector 还会错位,效率进一步下降。CUDA C++
1struct Particles { // 每个字段是一个独立的连续数组2 float *x, *y, *z;3 float *vx, *vy, *vz;4};5 6__global__ void stepSoA(Particles p, float dt, int n) {7 int i = blockIdx.x * blockDim.x + threadIdx.x;8 if (i >= n) return;9 p.x[i] += p.vx[i] * dt; // warp 读 x[0..31] = 连续 128 字节10 p.y[i] += p.vy[i] * dt;11 p.z[i] += p.vz[i] * dt;12}4. 无法避免的非合并:用共享内存中转
矩阵转置是个典型例子:读和写这两个方向,你至多只能保住一个是合并的。解法是让全局内存的读写都保持合并,把「转置」这个乱序动作放到共享内存里完成——共享内存的随机访问代价远低于显存。
CUDA C++
1#define TILE 322__global__ void transposeShared(const float* in, float* out, int w, int h) {3 __shared__ float tile[TILE][TILE + 1]; // +1 消除 bank conflict,下一课细讲4 5 int x = blockIdx.x * TILE + threadIdx.x;6 int y = blockIdx.y * TILE + threadIdx.y;7 8 // 读:warp 沿 x 连续 → 合并9 if (x < w && y < h) {10 tile[threadIdx.y][threadIdx.x] = in[y * w + x];11 }12 __syncthreads();13 14 // 写:交换 block 索引,让输出侧的 warp 也沿连续方向走 → 同样合并15 x = blockIdx.y * TILE + threadIdx.x;16 y = blockIdx.x * TILE + threadIdx.y;17 if (x < h && y < w) {18 out[y * h + x] = tile[threadIdx.x][threadIdx.y]; // 转置发生在共享内存里19 }20}自测
先自己在心里回答一遍,再展开对照。答不上来的说明这一段值得重读。