CUDA 编程完全理解系列(第二篇):从 Block 生命周期理解调度
前言
在第一篇中,我们从设计哲学层面理解了为什么 GPU 需要大量线程来隐藏内存延迟。
下面我们以厨师炒菜为例,复习一下上一篇的核心思想。厨师(核心)炒菜太快,但是配菜时间太长,所以多加几个灶台和配菜地方,厨师优先去准备好的灶台工作,其他灶台备菜,厨师工作完毕,然后"瞬间移动"去其准备好的灶台继续工作,而行政总厨(warp scheduler)负责调度,最大限度发挥厨师的工作效率,减少其等待切配的时间。
让我们把厨房的类比和GPU的专业术语做一个精确的映射,以加深理解:
| 厨房类比 | GPU 实际架构 | 核心作用 |
|---|---|---|
| 厨师 | GPU核心 或 流处理器 | 执行实际计算(炒菜)。计算速度极快。 |
| 切菜/备菜过程 | 访问全局内存 | 从GPU的板载显存中读取数据。这个操作非常慢(相对于核心计算速度,延迟可能高达数百甚至上千个时钟周期)。 |
| 灶台 | 线程 | 一个具体的"工作任务"的执行环境。每个灶台(线程)都有自己的锅和菜(寄存器中的局部数据)。 |
| 多个灶台和配菜区 | 海量的线程 | GPU拥有成千上万个线程,它们被组织成线程块。 |
| 厨师炒完一个菜,瞬间移动到下一个准备好的灶台 | 上下文切换 | 当一个线程束因为等待内存数据而停滞时,warp调度器会零开销 地切换到另一个就绪的线程束,让核心立刻开始工作。 |
| 行政总厨 | Warp调度器 | 这是GPU SM(流多处理器)的核心部件。它持续监控着所有活跃线程束的状态(哪些在计算,哪些在等内存),并决定下一个时钟周期让哪个就绪的线程束上核心执行。 |
| 减少厨师等待时间 | 隐藏内存延迟 | 这是最终目的。通过让核心几乎总是在忙于执行线程的计算,来"掩盖"或"隐藏"某个线程访问慢速内存所花费的时间,从宏观上看,核心的利用率接近100%。 |
GPU的哲学就是:
既然一个线程会花大量时间"发呆"(等数据),那我就准备好成千上万个线程。当一个线程发呆时,硬件调度器(行政总厨)立刻让核心(厨师)去执行另一个已经万事俱备的线程(灶台)。 通过保持核心始终处于忙碌状态,来"隐藏"那些不可避免的慢速内存访问延迟,从而榨干硬件的计算潜力。
但一个关键问题仍然未解:GPU工作流程的全貌是什么?
掌握本篇的核心后,你将能够:
- 理解 GPU 的整个工作流程
GPU 的整个工作流程:从启动到完成
让我们用一个完整的场景来描述 GPU 从接收 kernel 调用到完成所有 Block 执行的全过程。
场景设定
任务:处理 100 万个点的点云
配置:kernel<<<3907, 256>>>(args)
└─ 3907 个 Block,每个 256 threads(8 个 Warp)
硬件:RTX 3090
└─ 82 个 SM
└─ 每个 SM 有 4 个 Warp Scheduler
└─ 每个 SM 最多驻留 64 个 Warp(或约 8 个 256-thread Block)
第一阶段:启动与初始分配(T = 0 ~ 0.5 ms)
【CPU 侧】
你的 C++ 代码调用:
kernel<<<3907, 256>>>(points, output);
│
└─> CUDA 驱动拦截调用
└─> 创建 GridDescriptor(定义有 3907 个 Block)
└─> 将任务交给 GPU 的 Giga Engine
【GPU 侧】Giga Engine 启动
Step 1: 获取所有 SM 的当前资源状态
├─ SM 0: 空闲,64 个 Warp 槽位可用
├─ SM 1: 空闲,64 个 Warp 槽位可用
└─ ...
└─ SM 81: 空闲,64 个 Warp 槽位可用
Step 2: 初始分配(匹配 Block 与 SM)
├─ Block 0 → SM 0
├─ Block 1 → SM 1
├─ ...
└─ Block 81 → SM 81
├─ Block 82 → SM 0(SM 0 可驻留多个 Block)
├─ Block 83 → SM 1
├─ ...
└─ Block 163 → SM 81
└─ Block 164-3906 进入 等待队列
Step 3: 每个 SM 为分配到的 Block 创建 Warp
在 SM 0 上:
├─ Block 0 创建 8 个 Warp(0-7)
├─ Block 82 创建 8 个 Warp(8-15)
└─ 总计 16 个 Warp 在 SM 0 的活跃池中
在 SM 1 上:
├─ Block 1 创建 8 个 Warp(0-7)
├─ Block 83 创建 8 个 Warp(8-15)
└─ 总计 16 个 Warp 在 SM 1 的活跃池中
到此为止:
├─ 所有 82 个 SM 都被分配了多个 Block
├─ 每个 SM 有 16-64 个活跃 Warp(取决于资源限制:warp槽位、寄存器、共享内存、Block数大小、总线程数大小)
├─ 3743 个 Block 仍在等待队列中
└─ GPU 已准备好开始执行
第二阶段:执行循环(T = 0.5 ms ~ 50 ms)
这是最核心的部分:每个时钟周期内,每个 Warp Scheduler 在做什么。
背景:理解"指令发射与执行"
每个 SM 有 4 个 Warp Scheduler,它们是独立并行工作的单元。每个周期,每个 Scheduler 都会执行相同的操作:
【周期 N】Scheduler 0 的工作:
Step 1: 从 Warp 池中扫描可选的 Warp
├─ Warp 0: 状态 = 就绪(Ready)
│ └─ 理由:上一条指令已执行完,操作数已就位,无等待
│
├─ Warp 1: 状态 = 等待内存(Waiting Memory)
│ └─ 理由:3 个周期前发出了全局内存读请求,数据还未到达
│ └─ 此时不可选
│
├─ Warp 2: 状态 = 等待同步(Waiting Sync)
│ └─ 理由:遇到 __syncthreads(),等待同 Block 其他 Warp
│ └─ 此时不可选
│
├─ Warp 3: 状态 = 就绪(Ready)
└─ ...
Step 2: 选择第一个就绪的 Warp(例如 Warp 0)
Step 3: 发射该 Warp 的下一条指令
假设该指令是"从全局内存加载 4 个 float":
├─ 该请求被发送到内存子系统
├─ Warp 0 的状态立即改为"等待内存"
├─ 返回 Step 1,下个时钟周期再找下一个就绪 Warp 发射指令
假设下一条指令是"浮点乘法":
├─ 该指令被发射到 FP32 执行单元
├─ 在该周期内被执行
├─ Warp 的 PC(程序计数器)推进到下一条指令
├─ 下个周期,该 Warp 的下一条指令可能被发射
【同时进行】Scheduler 1、2、3 也在独立地做同样的事情
结果:在一个周期内,这 4 个 Scheduler 各从不同的 Warp 中发射 1 条指令
→ 最多 4 条指令被发射,但来自 4 个不同的 Warp
→ 这些指令针对不同的执行单元(SIMT),它们可以在硬件上真正并行执行
核心机制:如何隐藏 400 周期的内存延迟
现在理解为什么 64 个 Warp 是关键的:
【假设 1】如果 SM 只有 8 个 Warp:
时刻 Warp 状态 执行情况
─────────────────────────────────────────────────────
周期 1 W0-3 发起内存读 4 个 Scheduler 各发射 1 条
周期 2 W4-7 发起内存读 4 个 Scheduler 各发射 1 条
周期 3 W0-7 都在等待内存 Scheduler 轮询,全部跳过
...
周期 401 W0 数据到达(Ready) Scheduler 0 发射 W0 的指令
周期 402 W1 数据到达(Ready) Scheduler 0 或 1 发射 W1 的指令
...
缺陷:周期 3-400 期间,所有 Warp 都等待,Scheduler 无事可做
SM 的计算单元处于空闲状态(浪费)
【假设 2】如果 SM 有 64 个 Warp:
时刻 Warp 状态 执行情况
─────────────────────────────────────────────────────
周期 1 W0-3 发起内存读 各 Scheduler 发射指令
周期 2 W4-7 发起内存读
W0-3 等待内存 Scheduler 跳过 W0-3,转向 W4-7...
周期 3 W8-11 发起内存读
W0-7 等待内存 Scheduler 跳过,转向 W8-11...
...
周期 17 W60-63 发起内存读
W0-59 等待内存 Scheduler 转向 W60-63
周期 18 W0-59 仍在等待内存
W60-63 发起第二次内存读 Scheduler 转向 W0(仍等待)...
【关键时刻】周期 401:W0 的数据到达
W0 状态转为 Ready
Scheduler 发射 W0 的下一条指令(计算)
同时 W60 仍在等内存
其他 Scheduler 继续从其他等待中的 Warp 选择就绪的
优势:在 400 个周期的内存延迟中,始终有其他 Warp 在执行计算
SM 的计算单元从不闲置
总吞吐接近于"如果没有内存延迟"的理论值
【数学模型】
假设:
- 内存延迟:L = 400 周期
- Warp 数:W
- 每个 Warp 的"工作量"(直到需要下一次内存读):C 个周期
理想隐藏条件:W × C ≥ L
即:总计有 W 个 Warp,每个可以独立执行 C 个周期的计算
这 W 个 Warp 的计算时间总和应足以覆盖内存延迟 L
对于 L=400,C=20(假设每个 Warp 发起内存读后可执行 20 个周期的其他计算):
需要 W ≥ 400/20 = 20 个 Warp
但在实践中,W = 64 是为了应对:
- 不是所有 Warp 都恰好有 C 周期的无关计算(有些可能更少)
- 内存访问的不规则性(有些请求会命中缓存,有些不会)
- Warp 间的同步开销(__syncthreads())
- 为了保证高 occupancy(驻留 Block 数足够)
真实的指令发射序列
让我们看一个真实的点云处理 kernel 的 Warp 执行序列:
cpp
__global__ void processPoints(float* points, float* output) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx >= N) return;
// 加载输入点
float4 p = *(float4*)&points[idx * 4]; // 指令 1:全局内存读
// 简单处理
p.x = p.x * 2.0f; // 指令 2:浮点乘法
p.y = p.y * 2.0f; // 指令 3:浮点乘法
p.z = p.z * 2.0f; // 指令 4:浮点乘法
// 写回结果
*(float4*)&output[idx * 4] = p; // 指令 5:全局内存写
}
Warp 执行时间线:
周期 Warp 0 Warp 1 Warp 2 ... 状态
─────────────────────────────────────────────────────────────────────
1 发射:读指令 状态:就绪 发射:读指令 4 个 Scheduler
状态:等待内存 状态:等待内存 各选一个 Warp
2 状态:等待内存 发射:读指令 状态:等待内存 继续轮询
状态:等待内存
...(内存延迟中间的 300+ 周期,Scheduler 不断切换到其他就绪 Warp)...
401 状态转为就绪 状态:等待内存 ... 数据到达时刻
发射:乘法指令
402 PC 推进 状态转为就绪 ...
发射:乘法指令
403 发射:乘法指令 PC 推进 ...
404 发射:乘法指令 发射:乘法指令 ...
405 发射:写指令 发射:乘法指令 ...
406 状态:等待内存 PC 推进 ... (新的内存等待周期开始)
发射:乘法指令
407 发射:乘法指令 ...
408 发射:写指令 ...
... ... 不同 Warp 的数据到达时间不同
... 但 Scheduler 始终有就绪的 Warp 可选
关键洞察:
- Warp 0 和 Warp 1 的内存请求时刻不同,数据到达时刻也不同
- Scheduler 利用这个时间错开,不断地在不同 Warp 间切换
- 当 Warp 0 在等内存时,Scheduler 执行 Warp 1 的计算;当 Warp 1 也等内存时,Scheduler 执行 Warp 2...
- 结果:整个 SM 从不出现"所有 Warp 都在等待"的状态
第三阶段:Block 完成与滚动分配(T = 5 ms ~ 50 ms)
在执行循环进行的同时,Block 会陆续完成:
时刻 T = 5 ms:
SM 0 上 Block 0 完成
├─ Block 0 的 8 个 Warp 都执行完了最后一条指令
├─ 该 Block 占用的资源被释放:
│ ├─ 8 个 Warp 槽位 → 空闲
│ ├─ 寄存器 8192 个 → 释放回全局寄存器池
│ ├─ 共享内存 4 KB → 释放
│ └─ Block 计数器从"2 驻留"变成"1 驻留"
│
└─ Giga Engine 立即检测到释放事件
└─ 从等待队列取下一个 Block(例如 Block 164)
└─ 分配到 SM 0
└─ SM 0 为 Block 164 创建 8 个新 Warp
└─ 新 Warp 进入活跃池,下个周期开始执行
时刻 T = 5.5 ms:
SM 1 上 Block 1 完成
└─ 类似过程...
时刻 T = 10 ms:
所有 82 个 SM 都完成了至少一个 Block
目前状态:
├─ 每个 SM 都有新的 Block 正在执行
├─ 等待队列:3743 - 82 = 3661 个 Block
└─ Grid 调度器继续监控完成事件
...(滚动进行)...
时刻 T = 50 ms:
所有 Block 都被分配并执行完毕
└─ 等待队列为空,kernel 完成
现在理解:Block、Warp、Grid 调度器的真正角色
Block 是什么
从工作流程来看,Block 是资源分配和线程协作的单位:
【从 Grid 调度器的视角】
Block = 一个需要分配到某个 SM 的"工作包"
包含了它需要的资源信息(线程数、共享内存等)
以及在该 SM 上可以独立执行的信息(blockIdx 等)
【从 SM 的视角】
Block = 一个或多个 Warp 的集合
这些 Warp 可以通过共享内存相互通信
可以通过 __syncthreads() 进行同步
资源是以 Block 为单位锁定的
【关键后果】
- 一个 SM 可以同时驻留多个 Block(例如 8 个)
- 每个 Block 的 Warp 混合在 SM 的活跃 Warp 池中
- Scheduler 不区分"这个 Warp 属于哪个 Block",只要求"该 Warp 就绪就可选"
- 但当需要 __syncthreads() 时,只同步同一个 Block 内的 Warp
Warp 是什么
从工作流程来看,Warp 是硬件调度的最小单位:
【从 Scheduler 的视角】
Warp = 一个可以被独立选中并发射指令的单位
32 个线程必须执行同一条指令(SIMT)
但来自不同 Block 的 Warp 在 Scheduler 看来是一致的
【从状态管理的视角】
Warp 有 4 个状态:
├─ 就绪(Ready):可以发射下一条指令
├─ 等待内存(Waiting Memory):已发出请求,等待数据返回
├─ 等待同步(Waiting Sync):已到达 __syncthreads(),等待同 Block 的其他 Warp
└─ 已完成(Completed):所有指令执行完,等待 Block 其他 Warp
【状态转移】
就绪 → 发射内存指令 → 等待内存
→ 数据到达 → 就绪
就绪 → 执行计算指令 → 就绪(下一条指令)
就绪 → 到达 __syncthreads() → 等待同步
→ 同 Block 所有 Warp 到达 → 就绪
Giga Engine 是什么
从工作流程来看,Giga Engine 是 GPU 层级的"任务管理器":
【职责 1】初始分配
├─ 将 Grid 中的所有 Block 分配给 SM
├─ 考虑每个 SM 的资源限制(寄存器、共享内存、Warp 槽位)
├─ 如果资源不足,Block 进入等待队列
【职责 2】持续监控
├─ 监控所有 SM 的 Block 完成事件
├─ 当 Block 完成时,释放其资源
【职责 3】滚动分配
├─ 当某个 SM 有资源空闲时,立即从等待队列取新 Block
├─ 保证 SM 资源尽可能被充分利用(不闲置)
【职责 4】负载均衡(可选的高级功能)
├─ 某些架构会考虑 SM 的当前负载
├─ 倾向于将新 Block 分配给负载较低的 SM
总结
Giga Engine 作为上层调度器,以 Block 为单位做资源分配与生命周期管理,决定哪些 Block 能被送入空闲 SM 执行以及哪些 Block 的空间及资源可以被释放;而 SM 内部则以 Warp(32 线程)为最小硬件执行单元,由 warp scheduler 轮询调度处于 ready 状态的 Warp 执行指令,Block 只是承载线程的任务容器,SM 被动接收后拆解为 Warp 执行。
GPU 调度层级对应关系表
| 调度层级 | 核心组件 | 调度颗粒度 | 核心职责 | 主动/被动角色 | 与下一层级关系 |
|---|---|---|---|---|---|
| 顶层调度 | Giga Engine | Block(线程块) | 1. 跟踪所有 SM 的资源占用状态 2. 从任务队列分配 Block 到空闲 SM 3. Block 计算完成后释放资源 | 主动:主导 Block 的分配与回收 | 1 个 SM 可并行接收多个 Block(受 SM 资源限制) |
| 流式多处理器调度 | SM(流式多处理器) | Warp(线程束,32 threads) | 1. 被动接收 Giga Engine 分配的 Block 2. 将 Block 拆解为固定大小的 Warp 3. 管理 Warp 的生命周期 | 被动:接收 Block 后主动拆解调度 | 1 个 Block 包含若干 Warp(数量=Block 线程数/32,向上取整) |
| 硬件指令执行 | Warp Scheduler(线程束调度器) | Warp(硬件最小执行单元) | 1. 轮询检查 Warp 状态(ready/waiting/sync) 2. 只调度 ready 状态的 Warp 执行下一条指令 3. 保证 Warp 内 32 个线程锁步执行 | 主动:SM 内的指令调度核心 | 1 个 Warp 包含 32 个 Thread,同属一个 Block |
| 最细粒度执行 | CUDA Core | Thread(线程) | 1. 执行 Warp 分发的单条指令 2. 线程间通过共享内存/同步指令协作 | 被动:完全服从 Warp 调度 | Thread 是逻辑执行单元,无独立调度能力 |
快速检查清单
- 能解释 GPU 工作流程的主要阶段(启动、初始分配、执行循环、Block 完成、滚动分配、kernel 完成)
- 理解每个周期内,4 个 Warp Scheduler 各自独立地选择一个就绪 Warp 并发射指令
- 能用"Warp 状态机"解释延迟隐藏:当 Warp A 等待内存时,Scheduler 转向执行 Warp B
- 明白为什么 64 个 Warp 驻留是关键数字:足以覆盖 400 周期的内存延迟
- 理解 Block 是"资源分配与线程协作"的单位,而 Warp 是"硬件调度"的单位