阶段 3 · 优化实战08 / 15约 30 分钟cuda/06_reduction.cu
归约优化七步:一个 kernel 榨出 30 倍
从最朴素的版本一路优化到 warp shuffle,每一步都说清楚为什么变快
学完这一课你会
- 掌握并行归约这个最重要的 GPU 原语
- 逐步理解 divergence、bank conflict、idle thread、循环开销各自的代价
- 学会用 warp shuffle 完全绕开共享内存
- 建立「每次优化都要能说出消除了哪个具体瓶颈」的思维习惯
求和、求最大值、softmax 的分母、L2 范数、注意力的 rowsum……这些全都是归约(reduction)。它是 GPU 编程里最经典的教学案例,因为它把几乎所有优化技巧都串在了一条线上。下面这七个版本来自 Mark Harris 的经典分享,我们逐个拆解每一步到底消除了什么瓶颈。
V1 — 交错寻址:能跑,但每一步都在分叉
CUDA C++
1__global__ void reduce_v1(const float* in, float* out, int n) {2 extern __shared__ float sdata[];3 int tid = threadIdx.x;4 int i = blockIdx.x * blockDim.x + tid;5 6 sdata[tid] = (i < n) ? in[i] : 0.0f;7 __syncthreads();8 9 for (int s = 1; s < blockDim.x; s *= 2) {10 if (tid % (2 * s) == 0) { // 问题就在这里11 sdata[tid] += sdata[tid + s];12 }13 __syncthreads();14 }15 if (tid == 0) out[blockIdx.x] = sdata[0];16}tid % (2*s) == 0 让 warp 内只有一部分线程干活,且干活的线程是交错分布的——第一轮是 0、2、4、6…,warp 内 32 个线程一半工作一半空转,100% 分叉。更糟的是 % 是取模运算,在 GPU 上比较昂贵。
V2 — 换索引方式:消除 divergence
CUDA C++
1for (int s = 1; s < blockDim.x; s *= 2) {2 int index = 2 * s * tid; // 把「谁干活」改成「干哪一格」3 if (index < blockDim.x) {4 sdata[index] += sdata[index + s];5 }6 __syncthreads();7}工作量完全没变,但活跃线程变成了 tid 从 0 开始的连续一段。这样前几个 warp 是全员工作、后几个 warp 是全员空闲——warp 内不再分叉。代价是引入了新问题:index = 2*s*tid 让共享内存访问的 stride 变成 2、4、8…,bank conflict 出现了。
V3 — 顺序寻址:消除 bank conflict
CUDA C++
1for (int s = blockDim.x / 2; s > 0; s >>= 1) { // 从大步长往小走2 if (tid < s) {3 sdata[tid] += sdata[tid + s]; // 活跃线程访问连续地址4 }5 __syncthreads();6}V4 — 加载时先加一次:一半线程不再是摆设
看 V3 的第一轮:blockDim.x 个线程里只有一半在工作。也就是说,你启动的线程有一半从头到尾只干了「把数据搬进共享内存」这一件事。既然如此,不如让每个线程在加载阶段就读两个元素并相加——block 数量直接减半。
CUDA C++
1int tid = threadIdx.x;2int i = blockIdx.x * (blockDim.x * 2) + tid; // 每个 block 覆盖 2 倍数据3 4sdata[tid] = (i < n ? in[i] : 0.0f) + (i + blockDim.x < n ? in[i + blockDim.x] : 0.0f);5__syncthreads();6 7for (int s = blockDim.x / 2; s > 0; s >>= 1) {8 if (tid < s) sdata[tid] += sdata[tid + s];9 __syncthreads();10}V5 — 展开最后一个 warp:省掉多余的屏障
当 s <= 32 时,只剩一个 warp 在工作。此时 __syncthreads() 是纯粹的浪费——它是 block 级屏障,却只有一个 warp 需要同步。把最后 6 轮单独拿出来展开处理即可。
CUDA C++
1for (int s = blockDim.x / 2; s > 32; s >>= 1) { // 循环到 32 为止2 if (tid < s) sdata[tid] += sdata[tid + s];3 __syncthreads();4}5 6if (tid < 32) {7 // 注意:不能再依赖「warp 内隐式同步」,Volta 之后必须显式来8 float v = sdata[tid] + sdata[tid + 32];9 for (int offset = 16; offset > 0; offset >>= 1) {10 v += __shfl_down_sync(0xffffffff, v, offset); // 寄存器直接换数据11 }12 if (tid == 0) out[blockIdx.x] = v;13}V6 — 模板化完全展开
CUDA C++
1template <unsigned int blockSize>2__global__ void reduce_v6(const float* in, float* out, int n) {3 extern __shared__ float sdata[];4 unsigned tid = threadIdx.x;5 unsigned i = blockIdx.x * (blockSize * 2) + tid;6 unsigned gridSize = blockSize * 2 * gridDim.x;7 8 float sum = 0.0f;9 while (i < n) { // grid-stride:一个 block 吃掉任意多数据10 sum += in[i];11 if (i + blockSize < n) sum += in[i + blockSize];12 i += gridSize;13 }14 sdata[tid] = sum;15 __syncthreads();16 17 // blockSize 是编译期常量,这些 if 会被完全消解,循环彻底展开18 if (blockSize >= 512) { if (tid < 256) sdata[tid] += sdata[tid + 256]; __syncthreads(); }19 if (blockSize >= 256) { if (tid < 128) sdata[tid] += sdata[tid + 128]; __syncthreads(); }20 if (blockSize >= 128) { if (tid < 64) sdata[tid] += sdata[tid + 64]; __syncthreads(); }21 22 if (tid < 32) {23 float v = sdata[tid] + sdata[tid + 32];24 for (int off = 16; off > 0; off >>= 1) v += __shfl_down_sync(0xffffffff, v, off);25 if (tid == 0) out[blockIdx.x] = v;26 }27}28 29// 调用时要显式实例化30reduce_v6<256><<<blocks, 256, 256 * sizeof(float)>>>(d_in, d_out, n);V7 — 全 warp shuffle:共享内存只用来跨 warp
CUDA C++
1__inline__ __device__ float warpReduceSum(float v) {2 for (int off = warpSize / 2; off > 0; off >>= 1)3 v += __shfl_down_sync(0xffffffff, v, off); // 全程在寄存器里4 return v;5}6 7__global__ void reduce_v7(const float* in, float* out, int n) {8 float sum = 0.0f;9 for (int i = blockIdx.x * blockDim.x + threadIdx.x; i < n;10 i += blockDim.x * gridDim.x) sum += in[i];11 12 sum = warpReduceSum(sum); // 第一级:warp 内归约,零共享内存13 14 __shared__ float warpSums[32]; // 最多 1024/32 = 32 个 warp15 int lane = threadIdx.x % warpSize;16 int wid = threadIdx.x / warpSize;17 if (lane == 0) warpSums[wid] = sum; // 每个 warp 只写一个数18 __syncthreads();19 20 // 第二级:由第 0 个 warp 把各 warp 的结果再归约一次21 sum = (threadIdx.x < blockDim.x / warpSize) ? warpSums[lane] : 0.0f;22 if (wid == 0) sum = warpReduceSum(sum);23 24 if (threadIdx.x == 0) atomicAdd(out, sum); // 也可写成 out[blockIdx.x]25}| 版本 | 关键改动 | 消除的瓶颈 | 相对 V1 |
|---|---|---|---|
| V1 | tid % (2*s) | —(baseline) | 1× |
| V2 | 改成 index = 2*s*tid | warp divergence | ~2× |
| V3 | 循环步长从大到小 | bank conflict | ~4× |
| V4 | 加载时先加一次 | 一半线程空转 | ~8× |
| V5 | 展开最后一个 warp | 多余的 block 屏障 | ~12× |
| V6 | 模板 + 完全展开 + grid-stride | 循环与地址计算开销 | ~20× |
| V7 | warp shuffle 两级归约 | 共享内存往返 | ~30×,逼近带宽上限 |
CUDA C++
1#include <cub/cub.cuh>2 3void* d_temp = nullptr;4size_t temp_bytes = 0;5 6// 第一次调用只是问「需要多少临时空间」7cub::DeviceReduce::Sum(d_temp, temp_bytes, d_in, d_out, n);8CUDA_CHECK(cudaMalloc(&d_temp, temp_bytes));9 10// 第二次才真正执行11cub::DeviceReduce::Sum(d_temp, temp_bytes, d_in, d_out, n);自测
先自己在心里回答一遍,再展开对照。答不上来的说明这一段值得重读。