🔥 本文专栏:CUDA
🌸作者主页:努力努力再努力wz



💪 今日博客励志语录:
你可以暂时没有答案,但最好一直保持向答案靠近。
思维导图
text
CUDA 程序
│
├── Host:CPU 侧代码
│
└── Device:GPU 侧代码
↓
Kernel
↓
CPU 通过 <<<grid, block>>> 启动 Kernel
↓
确定本次 Kernel Launch 的线程组织方式
↓
软件线程模型
Grid
↓
Block
↓
Thread
↓
Block 被调度到 SM
↓
Block 中的 Thread 按 32 个组成 Warp
↓
Warp Scheduler 选择 ready Warp
↓
发射指令到对应执行流水线
↓
每个 Thread 执行同一份 Kernel 代码
但处理不同的数据
↓
线程必须知道"我是谁、我在哪里"
↓
CUDA 内置变量
threadIdx / blockIdx / blockDim / gridDim
↓
一维全局索引
blockIdx.x * blockDim.x + threadIdx.x
↓
二维全局坐标
x = blockIdx.x * blockDim.x + threadIdx.x
y = blockIdx.y * blockDim.y + threadIdx.y
引入
在此前对于 CUDA 的学习中,我们已经逐渐建立起一个最基本的认识:
CUDA 并不是单纯地"把一段 C++ 代码放到 GPU 上运行",而是一个 CPU 与 GPU 协同工作的异构编程模型。
一个 CUDA 程序中,通常同时存在两部分代码:
text
CUDA Program
│
├── Host Code
│ ↓
│ CPU 执行
│
└── Device Code
↓
GPU 执行
CPU 仍然负责整个程序的主要控制流程,例如准备数据、申请 GPU 内存、启动 GPU 计算任务以及等待计算结果等。
而真正需要交给 GPU 进行大规模并行计算的部分,则会写成一个特殊的函数:
cpp
__global__ void kernel(...)
{
// GPU 执行的计算逻辑
}
这个函数就是 Kernel,也就是核函数。
但是,仅仅知道"核函数是在 GPU 上执行的函数"还远远不够。
因为 GPU 的核心能力并不是执行一个函数,而是:
让大量线程并行执行同一份核函数代码,并让不同线程分别处理不同的数据。
于是接下来就会自然出现几个问题:
text
一个 Kernel 到底由多少线程执行?
这些线程是怎么组织起来的?
为什么 CUDA 中会出现 Grid、Block 和 Thread?
为什么又会出现 x、y、z 三个维度?
threadIdx、blockIdx 到底是什么?
一个线程最终又该如何知道自己应该处理数组中的哪个元素?
本文就沿着这条逻辑链路逐步展开。
一、Kernel:GPU 并行计算任务的入口
1. CUDA 程序中的 Host 与 Device
先从最基本的执行关系开始。
对于普通 C++ 程序来说,我们编写的代码通常都由 CPU 执行。
而 CUDA 程序中则同时存在:
text
Host
↓
CPU
Device
↓
GPU
CPU 侧代码可以理解为整个程序的"控制者"。
例如:
text
CPU
│
├── 准备输入数据
├── 申请显存
├── 将数据准备到 GPU 可访问的位置
├── 启动 Kernel
├── 等待 GPU 完成
└── 获取结果
GPU 侧则负责真正的大规模并行计算。
而 GPU 执行的主要入口,就是 Kernel。
2. __global__:告诉编译器这是一个 Kernel
一个最简单的 CUDA Kernel 可以写成:
cpp
__global__ void add(int* a, int* b, int* c)
{
// GPU 计算逻辑
}
这里最显眼的就是:
cpp
__global__
它是 CUDA 提供的函数执行空间说明符,用于告诉编译器:
这个函数是一个 GPU Kernel,可以通过 Kernel Launch 的方式被启动。
因此:
cpp
__global__ void add(...)
和普通的:
cpp
void add(...)
并不是一回事。
前者是 GPU 并行任务的入口,后者只是一个普通 C++ 函数。
3. Kernel 的返回值必须是 void
Kernel 的另一个重要特点是:
cpp
__global__ void kernel(...)
其返回值必须是:
cpp
void
不能写成:
cpp
__global__ int kernel(...)
{
return 1;
}
为什么?
因为普通函数调用通常是:
text
调用函数
↓
函数执行
↓
返回一个结果
例如:
cpp
int add(int a, int b)
{
return a + b;
}
但是 Kernel 并不是只由一个执行流调用一次。
假设:
cpp
kernel<<<100, 256>>>();
那么这一份 Kernel 代码会被大量线程执行。
此时如果每个线程都:
cpp
return value;
那么到底哪个线程的返回值才是整个 Kernel 的返回值?
因此 CUDA Kernel 不采用普通函数的返回值模型。
GPU 的计算结果通常通过:
text
Thread 执行计算
↓
将结果写入某块内存
↓
CPU 后续读取结果
完成传递。
例如:
cpp
__global__ void add(const int* a, const int* b, int* c)
{
int i = blockIdx.x * blockDim.x + threadIdx.x;
c[i] = a[i] + b[i];
}
这里结果并不是通过:
cpp
return
返回,而是写入:
cpp
c[i]
因此可以先形成一个认识:
Kernel 更像是"启动一批 GPU 并行任务的入口",而不是普通意义上的"输入参数然后返回一个值"的函数。
4. Kernel 参数的一些限制
Kernel 与普通 C++ 函数并不完全相同。
例如,__global__ 函数不支持 C 风格的变长参数:
cpp
__global__ void kernel(int n, ...)
{
}
这里的:
cpp
...
以及 va_list 形式的 C 风格变长参数都不能这样用于 Kernel 参数。
需要注意的是,这里说的是:
text
C 风格可变参数
...
va_list
而不是简单地说"CUDA 完全不支持任何可变参数形式"。
C++ 的可变参数模板属于另一套机制,不能和 C 风格 ... 混为一谈。
5. Host 与 Device 的函数地址不能简单混用
另外一个容易产生误解的问题是函数指针。
不能简单记成:
CUDA Kernel 完全不能使用函数指针。
更准确的理解应该是:
text
Host Execution Space
和
Device Execution Space
是两个不同的执行空间。
CPU 侧函数地址不能直接当成 GPU 侧可执行函数地址来使用,反过来也不能简单混用。
因此目前学习阶段可以先记住:
Host 与 Device 的函数地址属于不同执行空间,不能把 CPU 一侧的函数指针直接拿到 GPU 中执行。
至于 Device 侧函数指针、Device Lambda 等更深入的机制,目前不需要展开。
6. Kernel 并不是"不能使用 static"
另外一个很容易被记错的说法是:
Kernel 中不能有静态变量。
这个说法并不准确。
CUDA Device Code 本身允许一定形式的 static 局部变量,因此不能简单建立:
text
GPU 代码
↓
禁止 static
这种模型。
后续学习 CUDA Memory Model 时,再去讨论:
text
变量到底位于哪里?
生命周期是什么?
不同线程之间是否共享?
会更加合适。
现阶段只需要知道:
static并不是 Kernel 的绝对禁区。
7. Kernel 的几个核心认识
到这里,可以先把 Kernel 压缩成:
text
Kernel
│
├── 使用 __global__ 声明
├── 返回类型必须是 void
├── 不支持 C 风格 ... / va_list 参数
├── Host / Device 函数地址不能简单跨执行空间使用
├── static 并不是绝对禁止
└── 作为 GPU 并行计算任务的入口
另外,当前 CUDA 编程模型中的普通 __global__ Kernel 也不支持递归调用自身。
但是对于刚开始建立 Kernel 心智模型来说,真正重要的仍然是:
text
Kernel
=
大量 GPU Thread 并行执行的同一份计算逻辑
二、CUDA 为什么需要 Grid、Block 和 Thread
1. 真正执行 Kernel 代码的是 Thread
假设存在:
cpp
__global__ void add(...)
{
// 一段计算逻辑
}
GPU 并不是只执行一次这段代码。
而是会存在很多线程:
text
Thread 0 ─┐
Thread 1 ├──→ add()
Thread 2 ┤
Thread 3 ┤
... │
Thread N ─┘
这些线程执行的:
text
Kernel 代码
是相同的。
真正不同的是:
text
每个线程负责处理的数据
例如对一个数组做逐元素加法:
text
Thread 0 → 处理第 0 个元素
Thread 1 → 处理第 1 个元素
Thread 2 → 处理第 2 个元素
...
因此:
Thread 才是 CUDA 软件编程模型中真正承担具体计算任务的基本执行实体。
2. Thread 太多以后,就需要分层组织
GPU 一次可能会启动成千上万甚至更多线程。
如果只有:
text
Thread 0
Thread 1
Thread 2
...
Thread 100000
这种完全扁平的模型,线程管理会非常不方便。
于是 CUDA 将线程进行分层组织:
text
Grid
↓
Block
↓
Thread
其中:
text
Thread
↓
真正执行 Kernel 逻辑
Block
↓
一组 Thread 的集合
Grid
↓
一组 Block 的集合
因此最基本的软件线程模型就是:
text
一个 Grid
│
├── Block 0
│ ├── Thread 0
│ ├── Thread 1
│ ├── ...
│
├── Block 1
│ ├── Thread 0
│ ├── Thread 1
│ ├── ...
│
└── ...
需要注意:
Thread 0并不是整个 Grid 中唯一的。
每一个 Block 内部都会重新拥有自己的:
text
Thread 0
Thread 1
Thread 2
...
也正是因为这样,后面才需要计算"全局线程索引"。
三、从软件线程模型连接到 GPU 硬件执行模型
1. GPU 由多个 SM 组成
在硬件层面上,可以先把 GPU 粗略理解为:
text
GPU
│
├── SM 0
├── SM 1
├── SM 2
├── SM 3
└── ...
SM 即 Streaming Multiprocessor。
可以把一个 SM 暂时类比成一个"生产车间"。
一个 SM 中包含完成 GPU 计算所需要的大量资源,例如:
text
SM
│
├── Warp Scheduler
├── Register File
├── Shared Memory / L1 Cache
├── FP32 / INT32 等执行流水线
├── Load / Store 单元
├── SFU
├── Tensor Core(部分计算)
└── 其他硬件资源
具体数量和结构会随着 GPU 架构变化,但是这个抽象模型是成立的。
2. Block 会被调度到 SM
软件层面存在:
text
Grid
↓
多个 Block
真正执行时,这些 Block 会被调度到 GPU 中的不同 SM。
例如:
text
Grid
│
├── Block 0 ──→ SM 0
├── Block 1 ──→ SM 1
├── Block 2 ──→ SM 2
├── Block 3 ──→ SM 0
└── ...
一个 Block 的所有线程会在同一个 SM 上执行。
但是:
一个 SM 并不是只能同时驻留一个 Block。
如果寄存器、Shared Memory、线程数等资源允许,一个 SM 可以同时驻留多个 Block。
例如:
text
SM 0
│
├── Block 0
├── Block 4
└── Block 8
这也是后续 Occupancy 会涉及的重要基础。
3. Block 中的线程会进一步组成 Warp
一个 Block 被调度到 SM 以后,其内部线程并不是每一个都完全独立地作为硬件调度粒度进行调度。
NVIDIA GPU 会将线程按照:
text
32 Threads
组成一个:
text
Warp
例如一个 Block 有:
text
256 Threads
那么:
text
256 / 32 = 8 Warps
可以理解成:
text
Block
│
├── Warp 0 → Thread 0 ~ 31
├── Warp 1 → Thread 32 ~ 63
├── Warp 2 → Thread 64 ~ 95
├── ...
└── Warp 7 → Thread 224 ~ 255
因此可以区分两个概念:
text
Thread
↓
软件编程视角下最基本的执行实体
Warp
↓
GPU 硬件进行线程调度和指令发射的重要粒度
4. Warp Scheduler 负责选择可以执行的 Warp
一个 SM 中通常会驻留多个 Warp。
Warp Scheduler 会从当前 ready 的 Warp 中选择可以继续执行的 Warp,然后发射其下一条指令。
可以粗略理解为:
text
多个 Resident Warp
↓
Warp Scheduler
↓
选择一个 Ready Warp
↓
发射下一条指令
而不同类型的指令会进入对应的执行流水线。
例如:
text
Warp Instruction
│
├── FP32 运算
│ ↓
│ FP32 Pipeline
│
├── INT32 运算
│ ↓
│ INT32 Pipeline
│
├── Load / Store
│ ↓
│ Load/Store Unit + Memory System
│
├── 矩阵相关计算
│ ↓
│ Tensor Core
│
└── 特殊数学函数
↓
SFU
因此不能简单把所有计算都理解成:
text
全部交给 CUDA Core
更准确的理解是:
Warp Scheduler 负责"发射任务",不同类型的指令再进入对应的硬件执行流水线。
这里我们只需要建立宏观模型,不需要继续深入 GPU 微架构。
四、Kernel 到底如何决定启动多少线程
1. Kernel Launch 的 <<< >>>
Kernel 不是像普通函数一样直接调用:
cpp
kernel(...);
最常见的 CUDA Kernel Launch 写法是:
cpp
kernel<<<grid, block>>>(...);
例如:
cpp
kernel<<<10, 256>>>();
这里的:
text
<<<10, 256>>>
就是本次 Kernel Launch 的执行配置。
它决定:
text
启动多少个 Block
以及
每个 Block 有多少个 Thread
2. 第一个参数不是"Grid 的数量"
这里是一个很容易产生歧义的地方。
有时会看到:
text
第一个参数是 Grid 数
第二个参数是 Block 数
这种说法很容易让人误解。
例如:
cpp
kernel<<<10, 256>>>();
绝对不是:
text
10 个 Grid
每个 Grid 256 个 Block
而是:
text
启动 1 个 Grid
这个 Grid 中有 10 个 Block
每个 Block 中有 256 个 Thread
也就是:
text
Grid
│
├── Block 0 → 256 Threads
├── Block 1 → 256 Threads
├── Block 2 → 256 Threads
├── ...
└── Block 9 → 256 Threads
因此总线程数:
text
10 × 256 = 2560 Threads
对于最简单的一维情况,可以暂时把:
cpp
kernel<<<grid, block>>>();
理解成:
text
<<<Block 数量, 每个 Block 的 Thread 数量>>>
但是这种说法只适合帮助理解一维场景。
更加准确的定义应该是:
text
第一个参数 grid
↓
Grid 的维度和尺寸
第二个参数 block
↓
Block 的维度和尺寸
为什么要这样说?
因为 Grid 和 Block 都不一定只有一维。
五、为什么会出现 x、y、z 三个维度
1. Grid → Block → Thread 的层级没有变化
当第一次看到:
cpp
threadIdx.x
threadIdx.y
threadIdx.z
很多人会产生一个疑惑:
text
不是只有
Grid → Block → Thread
吗?
为什么突然又出现
x / y / z?
这里最重要的一点是:
x、y、z 并没有引入新的线程层级。
CUDA 的组织模型仍然是:
text
Grid
↓
Block
↓
Thread
没有发生任何变化。
x、y、z 只是:
用来描述 Grid 中 Block,以及 Block 中 Thread 的逻辑坐标。
2. 一维线程组织其实就是最简单的坐标
假设:
cpp
kernel<<<1, 16>>>();
一个 Block 内部有 16 个线程。
最简单的理解就是:
text
Thread 0
Thread 1
Thread 2
...
Thread 15
本质上可以看成:
text
x →
0 1 2 3 4 5 ... 15
因此:
cpp
threadIdx.x
就是当前线程在 x 方向上的坐标。
对于一维情况:
text
threadIdx.x
≈
当前 Thread 在 Block 内的编号
3. 为什么还需要二维线程组织
假设现在处理的是一个 4 × 4 矩阵:
text
x →
0 1 2 3
y 0 A B C D
↓ 1 E F G H
2 I J K L
3 M N O P
我们当然可以仍然创建 16 个一维线程:
text
Thread 0 → A
Thread 1 → B
Thread 2 → C
...
Thread 15 → P
然后再通过:
cpp
row = index / 4;
col = index % 4;
将一维编号转换成二维坐标。
这种方式完全可以。
但是原始数据本身就是二维结构,因此 CUDA 允许我们直接把线程逻辑上组织成:
text
Block
x →
0 1 2 3
y 0 T T T T
↓ 1 T T T T
2 T T T T
3 T T T T
此时线程不再只是用:
text
0 ~ 15
描述位置,而可以直接使用二维坐标:
text
(0,0) (1,0) (2,0) (3,0)
(0,1) (1,1) (2,1) (3,1)
(0,2) (1,2) (2,2) (3,2)
(0,3) (1,3) (2,3) (3,3)
于是:
cpp
threadIdx.x
threadIdx.y
就可以直接对应二维数据中的:
text
列坐标
行坐标
需要注意:
线程本身并没有发生变化,只是线程的逻辑编号方式从一维整数扩展成了二维坐标。
4. 三维也是同一个逻辑
如果数据本身是三维结构,例如:
text
层
↓
行
↓
列
那么还可以继续增加:
cpp
threadIdx.z
此时一个线程的位置就可以表示为:
text
(x, y, z)
所以:
text
一维
↓
第几个
二维
↓
第几行、第几列
三维
↓
第几层、第几行、第几列
本质上都是:
给 Thread 一个更加符合数据结构的逻辑坐标。
六、dim3 到底是什么
1. dim3 不是 CUDA 关键字
理解完前面的 x、y、z 三个维度以后,dim3 就很好理解了。
首先需要纠正一个很容易产生的误区:
dim3并不是 CUDA 的关键字,它本质上是 CUDA 提供的一个类型,用来方便我们描述一个对象在x、y、z三个维度上的尺寸。
如果类比我们熟悉的 C++,可以暂时把它想象成 CUDA 提前帮我们定义好的一个"结构体类型"。
例如概念上可以理解成:
cpp
struct dim3
{
unsigned int x;
unsigned int y;
unsigned int z;
};
当然,真实的 CUDA 定义和这里并不完全相同,我们现在也没有必要关心底层具体实现。
这里真正重要的是理解:
text
一个 dim3 对象
↓
里面保存了三个维度的信息
↓
x
y
z
所以当我们写:
cpp
dim3 block(16, 16);
其实可以类比成创建了一个结构体对象:
cpp
block
│
├── x = 16
├── y = 16
└── z = 1
也就是说:
text
block.x = 16
block.y = 16
block.z = 1
这里虽然只传入了两个参数:
cpp
(16, 16)
但是没有显式指定的 z 会使用默认值:
text
z = 1
因此:
cpp
dim3 block(256);
可以理解成:
text
block
│
├── x = 256
├── y = 1
└── z = 1
也就是:
text
(256, 1, 1)
而:
cpp
dim3 block(16, 16);
则可以理解成:
text
block
│
├── x = 16
├── y = 16
└── z = 1
也就是:
text
(16, 16, 1)
因此这里不要把 dim3 想得太神秘。
对于目前的学习阶段,可以直接把它理解成:
CUDA 提供了一个类似结构体的三维尺寸类型,里面保存
x、y、z三个值,我们可以用它来描述 Grid 或 Block 在三个维度上的大小。
比如:
cpp
dim3 grid(8, 8);
dim3 block(16, 16);
实际上就是分别准备了两个"尺寸对象":
text
grid
→ (8, 8, 1)
block
→ (16, 16, 1)
至于这两个尺寸什么时候真正作用到 Kernel 上,则是在后面的:
cpp
kernel<<<grid, block>>>();
这里才真正将它们作为本次 Kernel Launch 的线程配置使用。
2. 同样是 256 个线程,可以有不同逻辑形状
例如:
cpp
dim3 block(256);
表示:
text
256 × 1 × 1
=
256 Threads
也可以写:
cpp
dim3 block(16, 16);
表示:
text
16 × 16 × 1
=
256 Threads
两者线程数量完全相同。
区别只是:
text
block(256)
↓
一维组织
block(16, 16)
↓
二维组织
也就是说:
dim3并不是在创建一种新的线程,而是在描述这些 Thread 或 Block 在 x、y、z 三个维度上的逻辑尺寸。
3. dim3 本身不会"修改内置变量"
例如:
cpp
dim3 grid(8, 8);
dim3 block(16, 16);
这里只是创建了两个用于描述尺寸的对象。
真正让它们成为 Kernel 线程配置的是:
cpp
kernel<<<grid, block>>>();
此时:
text
grid
↓
本次 Kernel Launch 的 Grid 尺寸
block
↓
本次 Kernel Launch 中每个 Block 的尺寸
因此关系应该理解成:
text
dim3 grid(...)
dim3 block(...)
↓
kernel<<<grid, block>>>()
↓
确定本次 Kernel Launch 的线程组织
↓
CUDA 在 Kernel 中提供对应的
gridDim / blockDim / blockIdx / threadIdx
所以不是:
text
dim3
↓
直接修改内置变量
而是:
text
dim3 描述尺寸
↓
Kernel Launch 使用这些尺寸
↓
内置变量反映本次 Launch 的线程组织信息
4. 配置只对本次 Kernel Launch 生效
同一个 Kernel 可以:
cpp
kernel<<<10, 256>>>();
下一次又:
cpp
dim3 grid(8, 8);
dim3 block(16, 16);
kernel<<<grid, block>>>();
Kernel 代码完全相同。
但是两次启动的:
text
Grid / Block / Thread 组织方式
可以完全不同。
因此:
执行配置描述的是某一次 Kernel Launch,而不是永久绑定在 Kernel 函数上的属性。
七、Grid 和 Block 的维度可以不同
1. Grid 的维度与 Block 的维度相互独立
既然 Grid 和 Block 都可以是一维、二维甚至三维,那么这里还有一个重要认识:
Grid 和 Block 不要求使用相同的维度。
例如:
cpp
dim3 grid(100);
dim3 block(16, 16);
这是:
text
Grid
↓
一维
100 个 Block
Block
↓
二维
16 × 16 Threads
完全合法。
反过来:
cpp
dim3 grid(64, 64);
dim3 block(256);
则表示:
text
Grid
↓
二维
64 × 64 Blocks
Block
↓
一维
256 Threads
同样合法。
因此:
text
Grid 的维度
↓
描述 Block 如何排列
Block 的维度
↓
描述 Thread 如何排列
两者描述的是不同层级。
八、为什么 Grid 也需要二维
1. 一维 Grid 当然可以处理二维数据
当我们已经能够理解二维 Block 以后,很容易产生另一个疑问:
Thread 已经可以按照二维组织了,Grid 为什么还需要二维?全部 Block 一维排列不也可以吗?
答案是:
可以。
二维 Grid 并不是为了让 GPU 获得"一维 Grid 做不到的新能力"。
它和二维 Block 的作用一样,主要是为了:
让逻辑坐标更加自然地对应原始数据。
2. 一个 Block 无法无限大
假设现在需要处理:
text
1024 × 1024
的矩阵。
如果想让一个 Block 直接覆盖整个矩阵,那么就需要:
text
1024 × 1024
=
1048576 Threads
但是当前 CUDA GPU 上,一个 Thread Block 最多只能包含:
text
1024 Threads
因此一个 Block 无法覆盖这么大的矩阵。
最自然的方式就是:
将大矩阵切成很多小块,每一个 Block 负责其中一块。
例如:
cpp
dim3 block(16, 16);
每个 Block:
text
16 × 16
=
256 Threads
负责矩阵中的一个:
text
16 × 16 Tile
那么 1024 × 1024 的矩阵就可以切成:
text
64 × 64 个 Tile
于是 Grid 也自然可以组织成:
cpp
dim3 grid(64, 64);
3. Grid 的二维坐标描述"大矩阵中的哪一块"
这时:
text
整个矩阵
Block(0,0) Block(1,0) Block(2,0) ...
Block(0,1) Block(1,1) Block(2,1) ...
Block(0,2) Block(1,2) Block(2,2) ...
...
每一个 Block 内部又有:
text
Thread(0,0) Thread(1,0) ...
Thread(0,1) Thread(1,1) ...
...
于是:
text
blockIdx.x / blockIdx.y
↓
当前是整个大矩阵中的哪一个 Tile
threadIdx.x / threadIdx.y
↓
当前是这个 Tile 中的哪个元素
因此二维 Grid 与二维 Block 形成了非常自然的对应关系:
text
大数据
↓
切成很多 Tile
↓
Grid 中的一个 Block 对应一个 Tile
↓
Block 中的一个 Thread 对应 Tile 中的一个元素
4. 一维 Grid 也可以,只是需要自己转换
如果一定要把:
text
64 × 64 = 4096 个 Block
全部按照一维排列:
text
Block 0
Block 1
Block 2
...
Block 4095
当然也可以。
此时只需要自己计算:
cpp
int bx = blockIdx.x % 64;
int by = blockIdx.x / 64;
就可以重新得到二维 Tile 坐标。
因此:
二维 Grid 不是能力上的必须,而是一种更加符合二维数据结构的逻辑表达方式。
这和二维 Block 完全是同一个思路。
九、CUDA 内置变量到底是什么
1. 为什么 Thread 必须知道"自己是谁"
我们已经知道:
text
很多 Thread
↓
执行同一份 Kernel 代码
例如:
cpp
__global__ void add(...)
{
// 所有 Thread 执行的都是这里
}
但是每个线程处理的数据必须不同。
例如:
text
Thread 0 → 数组第 0 个元素
Thread 1 → 数组第 1 个元素
Thread 2 → 数组第 2 个元素
...
那么问题就来了:
所有线程执行的代码都一样,每一个线程怎么知道自己到底是哪个线程?
于是 CUDA 直接给正在执行 Kernel 的线程提供了一组描述线程组织结构的信息。
这些就是:
text
CUDA 内置变量
2. 四个最常见的内置变量
现阶段最重要的四个就是:
cpp
threadIdx
blockIdx
blockDim
gridDim
可以先将它们分成两组。
位置 / 身份信息
cpp
threadIdx
blockIdx
其中:
text
threadIdx
↓
当前 Thread 在所属 Block 中的位置
blockIdx
↓
当前 Block 在所属 Grid 中的位置
尺寸 / 规模信息
cpp
blockDim
gridDim
其中:
text
blockDim
↓
当前 Block 的尺寸
gridDim
↓
当前 Grid 的尺寸
因此可以总结成:
text
threadIdx
→ 我在 Block 中哪里?
blockIdx
→ 我的 Block 在 Grid 中哪里?
blockDim
→ 我的 Block 有多大?
gridDim
→ 整个 Grid 有多大?
3. 内置变量是 CUDA 自动提供的
我们并没有自己声明:
cpp
int threadIdx;
int blockIdx;
但是可以直接在 Kernel 中访问:
cpp
__global__ void kernel()
{
int tx = threadIdx.x;
int bx = blockIdx.x;
}
可以先把这理解成:
CUDA 编程模型直接提供给每个执行线程的一组"线程组织信息"。
至于 GPU 硬件、编译器以及运行时底层如何让这些值最终可用,目前完全不需要深入。
我们只需要知道:
text
Kernel 正在执行
↓
Thread 可以直接通过内置变量
知道自己在 Grid → Block → Thread
这套结构中的位置
十、.x、.y、.z 到底在访问什么
内置变量:
cpp
threadIdx
blockIdx
blockDim
gridDim
本身都带有:
text
x
y
z
三个分量。
例如:
cpp
threadIdx.x
threadIdx.y
threadIdx.z
这并不是说 CUDA 存在三种线程。
而是在读取:
text
当前 Thread 在 Block 中
x / y / z 三个方向上的逻辑坐标
同理:
cpp
blockIdx.x
blockIdx.y
blockIdx.z
描述当前 Block 在 Grid 中的坐标。
而:
cpp
blockDim.x
blockDim.y
blockDim.z
描述一个 Block 在三个方向上的尺寸。
例如:
cpp
dim3 block(16, 16);
那么:
text
blockDim.x = 16
blockDim.y = 16
blockDim.z = 1
而:
cpp
dim3 grid(64, 64);
则:
text
gridDim.x = 64
gridDim.y = 64
gridDim.z = 1
因此:
text
dim3
↓
描述 Launch 时的尺寸
xxxDim
↓
在 Kernel 内部读取组织尺寸
xxxIdx
↓
在 Kernel 内部读取当前位置
整个逻辑就串起来了。
十一、一维线程的全局索引如何计算
1. 为什么不能只使用 threadIdx.x
假设:
cpp
kernel<<<3, 4>>>();
此时存在:
text
Block 0
├── Thread 0
├── Thread 1
├── Thread 2
└── Thread 3
Block 1
├── Thread 0
├── Thread 1
├── Thread 2
└── Thread 3
Block 2
├── Thread 0
├── Thread 1
├── Thread 2
└── Thread 3
如果只看:
cpp
threadIdx.x
那么三个 Block 中都会出现:
text
Thread 0
因此:
cpp
threadIdx.x
只能表示:
当前 Thread 在自己所属 Block 内的局部编号。
它不是整个 Grid 中唯一的全局编号。
2. 全局索引公式
一维情况下最常见的计算方式是:
cpp
int i = blockIdx.x * blockDim.x + threadIdx.x;
bash
全局线程位置:
Block 0 Block 1 Block 2
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ T0 T1 T2 T3│ │ T0 T1 T2 T3│ │ T0 T1 T2 T3│
└───────────────┘ └───────────────┘ └───────────────┘
全局编号:
0 1 2 3 4 5 6 7 8 9 10 11
────────────────────────────────────────────────────────→
这个公式不要死记。
按照逻辑拆开就很好理解。
假设:
text
blockIdx.x = 2
blockDim.x = 256
threadIdx.x = 10
首先:
cpp
blockIdx.x * blockDim.x
也就是:
text
2 × 256
=
512
它表达的其实是:
当前 Block 前面已经存在多少个线程位置。
因为:
text
Block 0
↓
占用全局编号 0 ~ 255
Block 1
↓
占用全局编号 256 ~ 511
Block 2
↓
从 512 开始
然后当前线程在 Block 2 内部又是:
text
Thread 10
因此:
text
512 + 10 = 522
最终:
text
Global Thread Index = 522
所以整个公式的心智模型就是:
text
前面所有 Block 已经占掉的线程位置
+
当前 Thread 在本 Block 中的局部位置
=
当前 Thread 的全局位置
也就是:
cpp
globalIndex =
blockIdx.x * blockDim.x
+ threadIdx.x;
十二、二维线程的全局坐标如何计算
1. 二维只是把一个全局编号拆成 x 和 y
当 Grid 和 Block 都按照二维组织以后,思路并没有变化。
只是以前只计算:
text
global_x
现在分别计算:
text
global_x
global_y
x 方向:
cpp
int x = blockIdx.x * blockDim.x + threadIdx.x;
y 方向:
cpp
int y = blockIdx.y * blockDim.y + threadIdx.y;
两者完全对称。
bash
整个二维数据空间(由 Grid 和 Block 共同覆盖)
Block(0,0) Block(1,0) Block(2,0)
+-----------+ +-----------+ +-----------+
| (0,0) ... | | | | |
| ... | | | | |
| ... (3,2) | | | | |
+-----------+ +-----------+ +-----------+
Block(0,1) Block(1,1) Block(2,1)
+-----------+ +-----------+ +-----------+
| | | | | |
| | | | | |
| | | | | |
+-----------+ +-----------+ +-----------+
2. x 坐标如何理解
例如:
text
blockIdx.x = 3
blockDim.x = 16
threadIdx.x = 5
那么:
text
x
=
3 × 16 + 5
=
53
表示:
text
当前线程位于整个数据空间的第 53 列
3. y 坐标同理
假设:
text
blockIdx.y = 2
blockDim.y = 16
threadIdx.y = 7
那么:
text
y
=
2 × 16 + 7
=
39
于是当前线程的全局二维坐标就是:
text
(x, y)
=
(53, 39)
因此可以把二维索引的本质理解成:
text
Block 在整个数据中的块坐标
+
Thread 在 Block 中的局部坐标
=
Thread 在整个数据中的全局坐标
4. 如果底层还是连续一维内存怎么办
虽然我们逻辑上处理的是:
text
二维矩阵
但是 GPU 内存最终仍然经常是一段连续地址。
因此得到:
cpp
int x = ...;
int y = ...;
以后,如果矩阵每一行有 width 个元素,则还可以进一步转换成一维线性索引:
cpp
int index = y * width + x;
也就是:
text
二维线程坐标
(x, y)
↓
y * width + x
↓
连续内存中的线性下标
这个转换和普通 C/C++ 二维数组按行连续存储的思路完全一致。
十三、三维索引其实没有新的逻辑
如果进一步扩展到三维:
cpp
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
int z = blockIdx.z * blockDim.z + threadIdx.z;
本质仍然是:
text
每一个维度
↓
Block 坐标 × Block 尺寸
+
Thread 局部坐标
↓
当前维度上的全局坐标
所以从一维扩展到二维、三维以后:
公式没有发生本质变化,只是针对 x、y、z 分别做一次相同的坐标换算。
十四、Thread Block 与 Grid 的尺寸限制
1. 一个 Block 最多 1024 个线程
当前 CUDA GPU 上,一个 Thread Block 最多包含:
text
1024 Threads
因此:
cpp
dim3 block(1024);
在总线程数这一层面可以达到上限。
但是:
cpp
dim3 block(2048);
不可以,因为:
text
2048 Threads / Block
>
1024 Threads / Block
2. 二维、三维 Block 同样受总线程数限制
例如:
cpp
dim3 block(32, 32);
总线程数:
text
32 × 32
=
1024
可以。
而:
cpp
dim3 block(64, 32);
总线程数:
text
64 × 32
=
2048
超过 1024,因此不允许。
因此需要满足:
text
blockDim.x
×
blockDim.y
×
blockDim.z
<=
1024
当前 CUDA Compute Capability 表中,Thread Block 的单维上限通常为:
text
x <= 1024
y <= 1024
z <= 64
但是无论单维上限是多少,最终总线程数仍然不能超过 1024。
3. Grid 每个维度也有上限
对于当前 CUDA Compute Capability 表中的常见限制:
text
gridDim.x <= 2^31 - 1
gridDim.y <= 65535
gridDim.z <= 65535
所以不能简单说:
text
Grid 最多只有 2^31 - 1 个 Block
更准确的说法是:
Grid 的 x 维最大尺寸是
2^31 - 1,而 y、z 维分别有自己的尺寸上限。
十五、把整个线程模型完整串起来
到这里,就可以把本文所有知识重新串成一条完整链路。
首先,一个 CUDA 程序由:
text
CPU Host Code
+
GPU Device Code
共同组成。
GPU 上需要大量并行执行的逻辑写成:
cpp
__global__ void kernel(...)
{
}
然后 CPU 使用:
cpp
kernel<<<grid, block>>>(...);
启动 Kernel。
这里:
text
grid
↓
描述 Grid 的尺寸
↓
决定本次启动多少个 Block,以及这些 Block 如何按 x/y/z 排列
block
↓
描述每个 Block 的尺寸
↓
决定每个 Block 中有多少 Thread,以及这些 Thread 如何按 x/y/z 排列
因此软件层面得到:
text
Grid
↓
Block
↓
Thread
其中真正执行 Kernel 代码的是 Thread。
而到了硬件层:
text
Block
↓
调度到 SM
Thread
↓
每 32 个组成 Warp
Warp
↓
Warp Scheduler 进行调度和指令发射
Instruction
↓
进入对应执行流水线
同时,因为所有 Thread 都执行相同的 Kernel 代码,所以每个 Thread 必须知道:
text
我是谁?
我在哪?
于是 CUDA 提供:
cpp
threadIdx
blockIdx
blockDim
gridDim
其中:
text
threadIdx
↓
Thread 在 Block 中的位置
blockIdx
↓
Block 在 Grid 中的位置
blockDim
↓
Block 的尺寸
gridDim
↓
Grid 的尺寸
最后,一维情况下:
cpp
int i = blockIdx.x * blockDim.x + threadIdx.x;
得到全局线性线程索引。
二维情况下:
cpp
int x = blockIdx.x * blockDim.x + threadIdx.x;
int y = blockIdx.y * blockDim.y + threadIdx.y;
得到线程对应的全局二维坐标。
整个链条最终可以压缩成:
text
Kernel
↓
<<<grid, block>>>
↓
配置线程组织方式
↓
Grid → Block → Thread
↓
Thread 执行同一份代码
↓
threadIdx / blockIdx
确定当前 Thread 的位置
↓
blockDim / gridDim
描述整个线程组织的尺寸
↓
计算 Global Index
↓
每个 Thread 找到自己应该处理的数据
而这也正是 CUDA 最核心的并行编程思路之一:
代码只写一份,通过大量线程的不同索引,让每个线程处理不同的数据。
十六、一个完整的小例子
最后通过最经典的向量加法,把前面的知识全部连起来。
假设:
cpp
__global__ void vectorAdd(
const float* a,
const float* b,
float* c,
int n)
{
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n)
{
c[i] = a[i] + b[i];
}
}
CPU 侧:
cpp
int threadsPerBlock = 256;
int blocksPerGrid = (n + threadsPerBlock - 1) / threadsPerBlock;
vectorAdd<<<blocksPerGrid, threadsPerBlock>>>(
a,
b,
c,
n
);
这里:
text
threadsPerBlock = 256
↓
每个 Block 有 256 Threads
而:
cpp
(n + threadsPerBlock - 1) / threadsPerBlock
负责向上取整,保证线程总数能够覆盖所有数据。
例如:
text
n = 1000
threadsPerBlock = 256
那么:
text
blocksPerGrid
=
(1000 + 255) / 256
=
4
于是实际启动:
text
4 Blocks
×
256 Threads
=
1024 Threads
虽然数据只有:
text
1000 个元素
但是多出来的 24 个线程会被:
cpp
if (i < n)
挡住。
所以完整执行逻辑就是:
text
CPU 启动 vectorAdd
↓
4 Blocks × 256 Threads
↓
每个 Thread 执行同一份 vectorAdd()
↓
通过
blockIdx.x * blockDim.x + threadIdx.x
↓
得到自己的全局索引 i
↓
判断 i < n
↓
处理 a[i] + b[i]
到这里:
text
Kernel
Grid
Block
Thread
dim3
threadIdx
blockIdx
blockDim
gridDim
Global Index
这些此前看起来彼此独立的概念,就真正连成了一套完整的 CUDA 线程执行模型。
总结
CUDA 的线程模型一开始看起来名词很多,但真正按照逻辑关系拆开以后,核心其实非常清晰。
首先,GPU 上执行的并行计算逻辑写在 Kernel 中:
cpp
__global__ void kernel(...)
CPU 再通过:
cpp
kernel<<<grid, block>>>();
告诉 CUDA:
text
这一次 Kernel
需要怎样组织 Block 和 Thread
线程的软件组织始终只有:
text
Grid
↓
Block
↓
Thread
x、y、z 并不是新的层级,而只是:
text
Grid 中 Block 的逻辑坐标
以及
Block 中 Thread 的逻辑坐标
dim3 则用于描述这种三维尺寸。
Kernel 执行过程中,CUDA 又自动提供:
cpp
threadIdx
blockIdx
blockDim
gridDim
让每一个 Thread 知道自己在整个线程组织中的位置。
最终通过:
cpp
blockIdx.x * blockDim.x + threadIdx.x
或者二维情况下:
cpp
x = blockIdx.x * blockDim.x + threadIdx.x;
y = blockIdx.y * blockDim.y + threadIdx.y;
每一个线程就能够找到自己应该处理的数据。
因此,如果要用一句话概括这部分 CUDA 编程模型,可以理解为:
CUDA 先通过 Grid 和 Block 组织大量 Thread,再通过线程坐标和全局索引,让这些执行相同 Kernel 代码的 Thread 分别处理不同的数据。
