4. 线程的诞生:从Grid到Block再到Thread的层级结构

好,咱们今天聊聊CUDA里最核心的一个概念——线程是怎么来的。

你写一个kernel函数,调用它的时候,指定了<<>>。然后GPU里就“哗”地一下冒出来成千上万个线程。这些线程怎么组织的?谁先谁后?我刚开始学的时候,总觉得这层关系有点绕。今天咱们把它彻底捋清楚。

4.1 三层结构:Grid、Block、Thread

说白了,CUDA把线程分成了三个层级:

  • Thread(线程):最底层的执行单元。每个线程执行相同的kernel代码,但处理不同的数据。
  • Block(线程块):一组线程的集合。同一个block里的线程可以共享数据(通过shared memory),也能同步(通过__syncthreads())。
  • Grid(网格):所有block的集合。一个grid对应一次kernel调用。

你可以把Grid想象成一个工厂,Block是车间,Thread就是车间里的工人。每个工人干一样的活,但手里的零件不同。

关键点:线程是并行执行的,但block内部的线程可以协作,block之间不行(除非用全局内存+原子操作,但那很慢)。

4.2 为什么要有Block?

你可能会问:为什么不直接搞一堆线程,非要中间加个Block?

嗯,这里有两个原因。

第一,硬件限制。 GPU里的SM(流多处理器)一次能调度的线程数是有限的。比如一个SM最多支持2048个线程。如果你要跑100万个线程,SM得分批处理。Block就是分批的单位。

第二,数据共享。 我在项目中遇到过,需要让相邻的线程交换数据。如果没有Block,每个线程只能读写自己的寄存器,没法共享。有了Block,就能用shared memory做快速交换。我曾经做过一个矩阵转置的优化,用shared memory把性能提升了5倍多。

个人经验:Block的大小一般选128或256。太小了浪费SM资源,太大了shared memory不够用。我习惯先试256,不行再调。

4.3 线程的索引:threadIdx, blockIdx, blockDim, gridDim

每个线程都有自己唯一的ID。CUDA提供了几个内置变量:

变量 含义 例子
threadIdx 线程在block内的索引 threadIdx.x = 0, 1, 2, ...
blockIdx block在grid内的索引 blockIdx.x = 0, 1, 2, ...
blockDim block的维度(大小) blockDim.x = 256
gridDim grid的维度(大小) gridDim.x = 100

要计算全局唯一的线程ID,公式是:

int tid = blockIdx.x * blockDim.x + threadIdx.x;

这个公式我闭着眼睛都能写出来。你想想看,每个block有blockDim.x个线程,第blockIdx.x个block的起始位置就是blockIdx.x * blockDim.x,再加上线程在block内的偏移threadIdx.x,就是全局ID了。

4.4 一维、二维、三维:你选哪个?

CUDA支持一维、二维、三维的线程组织。什么意思?

  • 一维:处理数组、向量。最简单,最常用。
  • 二维:处理图像、矩阵。用(row, col)索引更直观。
  • 三维:处理体数据、3D网格。用得少,但需要时很爽。

我个人习惯,能用一维就用一维。除非数据本身是二维的(比如图像),用二维会让代码更清晰。

举个例子,处理一张1024x1024的图像:

dim3 blockDim(16, 16);   // 每个block 16x16 = 256个线程
dim3 gridDim(64, 64);    // 总共64x64 = 4096个block

// kernel里这样算坐标
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
int idx = row * width + col;  // 转成一维索引

你看,用二维组织,代码里直接操作行和列,不用自己算偏移。但底层执行时,硬件还是把它当成一维的线程来调度。说白了,维度只是方便你写代码。

4.5 线程的诞生过程:一张图说清楚

下面这张图展示了从Grid到Block再到Thread的完整层级。我画了个SVG,你一看就明白。

Grid(网格) Block (0,0) threadIdx.x: 0~127 threadIdx.y: 0~1 共 256 个线程 Block (1,0) threadIdx.x: 0~127 threadIdx.y: 0~1 共 256 个线程 Block (2,0) threadIdx.x: 0~127 threadIdx.y: 0~1 共 256 个线程 Thread(线程) 每个线程执行相同的kernel代码,处理不同的数据 全局ID = blockIdx.x * blockDim.x + threadIdx.x

这张图里,Grid里有3个Block,每个Block里有256个线程。实际项目中,Block的数量可能成百上千,线程数更是百万级。但结构是一样的。

4.6 避坑指南:Block大小怎么选?

我曾经踩过一个坑。刚开始写CUDA时,觉得线程越多越好,把Block设成1024。结果程序跑得比CPU还慢。

为什么?因为一个SM里的寄存器、shared memory是有限的。Block太大,每个线程分到的资源就少,反而限制了并行度。

我现在的经验是:

  • Block大小:128~512之间。256是个好起点。
  • Block数量:至少是SM数量的几倍。比如你的GPU有20个SM,那至少搞80个Block,让SM有事干。
  • 总线程数:覆盖你的数据量就行。别为了凑数多开线程。

注意:Block的线程数不能超过1024(MaxThreadsPerBlock)。这是硬件限制,你硬写超过会编译报错。

4.7 小结

好了,咱们总结一下:

  • Grid是最大的单位,对应一次kernel调用。
  • Block是中间层,负责组织线程,支持共享和同步。
  • Thread是最小单位,真正干活的就是它。
  • 用threadIdx、blockIdx、blockDim、gridDim这四个变量,就能算出每个线程的全局ID。
  • 维度(1D/2D/3D)只是方便你写代码,底层都一样。

下一节咱们聊聊线程怎么被调度到SM上执行。嗯,那才是真正有意思的部分。


专注资料整理