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

好,咱们直接进入正题。CUDA 的线程层次结构,说白了就是 GPU 怎么组织它那成千上万个干活的小工(线程)。你如果搞不懂这个,后面写核函数基本就是瞎蒙。

我个人习惯把 GPU 想象成一个大型工厂:Grid 是整个工厂,Block 是车间,Thread 就是车间里具体的工人。工人(Thread)在车间(Block)里干活,多个车间组成一个工厂(Grid)。就这么简单。

核心要点:CUDA 采用三级层次结构——Grid → Block → Thread。每个层次都有对应的索引,用来唯一标识每个线程。

1. 三个层次的概念

Thread(线程):最基础的执行单元。每个线程执行相同的核函数代码,但处理不同的数据。嗯,这就是 SIMT(单指令多线程)的精髓。

Block(线程块):一组线程的集合。同一个 Block 里的线程可以共享一块内存(Shared Memory),也能通过同步操作(__syncthreads())来协作。我在项目中遇到过,很多人以为 Block 越大越好,其实不是——每个 Block 能用的资源(寄存器、共享内存)是有限的。

Grid(网格):所有 Block 的集合。一个核函数启动时,就对应一个 Grid。Grid 可以是一维、二维或三维的,看你处理的数据结构。

我的经验:处理图像时用二维 Grid 最自然,因为图像本身就是二维的。处理一维数组就用一维 Grid。别为了炫技硬上三维,除非你真的需要。

2. 线程索引计算

这是新手最容易晕的地方。每个线程怎么知道自己该处理哪块数据?靠的就是内置变量:threadIdx、blockIdx、blockDim、gridDim。

咱们直接看代码,比说一万句都管用:

// 一维 Grid,一维 Block
int tid = blockIdx.x * blockDim.x + threadIdx.x;

// 二维 Grid,二维 Block(处理图像时常用)
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
int idx = y * width + x;  // 转成一维索引

// 三维 Grid,三维 Block(处理体数据)
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 = blockIdx * blockDim + threadIdx。你想想看,每个 Block 里有 blockDim 个线程,当前是第 blockIdx 个 Block,那这个 Block 的起始位置就是 blockIdx * blockDim,再加上线程在 Block 内的偏移 threadIdx,就得到了全局唯一的线程 ID。

避坑指南:我曾经犯过一个低级错误——在二维 Grid 里直接用 threadIdx.x 当全局索引。结果呢?不同 Block 里的线程索引重复了,数据被反复覆盖,跑出来的结果一塌糊涂。记住:threadIdx 只在 Block 内唯一,跨 Block 会重复。

3. 线程块大小选择

这是性能调优的关键一步。Block 大小选错了,性能可能差好几倍。我一般遵循以下几个原则:

因素 说明 建议值
Warp 大小 GPU 执行的基本单位是 Warp(32线程)。Block 大小最好是 32 的倍数 128、256、512
寄存器压力 每个线程用的寄存器越多,能同时活跃的线程越少 根据 Occupancy Calculator 算
共享内存 每个 Block 能用的共享内存有限,太大就限制活跃 Block 数 通常 16KB-48KB
SM 资源 每个 SM 能同时容纳的 Block 和线程数有上限 Max Blocks/SM: 16-32

我个人习惯从 256 开始试。为什么?256 是 32 的倍数,而且大多数 GPU 上能获得不错的 Occupancy(占用率)。如果核函数逻辑简单、寄存器用得少,我会试试 512。如果逻辑复杂、寄存器压力大,128 可能更合适。

一个小技巧:用 cudaOccupancyMaxPotentialBlockSize 这个 API 帮你算。它会根据你的核函数自动推荐最优的 Block 大小。我刚开始做优化时全靠手动试,后来发现这个 API 真香。

4. 知识体系总览

下面这张图把线程层次结构的核心逻辑串起来了,你一看就明白:

Grid(网格) Block (0,0) Block (1,0) Block (2,0) T(0,0) T(1,0) T(0,1) T(1,1) ... blockDim blockDim.x = 2 blockDim.y = 2 共 4 个线程 全局线程索引 = blockIdx × blockDim + threadIdx 例:Block(1,0) 中的 Thread(1,1) → 全局 x = 1×2 + 1 = 3, 全局 y = 0×2 + 1 = 1

这张图里,Grid 包含 3 个 Block,每个 Block 包含 4 个线程(2×2)。Block (1,0) 里的 Thread (1,1),它的全局 x 索引就是 1×2 + 1 = 3,全局 y 索引是 0×2 + 1 = 1。嗯,就是这么算的。

5. 实际项目中的选择策略

说了这么多理论,咱们来点实际的。我一般按这个流程来选 Block 大小:

  1. 先看数据量:总线程数要能覆盖所有数据。比如处理 100 万个元素,总线程数至少 100 万。
  2. 确定 Block 大小:从 256 开始,保证是 32 的倍数。
  3. 计算 Grid 大小:Grid 大小 = (数据量 + Block 大小 - 1) / Block 大小。向上取整。
  4. 检查资源限制:用 Occupancy API 验证,确保每个 SM 有足够的活跃 Warp。
  5. 微调:如果性能不理想,试试 128、192、256、512,看哪个最快。

记住:Block 大小不是越大越好。太大的 Block 会导致每个 SM 能同时处理的 Block 数减少,反而降低 Occupancy。我见过有人把 Block 设成 1024,结果性能还不如 256 的——因为寄存器不够用,线程被频繁换进换出。

好了,线程层次结构这块就讲这么多。你只要记住 Grid、Block、Thread 的关系,会算线程索引,知道怎么选 Block 大小,后面写核函数就顺了。

最后说一句:别怕试错。我刚开始调 Block 大小时,每个核函数至少试 5 种配置。跑一遍 profiler,看 Occupancy 和带宽利用率,哪个高就用哪个。时间长了,你一眼就能看出该用多大。

想要更多资料,联系可可