十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深入GPU硬件调度:从GPC/SM到Warp调度器的CUDA优化指南

深入GPU硬件调度:从GPC/SM到Warp调度器的CUDA优化指南 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 ClusterGPC组成每个GPC内部有若干个Texture Processing ClusterTPC每个TPC通常包含两个Streaming MultiprocessorSM。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硬件手册里线程块有个更正式的名字叫CTACooperative Thread Array。GPU前端收到内核启动命令后会由名为GigaThread的全局工作分配引擎来把整个grid的CTA逐一分配给各个SM。GigaThread引擎在GPU内担任的是“顶层调度器”它维护每个SM当前的空闲槽位、寄存器利用率、共享内存利用率然后按某种优先级把新的CTA发送到有空位的SM。在SM一侧接收这些CTA的是CUDA Work DistributorCWD或其他类似的微控制器单元。它会把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个block2048线程而不是32个。block槽、线程槽、寄存器、共享内存这四个约束取交集才是最终能落到SM上的CTA数量。对调度分析来说这个分配规则特别关键。我见过很多优化指南说“block size取256最好”但实际要看你的资源占用。如果一个block用了48KB共享内存而SM共享内存上限只有164KBA100那么最多只能同时驻留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线程算如果blockSize128可驻留16个block刚好凑满2048线程如果blockSize256可驻留8个block如果blockSize512只能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启动时延从几十微秒降到个位数微秒吞吐提升非常可观。第三在多任务场景下考虑用MIGMulti-Instance GPU做硬件级调度隔离。MIG把一个GPU切成多个实例每个实例有独享的SM集合、L2 slice和显存带宽。这样做的好处是不同租户的kernel不会因为抢L2或内存控制器而互相拖慢。反过来如果你希望单个任务能用到全GPU那就不该开MIG。这个决策本质上也是基于硬件结构的调度取舍。这篇先把硬件结构拆到这里。下一部分我会沿着“调度”这条线深入warp级指令调度和内存命令调度的具体细节结合Nsight Compute的指标看看硬件到底把时间花在了哪里。
返回列表