3、线程层次结构:Grid、Block、Thread

好,咱们今天聊聊CUDA里最核心的一个概念——线程层次结构。说白了,就是GPU怎么组织它那成千上万个干活的小工(线程)。

我第一次接触CUDA时,最困惑的就是这个三层结构。CPU上我们写多线程,顶多就是线程池、任务队列。GPU倒好,上来就是Grid、Block、Thread三层。为什么要这么设计?

嗯,咱们一步步拆开看。

3.1 三个基本概念:Thread、Block、Grid

先给个直观的理解:

  • Thread(线程):最基础的执行单元。每个线程执行同一个函数(叫kernel函数),但处理不同的数据。
  • Block(线程块):一组线程的集合。同一个Block里的线程可以协作——它们能共享一块内存(Shared Memory),也能同步执行。
  • Grid(线程网格):一组Block的集合。一个Grid对应一个kernel函数的启动。

你想想看,这就像一家公司:Thread是普通员工,Block是项目组,Grid是整个公司。项目组内部可以开会(共享内存)、同步进度(同步操作)。但不同项目组之间,基本各干各的,互不干扰。

关键点:同一个Block内的线程可以协作,不同Block的线程不能直接通信。这是硬件决定的,别想着绕过。

3.2 为什么需要三层结构?

我刚开始也觉得,直接搞个二维数组的线程不好吗?干嘛非要Grid包Block?

原因其实很实际:可扩展性

GPU的硬件是分层的:

  • 一个SM(流多处理器)可以同时运行多个Block
  • 一个Block内的线程在同一个SM上执行
  • 不同Block可能被分配到不同的SM上

所以,Grid这个层级让你可以写出与硬件无关的代码。你只需要告诉GPU:我要处理100万个元素,分成1000个Block,每个Block里1024个线程。至于这1000个Block怎么分配到SM上,那是硬件调度器的事。

我在项目中遇到过一种情况:有人把Block数设得特别少,比如只有4个Block。结果GPU上几十个SM大部分都在闲着。性能惨不忍睹。后来改成几百个Block,立马跑满。

我的建议:Block数至少是SM数量的几倍,才能充分利用GPU。一般建议Block数在100-1000之间比较稳妥。

3.3 线程ID的计算

好,现在问题来了:每个线程怎么知道自己该处理哪部分数据?

CUDA提供了几个内置变量:

  • threadIdx:线程在Block内的索引(三维)
  • blockIdx:Block在Grid内的索引(三维)
  • blockDim:Block的维度大小(每个维度有多少线程)
  • gridDim:Grid的维度大小(每个维度有多少Block)

每个线程通过这些变量,就能算出自己的全局唯一ID。这个ID通常用来访问数组中的对应元素。

一维线程网格

最简单的情况。假设我们有一个长度为N的一维数组,想用一维Block和一维Grid来处理:

// 一维情况
int tid = blockIdx.x * blockDim.x + threadIdx.x;

这个公式很直观:先算出前面有多少个Block(blockIdx.x * blockDim.x),再加上当前Block内的偏移(threadIdx.x)。

举个例子:blockDim.x = 256,blockIdx.x = 2,threadIdx.x = 50。那么tid = 2 * 256 + 50 = 562。这个线程就处理数组的第562个元素。

二维线程网格

处理图像、矩阵这类二维数据时,用二维网格更自然。比如一张1024x768的图片:

// 二维情况
int col = blockIdx.x * blockDim.x + threadIdx.x;  // 列索引
int row = blockIdx.y * blockDim.y + threadIdx.y;  // 行索引

// 如果需要一维的全局ID(比如访问一维数组)
int tid = row * width + col;

我个人习惯在处理图像时用二维网格。这样代码可读性更好,col和row直接对应像素坐标,不用来回换算。

三维线程网格

三维网格用在体数据、3D卷积等场景。比如一个256x256x256的体数据:

// 三维情况
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
int z = blockIdx.z * blockDim.z + threadIdx.z;

// 一维全局ID
int tid = z * width * height + y * width + x;

注意:三维网格的Block总数不能太大。gridDim.x * gridDim.y * gridDim.z 不能超过65535(计算能力2.x及以下)或2^31-1(计算能力3.x及以上)。但实际中很少用到这么大的三维网格。

3.4 维度选择的实战经验

这里我分享一些实际项目中的经验:

数据类型 推荐维度 理由
一维数组(向量) 一维Grid + 一维Block 最简单,计算ID方便
二维数组(图像) 二维Grid + 二维Block 直接对应像素坐标,代码清晰
三维数组(体数据) 三维Grid + 三维Block 自然映射到体素位置
不规则数据 一维Grid + 一维Block 用一维ID做索引映射更灵活

我曾经在一个项目里处理稀疏矩阵,一开始用了二维网格,结果发现很多线程在空转。后来改成一维网格,每个线程处理多个元素,效率反而更高。所以维度选择不是死的,要看具体问题。

3.5 一个完整的例子

咱们写个简单的向量加法,把上面这些概念串起来:

// kernel函数
__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];
    }
}

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

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

注意那个边界检查 if (tid < N)。为什么需要?因为 blocksPerGrid * threadsPerBlock 可能大于N,多出来的线程什么都不做,直接返回。这是CUDA编程的常见模式。

小技巧:Block大小一般选32的倍数(比如128、256、512)。因为GPU的warp大小是32,这样能避免warp内线程浪费。我一般用256,不大不小,刚刚好。

3.6 避坑指南

最后说几个我踩过的坑:

  • Block内线程数上限:不同计算能力上限不同,一般是1024。别设成2048,会直接报错。
  • Grid维度上限:gridDim.x/y/z最大是2^31-1(新卡)或65535(老卡)。但实际用不到那么大。
  • 线程ID计算别搞混:我见过有人把blockIdx和threadIdx搞反,结果数据全乱套。记住:blockIdx是Block的索引,threadIdx是线程在Block内的索引。
  • 别忘了边界检查:数据量不是Block大小整数倍时,多出来的线程要处理。不加边界检查,轻则数据错误,重则段错误。

嗯,线程层次结构这块就这些。说白了就是三个层级、四个内置变量、一个ID计算公式。但就是这些基础的东西,决定了你的CUDA程序能不能跑得快、跑得稳。

下一章咱们聊聊内存层次结构——全局内存、共享内存、寄存器这些。到时候你会发现,线程怎么组织,和内存怎么用,是紧密相关的。