不管在哪个语境下,thread 的本质都是:一段有自己的程序计数器(PC)和寄存器状态、能独立往前执行指令的控制流。
关键词是"独立的 PC + 独立的寄存器"。只要满足这个,就可以叫 thread。至于这个 thread 是被操作系统调度的、被硬件直接执行的、还是被 GPU 一组一组打包执行的,那是实现层面的差别。
CUDA 里一个 thread 是最细粒度的执行单位——你写的 kernel 函数,每个 thread 都会执行一遍,只不过通过 threadIdx 知道自己是第几个,从而处理不同的数据。
但是!CUDA 的 thread 不符合传统 thread 的定义,虽然每个 thread 有独立的寄存器,但是同一个 warp 里面的 thread,共享一个 PC。
真正符合传统 thread 定义的,其实是 warp。不同的 warp 有独立的 PC 和独立的寄存器。
那为什么 NVIDIA 还要叫每个 lane 是"thread"?因为从编程模型看,每个 lane 有自己的寄存器、自己的局部变量、可以走不同的控制流分支(虽然分支发散会损失性能),程序员写代码时可以把它当成一个独立的执行序列来思考。这是一种刻意的抽象:把 SIMD 包装成"很多个 thread",让程序员更容易写并行代码。NVIDIA 给这套东西起了个名字叫 SIMT(Single Instruction, Multiple Threads),就是强调这种"硬件是 SIMD,但编程模型是 thread"的混合。
当 host 调用 kernel<<<gridDim, blockDim, smem, stream>>>(...) 时,driver 把这次 launch 打包成一个命令写进 host 上对应 stream 的命令缓冲(push buffer),最终通过 GPU 的前端(host interface / front end)送到 GPU 的全局调度硬件 GigaThread Engine。
GigaThread Engine 拿到的信息包括:
- grid 的维度(gridDim.x/y/z)→ 总共多少个 block
- 每个 block 的维度(blockDim)→ 每个 block 多少 thread → 多少 warp
- 每个 thread 用的寄存器数(编译时由 ptxas 决定,写在 cubin 里)
- 每个 block 用的 shared memory 大小(静态 + 动态)
- kernel 入口地址、参数区地址等
这些资源数字非常关键,因为下一步是资源能不能塞下的判断。
GigaThread Engine 是一个 block 级别的分发器。它维护一个全局的"待发射 block 队列",并不断尝试把 block 派到有空间的 SM 上。
注意它派的单位是 block,而不是 thread 或 warp——这是 CUDA 编程模型的核心约束之一:一个 block 一旦开始执行,必须整体待在同一个 SM 上直到结束,因为 block 内的 shared memory 和 __syncthreads() 都依赖这一点。
GTE 如何判断一个 SM 能不能接收一个 block,主要看以下四个资源是否同时够用:
-
Warp slot
SM 上 warp 上下文槽位的数量是硬件固定的。GA10x 是每 SM 48 个 warp(每 partition 12 个 × 4 个 partition),Hopper/Blackwell 数字略有不同。新 block 的 warp 数加上已驻留 warp 数不能超。
warp slot 数量限制了每个 partition 的 warp scheduler 同时跟踪的 warp 上下文套数(PC、active mask、scoreboard、stall reason 等),硬件只为 12 套布线;scheduler 每 cycle 要并行扫描所有 slot 做仲裁,slot 越多仲裁 fanout 越大、关键路径越长、频率越难推高,所以这个数字本质上是 scheduler 复杂度和时钟频率之间的工程权衡。
Maximum number of resident warps per SM 和 Maximum number of resident threads per SM 是等价的。
现在有个问题。比如,SM A 上已经有一个 block 了,这个 block 有 39 个 warp。现在有一个新的 block,有 9 个 warp,这个新 block 能不能分发到 SM A 上?
如果只看 SM 的 warp slot 最大数量 48 个,很明显是可以的。但是 warp 实际上是分配给 partition 的。如果深入 partition 级别,答案比较复杂。
这实际上和 warp 分发到 partition 的策略有关。如果是强制的 warp ID mod 4,那么 partition 0 1 2 上已经存在 10 个 warp,partition 3 上已经存在 9 个 warp。而新的 block warp 0 4 8 会被分到 partition 0 上,1 5 2 6 3 7 会被分到 partition 1 2 3 上。那么 partition 0 上最后会有 13 个 warp,爆了。所以如果强制 mod 4,那么新的 block 不能分发到 SM A 上。
如果是更灵活的策略,那么可以把新 warp 0 4 8 分配到 partition 3 上,其他 warp 分配到 partition 0 1 2 上,这样子,4 个 partition 都有 12 个 warp,完美。
我们有理由认为英伟达采用了更灵活的分配策略:NVIDIA 官方的 occupancy calculator 完全按 SM 级总量计算,从不考虑 partition 级不均衡。如果硬件真的用死板的 mod 4 分配,这个工具就会系统性地高估 occupancy,NVIDIA 不太可能让自己的官方工具犯这种错。所以可以只看 SM 层面的数据进行判断。
-
寄存器
每个 SM 的寄存器堆是固定大小(GA10x 是每 SM 65536 个 32-bit 寄存器,每 partition 16384 个)。
寄存器堆是 per-partition 的,每个 partition 16384 个,各管各的。新 block 的 warp 按 mod 4 分到各 partition 后,每个 partition 上"已占用寄存器 + 新增 warp 数 × 每 warp 寄存器数"不能超过 16384。
同样,现在有个问题。比如,SM A 上已经有一个 block 了,这个 block 有 39 个 warp,用的寄存器数量是 39 x 32 x 40。现在有一个新的 block,有 9 个 warp,用的寄存器数量是 9 x 32 x 40。这个新 block 能不能分发到 SM A 上?
如果只看 SM 的寄存器最大数量 65536 个,48 x 32 x 40 < 65536,很明显是可以的。但是 warp 实际上是分配给 partition 的。如果深入 partition 级别,答案比较复杂。
这实际上和 warp 分发到 partition 的策略有关。如果是强制的 warp ID mod 4,那么 partition 0 1 2 上已经存在 10 个 warp,partition 3 上已经存在 9 个 warp。而新的 block warp 0 4 8 会被分到 partition 0 上,1 5 2 6 3 7 会被分到 partition 1 2 3 上。那么 partition 0 上最后会有 13 个 warp,寄存器数量 16640,爆了。所以如果强制 mod 4,那么新的 block 不能分发到 SM A 上。
如果是更灵活的策略,那么可以把新 warp 0 4 8 分配到 partition 3 上,其他 warp 分配到 partition 0 1 2 上,这样子,4 个 partition 都有 12 个 warp,寄存器数量 15360,完美。
我们有理由认为英伟达采用了更灵活的分配策略:NVIDIA 官方的 occupancy calculator 完全按 SM 级总量计算,从不考虑 partition 级不均衡。如果硬件真的用死板的 mod 4 分配,这个工具就会系统性地高估 occupancy,NVIDIA 不太可能让自己的官方工具犯这种错。所以可以只看 SM 层面的数据进行判断。
另外有个极端情况:如果一个 block 有 9 个 warp,每个 thread 用 200 个寄存器。分到 4 个 partition 是 3-2-2-2。寄存器总量在 SM 层面上看是合法的,看起来这个 block 可以分发到 SM 上。但是 3 x 32 x 200 = 19200 > 16384。爆了。不管怎么分配都塞不下。
实践中需要担心吗?不需要。每 thread 200 个寄存器本身就非常极端(编译器通常把寄存器压在 32–64 之间),再加上非 4 倍数 warp 数,两个条件同时满足的概率极低。正常写 CUDA 按 SM 级总量算就够了。
总结起来,如果图省事,warp slot 和寄存器数量可以只看 SM 层面的数据,但是我们要知道,硬件实际上把 block 里面的 warp 分发到 partition,如果想要更接近真实现象,需要考虑 partition 层面的数据。
-
Shared memory
每个 SM 有一块固定的 shared memory(GA10x 可配置最高 100KB 给 shared,剩下给 L1)。新 block 的 shared 用量要塞得下。
如果新 block 的 shared memory 用量,这个 SM 塞不下了,那么 SM 就不接收这个 block。
-
Block slot
每个 SM 能驻留的 block 数也有上限(GA10x 是 16 个/SM)。
block slot 限制了 SM 同时维护的 block 元数据套数(barrier 计数器、shared memory 基址等),硬件只为 16 套布线;这也间接迫使程序员用合理大小的 block(≥128 thread),否则大量小 block 会先撞 block slot 上限而浪费 warp slot 和寄存器。
只要其中任何一个资源不够,这个 SM 就不能再接 block,GigaThread 会试别的 SM;如果所有 SM 都不够,新 block 就在队列里等,直到某个 SM 上有 block 退出释放资源。这就是为什么 occupancy(实际驻留 warp / 理论最大 warp)由"最紧的那个资源"决定——常见的瓶颈是寄存器。
-
负载均衡的派发策略
派发策略上 NVIDIA 没完全公开,但大致是负载均衡 + 局部性考虑(比如同一个 GPC 内的 SM 优先),具体策略各代有微调。为什么是负载均衡?
在真实芯片设计中,GTE 绝对不会使用「先把 SM 0 塞满 100% 再去管 SM 1」这种“贪心策略”。
这种策略在硬件上是灾难性的:
-
显存控制器拥堵:如果只有 SM 0 满载,它会向特定的 L2 Cache Bank 和显存通道疯狂发起请求,导致局部带宽被打爆,而其他的物理显存通道却在睡大觉。
-
热分布不均 (Thermal Throttling): 芯片的某个角落温度飙升触发降频,而其他区域还是凉的。
因此,GTE 的核心分发哲学是:像发扑克牌一样,一圈一圈地把 Block 均匀地发给全机所有的 SM,让所有的 SM 处于同样的负载水平。
-
-
简化版 Wave 和 tail effect 概念
在这种负载均衡的策略下,我们可以自然而然得到 wave 的概念。
对于一个 kernel,一个 wave,指的是所有 SM 刚刚好达到当前资源上限时,可以同时运行的所有 Block。
比如一个 GPU 有 84 个 SM,你写了一个 Kernel A,经过资源评估,每个 SM 可以同时容纳 2 个 Block。
那么一个 wave 就是指 84 * 2 = 168 个同时执行的 block。
如果你的 grid 里有 338 个 block,那么时间线如下:
- Wave 0:GTE 根据负载均衡策略发出了 168 个 block,84 个 SM 满载
- Wave 1:GTE 再派发出 168 个 block,84 个 SM 再次满载
- Wave 2:剩下还有 2 个 block,GTE 全部派发出去,可以看到 SM 有很明显的资源浪费,这种现象就叫 tail effect
-
简化版 wave 的局限
我们上面做了一个假设:每个 SM 处理完 Wave 0 中的 2 个 block 以后,才会处理 Wave 1 中的 2 个 block。
但是实际上不是这样。根据我们上面讨论的 GTE block 派发原则,只要每个 SM 中有空闲资源,那么 GTE 就会派发新的 block。而每个 SM 在处理 Wave 0 中的 2 个 block 时,肯定会有一个 block 早一点结束执行。这时,GTE 马上会派发新的 block。
真实情况如下所示:
T0 T1 T2 T3 T4 SM0 | block 0 | block 168 block 168 | block 336 block 336 | | block 84 block 84 | block 252 block 252 | SM1 | block 1 | block 169 block 169 | block 337 block 337 | | block 85 block 85 | block 253 block 253 | SM2 | block 2 | block 170 block 170 | | block 86 block 86 | block 254 block 254 | ... SM82 | block 82 | block 250 block 250 | | block 166 block 166 | block 334 block 334 | SM83 | block 83 | block 251 block 251 | | block 167 block 167 | block 335 block 335 |上面实际上假设了不同的 SM 执行相同 kernel 的 block 时,节奏完全一样。但是实际上节奏是不一样的。
软件层面的原因主要是数据差异导致执行路径分歧,比如分支发散和循环次数差异。
硬件层面的原因主要有:L2 Cache 和显存控制器的共享,Warp 调度器的非确定性,物理上的微小差异(如散热条件不同导致的不同的工作频率)。
更真实的情况如下:
SM0 | block 84 | block 252 | waste | | block 0 | block 168 | block 336 | w | SM1 | block 85 | block 253 | waste | | block 1 | block 169 | block 337 | SM2 | block 86 | block 254 | waste | | block 2 | block 170 | waste | ... SM82 | block 166 | block 334 | waste | | block 82 | block 250 | waste | SM83 | block 167 | block 335 | waste | | block 83 | block 251 | waste | -
实际上的 wave 和资源浪费
我们可以看到,wave 0 里面的 168 个 block 的结束时间,有先有后。wave 1 里面的 168 个 block 的结束时间,也是有先有后。
wave 2 的 2 个 block,实际上是发给最早有空闲资源的 SM。
如果按照不同 SM 节奏相同的情况算,T3 - T4,SM0 SM1 还有 wave 2 block 可以执行,利用率还保持在 100%,但是其他 SM 利用率只有 50%。T4 之后,SM 0 SM1 执行好了 wave 1 block,利用率下降到 50%,其他 SM 利用率直接下降到 0。
-
尽量避免 tail
对于资源浪费,我们能做的就是,尽量让一个 grid 里的 block 是一个 wave 中 block 数量的倍数,不要留出一个 tail。
虽然就算一个 grid 里的 block 是一个 wave 中 block 数量的倍数,因为不同 SM 节奏不同,还是会有些许资源浪费。但是这样没有 tail 的情况,已经是资源利用率最高的情况了。
-
如果 tail 避免不了,怎么办?
如果实在没办法,发生了 tail effect。那么有没有不改变 kernel 来提升 SM 利用率的办法?
一般来说有两种办法,一是 concurrent kernel execution,下面进行详细讨论。CKE 需要手写多 stream 上的不同 kernel launch。
二是 CUDA Graphs。如果你把多个相互独立的 Kernel 扔进一个 CUDA Graph 里。在 Graph 触发执行(Launch)时,底层的硬件图调度器(Node Expander)会自动发现这些 Kernel 之间没有依赖关系。此时,即使你没有显式地去管理一堆 Stream,底层硬件也会自动把它们并发发射给 GTE,实现 SM 资源的回填。这比手写 Stream 的 Overhead 要小得多。
-
为什么要叫 wave
wave 其实是一个很形象的描述。我们可以:
- 把 kernel launch 的时候,比喻成,起风
- 把 kernel launch 后 SM 执行 block,比喻成,起风后,海水形成的一波又一波的浪
- 一波浪生成,就代表一定数量 block 开始被 SM 执行
- 一波浪消失,就代表一定数量 block 被 SM 执行完毕了
- 当最后一波浪消失,就代表 kernel 的最后一部分 block 执行完毕
在 SM 源源不断执行 block 的情况下,海浪的形状如下:
- 如果不同 SM 执行 block 时,节奏完全一致
- 一个 wave 中所有 block 的结束时间很整齐,是一个规律的锯齿波
- 如果不同 SM 执行 block 时,节奏不一致
- 一个 wave 中所有 block 的结束时间比较杂乱,是一个没什么形状规律的波
-
CKE
在 concurrent kernel execution 的情况下,如果 GTE 收到了 kernel A launch 和 kernel B launch,GTE 在发送 kernel A 的 block 时,会时刻关注所有 SM 的利用率。如果 kernel A block 已经发完了,但是出现了 tail effect,GTE 会马上发出 kernel B block,填补资源浪费。
-
独居和合租
在没触发 CKE 的时候,一个 SM 上只可能执行一个 kernel 的不同 block,换句话说,就是一个 SM 被一个 kernel 单独居住。
在触发 CKE 以后,一个 SM 上可能只有一个 kernel 的不同 block(独居),也可能有多个 kernel 的 block(合租)。简单例子如下:
假设我们的 GPU 只有 4 个 SM。我们 launch kernel A(5 个 block)和 kernel B(3 个 block),kernel A B 的 block 使用资源是一样的。每个 SM 最多只能容纳 2 个 block。
那么:
- SM0 分到 A block 0、A block 4(独居)。
- SM1 分到 A block 1、B block 0(合租)。
- SM2 分到 A block 2、B block 1(合租)。
- SM3 分到 A block 3、B block 2(合租)。
-
Grid Slot
显然,GTE 需要一些硬件设施来管理在 SM 上同时执行的不同 kernel,这个硬件设施就叫 grid slot。一般 GTE 有 128 个 grid slot 管理 128 个在 SM 上同时执行的不同 kernel。不过实际上很难达到这个数字。
-
需要多 stream
另外需要注意,为了触发 CKE,我们需要用不同的 Stream 发射指令,让前端的 CP 并发读取。只用一个 stream 做不到 CKE。
一个 block 被派到某个 SM 上,SM 内部要做几件事,这些都是硬件在 block 启动时一次性完成的静态绑定:
按 threadIdx 的线性顺序(先 x、再 y、再 z)每 32 个 thread 打包成一个 warp。比如 blockDim = (64, 2, 1) = 128 thread → 4 个 warp,warp 0 = thread 0–31,warp 1 = thread 32–63,依此类推。这个切分是纯几何的、确定性的,硬件直接按公式算。
每个 block 拿到 1 个 block slot,里面包含:
- blockIdx(x/y/z 三个分量)
- shared memory 基址和大小(指向 SM 的 shared memory SRAM 里这个 block 的那段)
- barrier 单元组:GA10x 每 block 有多个 named barrier,每个 barrier 单元有:
- Warp 到达位图:记录当前 Block 里有哪些 Warp 已经到了。
- Warp 预期位图:记录这个 Block 里总共有哪些 Warp 应该来。
- 这个 block 的 warp 列表 / 计数:知道还有几个 warp 没 ret,全部 ret 了就触发 block 完成
- 完成标志、in-flight 访存计数等退出所需状态
GA10x 的 SM 有 4 个 partition(processing block),每个 partition 有自己独立的 warp scheduler、寄存器堆、L0 i-cache、INT/FP/Tensor 单元。
block 内的 warp 会被分散到 4 个 partition 上,常见做法是按 warp ID mod 4。这样 block 里的 warp 0、4、8 进 partition 0,warp 1、5、9 进 partition 1,等等。
为什么要分散?因为每个 partition 是一个独立的执行引擎,分散开才能并行起来。如果一个 block 只有 1 个 warp,那只有 1 个 partition 在干活,其他 3 个对这个 block 而言是闲置的——这就是为什么 block 太小(< 128 thread)通常浪费 SM 资源。
每个 partition 的 16384 个寄存器是物理上连续的一大块 SRAM。block 启动时,硬件给落到这个 partition 的每个 warp 划一段连续区域,大小 = 每 thread 寄存器数 × 32。
比如 kernel 用 40 个寄存器/thread,那每个 warp 占 40 × 32 = 1280 个寄存器,这个 partition 最多能放 16384 / 1280 ≈ 12 个 warp(正好是 GA10x 每 partition 的 warp slot 上限,所以 40 寄存器/thread 是 GA10x 上达到 100% occupancy 的临界点)。
划好之后,这个 warp 在它生命周期里访问寄存器 R5 时,硬件就知道是去寄存器堆里它那块的偏移 5 处取——这个映射通过 per-warp base register 之类的机制实现,warp 内的寄存器编号是相对的。映射信息记录在 warp slot 内。
block 整体在 SM 的 shared memory 里拿到一块连续区域,所有同 block 的 warp 共享访问。shared memory 是 per-block 的,不是 per-warp 也不是 per-partition——尽管物理上 shared memory 是分 bank 的(GA10x 是 32 bank),但地址空间对整个 block 可见。
每个 warp 拿到一个 partition 内的 warp slot,里面包含:
- PC,初始化为 kernel 入口地址
- active mask,初始全 1(32 个 thread 全活)
- stall reason / ready 状态(这个 warp 在等什么:访存?barrier?tensor core?)
- scoreboard 条目(追踪这条 warp 发出去的未完成指令的寄存器依赖)
- 所属 block ID(回指到 SM 层的 block slot)
- warp ID
- barrier 寄存器(记录这个 warp 到底在等哪一个 barrier)
- convergence stack(用于处理分支发散和重新汇合,Volta 之后改成了 per-thread PC 的 ITS)
- 栈里每一层会存 PC,即这组 thread 当前要执行的指令地址
- 栈里每一层会存 active mask
- 栈里每一层会存 reconvergence PC
到这一步,block 就"就绪"了,它的所有 warp 都已经进入各自 partition 的待调度池。
现在进入稳态运行。每个 partition 的 warp scheduler 每个 cycle 做一件事:
- 扫描这个 partition 的所有 warp slots
- 找出 eligible warp:PC 指向的下一条指令的操作数都已 ready(没在等访存、没在等 tensor core 完成、没在等 barrier)
- 在 eligible warp 里按某个优先级策略(GTO、LRR 等,NVIDIA 没完全公开)挑一个
- 把它的下一条指令发射到对应执行单元(INT32、FP32、FP64、Tensor、LSU、SFU 之一)
- 更新这个 warp 的 PC
GA10x 每个 partition 每 cycle 只能发射 1 条 warp 指令。这条指令在执行单元里以 SIMD 方式进入 lane。
-
CUDA core
GA10x 一个 partition 有两条 16-wide datapath(A: 纯 FP32;B: FP32/INT32 共用)。
scheduler 一个周期只能发射一条指令,要么到 A,要么到 B。
一条浮点数指令进入 A 或 B,内部状态机会把这条指令对应的操作拆成 2 次(32/16)。
一条整数指令进入 B,内部状态机一样会拆成 2 次。
如果一个 scheduler 连续发射了两条浮点数指令,那么两条指令会先后进入 A 和 B。
-
Tensor core
调用 tensor core 的一条 SASS 指令进入 tensor core 以后,tensor core 内部状态机会把这条指令对应的操作进行拆分,在 tensor core 上多次执行(因为一个 tensor core 支持的操作数有限)。
关键点:这里没有任何"上下文切换"的开销。warp A 的指令发射后下个 cycle 立刻发 warp B 的指令,因为 A 和 B 的 PC、寄存器物理上同时存在于 partition 的硬件里,scheduler 只是选 mux 走哪一路。这就是 GPU 靠超大规模 SMT 隐藏延迟的物理基础——只要驻留 warp 足够多,每个 cycle 总能找到一个 ready 的,执行单元就不会闲。
另外需要注意,编译器在编译时会重排指令顺序,把独立的指令尽量提前、把有依赖的指令尽量推后。这样子,scheduler 可以尽可能地一直发射指令。
当一个 block 内所有 warp 都执行到 kernel 结尾(ret)后,等待所有 in-flight 访存落地,硬件标记这个 block 完成,回收它占用的:
- warp slots
- 寄存器区
- shared memory
- block slot
回收后 SM 通知 GigaThread Engine "我这边腾出空间了",GigaThread 就可以再派新 block 过来。整个过程就是一个生产-消费流水:grid 里上万个 block 持续往 SM 池子里灌,SM 跑完一个吐一个。
注意,warp 执行到 ret 后不释放任何资源,只是变成 not eligible 安静地占着 slot;必须等同一个 block 的所有 warp 都 ret 了,硬件才会以 block 为单位一次性回收 warp slot、寄存器、shared memory、block slot,然后通知 GigaThread Engine 派新 block 进来。
-
Kernel 执行时间的准确定义
当你在 Nsys 的 CUDA HW 行看到一个 Kernel 的方块(比如耗时 10 微秒),它的起始和结束在物理电路上是这样定义的:
真正的 Start 时间: 当 Command Processor (CP) 把 Kernel 任务交给 GigaThread Engine (GTE) 后,GTE 真正将第一个 Thread Block (CTA) 分发到物理 SM 上,SM 开始取指执行的那一瞬间。
真正的 End 时间: GTE 持续派发 Block(如果 Grid 很大的话可能要分好几波 wave)。当这个 Grid 中的最后一个 Block 在 SM 上执行完最后一条指令,彻底释放硬件资源(退休 Retire),GTE 收到所有 Block 已清空的信号的那一瞬间。
结论: Nsys 里 GPU Timeline 上显示的 Kernel 耗时,极其纯粹,它只代表了物理 SM 单元被这群 Block 占据并干活的时间。
-
例子:一个 SM 有多个 block,来自相同 kernel
- 程序员通过 cuda api launch 了一个 kernel A,有 168 个 block,kernel A 进入 GTE。
- 假设现在所有 SM 都是空的,GTE 开始把 A block 0 发给 SM 0,……,A block 83 发给 SM 83,A block 84 发给 SM 0,……,A block 167 发给 SM 83
-
例子:一个 SM 有多个 block,来自不同 kernel
- 程序员通过 cuda api launch 了一个 kernel A,有 84 个 block,kernel A 进入 GTE。
- 假设现在所有 SM 都是空的,GTE 开始把 A block 0 发给 SM 0,……,A block 83 发给 SM 83。
- 程序员又 launch 了 kernel B,也有 84 个 block,kernel B 进入 GTE。
- 假设所有 SM 都可以接收 kernel B 的一个 block,GTE 开始把 B block 0 发给 SM 0,……,B block 83 发给 SM 83。
- 注意,这种 Concurrent Kernel Execution 只能通过在不同的 stream 上 launch kernel A B 实现。在同一个 stream 上办不到。
-
例子:一个 partition 有多个 warp,来自相同 kernel
- 程序员通过 cuda api launch 了一个 kernel A,有 168 个 block,kernel A 进入 GTE。
- 假设现在所有 SM 都是空的,GTE 开始把 A block 0 发给 SM 0,……,A block 83 发给 SM 83,A block 84 发给 SM 0,……,A block 167 发给 SM 83
- kernel A 一个 block 有 8 个 warp,那么 SM 0 每个 partition 可以分到 4 个 warp,其中两个 warp 属于 A block 0,两个属于 A block 84。
-
例子:一个 partition 有多个 warp,来自不同 kernel
- 程序员通过 cuda api launch 了一个 kernel A,有 84 个 block,kernel A 进入 GTE。
- 假设现在所有 SM 都是空的,GTE 开始把 A block 0 发给 SM 0,……,A block 83 发给 SM 83。
- 程序员又 launch 了 kernel B,也有 84 个 block,kernel B 进入 GTE。
- 假设所有 SM 都可以接收 kernel B 的一个 block,GTE 开始把 B block 0 发给 SM 0,……,B block 83 发给 SM 83。
- kernel A 和 B 一个 block 有 8 个 warp,那么 SM 0 每个 partition 可以分到 4 个 warp,其中两个 warp 属于 A block 0,两个属于 B block 0。
-
软件概念:kernel/grid、block、thread
程序员只能指定 gridDim 和 blockDim。gridDim 和 blockDim 三维都有限制。另外,一个 block 里面总共最多只能有 1024 个 thread。如果多于 1024,会有运行时错误。
-
硬件概念:GigaThread Engine、SM、partition、warp
-
软硬件联系:GTE 将 kernel 的 block 一波一波分配到 SM
-
注意,一个 block 只能被分配到一个 SM,一旦分配完毕,这个 block 100% 会在这个 SM 上被执行完成,执行成功以后整个 block 一起释放资源。
-
注意,一个 SM 可以被分配多个 block,这些 block 可能来自相同的 kernel,也可能来自不同的 kernel
-
如何判断是否能分配
block slot warp slot shared memory 寄存器数量这四个数值存在上限完全是因为硬件设计的限制。GigaThread 硬件会通过这些数值判断一个 SM 能不能接收一个 block。
现在假设 GigaThread 拿到了一个 kernel,然后开始分发这个 kernel 的多个 block 到 SM 上。一个 SM 能不能接受一个 block,要看自己驻留的 warp 数量、block 数量、寄存器和 shared memory 用量。
如果一个 SM 上驻留的 warp 数量加上新 block 里的 warp 数量超过 4 * 12,那么 SM 不接受新 block。必要时考虑 partition 层面数据。
如果一个 SM 上已经被使用的寄存器加上新 block 里的寄存器,数量超过 65536,那么 SM 不接受新 block。必要时考虑 partition 层面数据。
如果一个 SM 上驻留的 block 数量加上新 block 超过 16,那么 SM 不接受新 block。
如果一个 SM 上已经分配出去的 shared memory 大小加上新 block 的,超过最大大小,那么 SM 不接受新 block。
-
-
硬件内部:SM 自行把一个 block 切分成多个 warp,并派发到 4 个 partition
-
注意,一个 warp 只能被分配到某一个 partition,直到执行完毕。
-
注意,一个 partition 可以被分配多个 warp,而且越多快好,零开销切换掩盖 stall。一个 partition 上不同的 warp,可以属于同一个 block,也可以属于同一个 kernel 的不同 block,也可以属于不同 kernel 的 block(见上面例子)。
-
注意,一个 partition 每 cycle 只能发射某一个 warp 的一条指令,不同 cycle 之间可以通过零开销切换发射不同 warp 的指令。所以流水线中可以同时有多个 warp 的指令在不同 stage 执行。
-
注意,一个 SM 4 个 partition 每 cycle 被发射命令的 4 个 warp,可以属于同一个 block,也可以属于同一个 kernel 的不同 block,也可以属于不同 kernel 的 block。
-
-
occupany 由 warp 决定
Occupancy 看的是 warp 数,不是 block 数,因为 warp 才是调度和延迟隐藏的基本单位。
换句话说,warp 越多,scheduler 可以挑选的选项更多,延迟越能被隐藏。如果一个 SM 上的 warp 数量达到 100%,那就非常好。
-
理论 occupancy:warp slot 使用情况
一个 kernel 的理论 occupany 即,一个 SM 实际可以驻留的 warp 数,和最大 warp 数的比值。
-
block slot、寄存器、shared memory 这三种资源不需要完全用掉
比如,blockDim = 256,一个 block 里有 8 个 warp。那么一个完全空的 SM,可以接收 6 个 block,每个 partition 12 个 warp。6 小于 SM 上 block 的最大数量 16,但是 SM 的所有 partition warp slot 已经被全部用完了。occupancy 已经达到了 100%。block slot 有大量空余完全不影响性能。
只要 block 数量、寄存器、shared memory 没有成为限制 warp 驻留数量的瓶颈,这三个资源用了多少无所谓。
-
最大化理论 occupancy 不是终极目标
但是!最大化 occupancy 本身也不是终极目标,终极目标是最大化性能(吞吐)。
有些情况下,故意降低 occupancy 反而能提升性能。比如一个 kernel 如果用 32 个寄存器/thread,occupancy 100%,但算法逻辑被迫频繁 spill 到 local memory(寄存器不够用,编译器把变量挤到显存里)。如果放开到 64 个寄存器/thread,occupancy 降到 67%,但 spill 完全消除了,每条指令都从寄存器读而不是从显存读,总性能反而更好。
同理,有时候多用 shared memory 做更大的 tile,数据复用率翻倍,虽然 occupancy 从 100% 掉到 75%,但 DRAM 访问量减半,性能净增。
所以更准确的优先级是:
- 先保证算法本身高效(减少 DRAM 访问、提高计算访存比)
- 在此基础上尽量提高 occupancy(给 scheduler 更多 warp 来隐藏延迟)
- 如果 occupancy 和算法效率冲突,以实测性能为准
-
实际 occupancy
我们已经知道了多个 block 在多个 SM 上运行的实际情况。
当执行到最后一波时,很多 SM 会逐渐空闲,这时驻留的 warp 数量就会变小。
如果考虑 CKE,情况会更复杂。
-
blockDim
-
必须是 32 的倍数:不是的话最后一个 warp 里有 lane 永远被 mask 掉,寄存器白分配、ALU 宽度白占、执行资源纯浪费。
-
最好是 128 的倍数:128 = 4 partition × 32 lane。这样 block 内的 warp 数是 4 的倍数,按任何策略分配到 4 个 partition 都能均匀,不会出现某个 partition 多扛一个 warp 的不均衡。
-
不要太小:如果 blockDim = 32(1 个 warp),这个 block 只用到 1 个 partition,其他 3 个 partition 对它而言闲置。而且 block slot 上限是 16,1 个 warp/block 意味着最多驻留 16 个 warp,远低于 48 的 warp slot 上限,occupancy 被 block slot 卡死。
-
不要太大:blockDim = 1024(32 个 warp)时,一个 block 就占了 32 个 warp slot,SM 最多驻留 1–2 个 block。这有两个问题:
- 第一,block 之间的离散性导致 warp slot 可能浪费(48 - 32 = 剩 16 个 slot,第二个 block 需要 32 个放不下)。
- 第二,
__syncthreads()要等 32 个 warp 全到齐,最慢的那个 warp 拖累所有人的概率更大。
-
128 或 256 是最常见的甜点。128 = 4 warp,均匀分到 4 个 partition 各 1 个;256 = 8 warp,各 2 个。block 不大不小,occupancy 容易拉满,
__syncthreads()的同步代价也可控。 -
blockDim 变化 -> 寄存器需求变化 -> SM 上 block 驻留数发生变化
- 每 thread 寄存器数 × blockDim = 这个 block 的总寄存器需求。
- blockDim 越大,总需求越大,SM 上能驻留的 block 越少。
-
blockDim 变化 -> shared memory 用量变化 -> SM 上 block 驻留数发生变化
- 很多 kernel 的 shared memory 大小和 blockDim 成正比(比如每个 thread 往 shared memory 里放一个元素)。
- blockDim 越大,每 block 占的 shared memory 越多,SM 上能驻留的 block 越少。同样要一起看。
-
-
gridDim
-
至少要够填满所有 SM 一波。GA10x 有 84 个 SM,如果每 SM 驻留 4 个 block,一波就是 336 个 block。gridDim 至少要 336 才能让所有 SM 都有活干。
-
最好是一波的整数倍。如果一波 336 个 block,你 launch 337 个 block,最后一波只有 1 个 block 跑在 1 个 SM 上,其他 83 个 SM 全闲着,尾部效应严重。
-
一般不用太操心 gridDim。因为大多数 kernel 的 gridDim 是由问题规模自然决定的(比如处理 N 个元素,每 block 处理 blockDim 个,gridDim = ceil(N / blockDim)),通常远大于一波的数量,尾部效应占比可以忽略。
-
gridDim 太大也没关系。多余的 block 在 GigaThread Engine 的队列里排队等,SM 跑完一个补一个,streaming 模式自动处理。不会因为 block 太多而出错或显著降低效率。
-
-
总结
- blockDim 是需要仔细调的参数,它直接影响 partition 均衡、occupancy、同步代价。
- gridDim 一般由问题规模决定,只要不太小就行。
- 实践中先把 blockDim 定为 128 或 256,然后用 occupancy calculator 确认 occupancy 不太低,这个起点在大多数情况下就已经接近最优了。
warp slot 中 active mask 为 0 的 lane(thread)不会执行任何操作,不是"执行了再丢掉结果"。
硬件实现是门控(gating):mask bit 为 0 的 lane,其对应的执行逻辑被关掉。具体到不同类型的指令:
-
load 指令
mask 为 0 的 lane 不会向内存系统发出请求。
这很重要——如果 32 个 lane 都发 load 再丢掉一半的结果,会白白浪费一倍的内存带宽,而且如果 mask 为 0 的 lane 的地址恰好是非法的(比如 NULL),还会触发不该触发的 fault。
所以硬件只为 active lane 生成访存请求。
-
ALU 指令
mask 为 0 的 lane 不写回寄存器。
ALU 本身可能在物理上仍然有信号在跑(门控做到什么粒度取决于具体实现和功耗优化策略),但关键是结果不会写入对应 lane 的寄存器,所以那个 lane 的状态不变。
-
store 指令
mask 为 0 的 lane 不会发出写请求。
这个更关键——如果 mask 为 0 的 lane 也 store,就会写坏不该写的内存。
所以 active mask 的语义是:mask bit = 0 的 lane 在这条指令上完全不存在,就好像这个 lane 对应的 thread 从来没有过一样。不发访存、不做计算、不写结果。
active mask 的用途:
这是最核心的用途。遇到 if/else 时:
if (threadIdx.x < 16) { // 指令地址 100
// 地址 101-200
A(); // mask = 0x0000FFFF,前16个lane active
} else {
// 地址 201-205
B(); // mask = 0xFFFF0000,后16个lane active
}
C(); // 地址 206
// 汇合后 mask = 0xFFFFFFFF两个分支各自执行时,不该参与的 lane 被 mask 掉。
-
涉及的硬件结构
老架构(Volta 之前)的每个 Warp 只有一个共享的 PC,为了处理分支发散,硬件需要依赖以下结构。
-
warp slot 内的 PC:指示整个 Warp 下一步要去哪取指。随着指令发射,它会自动递增(或随跳转改变)。
-
warp slot 内的 active mask:指示当前哪些 lane 应该实际执行指令。
-
warp slot 内的 convergence stack
这是硬件用来“存档”还没跑完的分支和汇合点的地方。栈里每一层存三个核心信息:
- PC:这组 thread 唤醒时,要从哪个地址开始跑。
- active mask:这组 thread 的参与者是谁。
- reconvergence PC(RPC):两个分支应该在哪里汇合
-
-
convergence stack 的状态机操作
-
执行
SSY时,一次压栈volta 之前有个专门的指令叫 SSY(Set SYnc)。比如
SSY 0x206,意思就是告诉硬件:“前面有分支要发散了,我算好了,你们最后都在 0x206 这个地址汇合”。硬件读到 SSY,将汇合点 0x206 和当前的 Mask 作为一个“汇合层”压入栈中备用。
-
执行分支指令时,两次压栈并触发状态切换
硬件读到条件分支指令(如 BRA)且检测到发散时:
- 硬件将 else 分支的起始 PC、Mask 和 RPC 压入栈中(存档,留着以后跑)。
- 硬件将 if 分支的起始 PC、Mask 和 RPC 压入栈中。
- 状态切换瞬间:硬件立刻从当前的栈顶(if 分支)读取出起始 PC 和 Mask,仅在这一瞬间覆写给 Warp slot 的 Active PC 和 Active Mask,正式启动该分支。
-
每个周期的执行与检查
- warp slot PC 正常递增,栈里的内容不变
- warp slot PC 是否等于栈顶的 RPC,如果是,说明当前分支跑完了。
- 硬件直接弹栈,暴露出新的栈顶
- 并再次触发状态切换(用新栈顶的 PC 和 Mask 覆写 Warp slot),从而无缝切入下一个分支或汇合点。
- 如果新的栈顶是汇合点,那么这个新的栈顶要被再一次弹栈
-
-
上面的例子详解
-
栈的初始状态为空
-
执行到 SSY 和分支指令
先执行 SSY,把汇合点信息压入栈。
然后执行到分支指令(地址 100),硬件检测到发散(一半 thread 条件为 true,一半为 false),然后往栈上压两层新的:
[ PC=206, mask=0xFFFFFFFF, RPC=— ] ← 汇合点,所有thread [ PC=201, mask=0xFFFF0000, RPC=206 ] ← else分支,后16 lane [ PC=101, mask=0x0000FFFF, RPC=206 ] ← if分支,前16 lane(栈顶)压栈完成后,立刻用栈顶更新 Warp slot 的当前状态:PC 变为 101,Active Mask 变为前 16 个 lane。
-
执行 if 分支
Warp 顺着 PC 开始执行 A()。PC 会自然递增(101, 102, 103...),因为还没碰到 RPC (206),所以栈保持不变,继续跑。
-
if 分支结束,触发弹栈
A() 跑完了,PC 走到了 206。硬件一检查:PC (206) == 栈顶的 RPC (206)。命中!硬件将当前的栈顶(if 分支)弹出。此时,暴露出来的新栈顶是 else 分支。
-
状态切换与执行 else 分支 (地址 201 - 205)
弹栈的瞬间,Warp 读取新栈顶:PC 瞬间被覆写为 201,Active Mask 被切成后 16 个 lane。接着开始执行 B(),PC 继续自然递增(201, 202...)。当这 5 条指令跑完,PC 又走到了 206。
可以看出,PC 覆写是必要的。
-
else 分支结束,触发弹栈
硬件再次检查:PC (206) == 栈顶的 RPC (206)。再次命中!硬件将 else 分支弹出。此时,暴露出来的新栈顶是汇合层。
-
重汇合
弹栈瞬间,Warp 读取汇合层状态:PC 覆写为 206,Active Mask 恢复全亮(0xFFFFFFFF)。然后汇合层也被弹出。32 个 lane 再次满宽沿着 206 继续执行 C()。
-
整个过程的关键是:硬件用栈自动管理"先跑谁、后跑谁、在哪汇合",编译器在编译时把汇合点(RPC)算好嵌入指令里,硬件运行时机械地压栈弹栈。程序员完全不用操心。
嵌套 if 也能处理——每嵌套一层多压一层栈,发散越深栈越高。
如果 blockDim 不是 32 的倍数,最后一个 warp 的多余 lane 从 block 启动的那一刻起 mask 就是 0,整个 kernel 执行期间这些 lane 永远不会被激活。
比如 blockDim = 100,切成 4 个 warp:warp 0–2 各 32 个 thread(mask = 0xFFFFFFFF),warp 3 只有 4 个 thread(mask = 0x0000000F)。warp 3 的后 28 个 lane 全程白占着 ALU 宽度。
这就是为什么 blockDim 应该是 32 的倍数——否则你白白浪费一个 warp 的大部分执行资源,而且那些 lane 对应的寄存器也照样被分配出去了,全浪费。
__ballot_sync、__shfl_sync、__reduce_sync 这些 warp 级原语都接受一个 mask 参数,指定哪些 lane 参与操作。硬件用这个 mask 来决定哪些 lane 贡献数据、哪些不参与。
如果 kernel 里有:
if (someCondition) return;
// 后续代码满足条件的 thread 提前退出,硬件把它们的 mask bit 清零。之后这些 lane 就和尾部 warp 的多余 lane 一样,永远不再被激活。
如果一个 warp 的所有 32 个 lane 都被清零了(全部 thread 都 return 了),这个 warp 就变成 not eligible,相当于提前 ret。
上面提到了 active mask 的一个用途,即分支发散和汇聚。但是那只是 volta 架构之前的情况,从 volta 开始的架构采用了 independent thread scheduling。
我们来看一个最经典的同 Warp 生产者-消费者(Spinlock 自旋锁)模型。
假设 Warp 里的 Thread 0 是消费者(等数据),Thread 1 是生产者(发数据)。它们通过一块 Shared Memory 里的标志位 flag 来通信。
__shared__ volatile int flag = 0;
if (threadIdx.x == 0) {
// 消费者:死等 flag 变成 1
while (flag == 0) {
// 空转自旋
}
printf("Thread 0 收到数据!\n");
}
else if (threadIdx.x == 1) {
// 生产者:负责把 flag 变成 1
flag = 1;
printf("Thread 1 发送数据!\n");
}
// 其他 30 个 thread 这里忽略
老架构(Volta 之前)会死锁。当执行到 if 的时候,发散产生了。硬件会把 else 分支 (Thread 1) 压入栈中存档,然后用 if 分支 (Thread 0) 的状态更新 Warp slot,开始执行。
此时,灾难发生了:
- Warp 带着 Thread 0 开始执行 if 分支里的 while(flag == 0)。
- 此时 flag 是 0,Thread 0 在 while 里不断循环。它的 Active PC 在循环体内疯狂打转,永远走不到 RPC(汇合点)。
- 因为走不到 RPC,硬件就永远不会弹栈。
- 躺在栈顶的 else 分支 (Thread 1) 就永远被压在五行山下,无法被唤醒去执行 flag = 1 这条指令。
这就是老架构臭名昭著的同 Warp 死锁(Intra-Warp Deadlock)。它本质上是硬件强制串行化导致的饥饿现象。
// 初始状态:32 个线程 PC 都在 100
if (threadIdx.x < 16) { // 分支指令地址 100
// 地址 101-200
A(); // 前 16 个 lane 需要执行
} else {
// 地址 201-205
B(); // 后 16 个 lane 需要执行
}
C(); // 汇合点地址 206-
硬件结构
在 Volta 及之后的架构中,Warp 的硬件上下文发生了翻天覆地的变化:
-
Per-Thread PC 寄存器:Warp 内的 32 个线程,每个线程在物理上都有一个自己独立的 PC 寄存器。
-
Per-Thread State 寄存器:每个线程有自己独立的状态(Ready,Stall,Yield,Wait 等)。
-
Schedule Optimizer(调度优化器):这是 ITS 的大脑。它取代了原来的 SIMT 栈,负责在每个周期扫描这 32 个独立 PC,并决定把哪些线程“打包”发射。
-
Convergence Barrier(重汇合屏障单元):虽然线程独立了,但为了保持 SIMD 的执行效率(不能真让 32 个线程完全乱序跑,那样吞吐量会暴跌),硬件引入了类似轻量级 Barrier 的机制,用于在分支结束时把大家重新聚拢。
-
-
执行流程
-
状态 0:进入分支前(统一发车)
- 硬件状态:32 个线程的 PC 全部是 100,状态全为 Ready。
- 调度器动作:调度器扫描发现 32 人的 PC 都在 100。完美!直接生成满宽 Active Mask = 0xFFFFFFFF,把大家打包发向执行单元执行分支判断。
-
状态 1:产生发散(各自领任务,不压栈!)
- 硬件状态:分支指令计算完毕。
- 前 16 个线程(if分支)的 PC 被更新为 101。
- 后 16 个线程(else分支)的 PC 被更新为 201。
- 核心区别:这里没有任何分支被挂起或压栈。32 个线程的状态依然都是 Ready,它们只是 PC 指向了不同的地方。它们在调度器眼里变成了两组平等的、嗷嗷待哺的待发射群体。
-
状态 2:动态组队与交替发射(Interleaving)
- 既然两组线程都是 Ready,调度器就可以在每个 cycle 自由选择发射哪一组。
- Cycle N:调度器看中了前 16 个线程。
- 动作:提取出 PC=101 的这组线程,动态生成 Active Mask = 0x0000FFFF,发射一条指令。
- 结果:前 16 个线程执行了一条 A() 中的指令,它们的 PC 推进到了 102。后 16 个线程的 PC 留在 201。
- Cycle N+1:调度器可以继续发射前 16 个线程,也可以突然切换目标。
- 动作:调度器决定“雨露均沾”,看中了后 16 个线程。提取出 PC=201 的这组线程,动态生成 Active Mask = 0xFFFF0000,发射一条指令。
- 结果:后 16 个线程执行了一条 B() 中的指令,PC 推进到了 202。前 16 个线程 PC 停在 102。
这就是 ITS 解决同 Warp 死锁的根本原因:if 和 else 处于平等的竞争地位,调度器可以在它们之间来回切换发射,谁也不会被谁彻底卡死。
-
状态 3:先跑完的一方进入等待(Wait)
由于 B() 只有 5 条指令,而 A() 有 100 条指令。几拍之后,后 16 个线程先跑完了 B()。
- 硬件状态:后 16 个线程的 PC 走到了 206(汇合点)。
- 调度器动作:编译器在编译时,在 206 处下达了重汇合指令。后 16 个线程到达 206 后,硬件将它们的状态从 Ready 改为 Wait(等待重汇合)。
- 结果:后 16 个线程暂时退出调度器的“选秀池”,安静等待。调度器现在只能专心发射前 16 个还在跑 A() 的线程。
-
状态 4:全员到齐,触发汇合
前 16 个线程吭哧吭哧跑完了剩下的 A() 指令,它们的 PC 也终于来到了 206。
- 硬件状态:参与该分支的所有存活线程,PC 全都变成了 206。
- 调度器动作:硬件 Convergence Barrier 单元检测到“人都到齐了”。
- 状态切换:将处于 Wait 状态的后 16 个线程重新激活为 Ready。
-
状态 5:重组 SIMD,继续满宽执行
- 硬件状态:32 个线程的 PC 再次统一为 206,状态全为 Ready。
- 调度器动作:调度器一扫描,发现 32 人的 PC 又一样了。再次动态生成满宽 Active Mask = 0xFFFFFFFF,发射指令 C()。
-
// PC 100:
if (threadIdx.x < 16) {
A1(); // PC 101
A2(); // PC 102
} else {
B1(); // PC 201
B2(); // PC 202
}
C(); // PC 300 (汇合)执行阶段 老架构 (SIMT Stack) 新架构 (Volta+ ITS)
Cycle 1 发射 A1 (Mask: 前16)。
后16线程在栈里吃灰。 发射 A1 (Mask: 前16)。
Cycle 2 发射 A2 (Mask: 前16)。 调度器可以根据优先级,选择发射 B1 (Mask: 后16)。
Cycle 3 if 跑完,弹栈。
开始发射 B1 (Mask: 后16)。 调度器继续发射 A2 (Mask: 前16)。
Cycle 4 发射 B2 (Mask: 后16)。 调度器发射 B2 (Mask: 后16)。
结果 严格串行 (A1->A2->B1->B2) 指令级交替执行 (A1 -> B1 -> A2 -> B2)
ITS 并不强制必须交替执行,调度器会根据寄存器依赖、访存延迟等实际情况,灵活决定当前 cycle 挑哪个 PC 来动态打包。
为每个 thread 增加独立的 PC 和 Call Stack 消耗了大量的 SRAM 晶体管,NVIDIA 这么做主要有两个好处:
-
解决上面提到的死锁问题
-
更智能的延迟隐藏
如果 A 分支触发了长延迟的全局内存读取(L2 Cache Miss),在老架构下,整个 warp 必须干等 A 分支走完才能去执行 B 分支。在 ITS 下,调度器一看 A 分支的 PC 对应的线程卡住了(Stall),马上把注意力切到 B 分支的 PC 上,把 B 分支的线程打包发射出去,大大提升了流水线的填充率。
但是需要注意,ITS 会导致一种极其危险的情况:跑得快的线程,根本不停下来等跑得慢的线程。比如:Thread 0-15 走 if 并且瞬间跑完了;Thread 16-31 走 else 并且因为去读全局内存卡住了。在 ITS 下,调度器可能让 Thread 0-15 的 PC 顺着代码直接往下冲,进入了后面的计算阶段!
如果不加 __syncwarp(),跑得快的 Thread 0 就直接往下冲,去读 Thread 16 应该写但还没来得及写的数据,直接读出一堆垃圾值。
用一个具体例子:block 有 8 个 warp(warp 0–7),分散在 4 个 partition 上(每个 partition 2 个)。
在 block 启动时,SM 级的 block slot 里的所有 Barrier 单元会初始化一个 Participation Mask(参与位图)。因为这个 block 有 8 个 warp,所以位图初始化为 11111111(表示期待这 8 个 warp 都来报到),同时维护一个初始为全 0 的 Arrival Mask(到达位图)。
a. 各 warp 陆续到达
假设 warp 2 最先执行到 __syncthreads() 那条指令(这在底层对应的是默认的 BAR.SYNC 0,即等待 0 号 Barrier):
- warp 2 所在 partition 的 scheduler 发射这条 barrier 指令
- warp slot 记账
- warp 2 的 warp slot 将自己的 stall reason 设为 Wait_For_Barrier。
- warp 2 的 warp slot 中 barrier 寄存器记下 barrier 的编号
- scheduler 把 warp 2 标记为 not eligible,不再 issue 它的指令。
- 跨层级通信:硬件产生一次跨层级通信。warp slot 从所在的 partition,向 SM 级的 block slot 里的 barrier 单元组发送一根电信号,报告 "warp 2 已到达 barrier 0"。
- block slot 记账:block slot 里的 0 号 barrier 单元收到信号,将 Arrival Mask 中代表 warp 2 的那个 bit 置为 1(变成 00000100)。
注意这里的分工:warp slot 在 partition 里负责“挂起自己并记住在等谁”,barrier 单元组在 SM 级的 block slot 里负责“全局统筹”。这是因为同一个 block 的 warp 分散在 4 个 partition 上,barrier 必须在 SM 级统一记账。
b. 更多 warp 到达
- warp 5 到达 → Warp Slot 记账,上报总指挥部,Block Slot 把 Arrival Mask 中 warp 5 的 bit 置为 1
- warp 0 到达 → Warp Slot 记账,上报总指挥部,Block Slot 把 Arrival Mask 中 warp 0 的 bit 置为 1
- ……依次类推。
在这个过程中,还没到达 barrier 的 warp 继续正常执行——它们还是 eligible 的,scheduler 照常 issue 它们的指令。已经到达的 warp 安静地占着 warp slot 和寄存器,但不消耗 issue 带宽。
c. 最后一个 warp 到达
warp 6 是最后一个到达的 → 它上报后,Block Slot 中 0 号 barrier 的 Arrival Mask 终于凑齐,变成了 11111111。
硬件中的比较电路检测到 Arrival Mask == Participation Mask,条件满足,触发全局释放:
- 定向广播:block slot 的 barrier 单元组向所有 4 个 partition 广播释放信号(附带 barrier 编号 0 )
- 精准唤醒:每个 partition 的 scheduler 收到信号后,去检查自己手下的 warp。只有 warp 满足以下条件,才能把它的 stall 状态清除,标记回 eligible。
- 属于这个 block
- stall reason 是 "Wait_For_Barrier"
- 其内部的 Barrier ID 寄存器等于 0
- 重置状态:block slot 将 0 号 barrier 的 Arrival Mask 清零(为下一次同步做准备)。
d. 所有 warp 继续执行
8 个 warp 重新变成 eligible,各自 partition 的 scheduler 可以从下个 cycle 开始 issue 它们 barrier 之后的指令。
GA10x 每个 block slot 里有 16 个独立的 barrier 单元,编号 0–15。
__syncthreads() 默认用 barrier 0。
但你可以通过 cooperative groups 用其他编号的 barrier,让 block 内不同的 warp 子集各自同步而不互相干扰。
每个 barrier 单元都有自己独立的 Arrival Mask(到达位图)和 Participation Mask(参与位图),互不影响。
-
block 的所有 thread 必须走同一个分支,执行同一个 syncthreads
CUDA 的规定是:
__syncthreads()必须被 block 内所有 thread 到达,而且是到达同一个__syncthreads()。如果
__syncthreads()放在条件分支里,这个条件必须对整个 block 的所有 thread 求值结果相同。否则是 undefined behavior。 -
解释
if (threadIdx.x % 2 == 0) { // 做一些事 __syncthreads(); // call A } else { // 做另一些事 __syncthreads(); // call B }
- Warp 带着前 16 个线程进入 if,遇到了 call A。这是一条 Barrier 指令。
- 硬件规定,执行到 Barrier 时,整个 Warp 必须被挂起 (Stall),等待 Block 内其他 Warp 也到达这里。
- 灾难发生了:因为这个 Warp 被挂起了,它永远无法推进到汇合点,也就永远无法弹栈去执行 else 分支。而 else 分支里的后 16 个线程如果不执行,就永远到不了 call B。
- 由于永远有线程没到达 Barrier,硬件的 Arrival Mask 永远匹配不上 Participation Mask。整个 Block 当场死锁,GPU 挂起。
-
只保证同一个 warp 的 thread 走同一个分支,不行
"只要保证同一个 warp 的 thread 内部不发散,只在 warp 之间发散(比如 Warp 0 全走 if,Warp 1 全走 else)"——在当前的某些硬件上,这段代码碰巧能正常执行,不会死锁。
因为 Warp 0 整体遇到了 call A 并挂起,Warp 1 整体遇到了 call B 并挂起。由于它们都向硬件的 Barrier 0 单元报到了(硬件不区分你是从代码的哪一行报到的,只认 Barrier ID),Arrival Mask 凑齐,硬件放行。
但是!这依然是 CUDA 规范严格禁止的 Undefined Behavior。NVIDIA 明确警告:不同代码位置的
__syncthreads()即使在时间上碰巧对齐,也不能保证在未来的架构升级、或者编译器的指令重排中依然安全。依赖这种特性的代码是非常脆弱的。
SPMD 的意思是,执行同一个程序的时候,处理不同的数据。没有其他更多的约束了。
每一个 CUDA kernel 就是一份 program,launch 时几万个 thread 都跑这同一份 program,但每个 thread 通过 threadIdx、blockIdx 知道自己是谁,从而访问不同的数据。这就是教科书级的 SPMD。
但 GPU 硬件实现混合了多个层次:
- 一个 warp 内部的 32 个 lane:SIMD(共享 PC,操作不同数据)
- 一个 SM partition 上的多个驻留 warp:MIMD 风格(每个 warp 有独立 PC,独立往前走,但是共享 partition 的执行资源,类似 SMT)
- 一个 SM 内的多个 partition/多个 SM 之间:MIMD
所以,在 warp 内部这个角度来看,所有 thread 共享 PC,永远在同一位置,分支靠 mask 模拟(ITS 是例外),可以处理不同的数据,满足 SPMD 定义。
在,一个 partition 上的多个 warp,一个 SM 内多个 partition,多个 SM,这三个角度来看,不同的 thread 有独立 PC,可以走到不同的位置,也可以走到相同的位置,可以处理不同的数据,满足 SPMD 定义。
这就是 NVIDIA 造 SIMT 这个词的原因——编程模型给你 SPMD 的体验(每个 thread 写起来像独立的),硬件用 SIMD 执行(一个 PC 驱动 32 lane)加 MIMD。SIMT 是把 SPMD 编程模型映射到 SIMD/MIMD 硬件上的桥梁。
所以 SPMD 和 SIMD/MIMD 的关系是:SPMD 是程序员看到的抽象,SIMD/MIMD 是硬件干活的方式,SIMT 是 NVIDIA 把两者粘在一起的胶水。