在计算机体系结构的语境下,性能主要指两个量:时间和吞吐量。
GPU 在进行运算时,比如大模型推理,我们通常希望端到端的执行时间越短越好,比如一次 prefill 或者一次 decode。
在端到端的执行时,首先从单个 GPU 操作视角看,不同的操作都会从头开始执行到结束;从多个操作视角看,多个操作的执行是可以重叠的,GTE 和 CE 的操作可以并行,一个 SM 也可以并发执行多个 kernel 的 block,一个 partition 也可以并发执行一个 kernel 的 cuda core 指令和 tensor core 指令。
下面进行详细讨论。
Host User Mode Driver
| | | |
PB PB PB PB
| | | |
GPU frontend Host Interface Unit
| | | |
FIFO FIFO FIFO FIFO
| | | |
Command Processor
| |
GPU backend GTE CE
|
SM - SMSP
| |
CUDA Tensor
core core
| |
A(FP) B(FP/INT)
- Host 端
- UMD 把信息写入队列 PushBuffer 中
- KMD 写队列的门铃寄存器
- GPU 前端
- HIU 监听到队列的门铃,从队列 PushBuffer 拉取指令,放到队列的 FIFO 中
- CP 轮询所有活跃的 FIFO,取出指令分发到合适的执行引擎
- GTE 处理 kernel launch,把 kernel 的计算任务分发给 SM
- CE 处理内存 DMA 搬运
- GPU 后端
- GTE 将 kernel 的 block 一波一波分配到 SM
- SM 自行把一个 block 切分成多个 warp,并派发到 4 个 partition
- 每个 partition 的 warp scheduler 每个 cycle 选一个 warp 发射它的 1 条指令,进入对应单元
- tensor core
- tensor core 只接受 tensor core SASS 指令
- 一条指令进入后,可能会分 n 次(一个经典值是 8)流经 tensor core 流水线
- 第一条指令进入后,只要在 n 个周期内,scheduler 可以再从一个 warp 里挑一条 tensor core SASS 指令发射进入,那么流水线就能保持满载
- A
- A 只接受浮点数 SASS 指令
- 一条指令进入后,分 2 次流经流水线
- 第一条指令进入后,只要在 2 个周期内,scheduler 可以再从一个 warp 里挑一条浮点数 SASS 指令发射进入 A,那么 A 流水线就能保持满载
- B
- B 接受浮点数 SASS 指令和整数 SASS 指令
- 一条指令进入后,分 2 次流经流水线
- 第一条指令进入后,只要在 2 个周期内,scheduler 可以再从一个 warp 里挑一条浮点数或整数 SASS 指令发射进入 B,那么 B 流水线就能保持满载
- tensor core
- 当这个 block 的所有 warp 在这个 SM 的 partition 上都执行好了以后,SM 通知 GTE,GTE 再派发新的 block
- 这个 kernel 的最后一个 block 执行完毕,GTE 收到该 kernel 执行完毕的信号
首先,我们对单个 kernel 的性能进行分析。主要关注的硬件是 GPU 后端的 SM。
-
算数强度
每一个 kernel 都有一个叫做算数强度的固有性质。
算数强度 I = W / Q。W 是一个 kernel 的(浮点)运算数,单位是 (FL)OPs。Q 是一个 kernel 读 DRAM 和写 DRAM 的数据量之和(因为 DRAM 是半双工的),单位是 bytes。
-
性能:每秒多少运算
有了算数强度的概念,我们接下来讨论一个 kernel 的理论性能,即每秒能执行多少运算。
一个 kernel 的理论性能,其实很好算。I 的定义是 kernel 的运算数除以 DRAM 读写数,只要乘上 DRAM 理论带宽 B(即理论状态下,kernel 每秒读写 DRAM 数),我们就可以得到理论状态下 kernel 每秒执行的运算数 P,即性能。
总结起来,即 P = I x B = W / Q x B。
-
一条斜线
如果我们用 I 做横轴,性能做纵轴,我们实际上可以画出一条斜线,表示 DRAM 理论带宽下,各种 kernel 的 I 对应的理论性能。斜率为 DRAM 理论带宽。
-
运算子系统的资源限制
但是一个 kernel 的理论性能不可能随着 I 的变大一直变大。kernel 的运算由运算子系统执行。运算子系统每秒执行的运算是有上限的,这个上限由运算子系统运算单元的硬件决定。
-
Roofline、Ridge point、memory bound、compute bound
体现在图中,就是一条向上的斜线,连着一条水平线。水平线对应的纵坐标就是运算子系统的硬件性能上限。
转折点的 I 对应的 kernel,在 DRAM 理论带宽下,理论性能刚好达到硬件性能上限。这个转折点就叫 ridge point。
如果一个 kernel 的 I 小于这个 I,我们称这个 kernel 为 memory bound。
- 在 DRAM 理论带宽下,这个 kernel 的理论性能还没有达到硬件性能上限。
- 就算换了更强的运算子系统(水平线升高,斜线斜率不变),也提升不了这个 kernel 的理论性能。
- 换更强的内存子系统(水平线不变,斜线斜率变大),可以提升这个 kernel 的理论性能
- 这个 kernel 的理论性能被内存子系统限制
如果一个 kernel 的 I 大于这个 I,我们称这个 kernel 为 compute bound。
- 在 DRAM 理论带宽下,这个 kernel 的理论性能已经达到硬件性能上限。
- 换更强的运算子系统(水平线升高,斜线斜率不变),可以提升这个 kernel 的理论性能。
- 换更强的内存子系统(水平线不变,斜线斜率变大),提升不了这个 kernel 的理论性能
- 这个 kernel 的理论性能被运算子系统限制
-
多种算数强度
GPU 的 SM partition 中负责运算的有 CUDA Core 和 Tensor Core。这两种运算单元又支持不同的数据类型。两种运算单元的不同数据类型,往往有不同的硬件性能上限。
所以在计算一个 kernel 的 I 时,我们需要把运算数 W 进行细分,如:FP64、FP32、FP16、BF16、INT32、FP16mFP16a(tensor)、FP16mFP32a(tensor)、BF16mFP32a(tensor)、TF32(tensor)、INT8(tensor)、INT4(tensor)。
但是 Q 不用细分。I 的主要用途就是乘上 DRAM 带宽,算性能。而 DRAM 带宽是所有运算单元共享的。
比如一个 kernel 同时调用 CUDA Core 和 Tensor Core 进行运算,一共与 DRAM 读写了 100 GB 数据,一部分给 CUDA Core,一部分给 Tensor Core。kernel 用 DRAM 理论带宽读写 DRAM 时,数据可能同时服务 CUDA Core 和 Tensor Core。
所以 I 的分母 Q 也要把所有运算单元的读写量算上。
我们可以通过一个 vector add 例子来具体分析一下。
#include <cuda_runtime.h>
#include <iostream>
__global__ void vectorAdd(const float *A, const float *B, float *C,
int numElements) { // line 5
// 计算全局线程索引
int i = blockDim.x * blockIdx.x + threadIdx.x; // line 7
// 边界检查,防止越界访存
if (i < numElements) { // line 10
C[i] = A[i] + B[i];
}
} // line 13
int main() {
// 仅为了通过编译,省略了 Host 端的内存分配和 Kernel 调用代码
return 0;
}运行指令:
nvcc -arch=sm_86 -O3 -cubin vector_add.cu -o vector_add.cubin -lineinfo
nvdisasm -c -g vector_add.cubin > vector_add.sass
SASS 指令如下:
.text._Z9vectorAddPKfS0_Pfi:
line 5
/*0000*/ MOV R1, c[0x0][0x28] ;
line 7
/*0010*/ S2R R6, SR_CTAID.X ;
/*0020*/ S2R R3, SR_TID.X ;
/*0030*/ IMAD R6, R6, c[0x0][0x0], R3 ;
line 10
/*0040*/ ISETP.GE.AND P0, PT, R6, c[0x0][0x178], PT ;
/*0050*/ @P0 EXIT ;
line 11
/*0060*/ MOV R7, 0x4 ;
/*0070*/ ULDC.64 UR4, c[0x0][0x118] ; # 没起作用
/*0080*/ IMAD.WIDE R4, R6, R7, c[0x0][0x168] ;
/*0090*/ IMAD.WIDE R2, R6.reuse, R7.reuse, c[0x0][0x160] ;
/*00a0*/ LDG.E R4, [R4.64] ;
/*00b0*/ LDG.E R3, [R2.64] ;
/*00c0*/ IMAD.WIDE R6, R6, R7, c[0x0][0x170] ;
/*00d0*/ FADD R9, R4, R3 ; # 算出 C[i]
/*00e0*/ STG.E [R6.64], R9 ; # 写入 C[i]
line 13
/*00f0*/ EXIT ;
假设 kernel launch 的时候,block size 是 256(8 个 warp),grid size 是 504。GPU 后端操作如下:
-
GTE 将 kernel 的 block 一个一个分配到所有 SM 上
-
GTE 分配 6 个 block 给一个 SM
- 寄存器用量每个 thread 12 个(
SHI_REGISTERS=12),warp 数量每个 block 8 个,shared memory 用量每个 block 为 0 - 一个 SM 得到 1 个 block 后,每个 SMSP 的 warp slot 用掉 2 个,每个 SMSP 的寄存器用掉 2 x 32 x 12 = 768,这个 SM 的 block slot 用掉 1 个
- 一个 SM 得到 6 个 block 后,每个 SMSP 的 warp slot 用掉 12 个,每个 SMSP 的寄存器用掉 12 x 32 x 12 = 4608 < 16384,这个 SM 的 block slot 用掉 6 个
- 这个 SM 不能再继续被分配 block 了,warp slot 已经被用完。
- 寄存器用量每个 thread 12 个(
-
GTE 分配 6 x 84 个 block 给 84 个 SM
- GTE 分配 block 0 给 SM 0,block 1 给 SM 1,...,block 83 给 SM 83
- GTE 分配 block 84 给 SM 0,block 85 给 SM 1,...,block 167 给 SM 83
- ...
- GTE 分配 block 420 给 SM 0,block 421 给 SM 1,...,block 503 给 SM 83
-
-
SM 自行把每个 block 切成多个 warp,并派发到 4 个 SMSP 上
- SM 0 把 b 0 w 0 给 SMSP 0,b 0 w 1 给 SMSP 1,b 0 w 2 给 SMSP 2,b 0 w 3 给 SMSP 3
- SM 0 把 b 0 w 4 给 SMSP 0,b 0 w 5 给 SMSP 1,b 0 w 6 给 SMSP 2,b 0 w 7 给 SMSP 3
- SM 0 把 b 84 w 0 给 SMSP 0,b 84 w 1 给 SMSP 1,b 84 w 2 给 SMSP 2,b 84 w 3 给 SMSP 3
- SM 0 把 b 84 w 4 给 SMSP 0,b 84 w 5 给 SMSP 1,b 84 w 6 给 SMSP 2,b 84 w 7 给 SMSP 3
- ...
- ...
- SM 0 把 b 420 w 0 给 SMSP 0,b 420 w 1 给 SMSP 1,b 420 w 2 给 SMSP 2,b 420 w 3 给 SMSP 3
- SM 0 把 b 420 w 4 给 SMSP 0,b 420 w 5 给 SMSP 1,b 420 w 6 给 SMSP 2,b 420 w 7 给 SMSP 3
- 最后结果:SM 0 的 4 个 SMSP,每个上面都有 12 个 warp,occupancy 达到 100%。其他 SM 也是类似的情况。
-
每个 SMSP 的 warp scheduler 每个 cycle 选一个 warp 发射它的 1 条指令,进入对应单元
- 假设 warp 的挑选是按进入 SMSP 的先后,对 SM 0 SMSP 0 有:
- 发射第 1 条指令
- 周期 n, 发射 b 0 w 0 第 1 条指令 MOV
- 周期 n+1, 发射 b 0 w 4 第 1 条指令 MOV
- 周期 n+2, 发射 b 84 w 0 第 1 条指令 MOV
- 周期 n+3, 发射 b 84 w 4 第 1 条指令 MOV
- ...
- 周期 n+10,发射 b 420 w 0 第 1 条指令 MOV
- 周期 n+11,发射 b 420 w 4 第 1 条指令 MOV
- 发射第 2 条指令
- 周期 n+12,发射 b 0 w 0 第 2 条指令 S2R
- ...
- 发射第 3 条指令
- 周期 n+12x10,发射 b 0 w 0 第 11 条指令
LDG.E R4, [R4.64] - 周期 n+12x10,发射 b 0 w 0 第 11 条指令
LDG.E R4, [R4.64]
我们只关注单精度浮点运算,可以看到,运算 FADD 和访存 LDG STG 实际上是交错的。而且,必须先执行两次
-
隐含条件
-
运算与访存完美重叠
根据公式 P = I x B = W / Q x B,带入 B 的值(理论状态下,kernel 每秒读写 DRAM 数),我们马上就能得到理论状态下 kernel 每秒执行的运算数 P。那么我们在这里实际上默认了,kernel 每秒在读写 DRAM 的同时,也在每秒执行运算。
即 roofline model 认为访存和运算是完美重叠的。
真实发生的情况是:
- 内存子系统搬来第一批数据
- 运算子系统进行第一次
-
-
如何计算
一个 kernel launch 的时候,给出了 thread 的数量。我们可以把一个 thread 需要执行的运算数 W 乘上 thread 的数量,得到这个 kernel 的运算数量总和。
一个 kernel 有对应的在 SM 上的执行时间,可以通过 NSYS 获取。
两者相除,就可以得到,对于这个 kernel 来说,硬件一秒可以执行多少运算。
-
实测性能的意义
给定一个 kernel 的 AI,我们可以通过 roofline 得到理论性能,通过上面的计算获得实测性能。
如果 AI 小于 ridge point,那么实测性能比理论性能小,反映内存子系统没有充分利用。
如果 AI 大于 ridge point,那么实测性能比理论性能小,反映运算子系统没有充分利用。
举个例子,ga102 tensor core fp16mfp16a 的硬件性能上限是 149.7 TFLOPS,即 1s 1740 M 个周期内,最多可以做 149.7 T 的运算。即 1 个周期内,最多可以做 128 x 4 x 84 次运算。
要达到这个值,我们实际上需要 84 个 SM 里面的 84x4 = 336 个 tensor core 都在执行有效运算。而一个 tensor core,要么一个周期进行 128 次有效运算,要么进行 0 次有效运算。
所以,某一个周期内,如果 336 个 tensor core 中,只有 n 个 tensor core 在进行有效运算,那么这个周期内的硬件瞬时性能就是 128 x n。当 n = 336 时,这个周期内的硬件瞬时性能达到硬件性能上限。
需要注意的是,我们说的硬件性能上限,是不可达上界,实际可达的是微基准实测峰值(如 V100 125->96 TFLOPS)。
-
没有 CKE 的情况,所有 SM 硬件全部服务一个 kernel
为了分析一个 kernel 的实测性能,比如 tensor core 实测性能,我们需要从 kernel 的第一个 block 派发到 SM 上,SM 开始取指的那个周期开始算,到 GTE 收到这个 kernel 的所有 block 执行完毕的那个周期结束。
其中的每个周期,336 个 tensor core 都有确定的状态,要么在执行有效运算,要么在空转。所以每个周期都有一个硬件的瞬时性能 128 x n。比如刚开始的一些周期,n = 0,然后 n 慢慢变大,最后 n 再次变成 0。
需要注意的是,一条 tensor core SASS 指令进入 tensor core 以后,要经过流水线(n 个周期)才会从流水线末端出来,有效运算才算被执行。
如果对我们设定好的周期范围,求 128 x n 的每周期平均数。那么分母实际上对应这个周期范围里面的周期数量,分子对应 kernel 的 tensor MMA 运算数量总和。
如果频率保持不变,那么分母除以频率,就可以得到「一个 kernel 有对应的在 SM 上的执行时间」。分子实际上就是「这个 kernel 的运算数量总和」。所以上面说的「一个 kernel 的实测性能」可以真实反映实际情况。
如果频率是变化的,那有点麻烦。我们只能得到每周期平均运算数。
SM 具体执行情况看第一节的 GPU 后端部分。
-
有 CKE 的情况,一个 SM 硬件上可能会有多个 kernel 的 block 在跑。
没有意义。
TODO
现在,我们对涉及多个 kernel 的几种常见情况进行分析,串联起 CPU、GPU 前端和 GPU 后端。
TODO 实际上更复杂 CWD
kernel_A<<<...>>>();
kernel_B<<<...>>>();-
PushBuffer 内容
我们知道 PushBuffer 里真实指令流大概是这样:
[Launch Kernel A] [Signal Syncpoint X] [Wait on Syncpoint X] [Launch Kernel B] -
执行流程
-
CP 读取 FIFO:现在,指令跨过 PCIe 来到了 GPU 前端,CP 开始从这个 Stream 对应的 FIFO 里读指令。
-
T0 时刻,发射 A:CP 读到
[Launch Kernel A]。CP 把它丢给 GTE。GTE 开始把 Kernel A 的 Block 派发给各个 SM。 -
T1 时刻,设置哨兵:CP 继续读下一条指令,读到
[Signal Syncpoint X]。CP 会在 GTE 上注册一个回调:你把刚才那个计算任务(Kernel A)做完之后,记得去触发一下 Syncpoint X。 -
T2 时刻,CP 物理阻塞:CP 继续读下一条指令,读到
[Wait on Syncpoint X]。- 此时,CP 去检查硬件寄存器 Syncpoint X 的状态。发现条件没满足(Kernel A 还在 SM 上跑着呢)。
- 关键动作来了:对于这个特定的硬件队列(FIFO),CP 停止 Fetch(取指)。这个队列被物理挂起(Stall)。
- 此时,虽然 FIFO 的后面就躺着
[Launch Kernel B],但 CP 根本不会去读它,更不可能把它发给 GTE。
-
T3 时刻,GTE 浑然不知:在这个阶段,GTE 的状态机里只有 Kernel A。不管 SM 闲成了什么样(哪怕产生了巨大的尾部效应,空着 60 个 SM),GTE 手里没有 Kernel B 的 Block,所以只能让 SM 闲着。
-
T4 时刻,任务 A 结束:GTE 监测到 Kernel A 的所有 Block 都从 SM 上退休了。GTE 触发信号,硬件底层将 Syncpoint X 的值加 1。
-
T5 时刻,CP 唤醒:阻塞在
[Wait on Syncpoint X]上的 CP 发现条件满足了,屏障解除。 -
T6 时刻,发射 B:CP 终于继续往下读,从 FIFO 里取出了
[Launch Kernel B],把它交给 GTE。GTE 此时才第一次知道 Kernel B 的存在,开始分配 B 的 Block。
-
-
总结
CPU 继续向下执行,填充 pushbuffer。
CP 在取某一个 FIFO 的内容的时候,被卡住了。必须要等这个 FIFO 里
[Wait on Syncpoint X]前面的操作彻底完成,才能继续这个 FIFO 里的操作。 -
SM 资源被严重浪费
可以看到,SM 必须先把 Kernel A 的所有 block 都执行完,才能再开始执行 Kernel B。
如果执行 Kernel A 的时候,第二波有 60 个 SM 是空的,这些计算资源就白白被浪费了。
-
改善措施
- 多 stream 的 Concurrent Kernel Execution
- CUDA Graphs
kernel_A<<<..., stream_1>>>();
kernel_B<<<..., stream_2>>>();-
PushBuffer 内容
得益于 Hyper-Q 技术,UMD 会将 stream_1 和 stream_2 映射到两个独立的物理硬件队列上。因此,这两个 Kernel 的指令会被分别打包进两个独立的 PushBuffer 中,且它们之间没有任何隐式的 Syncpoint 依赖指令。
真实指令流大概是这样:
// PushBuffer 1 (对应 stream 1) [Launch Kernel A] // PushBuffer 2 (对应 stream 2) [Launch Kernel B] -
执行流程
-
CP 并发读取 FIFO:指令跨过 PCIe 来到 GPU 前端。此时片上的 SRAM 中激活了 FIFO 1 和 FIFO 2。CP 作为一个支持多队列的仲裁器,开始轮询这两个 FIFO。
-
T0 时刻,发射 A:CP 从 FIFO 1 中读到
[Launch Kernel A]。CP 把它丢给 GTE。GTE 在内部状态机中为 Kernel A 分配一个 Grid Slot,并开始把 Kernel A 的 Block 派发给各个 SM。 -
T1 时刻,无阻塞切换:因为 FIFO 1 中没有后续的
[Wait]指令,FIFO 1 不会被挂起。更重要的是,哪怕 FIFO 1 空了,CP 也不会停下,它立刻切换到 FIFO 2 进行读取。 -
T2 时刻,发射 B:CP 从 FIFO 2 中读到了
[Launch Kernel B]。CP 毫不犹豫地把它也丢给 GTE。 -
T3 时刻,GTE 掌握全局:这是最关键的转折点。此时,GTE 的状态机里同时驻留了 Kernel A 和 Kernel B。GTE 变成了拥有全局视野的“包工头”。
-
T4 时刻,混合派发 (Co-dispatch):GTE 开始严格审查全机所有 SM 的剩余资源(Warp 槽、寄存器、Shared Memory、Block 槽)。只要有任何一个 SM 满足“四大护法”的资源上限条件,GTE 就会立刻把 Kernel B 的 Block 塞进去。
-
T5时刻,SM 内部并发:在真实的硅片底层,由于 CKE 的触发,出现了两种并发形态:
- 空间复用: SM 0 到 SM 19 满载运行 Kernel A,SM 20 到 SM 79 满载运行 Kernel B。
- SM 内合租: 同一个 SM 内部,Partition 0 的调度器在发射 Kernel A 的指令,Partition 1 的调度器在发射 Kernel B 的指令。
-
-
SM 资源的高效利用
可以看到,由于去掉了 Wait 屏障,且 CP 从两个硬件 FIFO 并发取指,GTE 获得了同时调度两个 Kernel 的权力。
如果执行 Kernel A 的时候,第二波(尾部效应)有 60 个 SM 是空的,GTE 会瞬间将 Kernel B 的 Block 填满这 60 个空闲的 SM。不仅如此,如果 Kernel A 本身的寄存器需求极小,塞不满一个 SM,GTE 甚至会把 Kernel B 挤进同一个 SM 里运行。系统的算力碎片被彻底榨干。
这类同步的本质是跨越 PCIe 的物理阻塞。
UMD 必须在 PushBuffer 中强行注入向 Host 内存写状态的指令(Semaphore Write),而 CPU 侧的线程会被挂起或陷入自旋轮询(Spinning),直到总线把完成信号传回内存。
kernel_A<<<..., stream_1>>>();
kernel_B<<<..., stream_2>>>();
cudaDeviceSynchronize(); // CPU 在此处死等,直到整个 GPU 彻底安静-
PushBuffer 内容
cudaDeviceSynchronize放入[Wait For Context Idle][Semaphore Write to Host Memory]。[Launch Kernel A] [Launch Kernel B] [Wait For Context Idle] [Semaphore Write to Host Memory] -
执行流程
-
T1 时刻(UMD - 强制排空与封包):UMD 遍历当前 Context 下所有活跃的 Stream,把它们在 Host 端 PushBuffer 里积压的指令全部封包,强制触发一次批处理提交(Kickoff),KMD 敲响所有活跃队列的 Doorbell。这保证了所有在 CPU 用户态排队的任务都物理送达了 GPU。
-
T2 时刻(UMD - 注入全局屏障指令):UMD 不会在每个队列后面都加内存写指令。相反,UMD 会选中当前 Context 下的某一个控制队列(或者 Default Stream),向其 PushBuffer 注入一条极其特殊的硬件指令:
[Wait For Context Idle](等待上下文空闲)。 -
T3 时刻(UMD - 注入唯一写指令):紧跟在 Wait For Context Idle 后面,UMD 在这个控制队列里注入唯一一条
[Semaphore Write to Host Memory]指令。随后 CPU 线程进入睡眠或单点轮询,死死盯住这唯一的一个 Host 内存地址。 -
T4 时刻(CP - 全局阻塞):GPU 前端的 CP 在读取这个控制队列时,遇到了
[Wait For Context Idle]指令。CP 的处理方式非常霸道:它会向后端的 GTE(全局计算调度器)和 CE(显存拷贝引擎)等所有执行单元发出全局状态查询请求。 -
T5 时刻(等待所有波浪平息):只要 GTE 报告说「SM 上还有哪怕半个 Block 没退役」,或者 CE 报告说「还有 1 个 Byte 的数据没搬完」,CP 就会一直处于挂起状态。在这个漫长的过程中,GPU 上的尾波(Tail Wave)慢慢结束,SM 一个接一个地熄火、闲置。
-
T6 时刻(解冻与跨总线汇报):当整个 GPU 硅片上属于这个 Context 的所有微观物理活动彻底归零(真正的 100% 空闲),CP 收到了全部的 Idle 信号。此时,Wait For Context Idle 屏障解除。CP 读取并执行下一条指令
[Semaphore Write],启动 DMA 将一个完成标记跨越 PCIe 写入那个唯一的 Host 内存地址。 -
T7 时刻(CPU 唤醒):CPU 看到内存地址的值发生变化,函数返回,程序继续向下执行。
-
-
总结
CPU 一直往 pushbuffer 填充内容,执行到
cudaDeviceSynchronize,停止填充。然后开始等待 GPU 内存写的信号,然后继续执行。GPU 的 CP 在执行到
[Wait For Context Idle]时,挂起了所有 FIFO。直到所有微观物理活动归零,CP 才继续所有 FIFO 的操作,执行内存写指令,通知 CPU。 -
GTE 与 SM 利用率影响 (灾难级)
极差。在等待 Wait For Context Idle 的过程中,整个 GPU 的流水线被彻底排空(Pipeline Flush)。由于没有新的指令补充进来,随着各个 Kernel 的收尾,所有的 SM 最终都会陷入 100% 的资源闲置状态。
kernel_A<<<..., stream_1>>>();
kernel_B<<<..., stream_2>>>();
cudaStreamSynchronize(stream_1); // CPU 只等 stream_1,此时 stream_2 如果在跑,CPU 不管
-
PushBuffer 内容
cudaStreamSynchronize放入[Semaphore Write to Host Memory]。[Launch Kernel A] [Launch Kernel B] [Semaphore Write to Host Memory] -
执行流程
-
T1 时刻(UMD - 局部封包与注入):UMD 找到软件 stream_1 当前映射的物理硬件队列,强制封包该队列在 Host 端的 PushBuffer(踢出未满的 Batch)。在末尾追加一条
[Semaphore Write to Host Memory]指令,并敲击该队列专属的 Doorbell。注意,这里绝对不会注入 Wait For Context Idle 这种全局屏障。 -
T2 时刻 (CPU - 定向阻塞):CPU 线程进入睡眠或轮询(Spinning),死死盯住代表 stream_1 执行进度的专属 Host 内存地址。
-
T3 时刻 (CP - 独立推进与并发):GPU 前端 CP 继续它的轮询打地鼠工作。CP 从 stream_1 对应的 FIFO 中取出任务交办给 GTE。注意,stream_1 对应的 FIFO 不会被挂起。如果
[Semaphore Write to Host Memory]后面有内容,会一直执行下去。最关键的是,在此期间,stream_2 对应的 FIFO 完全不受影响,CP 依然会并发地读取它,并把 kernel_B 扔给 GTE。 -
T4 时刻 (GTE - 局部管线排空):随着时间推移,属于 stream_1 的最后一个 Kernel 的最后一个 Block 从 SM 上退役。
-
T5 时刻 (跨总线汇报):CP 唤醒 stream_1 的 FIFO,执行那条
[Semaphore Write]指令。CP 启动 DMA,将完成信号跨越 PCIe 写入 CPU 正在盯防的那个物理内存地址。 -
T6 时刻 (CPU 唤醒):CPU 内存值发生翻转,cudaStreamSynchronize 函数返回,CPU 侧的 C++ 代码继续向下执行。
-
-
总结
CPU 一直往 pushbuffer 填充内容,执行到
cudaStreamSynchronize,停止填充。然后开始等待 GPU 内存写的信号,然后继续执行。GPU 的 CP 一直在不停抽取所有 FIFO 的内容执行,直到 FIFO 为空。其中包括执行内存写指令,通知 CPU。
-
GTE 与 SM 利用率影响 (中等/视情况而定)
-
比全局同步仁慈得多。它只阻断了 CPU 向 stream_1 喂新数据的能力,但没有对 GPU 硅片内部施加任何全局物理冻结。CPU 阻塞期间,stream_2 的 FIFO 依然活跃,CP 依然可以把 stream_2 的指令发给 GTE。
-
如果程序是单流: 结果和全局同步一样惨烈。随着 kernel_A 产生尾波,SM 会慢慢闲置到 0。
-
如果程序是多流 (完美填缝):这是高性能程序的常态。当 stream_1 开始产生尾波(比如只剩下 10 个 Block 在跑),只要 stream_2 的 kernel_B 足够胖,GTE 就会毫不犹豫地触发 CKE (并发内核执行),把 kernel_B 的 Block 塞进那些发呆的 SM 里。这样即使 CPU 被挂起了,整张 GPU 依然能保持 100% 的满载轰鸣,这就是所谓的“计算与同步延迟的完美掩盖”。
-
kernel_A<<<..., stream_1>>>();
cudaEventRecord(event, stream_1);
kernel_B<<<..., stream_1>>>();
cudaEventSynchronize(event); // CPU 死等这个特定标记被触发-
PushBuffer 内容
cudaEventRecord放入[Signal Syncpoint X]和[Semaphore Write to Host Memory](默认事件),或者只放入[Signal Syncpoint X](轻量级事件)。这种同步方式下,我们只需要[Semaphore Write to Host Memory],[Signal Syncpoint X]不起作用。cudaEventSynchronize什么都不放。注意,因为
cudaEventRecord写入[Semaphore Write to Host Memory]以后,不会让 CPU 阻塞等待,CPU 可以继续执行下去,所以[Semaphore Write to Host Memory]之后还可以有 kernel launch。直到 CPU 遇到cudaEventSynchronize,进行阻塞等待。cudaStreamSynchronize写入[Semaphore Write to Host Memory]以后,会让 CPU 阻塞等待,CPU 不能继续执行下去,所以[Semaphore Write to Host Memory]之后没有 kernel launch。[Launch Kernel A] [Signal Syncpoint X] // 没发挥作用 [Semaphore Write to Host Memory] [Launch Kernel B] -
执行流程
-
T1 时刻(UMD - 放置浮标):当调用 cudaEventRecord 时,UMD 会在 stream_1 的 PushBuffer 里紧跟着 kernel_A 的启动指令后,注入一条
[Semaphore Write to Host Memory]指令(如果非轻量级事件,还会附带 Signal Syncpoint)。这个内存写操作关联着一个唯一的 Host 内存地址(代表这个 event 的状态)。 -
T2 时刻(UMD - 继续填装):注意!与前两种同步截然不同。cudaEventRecord 是极速返回的非阻塞调用。UMD 可以继续把 kernel_B 甚至更多任务打包进 PushBuffer,然后敲击 Doorbell 发送给 GPU。
-
T3 时刻(CPU - 挂起死盯):随后 CPU 执行到 cudaEventSynchronize,线程陷入睡眠或自旋(Spinning),眼睛死死盯住代表该 event 的那个特定 Host 内存地址。
-
T4 时刻(CP - 流水线畅通无阻):跨越 PCIe,GPU 前端的 CP 在读取 stream_1 的 FIFO 时,把 kernel_A 发给 GTE。接着读到了那个
[Semaphore Write]指令。如我们之前推演的,CP 把这个写动作挂在 kernel_A 的退役回调上。最精妙的是,CP 不会挂起该队列! CP 会继续往下读,把 kernel_B 也丢给 GTE 排队。 -
T5 时刻(GTE - 跨越浮标):kernel_A 执行完毕。GTE 触发回调,告诉 CP:“那个浮标前面的活儿干完了”。同时,GTE 开始无缝执行 kernel_B。
-
T6 时刻(跨总线汇报与 CPU 唤醒):CP 收到回调,立即启动 DMA 执行那条
[Semaphore Write],跨 PCIe 将完成状态写入 CPU 正在盯防的 Host 内存。CPU 瞬间唤醒,C++ 代码继续往下走。而此时的 GPU 硅片上,kernel_B 正在轰鸣运转。
-
-
总结
CPU 一直往 pushbuffer 填充内容,执行到
cudaEventSynchronize,停止填充。然后开始等待 GPU 内存写的信号,然后继续执行。GPU 的 CP 一直在不停抽取所有 FIFO 的内容执行。其中包括执行内存写指令,通知 CPU。
-
GTE 与 SM 利用率影响(极高 / 极少浪费)
几乎完美掩盖了同步开销。 这是写 Profiler 测速代码或复杂流水线控制时的标准范式。
因为在 CPU 被挂起并等待唤醒的这几微秒(甚至几十微秒的 PCIe 往返时间)里,GPU 内部的 GTE 根本没有停下脚步。GTE 直接跨过了 Event 标记,继续执行同一个 Stream 里的后续任务(kernel_B)。
SM 得到了源源不断的 Block 供给,算力黑洞被降到了物理极限上的最低点。
// 假设有个 event_ready 创建时带有 cudaEventDisableTiming 标志
kernel_A<<<..., stream_1>>>();
cudaEventRecord(event_ready, stream_1); // A 做完,插旗
kernel_C<<<..., stream_2>>>();
cudaStreamWaitEvent(stream_2, event_ready, 0); // stream_2 看到旗子才能放行
kernel_B<<<..., stream_2>>>(); // 强依赖于 A 的结果-
PushBuffer 内容
cudaEventRecord放入[Signal Syncpoint X]和[Semaphore Write to Host Memory](默认事件),或者只放入[Signal Syncpoint X](轻量级事件)。这种同步方式下,我们只需要[Signal Syncpoint X]。cudaStreamWaitEvent放入[Wait Syncpoint X]。[Launch Kernel A] [Launch Kernel C] [Signal Syncpoint X] [Wait Syncpoint X] [Launch Kernel B]另外,如果
cudaEventRecord(event_ready, stream_1)往 PushBuffer 里面放了[Semaphore Write to Host Memory],Host Memory 确实会被写,但这笔操作是异步派发出去的,Stream 2 的等待和唤醒只依赖于片上的 Syncpoint,整个同步过程依然闭环在 GPU 内部,延迟极低(纳秒级)。所以说,
cudaEventRecord在 PushBuffer 里面如果要放[Signal Syncpoint X]和[Semaphore Write to Host Memory],必须先放[Signal Syncpoint X]。 -
执行流程
-
T1 时刻(UMD - 注入依赖屏障)
- 当执行 cudaEventRecord 时,UMD 往 stream_1 的 PushBuffer 注入:
[Signal Syncpoint X]。 - 当执行 cudaStreamWaitEvent 时,UMD 往 stream_2 的 PushBuffer 注入:
[Wait Syncpoint X]。
- 当执行 cudaEventRecord 时,UMD 往 stream_1 的 PushBuffer 注入:
-
T2 时刻(CPU - 瞬间脱身)
这两个 API 都是极其轻量级的非阻塞调用。CPU 把指令塞进 PushBuffer 后瞬间返回,继续去处理后续的 C++ 逻辑(比如准备下一帧的数据)。CPU 绝对不会被挂起。
-
T3 时刻(CP - 物理分流)
- 指令来到 GPU 前端。CP 轮询读取 FIFO。
- 当 CP 读到 stream_2 中的
[Wait Syncpoint X]时,它去检查片上寄存器 Syncpoint X,发现条件未满足。 - 关键动作: CP 在物理上挂起(Stall)了 stream_2 对应的 FIFO。但是,CP 并没有闲着,它立刻切换去抽水 stream_1 的 FIFO,把 kernel_A 发给了后端的 GTE。
-
T4 时刻(GTE - 挥汗如雨)
GTE 调度 SM 全力计算 kernel_A 和 kernel_C。此时 stream_2 的 kernel_B 被安全地冻结在前端的 FIFO 里,绝不会发生提前执行的逻辑错误。
-
T5 时刻(硬件回调 - 瞬间解锁)
kernel_A 的所有 Block 从 SM 上退役。GTE 触发底层的硬件连线,将片上寄存器 Syncpoint X 的值加 1。
-
T6 时刻(CP 唤醒与无缝衔接)
一直处于监听状态的 CP 瞬间(纳秒级)发现 Syncpoint X 的屏障解除了。stream_2 的 FIFO 立即解冻,CP 读出 kernel_B 并火速发给 GTE。
-
-
总结
CPU 不会阻塞,一直向下执行,填充 pushbuffer。
GPU 的 CP 在执行到
[Wait Syncpoint X]时,挂起了 stream 1 对应的 FIFO,在等待。直到 stream 2 FIFO 的
[Signal Syncpoint X]的回调被执行,CP 才继续 stream 1 FIFO 的操作。 -
GTE 与 SM 利用率影响 (极高/最优)
- 完美。这是对硬件最友好的同步机制。
- 整个等待过程完全闭环在 GPU 前端的 CP 和 Syncpoint 之间,没有任何跨 PCIe 的宏观延迟(Microseconds -> Nanoseconds)。
- 误区澄清:绝对不要认为跨流同步能让「具有依赖关系」的 Kernel B 去填补 Kernel A 的尾波。因为依赖的本质就是等待,只要 Wait 不解除,B 就无法进入 GTE。对于纯粹的 A -> B 依赖,单流和多流跨流同步的 SM 空转情况是一模一样的。
- 真实优势:多流跨流同步的真正意义在于隔离。它使得 stream_2 中位于 Wait 之前的独立任务(kernel_C),以及其他完全不相关的 stream_3、stream_4,能够与 kernel_A 并发执行。它在确保严格依赖关系的同时,将对全局并发的「物理株连」降到了最低。
-
形态一:传统的默认流
如果你在编译代码时没有加任何特殊的编译宏,那么你代码里所有没指定 stream 参数的 API(比如
kernel<<<grid, block>>>或者默认的cudaMemcpy),都会被扔进这个传统的 Stream 0(Legacy Default Stream)中。-
1 它的毒性在于隐式同步语义
官方手册对传统 Stream 0 的定义是:在 Stream 0 中启动的任何操作,必须等待所有其他 Stream 中的所有先前操作完成才能开始;而任何其他 Stream 中的后续操作,必须等待 Stream 0 中的操作完成才能开始。
-
PushBuffer 内容
当 UMD 看到你要在传统默认流里跑任务时,它在底层 PushBuffer 里生成的指令序列大概是这样的:
-
[Wait For Context Idle]:强制前端 CP 挂起,死等 GTE 和 CE 把之前所有 Stream 派发的任务全部执行完(管线排空)。这就实现了「前面的车必须全走光」。 -
[Launch Kernel (Stream 0)]:此时整个 GPU 硅片上 100% 空闲,只有这个 Kernel 被发给 GTE,它享受到了一种极其奢侈的「绝对独占」状态。 -
[Wait For Context Idle](或由 CP 状态机隐式执行的等价挂起):强制 CP 必须等当前这个独占的 Kernel 彻底退役后,才能去读取和分发属于其他 Stream 的后续指令。这就实现了“后面的车全得堵着等它扫完”。
-
-
2 在硬件底层,CP 是怎么执行这条“霸王条款”的?
结合我们之前讲的架构,当你在 Stream 0 里塞了一个 Kernel 时,底层的流转极其暴力:
- UMD 的特殊标记:用户态驱动在把这个 Kernel 打包进 PushBuffer 时,会加上 wait for context idle。
- CP 的管线排空:当硬件前端的 Command Processor (CP) 从某个硬件队列里读到了这个 wait for context idle,CP 会立刻“拉响警报”。
- 停止并发分发:CP 会立刻冻结对所有其他硬件队列的解析。
- 等待 GTE/CE 排空:CP 会死死盯着后端的 GigaThread Engine 和 Copy Engine,必须等到之前发给它们的、属于其他 Stream 的所有 Block 和 Memcpy 全部物理执行完毕。
- 独占执行:等整个 GPU 彻底空了,CP 才会把 Stream 0 的这个 Kernel 发给 GTE 执行。
- 解除封锁:等 Stream 0 的 Kernel 彻底跑完,CP 才会解除封锁,继续去读取和分发其他硬件队列里的新任务。
-
3 两个 kernel 一起
如果你在默认流里连续 launch 两个 kernel,那么 pushbuffer 内容大概是这样的:
[Wait For Context Idle] // 保证 A 之前的所有流都执行完 [Launch Kernel A] // A 享受全机独占 [Wait For Context Idle] // 保证 A 彻底执行完,其他流才能开始,B 也必须等 [Launch Kernel B] // B 享受全机独占 [Wait For Context Idle] // 保证 B 彻底执行完这是灾难性的。
-
4 结论
传统的 Stream 0 就像是高速公路上的全封闭扫路车。只要它一上路,前面的车必须全走光,后面的车全得堵着等它扫完。这就导致 Hyper-Q(多硬件队列)带来的并发能力瞬间归零。
-
-
形态二:每个线程独立的默认流
正是因为传统的 Stream 0 这种“核弹级”的全局同步语义太容易摧毁性能,特别是当开发者试图在大型项目中组合不同的第三方库时(只要有个库偷偷用了一下 Stream 0,整个并发就全毁了),NVIDIA 后来引入了 PTDS(Per-Thread Default Stream)。
-
1 如何开启?
在用 NVCC 编译时,加上编译选项:
--default-stream per-thread。 -
2 开启后发生了什么?
- 剥夺特权: 那个具有「全局屏障」语义的传统 Stream 0 被彻底废弃了。
- 隐式分配: 驱动 (UMD) 会为你的 C++ Host 端程序中的每一个 CPU 线程 (Host Thread),隐式地创建一个普通、独立的异步 Stream。
- 此时,如果你在代码里不写 stream 参数(即默认传入 0),Runtime 会将其自动重定向到当前 CPU 线程绑定的那个普通隐式 Stream 中。
-
3 硬件底层的变化:
- 既然它变成了一个普通的异步 Stream,UMD 就不再往 PushBuffer 里打那个要命的 Global Barrier 标签了。
- 它会被 UMD 像其他你显式创建的
cudaStream_t一样,映射到某个常规的硬件队列中。 - 前端的 CP 看到它,只会把它当作芸芸众生中的一个普通队列,完全支持与其他 Stream 的并发调度和 Overlap。
-
有时候你明明写了一堆异步的流,结果用 Nsys 一抓 Timeline,发现它们还是串行了。这往往是因为你在 CPU 端触发了隐式同步操作。
一旦执行以下操作,驱动 (UMD) 会被迫把手头所有 PushBuffer 里的东西先冲刷 (Flush) 到 GPU 执行完,导致全局阻塞:
-
非异步的内存拷贝:调用了
cudaMemcpy(而不是带 Async 的版本)-
传统默认流
如果你没有明确指定 Stream,或者使用的是隐式的 Stream 0,那么 cudaMemcpy 就会和其他 kernel 一样变成一个全局屏障。它会等待之前所有 Stream 的任务做完,然后自己做拷贝,做完之后才允许后面的任务开始。这时候它会导致全局管线排空。
-
非默认流
如果你只是在某个特定的流里不小心用错了 API,把
cudaMemcpyAsync写成了cudaMemcpy。此时,驱动把这个拷贝指令打包塞进 PushBuffer,敲击门铃后,CPU 线程就会立刻被阻塞(陷入等待),死死盯着这个拷贝操作对应的 Semaphore。和上面的区别在于:此时 GPU 上其他流(如果映射到了其他硬件通道)可能还在并发执行,并未被强行中断。
破坏力在于: 虽然 GPU 没闲着,但你的 CPU 线程被卡住了。CPU 无法继续去解析后续的代码,也就无法继续往其他 PushBuffer 里塞新的 Kernel。一旦现有的 PushBuffer 被 GPU 消耗光,GPU 也会因为“断粮”而被迫陷入空闲。
-
-
显存分配/释放:传统的
cudaMalloc和cudaFree是极其昂贵的同步操作。它们会阻塞设备。(现代 CUDA 引入了 Stream-Ordered Memory AllocatorcudaMallocAsync来解决这个问题)。-
发生了什么:当你调用 cudaMalloc 时,驱动不仅要在 Host 端更新数据结构,还要去修改 GPU 内部的页表(Page Tables / MMU),以建立虚拟地址到物理显存的映射。
-
为什么必须等:这是一个极其危险的底层操作。如果 GTE(计算引擎)或 CE(拷贝引擎)正在疯狂地读写显存,而驱动同时在底下抽换显存地址的映射表,整个 GPU 就会直接崩溃(Page Fault 甚至导致系统死机)。
-
硬件执行动作:
- CPU 调用 cudaMalloc。
- 驱动(UMD/KMD)立刻下达“封锁令”:停止接收任何新的 Launch 指令。
- CPU 死死盯住 Host 内存中的所有 Semaphore。
- 必须等到前端所有活跃的硬件队列(PushBuffers)里的指令全部分发完,且 GTE 和 CE 把所有正在跑的 Block 和 Memcpy 全都执行完。
- 等所有队列的最后一条“内存写指令”都将完成信号发回 Host 后(此时 GPU 彻底陷入 0% 负载的死寂状态)。
- 驱动安全地修改页表,分配显存。
- CPU 拿到新分配的指针,解除阻塞,继续往下执行。
-
-
L1/Shared Memory 配置修改: 比如通过 API 动态修改了某个 Kernel 的共享内存和 L1 Cache 的划分比例。