第二章:线程层次结构——Grid、Block、Thread 与 Warp 调度
好,咱们今天来聊聊 CUDA 里最核心的概念——线程层次结构。说实话,我刚接触 CUDA 那会儿,最头疼的就是搞不清 Grid、Block、Thread 到底是个什么关系。后来在项目里调优调得多了,才慢慢摸透了它们的脾气。
2.1 从硬件到软件:线程的“三级组织”
你可以把 GPU 想象成一个超大型的工厂。工厂里有多个车间(Grid),每个车间里有若干条生产线(Block),每条生产线上有多个工人(Thread)。工人是真正干活的人,生产线负责协调工人,车间则管理着整条生产线。
在 CUDA 里,Thread 是最小的执行单元。每个线程执行相同的核函数,但处理不同的数据。这就是所谓的 SIMT(单指令多线程)模型。
Block 是一组线程的集合。同一个 Block 里的线程可以共享一块内存(Shared Memory),也能通过同步操作来协作。我个人习惯把 Block 大小设为 256 或 512,这两个值在大多数 GPU 上表现都不错。
Grid 是所有 Block 的集合。一个核函数启动时,就对应一个 Grid。Grid 的维度决定了有多少个 Block 被调度。
一句话总结:Grid 包含 Block,Block 包含 Thread。你写核函数时,就是在定义这些线程如何协作。
2.2 一维、二维、三维线程索引
为什么要有维度?说白了,是为了方便处理不同结构的数据。
- 一维索引:处理数组、向量这类线性数据。用
threadIdx.x和blockIdx.x就能定位。 - 二维索引:处理图像、矩阵。用
threadIdx.x、threadIdx.y和对应的 block 索引。 - 三维索引:处理体数据、3D 卷积。用得少,但需要时很顺手。
我记得有一次做图像滤波,数据是 1920x1080 的二维图像。我一开始傻乎乎地用一维索引,结果代码写得又臭又长。后来改成二维 Block,每个线程处理一个像素,代码瞬间清爽了。
// 一维索引示例
int tid = threadIdx.x + blockIdx.x * blockDim.x;
// 二维索引示例
int x = threadIdx.x + blockIdx.x * blockDim.x;
int y = threadIdx.y + blockIdx.y * blockDim.y;
int idx = y * width + x;
// 三维索引示例
int x = threadIdx.x + blockIdx.x * blockDim.x;
int y = threadIdx.y + blockIdx.y * blockDim.y;
int z = threadIdx.z + blockIdx.z * blockDim.z;
int idx = z * height * width + y * width + x;
我的小技巧:能用二维就别用一维,能用三维就别用二维。维度越高,索引计算越直观,代码可读性越好。
2.3 线程束(Warp)与调度
嗯,这里要注意。线程束是硬件层面的概念。GPU 执行指令时,不是一个个线程单独跑的,而是 32 个线程绑在一起,组成一个 Warp。这 32 个线程执行同一条指令,只是处理的数据不同。
为什么会这样?因为 GPU 的设计哲学就是“用更多的线程来隐藏延迟”。32 个线程一起跑,如果某个线程在等数据,其他线程可以继续干活。
我曾经在项目里遇到过一个坑:Block 大小设成了 100。你想想看,100 不是 32 的整数倍,最后一个 Warp 只有 4 个线程在干活,剩下 28 个线程空转。这浪费了多少计算资源!
避坑指南:Block 大小最好是 32 的整数倍。我一般用 128、256、512。别问我为什么不用 1024,因为有些老 GPU 不支持。
Warp 的调度是硬件自动完成的。每个 SM(流多处理器)里有多个 Warp Scheduler,它们负责把 Warp 分配给执行单元。你不需要手动干预,但需要理解它的行为——比如分支发散。
什么叫分支发散?就是同一个 Warp 里的线程走了不同的 if-else 分支。这时候 GPU 会串行执行所有分支,导致性能下降。我见过最夸张的例子,一个简单的 if 判断让性能掉了 50%。
// 不好的写法:分支发散
if (tid % 2 == 0) {
// 偶数线程走这里
} else {
// 奇数线程走这里
}
// 好的写法:用三元运算符或重新组织数据
result[tid] = (tid % 2 == 0) ? a[tid] : b[tid];
2.4 Occupancy 计算——你的 GPU 吃饱了吗?
Occupancy,说白了就是 GPU 的“上座率”。它表示每个 SM 上活跃的 Warp 数量与最大 Warp 数量的比值。Occupancy 越高,说明 GPU 越忙,理论上性能越好。
但 Occupancy 不是越高越好。我记得有一次调优,把 Occupancy 从 100% 降到 67%,性能反而提升了 30%。为什么?因为每个线程用了更多的寄存器,减少了寄存器溢出。
Occupancy 的计算公式其实不复杂:
Occupancy = (活跃 Warp 数) / (最大 Warp 数) * 100%
影响因素:
- 每个 Block 的线程数
- 每个线程使用的寄存器数
- 每个 Block 使用的共享内存大小
- GPU 的硬件限制(如 Max Threads per SM)
你可以用 NVIDIA 的 CUDA Occupancy Calculator 工具来算。或者直接在代码里调用 cudaOccupancyMaxActiveBlocksPerMultiprocessor 函数。
| GPU 型号 | Max Warps per SM | Max Threads per SM | Max Blocks per SM |
|---|---|---|---|
| RTX 3090 | 64 | 1024 | 32 |
| A100 | 64 | 2048 | 32 |
| V100 | 64 | 2048 | 32 |
我的经验:Occupancy 在 50% 以上通常就够用了。别盲目追求 100%,关键是看实际吞吐量。用 ncu 或 nsys 跑一下,看看瓶颈在哪。
2.5 知识体系图
下面这张图是我自己画的,帮你理清本章的核心逻辑:
这张图把本章的核心概念串起来了。你从上往下看:Grid 包含多个 Block,Block 包含多个 Thread。Thread 是执行单元,32 个 Thread 组成一个 Warp,Warp 是硬件调度的基本单位。Occupancy 则反映了 SM 上活跃 Warp 的比例。
好了,这一章的内容就到这儿。线程层次结构是 CUDA 编程的基石,搞懂了它,后面的优化技巧才能用得上。下一章咱们聊聊内存层次结构——Global Memory、Shared Memory、Register 这些,到时候你会看到,内存访问模式对性能的影响有多大。
想要更多资料,联系可可