Lesson 08 · CUDA 性能调优 · 第一个访存瓶颈算子的极限压榨
上一节把 roofline 钉到了真卡上。这一节换一种瓶颈:tiled GEMM 是计算瓶颈, 而 reduction(归约)——把一个大数组加成一个标量——是教科书级的访存瓶颈算子。 它在 AI 里无处不在:softmax 的分母、LayerNorm 的均值/方差、loss、梯度范数,本质都是 reduction。 本节只针对 A100(sm_80),把它从朴素版一路优化到逼近 HBM 带宽极限。
code/0008-reduce.cu 走
六版调优阶梯(v0→v5)——每一刀只治一个病,逐版 benchmark 看加速比,
最后用 ncu 的 DRAM Throughput 验证你吃到了带宽的几成。这条阶梯正是
Mark Harris reduce0→reduce7 的标准路线。
code/0008-reduce.cu 的六个 kernel):
| 版本 | 动作 | 治的病 |
|---|---|---|
v0 | interleaved + 取模 | baseline:warp 发散 + bank conflict |
v1 | interleaved 去取模 | 治发散(bank conflict 仍在) |
v2 | sequential addressing | 第一刀:一并治发散 + bank conflict |
v3 | first-add-on-load | 加载即相加,grid 减半 |
v4 | unroll last warp | 第二刀:最后一个 warp 走 shuffle |
v5 | grid-stride 多元素 | 第三刀:grid 与 N 解耦 + 摊薄开销 |
把 N 个 float 加成 1 个标量。读 N×4 字节,算 N−1 次加法。算术强度:
算术强度 = FLOPs / Bytes ≈ (N-1) / (4N) ≈ 0.25 FLOP/Byte
A100 的脊点约 12.6 FLOP/Byte。0.25 远远在脊点左侧 → 铁定的访存瓶颈。这立刻给了你一条铁律:
N×4 / 带宽。比如 N=2²⁸(1 GB),A100 ~1.8 TB/s → 下限约 0.55 ms。你的 kernel 离它多远,就是优化空间。
经典写法: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。
不动跨步访问,只把"取模筛线程"换成"把活跃线程压到 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();
}
把循环反过来——从大步长往小走,让活跃线程永远是前一半连续的 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]
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 的引子。
注意上面循环走到 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 步全在寄存器里完成。
BLOCK/32 个槽,几乎无 bank conflict。__reduce_add_sync(sm_80 原生指令)还能把整 warp 求和压成一条指令。
前面每个线程只读 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]
v4 就已吃满带宽(~310 GB/s),v5 与之打平甚至略慢(~0.9x)——
因为带宽已是天花板,grid 大小不再是瓶颈。到了 A100(~1.8 TB/s)带宽墙高得多,grid-stride 摊薄开销的收益才显现。
所以"哪一刀最关键"是看卡的,不是绝对的。这正是第 7 节脊点直觉的延续:同一 kernel 在不同卡上瓶颈不同。
每个 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 都很快,差别可忽略——
瓶颈始终是第一趟那次"完整读一遍数组",收尾只是零头。
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+modulo | 10.9 | 98 | — |
v1 去发散 | 9.7 | 110 | 1.10x |
v2 sequential addr | 9.7 | 111 | 1.22x |
v3 first-add-on-load | 5.4 | 199 | 1.71x |
v4 warp shuffle | 3.5 | 310 | 1.60x |
v5 grid-stride | 3.7 | 286 | 0.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 / SOL | Speed of Light 里 Memory 那条 | ✅ 高且 Compute 低 = 对了 |
Bank Conflicts | shared 内冲突数 | ✅ v2 起应为 0(第 3 节已治) |
N×4/带宽 → 跑 kernel 测实际耗时 →
实测带宽 = 读字节/耗时 → 对比峰值。当 DRAM Throughput 上到 80%+ 且 Duration 逼近理论下限,这个访存瓶颈算子就算调到头了。
再快不会来自 reduction 本身,而是融合(把 reduction 和上游算子合成一个 kernel,省掉中间结果的读写)。
| 版本 | 动作 | 治的病 / 用的前节知识 |
|---|---|---|
| 定位 | 算术强度 0.25 → 访存瓶颈 | Roofline(L1) |
v0 | interleaved + 取模 | baseline:发散 + bank conflict |
v1 | 去取模 | 分支发散(L6) |
v2 | sequential addressing | bank conflict(L5)+ 残余发散,一刀治 |
v3 | first-add-on-load | 消首轮空转,grid 减半 |
v4 | warp shuffle 收尾 | 省 shared 往返与 sync |
v5 | grid-stride 多元素 | 合并访问(L3)+ grid 与 N 解耦(带宽墙高的卡才显收益) |
| 验证 | event 计时 / ncu DRAM Throughput ≥80% | 读 SOL 报告(L2) |
点选项看反馈。这些题交错了 roofline、合并、bank conflict、occupancy——这正是检验你是否真懂的方式。
__shfl_down_sync 的 mask 为什么是 0xffffffff?block 大小到底选 256 还是 512?
softmax/LayerNorm 里的 reduction 怎么和指数/归一化融合?把问题抛来。我是你的老师。
📘 Mark Harris, "Optimizing Parallel Reduction in CUDA"(NVIDIA 经典 slide) ——逐版本演示 7 步优化(交错→顺序→首次加载即相加→展开→多元素),本节的骨架就来自它,只是把收尾换成现代的 warp shuffle 并落到 A100。