4、执行配置优化:Grid与Block大小选择、Occupancy计算与优化、Warp调度与分支发散

好,咱们进入第四章。这一章,说白了就是教你「怎么把核函数喂饱」。

你写了一个完美的核函数,算法对,数据也对。但一跑起来,GPU利用率只有30%。为什么?因为你没告诉GPU「该用多少人、怎么分工」。这就是执行配置的活儿。

我个人习惯,调优的第一步永远是看执行配置。配置不对,后面全是白费。

4.1 Grid与Block大小选择:别拍脑袋定

很多人上来就写 dim3 block(256, 1, 1),然后 Grid 随便算一下。嗯,这其实是个坑。

Block 大小不是随便选的。它直接影响两个东西:每个 SM 能驻留多少线程,以及线程间怎么协作。

核心原则:

  • Block 大小最好是 32 的倍数。因为 warp 是 32 线程一组。不是 32 的倍数,最后一个 warp 就浪费了。
  • 不要小于 64,也不要大于 512。太小了 SM 利用率上不去;太大了寄存器压力大,容易 spill。
  • Grid 大小要足够大,至少是 SM 数量的几倍。这样才能隐藏延迟。

我在项目中遇到过,有人把 block 设成 128,结果 occupancy 只有 33%。改成 256 后,直接翻倍。为什么?因为 128 让每个 SM 能放的 block 数量受限了。

一个常用的经验公式:

// 对于大多数通用计算
dim3 block(256, 1, 1);  // 或者 128, 512

// 对于矩阵乘法等访存密集型
dim3 block(16, 16, 1);  // 2D tile

// 对于归约等计算密集型
dim3 block(512, 1, 1);  // 更多线程做计算

你想想看,如果你的数据量是 10000,用 block=256,grid=40。每个 SM 分到几个 block?如果只有 4 个 SM,每个 SM 分 10 个 block,还行。但如果只有 2 个 SM,每个分 20 个,那调度器就忙不过来了。

我的小技巧:用 cudaOccupancyMaxPotentialBlockSize 这个 API 自动算。它会根据你的核函数资源占用,给出最优的 block 大小。省心。

4.2 Occupancy计算与优化:别被数字骗了

Occupancy,直译就是「占用率」。它表示每个 SM 上活跃的 warp 数量与最大 warp 数量的比值。

很多人以为 occupancy 越高越好。其实不一定。我见过 occupancy 100% 但性能很差的例子。

为什么?因为 occupancy 只是「能放多少」,不是「实际跑多快」。如果每个线程用了太多寄存器,虽然 occupancy 低,但每个线程跑得快,整体反而更快。

注意:Occupancy 是手段,不是目的。目的是隐藏延迟。只要延迟能被隐藏,occupancy 低一点也没关系。

Occupancy 的计算公式:

Occupancy = (活跃 warp 数) / (SM 最大 warp 数)

影响因素:
- 每个线程的寄存器数
- 每个 block 的共享内存大小
- block 大小
- SM 硬件限制(最大线程数、最大 block 数)

举个例子,假设一个 SM 最多 64 个 warp(2048 线程),每个线程用 32 个寄存器。SM 总共 65536 个寄存器。那么最多能放 65536/32 = 2048 个线程,刚好 64 个 warp。occupancy 100%。

但如果每个线程用 64 个寄存器,那只能放 1024 个线程,occupancy 50%。

我曾经优化一个图像处理核函数,一开始 occupancy 只有 25%。我以为是寄存器太多,拼命减少寄存器。结果 occupancy 上去了,性能反而下降了。为什么?因为我把循环展开取消了,指令数变多了。后来我保持 occupancy 在 50%,但优化了指令级并行,性能反而提升了 30%。

Occupancy 优化策略:

  • 用 __launch_bounds__ 限制最大线程数,让编译器少用寄存器。
  • 减少共享内存使用,每个 block 少用一点,就能放更多 block。
  • 调整 block 大小,找到 occupancy 和指令级并行的平衡点。

用 NVIDIA 的 occupancy calculator 工具,或者直接在代码里用 API 查:

int minGridSize, blockSize;
cudaOccupancyMaxPotentialBlockSize(&minGridSize, &blockSize, myKernel, 0, 0);
// blockSize 就是推荐值

4.3 Warp调度与分支发散:别让线程吵架

Warp 是 GPU 执行的基本单位。32 个线程一组,一起执行同一条指令。

问题来了:如果这 32 个线程走不同的分支呢?比如 if-else,一半走 if,一半走 else。

这就是分支发散。GPU 的做法是:先执行 if 分支,把走 else 的线程屏蔽掉;再执行 else 分支,把走 if 的线程屏蔽掉。相当于串行化了。

分支发散的影响:

  • 同一个 warp 内,分支越多,性能越差。
  • 不同 warp 之间,分支不影响。因为 warp 是独立调度的。
  • 最坏情况:32 个线程走 32 个不同分支,性能降到 1/32。

我遇到过最典型的例子:一个粒子模拟程序,每个粒子根据位置做不同处理。一开始用 if-else 判断区域,结果性能惨不忍睹。后来我改成数据重排,把相同区域的粒子放到一起,让同一个 warp 内的粒子走相同分支。性能提升了 5 倍。

怎么避免分支发散?

  • 用三元运算符代替 if-else。三元运算符会被编译成选择指令,不会发散。
  • 数据重排,让同一个 warp 的数据性质相同。
  • 用查找表代替复杂分支。
  • 把分支提到 warp 级别,用 __any() 或 __all() 判断整个 warp 是否走同一分支。

举个例子:

// 坏:分支发散
if (data[tid] > 0) {
    result[tid] = data[tid] * 2;
} else {
    result[tid] = data[tid] + 1;
}

// 好:用三元运算符
result[tid] = (data[tid] > 0) ? (data[tid] * 2) : (data[tid] + 1);

你想想看,三元运算符在硬件层面就是一个 sel 指令,所有线程同时执行,只是选择不同的结果。没有分支,没有发散。

避坑指南:我曾经在循环里用了 break,结果不同线程在不同迭代退出,导致 warp 内严重发散。后来改成用 continue 加条件判断,虽然多算了几个无用迭代,但整体快了 2 倍。有时候「多做一点」比「发散」更好。

4.4 本章小结:配置调优三板斧

好,咱们捋一下:

  1. Grid/Block 大小:block 选 128-512,32 的倍数,grid 要够大。
  2. Occupancy:别盲目追求 100%,找到平衡点。用工具算,别拍脑袋。
  3. 分支发散:尽量让同一个 warp 走同一条路。用三元运算符、数据重排、查找表。

这三板斧用好了,你的核函数性能至少能翻一倍。我见过太多人在这上面栽跟头,所以这一章我讲得特别细。嗯,下一章咱们聊聊共享内存和 bank conflict,那又是一个大坑。

执行配置优化核心逻辑 Grid/Block 大小 Occupancy 计算 Warp 调度 Block 32 的倍数 Grid 至少 SM 数×2 寄存器/共享内存限制 平衡点:50%-100% 分支发散 → 串行化 三元运算符/数据重排 目标:隐藏延迟,提高吞吐

想要更多资料,联系可可