1. CUDA 执行模型解决什么问题?
CUDA 编程模型告诉我们:
如何使用 Thread、Block 和 Grid 描述一个并行任务。
CUDA 执行模型进一步回答:
GPU 如何把这些线程组织起来,并实际执行它们?
一次 Kernel 启动后,GPU 大致会经历以下过程:
Grid ↓ Block 被分配到 SM ↓ Block 中的 Thread 被划分为 Warp ↓ Warp 以 SIMT 方式执行 ↓ 线程访问寄存器、共享内存或全局内存2. Thread、Block 和 Grid 的实际含义
2.1 Thread:逻辑执行单位
Thread 是 CUDA 中最小的逻辑执行单位。
例如,计算两个数组相加:
C[i] = A[i] + B[i]可以让每个线程负责一个元素:
Thread 0:计算 C[0] Thread 1:计算 C[1] Thread 2:计算 C[2]每个线程通常拥有自己的:
- 线程索引
- 寄存器
- 局部变量
- 执行状态
不过,Thread 是程序员看到的逻辑概念,并不等于 GPU 中一个独立的物理核心。
2.2 Block:线程协作和调度单位
Block 是一组线程的集合。
例如:
Block 0 ├─ Thread 0 ├─ Thread 1 ├─ Thread 2 └─ ...Block 有两个重要作用:
- 作为 GPU 调度的基本单位
- 允许其中的线程进行协作和同步
同一个 Block 中的线程可以通过共享内存交换数据,并使用线程同步机制协调执行。
需要注意:
Block 是调度单位,但不是一个固定的硬件核心。
一个 Block 可能包含多个 Warp,并由一个 SM 负责执行。
2.3 Grid:一次 Kernel 启动的全部线程块
一次 Kernel 启动产生的所有 Block 组成一个 Grid:
Grid ├─ Block 0 ├─ Block 1 ├─ Block 2 └─ Block 3Grid 只表示一次 Kernel 启动包含的全部计算任务,不表示这些 Block 会同时执行。
实际运行时:
- GPU 可能同时执行部分 Block
- 其他 Block 等待资源空闲后再执行
- Block 的执行顺序通常不应由程序假设
3. SM 与线程块调度
3.1 SM 是什么?
SM 是Streaming Multiprocessor的缩写,可以理解为 GPU 中负责执行线程块的处理单元。
一个 SM 通常包含:
- 用于执行指令的计算资源
- 寄存器
- 共享内存
- Warp 调度相关资源
GPU 中通常有多个 SM:
GPU ├─ SM 0 ├─ SM 1 ├─ SM 2 └─ ...3.2 Block 如何分配到 SM?
当 Kernel 启动后,GPU 会把 Grid 中的 Block 分配给可用的 SM:
Grid ├─ Block 0 ──> SM 0 ├─ Block 1 ──> SM 1 ├─ Block 2 ──> SM 0 └─ Block 3 ──> 等待资源一个 SM 可以同时驻留多个 Block,但能驻留多少 Block,取决于资源限制,例如:
- SM 中可用的寄存器数量
- 共享内存使用量
- 线程数量限制
- Block 数量限制
因此,Block 不一定会立即执行,也不一定全部同时执行。
3.3 Block 为什么应该相互独立?
CUDA 通常按照“Block 之间相互独立”的方式设计程序。
原因是:
- 不同 Block 可能被分配到不同 SM
- 不同 Block 的执行速度可能不同
- 不同 Block 的执行顺序通常不固定
- 某些 Block 可能要等资源释放后才能启动
因此,下面这种假设是不安全的:
Block 0 一定先于 Block 1 执行 Block 0 执行完后 Block 1 才开始 所有 Block 一定同时执行更可靠的设计方式是:
让每个 Block 尽量独立完成自己的工作。
如果不同阶段之间存在严格依赖,通常可以拆分为多个 Kernel:
Kernel 1 完成 ↓ Kernel 2 开始4. Warp 与 SIMT 执行
4.1 Warp 是什么?
Warp 是 GPU 实际调度线程的重要执行单位。
在 NVIDIA CUDA 中:
一个 Warp 通常包含 32 个线程。
例如,一个 Block 有 128 个线程,通常会被划分为:
Warp 0:Thread 0 ~ Thread 31 Warp 1:Thread 32 ~ Thread 63 Warp 2:Thread 64 ~ Thread 95 Warp 3:Thread 96 ~ Thread 127Block 是调度单位,而 Warp 是 SM 中实际进行指令调度的重要单位。
4.2 什么是 SIMT?
SIMT 是Single Instruction, Multiple Threads的缩写,意思是:
单条指令同时作用于多个线程。
例如,下面的 Kernel:
__global__voidadd(int*data){inti=/* 当前线程索引 */;data[i]=data[i]+1;}当一个 Warp 中的线程执行这段代码时,通常会执行相同的加法指令,但每个线程使用自己的i,因此操作不同的数据。
可以理解为:
同一条指令: data[i] = data[i] + 1 Thread 0 使用 i = 0 Thread 1 使用 i = 1 Thread 2 使用 i = 2 ...这就是“相同指令,不同线程,不同数据”。
4.3 Warp 中的线程不一定完全独立
虽然程序员把每个 Thread 看成独立执行,但硬件通常以 Warp 为单位调度。
如果一个 Warp 中的线程走不同分支:
if(condition){// 路径 A}else{// 路径 B}而同一个 Warp 中:
部分线程满足 condition 部分线程不满足 condition就会出现分支分化。
4.4 什么是分支分化?
分支分化是指同一个 Warp 中的线程需要执行不同的分支路径。
GPU 通常需要分阶段执行这些路径:
先执行路径 A - 满足条件的线程有效 - 其他线程暂时等待 再执行路径 B - 不满足条件的线程有效 - 其他线程暂时等待因此,虽然线程逻辑上走了不同路径,但硬件可能需要依次执行这些路径。
示例:
if(threadIdx.x%2==0){doA();}else{doB();}同一个 Warp 中通常既有偶数线程,也有奇数线程,因此可能发生分支分化。
需要注意:
分支分化不一定导致程序错误,但可能降低执行效率。
5. 一个完整的执行示例
假设启动一个 Kernel:
kernel<<<2,64>>>();它表示:
- Grid 中有 2 个 Block
- 每个 Block 有 64 个 Thread
- 总线程数为 128
执行过程可以理解为:
Grid ├─ Block 0:64 个线程 │ ├─ Warp 0:32 个线程 │ └─ Warp 1:32 个线程 │ └─ Block 1:64 个线程 ├─ Warp 2:32 个线程 └─ Warp 3:32 个线程随后:
- GPU 将 Block 分配给可用的 SM
- 每个 Block 中的线程被划分成 Warp
- SM 调度 Warp 执行指令
- Warp 中的线程以 SIMT 方式执行
- 每个线程根据自己的索引访问对应数据
需要注意:
- Block 0 不一定先于 Block 1 执行
- Warp 0 不一定一直连续执行到结束
- GPU 可能在多个 Warp 之间切换
- 程序不应依赖这些未保证的顺序
6. 线程执行与内存访问
线程执行不仅包括计算指令,也包括访问数据。
CUDA 中常见的内存层次如下:
| 内存类型 | 主要使用者 | 特点 |
|---|---|---|
| 寄存器 | 单个线程 | 速度快,每个线程独有 |
| 共享内存 | 同一个 Block 的线程 | 速度较快,可用于线程协作 |
| 全局内存 | Grid 中的所有线程 | 容量较大,但访问延迟较高 |
| 常量内存等只读空间 | 多个线程 | 适合读取不会改变的数据 |
6.1 寄存器
每个线程通常有自己的寄存器,用于保存:
- 临时变量
- 计算中间结果
- 线程索引
一个线程不能直接使用另一个线程的寄存器。
6.2 共享内存
同一个 Block 中的线程可以使用共享内存交换数据。
例如:
Block 内的多个线程 ↓ 共同使用共享内存共享内存适合用于:
- 缓存重复访问的数据
- 线程之间交换中间结果
- 减少对全局内存的访问
但使用共享内存时,通常需要考虑线程同步,避免一个线程读取到尚未准备好的数据。
6.3 全局内存
全局内存可以被多个 Block 访问,容量通常较大,但访问延迟也较高。
如果一个 Warp 中的线程访问相邻地址,通常更有利于内存访问效率。相反,如果线程访问地址非常分散,可能降低效率。
本次只需记住:
GPU 程序的执行效率不仅取决于计算,还取决于线程如何访问内存。
7. 同步与执行边界
7.1 Block 内同步
同一个 Block 中的线程可以使用同步机制:
__syncthreads();它的作用是:
让同一个 Block 中的线程在某个位置等待,直到该 Block 中满足条件的线程都到达这里。
典型场景:
线程将数据写入共享内存 ↓ 等待其他线程写入完成 ↓ 线程读取共享内存如果没有正确同步,可能出现:
线程 A 还没有写完数据 线程 B 已经开始读取数据从而导致结果错误。
7.2 Block 之间不能随意同步
普通 CUDA Kernel 中,不同 Block 之间通常不能直接使用普通的 Block 内同步机制。
原因是:
- 不同 Block 可能运行在不同 SM
- 不同 Block 的执行时间不同
- 某些 Block 可能还没有开始执行
- 如果互相等待,可能造成死锁
因此,跨 Block 的阶段依赖通常使用多个 Kernel 表示:
Kernel 1: 不同 Block 完成第一阶段 ↓ Kernel 1 结束 ↓ Kernel 2: 不同 Block 开始第二阶段7.3 Kernel 是重要的执行边界
不同 Kernel 之间通常形成明确的阶段边界:
Kernel 1 ↓ Kernel 2如果 Kernel 2 依赖 Kernel 1 的结果,就应确保 Kernel 1 已经完成,或者使用适当的 CUDA 同步机制。
所以,Kernel 不只是一个函数,也是:
- 一个 GPU 任务阶段
- 一个全局依赖边界
- 一个程序组织并行计算的基本单位
8. 常见误解
误解一:一个线程对应一个物理核心
不是。
Thread 是逻辑执行单位。GPU 通常以 Warp 为单位调度线程,实际硬件资源会被多个线程和 Warp 共同使用。
误解二:一个 Block 就是一个 Warp
不一定。
Block 可以包含多个 Warp。例如:
Block 有 256 个线程 256 ÷ 32 = 8 个 Warp误解三:所有线程都严格同时执行
不完全是。
一个 Warp 中的线程通常执行相同指令,但不同 Warp 之间可能交替执行,Block 之间也可能按不同顺序执行。
误解四:Block 一定按照编号顺序执行
不是。
Block 的执行顺序通常不应由程序假设。
误解五:__syncthreads()可以同步整个 Grid
不是。
它主要用于同步同一个 Block 中的线程,不能直接作为普通的 Grid 级同步机制。
误解六:分支分化会让程序一定出错
不一定。
分支分化通常首先影响性能。只有在程序错误地依赖线程执行顺序或同步关系时,才可能导致结果错误。
9. 编程模型与执行模型的联系
两者可以按照下面的方式理解:
| 层次 | 关注内容 |
|---|---|
| 编程模型 | 程序员如何组织 Host、Device、Kernel、Grid、Block、Thread |
| 执行模型 | GPU 如何将 Block 分配到 SM,如何划分 Warp 并执行 |
| 硬件实现 | SM 中的计算单元、寄存器、缓存和调度资源 |
整体关系如下:
程序员编写 Kernel ↓ 使用 Grid、Block、Thread 描述任务 ↓ GPU 将 Block 分配到 SM ↓ Thread 被划分为 Warp ↓ Warp 按 SIMT 方式执行 ↓ 线程访问不同层次的内存编程模型是程序员使用的抽象,执行模型是 GPU 实际运行这些抽象的方式。
10. 总结
CUDA 执行模型的核心可以概括为:
Grid 中的 Block 被分配到 SM,Block 中的 Thread 被划分为 Warp,Warp 以 SIMT 方式执行指令。
需要掌握的关键关系是:
Grid:一次 Kernel 启动的全部 Block Block:GPU 调度和线程协作的基本单位 Thread:最小逻辑执行单位 Warp:通常包含 32 个线程,是重要的硬件调度单位 SM:负责执行一个或多个 Block 的 GPU 处理单元 SIMT:多个线程执行相同指令,但处理各自的数据还需要记住三个边界:
- 不要假设 Block 的执行顺序
- 不要把普通 Thread 等同于物理核心
- 不要把 Block 内同步机制当作整个 Grid 的同步机制