【CUDA 入门系列】核函数与线程模型:一文理清 Grid、Block、dim3、内置变量与全局索引

🔥 本文专栏: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 分别处理不同的数据。


相关推荐
海宇数据44 分钟前
零信任架构实战:基于海宇活体识别V步骤1构建自动化考前活体认证网关
运维·人工智能·架构·自动化
bullkingluo1 小时前
从零到一搭建企业级智能问答系统:Ch12 · 结构化输出与工具调用
人工智能·架构·llm
bullkingluo1 小时前
从零到一搭建企业级智能问答系统:Ch11 ·大模型调参与效果评测
人工智能·架构·llm
ShineWinsu1 小时前
对于Redis:ZSet类型的解析
数据库·c++·redis·缓存·面试·有序集合·zset
All for pursuit.1 小时前
【回溯-5】78.子集
数据结构·c++·算法·leetcode
辛苦才能1 小时前
C++ 红黑树(上):五条规则背后的近似平衡与插入的三类情况
开发语言·c++
by209991 小时前
学会使用 std::string 类,并理解其内部是如何管理字符串的详细阐述(下)
c++·经验分享·笔记·字符串·类和对象·string
.道阻且长.1 小时前
C++11 :新的类功能,lambda,包装器
开发语言·c++·后端
liulilittle1 小时前
短期内不存在通用 AI 工作流魔法
大数据·开发语言·c++·人工智能·llm·agent·tools