返回首页

Reduction 优化(单卡 A100)

Lesson 08 · CUDA 性能调优 · 第一个访存瓶颈算子的极限压榨

上一节把 roofline 钉到了真卡上。这一节换一种瓶颈:tiled GEMM计算瓶颈, 而 reduction(归约)——把一个大数组加成一个标量——是教科书级的访存瓶颈算子。 它在 AI 里无处不在:softmax 的分母、LayerNorm 的均值/方差、loss、梯度范数,本质都是 reduction。 本节只针对 A100(sm_80),把它从朴素版一路优化到逼近 HBM 带宽极限。

本节目标(一个可带走的胜利): 你能说清"reduction 的天花板是带宽不是算力", 并把它从朴素版一路优化到逼近 HBM 带宽极限。本节跟着配套代码 code/0008-reduce.cu六版调优阶梯(v0→v5)——每一刀只治一个病,逐版 benchmark 看加速比, 最后用 ncu 的 DRAM Throughput 验证你吃到了带宽的几成。这条阶梯正是 Mark Harris reduce0→reduce7 的标准路线。
六版阶梯总览(对照 code/0008-reduce.cu 的六个 kernel):
版本动作治的病
v0interleaved + 取模baseline:warp 发散 + bank conflict
v1interleaved 去取模治发散(bank conflict 仍在)
v2sequential addressing第一刀:一并治发散 + bank conflict
v3first-add-on-load加载即相加,grid 减半
v4unroll last warp第二刀:最后一个 warp 走 shuffle
v5grid-stride 多元素第三刀:grid 与 N 解耦 + 摊薄开销

1. 先定瓶颈:reduction 注定是访存瓶颈

把 N 个 float 加成 1 个标量。 N×4 字节, N−1 次加法。算术强度:

算术强度 = FLOPs / Bytes ≈ (N-1) / (4N) ≈ 0.25 FLOP/Byte

A100 的脊点12.6 FLOP/Byte。0.25 远远在脊点左侧铁定的访存瓶颈。这立刻给了你一条铁律:

reduction 优化的唯一目标 = 把 HBM 带宽吃满。 不要去想"减少加法次数"(算力根本没用满), 要想的是"每个字节只从显存读一次,且读得完全合并"。理论下限就是把整个数组完整读一遍的时间: N×4 / 带宽。比如 N=2²⁸(1 GB),A100 ~1.8 TB/s → 下限约 0.55 ms。你的 kernel 离它多远,就是优化空间。

2. v0 朴素版:interleaved addressing(慢在哪)

经典写法:block 内每个线程搬一个元素进 shared,然后"树形"两两相加。第一版用交错寻址:

__shared__ float sdata[BLOCK];
int tid = threadIdx.x;
sdata[tid] = g_in[blockIdx.x*BLOCK + tid];   // 合并加载 ✅
__syncthreads();

for (int s = 1; s < BLOCK; s *= 2) {
    if (tid % (2*s) == 0)              // ❌ 取模 → warp 内严重分支发散
        sdata[tid] += sdata[tid + s];        // ❌ 跨步访问 → bank conflict
    __syncthreads();
}
if (tid == 0) g_out[blockIdx.x] = sdata[0];

两个病:(1) tid % (2*s) 让一个 warp 里只有零星线程干活,其余空转却仍占用调度—— 这正是第 6 节说的 warp 分支发散。 (2) sdata[tid+s] 这种跨步访问,在 shared 里会撞 bank conflict

v1:先只治发散(留着 bank conflict 当对照)

不动跨步访问,只把"取模筛线程"换成"把活跃线程压到 warp 前部":index = 2*s*tid。 小步长时活跃 lane 在 warp 内连续 → 不再发散;但 sdata[index+s] 仍是跨步 → bank conflict 没治。 这一版单独存在,是为了让你在 benchmark 里看清"去发散"本身值多少。

for (int s = 1; s < BLOCK; s *= 2) {
    int index = 2*s*tid;                  // ✅ 活跃线程连续 → 无发散
    if (index < BLOCK)
        sdata[index] += sdata[index + s];   // ❌ 仍跨步 → bank conflict
    __syncthreads();
}

3. v2 第一刀:sequential addressing(干掉发散与 bank conflict)

把循环反过来——从大步长往小走,让活跃线程永远是前一半连续的 tid:

for (int s = BLOCK/2; s > 0; s >>= 1) {
    if (tid < s)                          // ✅ 活跃线程是连续的 0..s-1
        sdata[tid] += sdata[tid + s];
    __syncthreads();
}

同一个 warp 的 32 个线程要么全活跃、要么全休眠 → 无分支发散。 而且 sdata[tid]sdata[tid+s] 都是连续地址 → 无 bank conflict[1]

同一行代码,只是循环方向反了,就同时治好两个病。 这是 reduction 优化里最经典的一步, 也是"让一个 warp 内线程行为一致"这条通用直觉的最佳示范。

4. v3 加载即相加:first add during load

v2 的循环第一轮就有一半线程闲着(s=BLOCK/2 时只有前一半 tid 干活)。 与其让它们空转,不如让每个线程在写进 shared 之前先读两个元素加起来—— 于是每个 block 覆盖 2*BLOCK 个元素,grid 直接减半,固定开销摊薄一倍:

int base = blockIdx.x*(BLOCK*2) + tid;
float a = g_in[base];
float b = g_in[base + BLOCK];             // 两段都连续 → 仍合并访问
sdata[tid] = a + b;                       // ✅ 加载即相加,省掉第一轮的一半空转
__syncthreads();
// …接 v2 的 sequential 循环…

这是从"块内归约"转向"每线程多干活"的第一步,也是下一刀 grid-stride 的引子。

5. v4 第二刀:warp shuffle —— 最后 32 个线程不走 shared

注意上面循环走到 s ≤ 32 时,只剩一个 warp 在干活。这时还在用 __syncthreads() 和 shared memory,纯属浪费——同一 warp 内的线程本就是锁步执行的,可以直接交换寄存器。 A100 上用 __shfl_down_sync:

// 当 s 降到 32:不再回写 shared,改用 warp 内寄存器洗牌
float v = sdata[tid];
for (int off = 16; off > 0; off >>= 1)
    v += __shfl_down_sync(0xffffffff, v, off);   // 寄存器→寄存器,零 shared、零 sync
if (tid == 0) g_out[blockIdx.x] = v;

__shfl_down_sync(mask, v, off):让 lane tid 直接读到 lane tid+off 寄存器里的 v, 全程不碰 shared memory,也不需要 __syncthreads()[2] 最后一个 warp 的 5 步全在寄存器里完成。

warp shuffle 是现代 reduction 的标配。 它省掉了最后 log₂(32)=5 轮的 shared 往返与同步。 更进一步:整块归约都可以写成"每个 warp 先 shuffle 出 32 个部分和 → 写进一小块 shared → 第一个 warp 再 shuffle 一次", shared 只用 BLOCK/32 个槽,几乎无 bank conflict。__reduce_add_sync(sm_80 原生指令)还能把整 warp 求和压成一条指令。

6. v5 第三刀:grid-stride —— 一个线程先吞多个元素

前面每个线程只读 1~2 个元素。但启动 block、加载、做 log₂ 轮归约这些开销是固定的, 摊在 1 个元素上太亏。更进一步是让每个线程在进 shared 之前,先用一个 grid-stride 循环把 N/总线程数 个元素加起来:

int i = blockIdx.x*BLOCK + tid;
int stride = gridDim.x * BLOCK;          // 整个 grid 的线程数
float sum = 0;
while (i < N) {                          // 每次跳一整个 grid → 始终合并访问
    sum += g_in[i];
    i += stride;
}
sdata[tid] = sum;                         // 进 shared 时已经是局部和
__syncthreads();
// …接 v2/v4 的块内归约 + warp shuffle 收尾…

为什么是 grid-stride 而不是"每个线程连读一段连续区间"?因为同一拍里 warp 的 32 个线程地址必须相邻才合并。 跳 stride = gridDim.x*BLOCK 保证每一轮 32 个线程读的是连续的 32 个 float。[3]

grid-stride 的真正价值是"grid 与 N 解耦"。 它把 block 数从百万级(每线程 1 元素)压到几百, block 数选 SM 数的若干倍(A100 108 SM → 几百~上千),每个线程吞几十~几百个元素, 规模随 N 自适应,固定开销被彻底摊薄。
⚠️ 实测警告(别迷信"最关键一刀"): grid-stride 的性能领先,只在带宽墙高的卡上才看得出来。 本机 3060 Laptop(~330 GB/s)上 v4 就已吃满带宽(~310 GB/s),v5 与之打平甚至略慢(~0.9x)—— 因为带宽已是天花板,grid 大小不再是瓶颈。到了 A100(~1.8 TB/s)带宽墙高得多,grid-stride 摊薄开销的收益才显现。 所以"哪一刀最关键"是看卡的,不是绝对的。这正是第 7 节脊点直觉的延续:同一 kernel 在不同卡上瓶颈不同。

7. 收尾:G 个部分和怎么变一个标量

每个 block 产出一个部分和,grid 有 G 个 block → 得到 G 个部分和。怎么合成最终一个标量?三种路子:

做法说明何时用
两趟 kernel第一趟 N→G,第二趟 G→1(同一 kernel 再跑一次)最稳,最常用,易调试
atomicAdd每个 block 算完 atomicAdd(g_out, partial)G 不大时;一趟搞定
cooperative groups 网格同步grid.sync() 一个 kernel 内做完两级进阶,需 launch 支持

A100 上 grid-stride 已经把 G 压得很小(几百~上千),所以 atomicAdd 收尾或两趟 kernel 都很快,差别可忽略—— 瓶颈始终是第一趟那次"完整读一遍数组",收尾只是零头。

8. 真机验证:实测带宽 + ncu 看 DRAM Throughput

reduction 不看 Compute%,要盯内存吞吐。配套代码 code/0008-reduce.cu 把六版串成一个程序, 用 CUDA event 计时逐版打印带宽与加速比。先看本机 3060 Laptop(sm_86,~330 GB/s 峰值)的实测:

nvcc -O3 -arch=sm_86 0008-reduce.cu -o reduce   # 3060;A100 用 -arch=sm_80
./reduce
版本耗时(ms)带宽(GB/s)相对上一版
v0 interleaved+modulo10.998
v1 去发散9.71101.10x
v2 sequential addr9.71111.22x
v3 first-add-on-load5.41991.71x
v4 warp shuffle3.53101.60x
v5 grid-stride3.72860.92x

读这张表的三个要点:(1) v3/v4 各贡献最大的两跳——加载即相加摊薄开销、warp shuffle 省掉最后 5 轮同步; (2) v4 已到 ~310 GB/s ≈ 峰值 92%,撞上 3060 的带宽墙; (3) v5 grid-stride 反而略慢(0.92x)——不是写错了,而是带宽已封顶,grid 大小不再是瓶颈。 同一条代码阶梯换到 A100,v5 才会反超

原生 Linux 上还可以用 ncu 看更细的读数(WSL 下 ncu 内核级 profiling 不可用,只能用上面的 event 计时):

ncu --set full --section MemoryWorkloadAnalysis ./reduce

三个关键读数,按这个顺序判断:

指标看什么健康值
DRAM Throughput占峰值带宽的百分比 = 你离极限多远✅ 优化后应 ≥ 80%
Memory Throughput / SOLSpeed of Light 里 Memory 那条✅ 高且 Compute 低 = 对了
Bank Conflictsshared 内冲突数✅ v2 起应为 0(第 3 节已治)
验证逻辑闭环: 先算理论下限 N×4/带宽 → 跑 kernel 测实际耗时 → 实测带宽 = 读字节/耗时 → 对比峰值。当 DRAM Throughput 上到 80%+ 且 Duration 逼近理论下限,这个访存瓶颈算子就算调到头了。 再快不会来自 reduction 本身,而是融合(把 reduction 和上游算子合成一个 kernel,省掉中间结果的读写)。

9. 整条优化链回顾

版本动作治的病 / 用的前节知识
定位算术强度 0.25 → 访存瓶颈Roofline(L1)
v0interleaved + 取模baseline:发散 + bank conflict
v1去取模分支发散(L6)
v2sequential addressingbank conflict(L5)+ 残余发散,一刀治
v3first-add-on-load消首轮空转,grid 减半
v4warp shuffle 收尾省 shared 往返与 sync
v5grid-stride 多元素合并访问(L3)+ grid 与 N 解耦(带宽墙高的卡才显收益)
验证event 计时 / ncu DRAM Throughput ≥80%读 SOL 报告(L2)

10. 练习:reduction 决策(混合前几节)

点选项看反馈。这些题交错了 roofline、合并、bank conflict、occupancy——这正是检验你是否真懂的方式。

💬 随时问我。 想看一份完整可编译的 A100 reduction(含 grid-stride + warp shuffle + 收尾)? __shfl_down_sync 的 mask 为什么是 0xffffffff?block 大小到底选 256 还是 512? softmax/LayerNorm 里的 reduction 怎么和指数/归一化融合?把问题抛来。我是你的老师。

主源推荐(本节精读)

📘 Mark Harris, "Optimizing Parallel Reduction in CUDA"(NVIDIA 经典 slide) ——逐版本演示 7 步优化(交错→顺序→首次加载即相加→展开→多元素),本节的骨架就来自它,只是把收尾换成现代的 warp shuffle 并落到 A100。