02 > 两个线程块,每块 1024 个线程
一个线程块最多只能有 1024 个线程,想运行更多线程,就得增加线程块。这一课启动 2 个线程块 x 1024 个线程 = 2048 个线程,并演示每个线程怎样得到一个在整个网格里唯一的编号。
[!NOTE] 本页所有输出都来自一块 NVIDIA L40S,环境是 CUDA 13.0 和 Ubuntu 24。
1024 个线程的上限
单个线程块最多只能有 1024 个线程,这是计算能力规定的硬性规则。计算能力是 GPU 的版本号,第 03 课会详细讲。从 2010 年起,NVIDIA 生产的每一款 GPU 上这个值都是 1024。
这个上限并不是“一个 SM 最多能容纳多少线程”。只要分布在多个线程块里,一个 SM 可以同时容纳更多线程。在 L40S 上,一个 SM 最多容纳 1536 个线程,比如 3 个线程块、每块 512 个线程。在 A100、H100 这样的数据中心 GPU 上,一个 SM 最多容纳 2048 个线程。
流式多处理器
SM 是 GPU 内部的一个物理处理单元。每个 SM 都有 CUDA 核心、寄存器堆、共享内存、L1 缓存和线程束调度器。启动核函数时,线程块会被分配到各个 SM 上。一个 SM 能同时运行一个还是多个线程块,取决于每个线程块需要多少资源。一个线程块从头到尾都待在同一个 SM 上。
[!NOTE] SM 的数量因 GPU 而异。L40S 有 142 个 SM,RTX 3080 这样的中端 GPU 有 68 个。
多个线程块时的线程编号
printIDs<<<2, 1024>>>();
// ^ ^
// blocks -+ +- threads per block
线程块 0 有线程 0-1023,线程块 1 也有自己的线程 0-1023,线程编号在每个线程块里都从 0 重新开始。所以只看 threadIdx.x,没法区分两个编号都是 5 的线程。要得到唯一的全局线程 ID,就用下面这个公式。
global_id = blockIdx.x * blockDim.x + threadIdx.x
blockDim.x 是一个内置变量,保存启动时设定的每个线程块的线程数,这里是 1024。每个线程块都要跳过排在它前面的所有线程块的线程。
- 线程块 0 里的线程 5 得到 0 * 1024 + 5 = 5
- 线程块 1 里的线程 5 得到 1 * 1024 + 5 = 1029
- 线程块 1 里的线程 1023 得到 1 * 1024 + 1023 = 2047,也就是 2048 个线程中的最后一个
换成小一点的数就更好理解了。假设每个线程块有 4 个线程,那么线程块 2 里的线程 3 得到 2 * 4 + 3 = 11。全局线程 ID 在线程块 0 里是 0 到 3,在线程块 1 里是 4 到 7,在线程块 2 里是 8 到 11。拖动滑块,再把鼠标悬停在某个线程上,就能看到代入具体数字后的公式。
处理数组的核函数就靠这个公式让每个线程负责一个元素,比如线程 1029 处理第 1029 个元素。
<<<1, 2048>>> 的无声失败
// printIDs<<<1, 2048>>>(); exceeds 1024 thread-per-block limit
这一行编译时不会报错,因为编译器不检查启动配置。CUDA 运行时会在核函数启动时检查,一看一个线程块里有 2048 个线程,就把这次核函数调用整个丢掉。
[!WARNING] 无效的启动不会有输出,不会崩溃,也没有任何错误信息,程序就这么结束了。在启动之后马上调用
cudaGetLastError(),才能看到错误,这里是invalid configuration argument。第 08 课会用一个CHECK宏来做这件事。[!TIP] 把这一行的注释去掉,运行一下,再对比输出。
线程块调度
线程块在 SM 上的运行顺序是非确定性的,也就是说顺序不固定。每个线程块会被分配到一个还有空间容纳它的 SM 上,线程块 0 和线程块 1 可以同时在不同的 SM 上运行。所以每次运行,它们的输出行都会以不同的顺序交错在一起。
代码
#include "cuda_runtime.h"
#include "device_launch_parameters.h"
#include <stdio.h>
__global__ void printIDs()
{
printf("\nBlock ID: %d === Thread ID: %d", blockIdx.x, threadIdx.x);
}
int main()
{
// printIDs<<<1, 2048>>>(); exceeds 1024 thread-per-block limit, launches nothing at runtime
printIDs<<<2, 1024>>>();
cudaDeviceSynchronize();
return 0;
}
- 被注释掉的那一行就是上一节讲的无效启动。它一直保持注释状态,程序才能正常工作。
printIDs<<<2, 1024>>>();启动 2 个线程块,每块 1024 个线程。这样没有超出上限,同样运行了 2048 个线程。- 其余部分和第 00 课、第 01 课相同。
代码逐步讲解
按照写代码的顺序,一步一步看这个程序。新内容是用两个线程块来启动。
1-3 cpu头文件。 和之前一样的三行#include,分别用于 CUDA 运行时、内置变量和printf。增加线程块不需要新的头文件。5-8 gpu核函数。 增加线程块时,核函数不用改。每个线程打印blockIdx.x和threadIdx.x,现在blockIdx.x是 0 或 1。记住,threadIdx.x在每个线程块里都从 0 重新开始,单看它并不唯一。10-11,15-16 cpumain 函数。 写好main,最后是return 0;。和启动相关的几行写在中间。13 cpu用 2 个线程块启动。 要得到 2048 个线程,就写<<<2, 1024>>>,也就是 2 个线程块乘以 1024 个线程。规则是第二个数不超过 1024,需要更多线程时就加大第一个数。14 cpu等待 GPU。cudaDeviceSynchronize();会一直等到两个线程块都执行完。少了它,你可能一行输出都看不到。12 cpu写成注释的无效启动。 最后加上这一行,提醒自己别这样写。<<<1, 2048>>>能通过编译,但运行时会被丢弃。如果去掉//,这次启动既不打印任何内容,也不报错,所以这种错误很容易被忽略。
编译和运行
第一条命令把代码编译成程序,第二条命令运行它。
nvcc -o first_kernel first_kernel.cu
./first_kernel
nvcc是 CUDA 编译器,它会把文件里的 CPU 部分和 GPU 部分一起编译。-o first_kernel把程序命名为first_kernel。不加这个选项,程序名默认是a.out。first_kernel.cu是包含上面代码的源文件。./first_kernel运行当前目录下的程序。
输出
程序会打印 2048 行,每个线程一行。下面是开头几行。
Block ID: 0 === Thread ID: 0
Block ID: 1 === Thread ID: 0
Block ID: 0 === Thread ID: 1
Block ID: 1 === Thread ID: 1
...
...代表 2048 行里剩下的部分。Block ID是 0 或 1,因为有两个线程块。- 0 到 1023 的每个
Thread ID都出现两次,每个线程块各一次,因为线程编号在每个线程块里都从 0 重新开始。 - 线程块 0 和线程块 1 的输出行交错在一起,而且每次运行的顺序都不一样。原因在“线程块调度”一节讲过,两个线程块可以同时在不同的 SM 上运行。
自己动手写
自己算出全局线程 ID,让网格里的每个线程都有一个唯一的编号。
- 用下面的框架创建
global_id.cu。 - 在核函数里,用这一课的公式算出
id,再把它和线程块编号、线程编号一起打印出来。 - 启动 3 个线程块,每块 4 个线程。
#include "cuda_runtime.h"
#include <stdio.h>
__global__ void printGlobalIDs()
{
// TODO: compute the global ID: block index times block size plus thread index
// TODO: print "block b, thread t -> global ID id"
}
int main()
{
// TODO: launch printGlobalIDs with 3 blocks of 4 threads
cudaDeviceSynchronize();
return 0;
}
提示
公式是 blockIdx.x * blockDim.x + threadIdx.x。启动配置里线程块数在前,比如 <<<3, 4>>>。
答案
#include "cuda_runtime.h"
#include <stdio.h>
__global__ void printGlobalIDs()
{
int id = blockIdx.x * blockDim.x + threadIdx.x;
printf("block %d, thread %d -> global ID %d\n", blockIdx.x, threadIdx.x, id);
}
int main()
{
printGlobalIDs<<<3, 4>>>();
cudaDeviceSynchronize();
return 0;
}
用 nvcc -o global_id global_id.cu 编译,再用 ./global_id 运行。你应该会看到 12 行,0 到 11 的每个全局 ID 恰好出现一次,顺序不固定。线程块 2 里的线程 3 会打印全局 ID 11,因为 2 * 4 + 3 = 11。
术语表
- GPU(Graphics Processing Unit,图形处理器):运行核函数的处理器。
- SM(Streaming Multiprocessor,流式多处理器):GPU 内部的物理处理器,线程块就在 SM 上运行。只要资源够,一个 SM 可以同时运行多个线程块。
- L1 缓存(level 1 cache,一级缓存):每个 SM 内部一块又小又快的存储,把最近用过的数据放在离核心很近的地方。
blockDim.x:内置变量,保存每个线程块的线程数,也就是<<<blocks, threads>>>中的第二个数。- 全局线程 ID(global thread ID):整个网格里每个线程独有的编号,算法是
blockIdx.x * blockDim.x + threadIdx.x。线程编号在不同线程块之间会重复,全局线程 ID 不会。 cudaGetLastError():返回最近一次的 CUDA 错误码。它能发现无声的失败,比如被悄悄丢弃、没有任何提示的无效启动配置。- 非确定性(non-deterministic):结果或顺序无法预测。线程块怎么调度,取决于启动时哪个 SM 还有空间。
- CPU(Central Processing Unit,中央处理器):运行
main()并启动核函数的主处理器。 - CUDA(Compute Unified Device Architecture,统一计算设备架构):NVIDIA 的平台,让你在 GPU 上运行自己的代码。
- 计算能力(compute capability):一代 GPU 的版本号,L40S 是 8.9。每个线程块最多 1024 个线程这样的上限就由它规定(第 03 课)。
- 寄存器堆(register file):每个 SM 里的高速存储,存放线程的局部变量。L40S 每个 SM 有 65536 个 32 位寄存器。
- 共享内存(shared memory):每个 SM 内部的高速内存,同一个线程块里的线程可以共用它。
- 线程束调度器(warp scheduler):SM 里负责决定下一个运行哪个线程束的单元,每个 SM 有好几个。
- 线程块(block):由最多 1024 个线程组成的一组,在一个 SM 上运行。每个线程块里的线程编号都从 0 重新开始。
- 网格(grid):一次启动的全部线程块。
<<<2, 1024>>>会建立一个有 2 个线程块、共 2048 个线程的网格。 blockIdx.x:线程所在线程块的编号,这一课里是 0 或 1。threadIdx.x:线程在自己线程块里的编号,这里是 0 到 1023。它在每个线程块里都从 0 重新开始。- 启动配置(launch configuration):
<<<blocks, threads>>>里的两个数。编译器不检查它们,由 CUDA 运行时在核函数启动时检查。 - CUDA 运行时(CUDA runtime):程序让 GPU 干活时调用的库,比如
cudaDeviceSynchronize()。每一个启动配置也由它来检查。 - 无效启动(invalid launch):违反了某个上限的启动,比如
<<<1, 2048>>>。核函数根本不会运行,只有cudaGetLastError()会报出错误(invalid configuration argument)。 nvcc:CUDA 编译器。它把.cu文件里的 CPU 部分和 GPU 部分编译成一个程序。-o:设置输出程序的名字。不加时,名字是a.out。