线程层次结构:Grid、Block、Thread的三层映射

说实话,刚接触CUDA的时候,我也被Grid、Block、Thread这三层结构搞得有点晕。你想想看,CPU编程里我们最多就考虑多核多线程,到了GPU这里突然冒出三层来,确实需要一点时间消化。

但别担心,这三层结构其实很直观。我习惯把它想象成一个军队的编制:Grid是军团,Block是连队,Thread就是每个士兵。每个士兵(Thread)执行相同的指令,但处理不同的数据——这就是SIMT(单指令多线程)的精髓。

为什么需要三层映射?

GPU有成千上万个核心,但你不能让所有线程都乱跑。需要一套组织体系来管理它们。Grid和Block就是这套管理体系。

  • Grid:整个任务,对应一个核函数调用
  • Block:任务的一个子块,可以独立执行
  • Thread:最小的执行单元,每个线程执行核函数中的代码

我在项目中遇到过一个问题:如果只用一维的线程索引,处理二维图像时特别别扭。后来发现CUDA支持一维、二维、三维的索引,这才舒服多了。

内置变量:你的导航工具

CUDA提供了几个内置变量,帮你定位当前线程在全局中的位置。这些变量在核函数里可以直接用,不需要声明。

变量名 类型 含义
threadIdx dim3 当前线程在Block内的索引
blockIdx dim3 当前Block在Grid内的索引
blockDim dim3 Block的维度大小(每个Block有多少线程)
gridDim dim3 Grid的维度大小(有多少个Block)

嗯,这里要注意:threadIdx 和 blockIdx 都是从0开始的。我第一次写的时候忘了这一点,结果数组越界了,排查了半天。

一维到三维索引计算

这是最核心的部分。你要学会如何把三维的线程索引映射到一维的数据上。说白了,就是算一个全局ID。

一维情况

最简单的情况。每个Block是一维的,Grid也是一维的。

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

这个公式我闭着眼睛都能写出来。它把Block的偏移量和Block内的偏移量加起来,得到全局唯一的线程ID。

二维情况

处理图像时最常用。每个Block是二维的(比如16x16),Grid也是二维的。

int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
int tid = y * width + x;  // 映射到一维数组

我个人习惯用 row-major 的存储方式,也就是先存行再存列。这样 y * width + x 就是正确的线性索引。

三维情况

处理体数据(比如CT扫描)时会用到。每个Block是三维的,Grid也是三维的。

int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
int z = blockIdx.z * blockDim.z + threadIdx.z;
int tid = z * width * height + y * width + x;

核心公式:全局线程ID = blockIdx * blockDim + threadIdx

这个公式适用于任意维度,只是每个维度单独计算。

一个完整的例子

来看一个向量加法的核函数。假设我们要把两个长度为N的向量相加。

__global__ void vecAdd(float* A, float* B, float* C, int N) {
    // 计算全局线程ID
    int tid = blockIdx.x * blockDim.x + threadIdx.x;
    
    // 边界检查,防止越界
    if (tid < N) {
        C[tid] = A[tid] + B[tid];
    }
}

调用时,我们需要指定Grid和Block的大小:

int N = 1000000;
int threadsPerBlock = 256;
int blocksPerGrid = (N + threadsPerBlock - 1) / threadsPerBlock;

vecAdd<<<blocksPerGrid, threadsPerBlock>>>(d_A, d_B, d_C, N);

小技巧:Block的大小通常是32的倍数(因为warp大小是32)。我一般用128或256,性能比较稳定。

避坑指南

我曾经踩过的坑:

  • 忘记做边界检查:当N不能被blockDim整除时,最后一个Block会有多余的线程。不做边界检查就会访问非法内存。
  • 混淆了blockDim和gridDim:blockDim是每个Block的大小,gridDim是Block的数量。我刚开始总搞反。
  • 三维索引计算错误:处理三维数据时,一定要搞清楚哪个维度是x,哪个是y,哪个是z。CUDA的维度顺序是(x, y, z)。

用SVG图来理解三层结构

下面这张图展示了Grid、Block、Thread的层次关系,以及索引的计算方式。我建议你仔细看看,理解了这个图,三层映射就掌握了八成。

Grid (gridDim.x = 3, gridDim.y = 2) Block (0,0) blockIdx.x=0, blockIdx.y=0 Block (1,0) blockIdx.x=1, blockIdx.y=0 Block (2,0) blockIdx.x=2, blockIdx.y=0 Block (0,1) blockIdx.x=0, blockIdx.y=1 Block (1,1) blockIdx.x=1, blockIdx.y=1 Block (2,1) blockIdx.x=2, blockIdx.y=1 Thread (0,0) ... Thread (3,0) Thread (0,1) ... Thread (3,1) Thread (0,2) ... Thread (3,2) blockDim.x=4, blockDim.y=3 gridDim.y = 2 gridDim.x = 3

这张图里,Grid有3x2=6个Block,每个Block有4x3=12个Thread。总共72个线程。每个线程的全局ID可以通过 blockIdx * blockDim + threadIdx 计算出来。

实际开发中的选择建议

我做了几年CUDA开发,总结了一些经验:

  • Block大小:建议128-512之间。太小了硬件利用率低,太大了可能超过资源限制(每个Block的共享内存和寄存器是有限的)。
  • Grid大小:尽量让Block数量远大于SM数量(通常是几十倍),这样硬件可以自动做负载均衡。
  • 维度选择:处理一维数据用一维索引,处理二维图像用二维索引,处理三维体数据用三维索引。不要为了炫技强行用三维。

说白了,线程层次结构就是CUDA的骨架。理解了它,你就知道怎么把数据切分给成千上万个线程去并行处理。我刚开始学的时候,花了一周才真正搞明白。但一旦理解了,后面的学习就顺畅多了。

记住那个核心公式:全局ID = blockIdx * blockDim + threadIdx。这是你写所有CUDA核函数的基础。

练习建议:写一个简单的核函数,打印出每个线程的blockIdx、threadIdx和计算出的全局ID。观察输出结果,验证你的理解是否正确。

想要更多资料,联系可可