1. 为什么写调度分析先讲硬件:从一张GPU系统图说起
在优化CUDA程序的时候,我经常碰到一类问题:明明已经把grid和block调成了192的倍数,shared memory也压到了32字节对齐,但程序就是跑不满,或者说延迟始终压不下去。后来把NVIDIA的硬件结构细细过了一遍,才意识到所谓“调度”,并不是软件层面一个简单的任务队列分发,而是从CPU提交命令到GPU前端,再到GigaThread引擎把线程块分配给SM,最后到SM内部四个warp调度器逐周期选指令执行,一层套一层的流水线过程。每一层的硬件设计都决定了你在编程时那些看似“玄学”的性能差异。
所以这个系列我打算从硬件结构开始讲起,先把NVIDIA GPU的物理骨架画出来,再把每一层涉及的调度机制拆开,最后回到CUDA编程里那些和调度强相关的优化点。这一篇是第一篇,重点放在硬件布局和资源单元上:GPC/TPC/SM的层级、SM内部的关键执行单元、线程块到warp的硬件接管逻辑,以及内存层次如何反过影响调度。如果你正在做CUDA内核调优、推理引擎开发,或者只是好奇一个GPU为什么能把几百万个线程安排得明明白白,这篇文章都值得读下去。
1.1 CPU与GPU之间的硬件分界线
我们平时写代码,CPU侧调用cudaLaunchKernel,好像只是把参数推给驱动就完事了。但硬件上,这条路径要穿过PCIe或NVLink通道,到达GPU芯片上的Host Interface,进入GPU前端的命令处理单元。GPU前端维护着一组硬件命令队列,驱动会把内核启动包、内存拷贝请求、同步标记都发到这些队列里,再由GPU端的硬件调度器按队列顺序取出并分发。
这个分界线决定了第一次调度延迟的底数。实测一次简单的空kernel启动,从CPU发起cudaLaunch到kernel在GPU上真正开始执行,通常要几微秒,其中大部分就消耗在命令包穿过总线、进入硬件队列再到前端分发的过程。NVIDIA引入CUDA Graphs,本质就是把这若干次命令提交压缩成一个图对象,一次提交让前端连续执行多个节点,从而躲掉反复的CPU到GPU往返开销。理解这条路径,你才能明白为什么小kernel密集调用时,我的建议永远是“该用Streams或Graphs把它们合并”,而不是抱怨GPU调度慢。
1.2 硬件资源池全景图:GPC、TPC、SM和内存
往芯片内部看,NVIDIA GPU不是一大坨执行单元堆在一起,而是像工厂车间一样分成若干区域。以现代Ampere架构为例,整个GPU由多个Graphics Processing Cluster(GPC)组成,每个GPC内部有若干个Texture Processing Cluster(TPC),每个TPC通常包含两个Streaming Multiprocessor(SM)。GPC本质上是一个相对独立的处理单元组,它还包含光栅化、纹理处理等图形相关硬件;但如果只做通用计算调度,我们最关心的还是SM这一层。
这张分层图对应着调度的重要边界:GPC是物理布局上的组,SM是任务真正驻留并执行的最小单元。线程块(Block/CTA)被分配到哪个SM,不由程序员直接指定,而是由硬件工作分发器根据各SM的空闲资源动态决定。后面我会详细说这个机制。内存层次上,每个SM有自己私有的L1/共享内存,整块GPU共享一个统一的L2缓存和多个显存控制器,L2和DRAM之间通过跨bar互联。这个布局决定了:如果两个线程访问的数据恰好落在同一个L2 slice上,延迟就低;如果落到了不同slice甚至不同显存通道,硬件互连就会去“调度”数据通路,最终影响整体吞吐。
2. 宏观调度骨架:GPC/TPC/SM 是怎么划分的
2.1 GPC和TPC:从物理布局看任务分组
先从GPC说起。GPC在计算调度中的角色更像一个“物理小区”:它内部包含固定的SM数量、纹理单元、L2切片端口,外部与其他GPC通过片内总线互联。程序运行时,由全局工作分配器往各个GPC内的SM分发线程块,分配器会优先选择当前负载更低的GPC和SM。这个分配不是严格轮流,而是类似于负载均衡的模糊策略。所以,如果你的kernel使用的block数量刚好是GPC数量的整数倍,负载通常会更均衡;如果block数量特别少,比如只有8个block,而显卡有上百个SM,就可能出现某些GPC忙、某些GPC闲的情况。
TPC这个中间层,更多是硬件的实现组合:两个SM组成一个TPC,共享一些局部硬件资源,比如指令缓存或纹理单元。单纯做CUDA计算时,TPC并不是调度决策的子单元,调度单位仍然到SM为止。但了解TPC有意义:在通过NVLink做多GPU通信时,同一个TPC内SM到GPC的物理位置更近,访问片内互连的路径更短;虽然我不建议程序员去显式配置NUMA级别的东西,但当你做多GPU拓扑感知数据分区时,知道这个中间层级能帮助你理解为什么相邻SM访问某个GPC侧显存的延迟会不同。
2.2 单SM和多SM的任务分散规则
一个kernel的线程块网格被启动后,硬件工作分配器会逐个把Block分配到SM上。一个SM可以同时驻留多个线程块,只要资源足够。这个“足够”有三个硬指标:线程数上限、寄存器文件可用量、共享内存可用量。以比较常见的A100为例,一个SM最多可以驻留2048个线程,最多32个线程块。如果你把每个block设为256线程,理论上单个SM最多同时装8个block;但如果每个block用了很多共享内存,那实际能装的block数会被shared memory进一步限制。
这里有一个很多人忽略的规则:线程块一旦被分配到SM,在该block生命周期内不会迁移到其他SM。也就是说,硬件只是在“最初放置”时做了全局调度,之后block内线程的调度完全由所属SM内部的warp调度器接管。这个规则直接影响我们的“grid size”选择:如果gridSize小于SM数量,必然有SM拿不到block,叫做“tail effect”;如果gridSize是SM数量的几倍,工作分配器能让各SM负载更均衡,后续block还可以填补前面block退出后的空档,从而提升硬件利用效率。
3. SM内部:真正决定线程调度效率的关键单元
3.1 Warp调度器和指令分发单元:微观调度的心脏
线程块被放上SM之后,硬件会把它拆成以32个线程为一个单位的warp。SM内部有多个warp调度器,每个调度器对应一组执行端口,比如Ampere架构里一个SM有4个warp scheduler,每个scheduler关联一组FP32/INT单元以及部分Tensor Core端口。每个时钟周期,每个scheduler会从它负责的若干个“就绪warp”中选一个,取出一条指令,发送给执行单元。这就是最微观的“调度”。
为什么我们要关注这个?因为硬件调度的本质是在“隐藏延迟”:每个warp执行一次内存load,可能需要数百个时钟周期等待数据返回,如果scheduler只有一个warp可以发,那这数百个周期整条执行流水线都会空转。如果scheduler手头有8个甚至16个就绪warp,它可以在等待第一个warp的load时,继续发第二个warp的算术指令,把空闲周期填满。所以,“占用率越高越好”并不是绝对真理,但“让调度器有足够多可切换的warp”是绝对必要的。你可以通俗地理解成:硬件是一个包工头,手下只有一个工人,工人搬砖时包工头只能等;如果手下有二十个工人,这个搬砖那个砌墙,工地就能一直热火朝天。
3.2 寄存器文件和线程束状态:调度依赖的物理资源
在调度器背后,SM里有一个巨大的寄存器文件。A100的每个SM有65536个32位寄存器,共256KB。这些寄存器并不是按SM内不同计算单元分开的,而是统一的一个寄存器池。寄存器分配以线程为单位,一个线程用掉N个寄存器,SM上能驻留的总线程数就受限于65536/N。举例来说,如果我的kernel每个线程用了32个寄存器,那么单个SM最多驻留2048个线程(刚好达到线程上限);但要是每个线程用到40个寄存器,那么寄存器允许的最大线程数变成65536/40≈1638,就会低于2048的线程上限,实际占用率变成1638/2048≈80%。
寄存器文件还承担着warp状态存储的职责。每一条由warp scheduler管理的warp,都有对应的程序计数器、当前指令地址、分支状态等,这些状态同样要占硬件资源。硬件能够同时跟踪的warp数量是有限的,通常SM内部最大可驻留的warp数量就是64(对应2048线程/32)。当线程块请求过多寄存器时,warp state table还可能放不下,同样限制并发warp数。我的经验是:写kernel时可以适当调低maxrregcount,迫使编译器少用寄存器,以换来更多并发warp。但千万别压到spill到local memory,那会把片上访问变成显存访问,性能会断崖式下跌。
3.3 同一warp的分支分歧:硬件调度要额外付代价
warp里的32个线程是“锁步”执行同一指令流吗?理论上是。但当出现if-else分支时,硬件warp调度器会为每个分支单独设置active mask。也就是说,如果warp里一半线程走if,一半走else,调度器需要先发射if分支指令,这时else分支的线程被mask遮住不执行,然后再发射else分支指令,前者被遮住。两个分支串行执行,总耗时等于两者相加,而不是并行。
这种分支分歧在硬件调度层面会产生额外指令周期,严重时可以抵消优化带来的收益。我做过一个路径追踪器的kernel,起初一个warp内部光线随机散射方向不一致,导致每个warp里四五个分支,性能比预期低了近一倍。后来我改成把同类光线重排到同一warp,让warp内分支尽量一致,吞吐几乎直线上升。所以从调度的角度讲,分支分歧不只是“代码风格问题”,它直接改变调度器要发射的指令数量,是硬件结构里绕不开的代价。
4. 从线程块到Warp:硬件到底是怎么接手线程的
4.1 GigaThread引擎与CUDA工作分发器的角色
我们写kernel时看到的是“grid”和“block”,但在NVIDIA硬件手册里,线程块有个更正式的名字叫CTA(Cooperative Thread Array)。GPU前端收到内核启动命令后,会由名为GigaThread的全局工作分配引擎来把整个grid的CTA逐一分配给各个SM。GigaThread引擎在GPU内担任的是“顶层调度器”,它维护每个SM当前的空闲槽位、寄存器利用率、共享内存利用率,然后按某种优先级把新的CTA发送到有空位的SM。
在SM一侧,接收这些CTA的是CUDA Work Distributor(CWD)或其他类似的微控制器单元。它会把CTA进一步拆成warp,写入SM内的warp槽位。不过,CTA的分配顺序和创建顺序并不一定一致,硬件也没有向程序员承诺任何block调度顺序。因此,kernel里的块间同步不能依赖block索引的相对顺序,必须用atomic或cooperative groups等真正的同步机制。我自己踩过坑:以为blockId小的会先跑完,拿共享标志位做块间同步,结果在部分显卡上表现不稳定。后来才明白这是GigaThread硬性调度顺序不确定性导致的。
4.2 线程块如何被装进SM:资源占用与分配规则
当一个CTA被分配给SM时,它必须先“占坑”。坑位由三类资源决定:线程槽位、寄存器份额、共享内存份额。SM有最大线程驻留数,比如A100/H100的2048,也有最大块数32;哪怕某个CTA只用了一个线程,它同时也要占用一个block槽位,所以SM最多装32个单线程block。反过来说,如果把block设为1024线程,那么最多同时装2个block(2048线程),而不是32个。block槽、线程槽、寄存器、共享内存,这四个约束取交集,才是最终能落到SM上的CTA数量。
对调度分析来说,这个分配规则特别关键。我见过很多优化指南说“block size取256最好”,但实际要看你的资源占用。如果一个block用了48KB共享内存,而SM共享内存上限只有164KB(A100),那么最多只能同时驻留3个block,不一定能达到线程上限。这时候把block拆小或减少共享内存分配,可以让更多block并行,减少调度空档。记住:硬件调度器的目标是把每个SM都塞满,但程序员可以通过block size和资源申请量,决定它塞得顺不顺。
5. 内存层次结构:调度不是只盯计算,还要盯数据移动
5.1 全局内存与L2对调度的影响
SM内部warp调度器在等什么数据?很大一部分是全局内存访问。现代NVIDIA GPU把全局内存的访问路径设计成:SM发出请求,先经过L1/共享内存级,再到统一的L2缓存,最后才到DRAM。L2缓存本身是物理切片的,每个GPC或每个内存控制器附近有对应的L2 slice,跨slice访问会经过片内Crossbar。硬件内存系统会对这些请求做调度,把冲突bank、pending队列、回写请求统一管理。
如果你的kernel对全局内存的访问不够局部化,比如每个warp访问地址分散在不同显存页,L2命中率低,大量请求会涌向DRAM控制器,此时硬件仲裁器只能按优先级排队,表现为“内存延迟进一步拉长”。即使SM内warp调度器有足够的并发warp,也会被内存系统的瓶颈卡住。调度分析如果只盯warp发射,不看内存管线,等于只看了一半。实际操作中我会通过Nsight Compute看Memory Throughput和L2 Hit Rate,来判断是调度隐藏延迟不够,还是数据局部性本身太差。
5.2 NVLink、多GPU与跨设备调度
当系统里有多个GPU时,硬件结构又多了一个调度维度:跨设备内存访问和同步。NVLink把GPU两两相联,或者通过NVSwitch组成全连接拓扑。从调度角度看,一个GPU的SM可以发起对另一个GPU显存的load,但这条路径的延迟和带宽都远不如访问本设备显存。硬件在此时会把这类请求当作远程访问,走NVLink控制器,并占用发送和接收两端的硬件资源。
在多GPU调度上,我的建议是尽量把数据划分成“各算各的”模式,避免频繁跨GPU访问。如果实在需要跨GPU通信,就要考虑利用NVLink带宽和CUDA事件跨设备同步。NVLink虽然很快,但跨设备调度仍需要把远程数据load进本地SM,调度器的等待延迟会比本地访问多。这时候,一个可行的方案是使用CUDA Graphs,把跨设备的多个kernel用依赖边连接起来,让硬件前端一次性调度整个图,减少CPU参与带来的等待空隙。
6. 看硬件结构能推出的调度优化结论
6.1 从结构反推Occupancy与Block Size选择
讲完硬件结构,最后落到每个调优者都纠结的问题:怎么选block size和grid size?这里没有万能解,但有通用的推导方法。比如在一张A100上做简单向量加法,每个线程处理一个float4,约使用16到24个寄存器,共享内存使用0。按SM最大2048线程算,如果blockSize=128,可驻留16个block,刚好凑满2048线程;如果blockSize=256,可驻留8个block;如果blockSize=512,只能4个block。看起来都不错,但要注意block最多32个的槽位和warp调度器数量:每个调度器在同一时刻会持有若干warp,如果block太小比如32,虽然线程数和warp数不变,但block槽位被快速占满,可能触发block调度额外开销。
我实的做法是:先用cudaOccupancyMaxActiveBlocksPerMultiprocessor查一下目标kernel在每个blockSize下的最大活动块数,再结合一个“网格要被SM数量整除”的原则选gridSize。例如查得SM最多支持8个256线程block,则网格大小设为SM数量*8为最佳下限,为了消除尾效应,再乘2到4。
6.2 把硬件调度器喂饱的几个实测技巧
最后分享几个在真实项目里验证有效的技巧。
第一,不要只关心“占用率百分比”,而是看“每个调度器平均有多少个可切换warp”。A100/H100每SM有4个warp调度器,如果整个SM驻留64个warp,每个调度器对应16个warp;驻留32个warp时,每个调度器8个。对于延迟敏感型kernel,我一般保证每个调度器至少10-12个warp,也就是SM内至少40到48个warp。如果低于这个数,虽然线程很多,但调度器切换余地小,延迟隐藏能力明显不足。
第二,善用异步拷贝与CUDA Graphs来减少“调度间隙”。硬件前端一次执行任务的切换,不像软件线程切换那么轻量。要发几十个几百个micro-kernel,与其在CPU侧循环launch,不如把整个序列做成一个图,让硬件连续调度。我在做推理引擎时,把前处理、推理、后处理三个kernel和一个GPU拷贝节点放进一张CUDA Graph,启动时延从几十微秒降到个位数微秒,吞吐提升非常可观。
第三,在多任务场景下考虑用MIG(Multi-Instance GPU)做硬件级调度隔离。MIG把一个GPU切成多个实例,每个实例有独享的SM集合、L2 slice和显存带宽。这样做的好处是,不同租户的kernel不会因为抢L2或内存控制器而互相拖慢。反过来,如果你希望单个任务能用到全GPU,那就不该开MIG。这个决策本质上也是基于硬件结构的调度取舍。
这篇先把硬件结构拆到这里。下一部分,我会沿着“调度”这条线,深入warp级指令调度和内存命令调度的具体细节,结合Nsight Compute的指标,看看硬件到底把时间花在了哪里。