ARTICLE DETAIL

资讯详情

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

任务级乱序执行:NPU/GPGPU突破利用率瓶颈的新思路

任务级乱序执行:NPU/GPGPU突破利用率瓶颈的新思路 1. 乱序不是CPU的专利NPU和GPGPU为什么要重新捡起OoO大概在几年前我跟朋友聊起NPU设计时提到Out-of-Order乱序执行OoO对方的第一反应是“这不是CPU那边玩剩下的东西吗GPU和NPU靠的是吞吐量缩放并行度就完了搞乱序纯属浪费面积和功耗。” 当时我也觉得这话有道理。毕竟GPU和NPU的设计哲学一直很明确用大量简单计算单元堆吞吐用并发掩盖延迟而不是像CPU那样花重金做指令级并行ILP挖掘。但这几年AI负载的变化让我对这个问题的看法彻底扭转了。尤其是当你开始认真审视Transformer架构里的复杂数据流、张量并行Tensor Parallelism下跨卡通信的等待、以及NPU上越来越重的控制流逻辑时就会发现光靠固定流水线顺序调度已经有大量主计算单元比如MAC阵列在空闲等待。这个空闲比例在某些真实负载上高得吓人甚至超过40%。换句话说你花重金买来的算力将近一半时间在“摸鱼”。为什么会出现这种局面这得从In-Order顺序执行和OoO的根本区别说起。顺序执行引擎的本质是“静态调度”编译器或调度器在编译期就尽量排好指令/任务的执行顺序硬件只负责按部就班地跑一旦遇到依赖阻塞或长延迟操作如全局内存访问、同步屏障整个流水线或后续执行单元就被卡住直到阻塞条件消除。这种设计的优势是硬件简单、验证容易、频率好拉高、面积也不大。但代价非常直接它对执行延迟的容忍能力极差。这就好比一条单车道公路只要前面有一辆慢车后面所有人都得等着哪怕旁边还有空车道。而OoO引擎的本质是“动态调度”硬件实时分析指令/任务之间的依赖关系让不冲突的操作绕过阻塞的指令先跑去执行。核心思想就一句话别让等待成为常态能跑的先跑。传统观点认为GPU/NPU本质上是SIMD/SIMT架构计算密集天然适合顺序执行加多线程交替interleave来隐藏延迟。这个判断在CNN时代基本成立卷积网络结构规则、并行度高计算/访存模式极其规律编译器很容易找到足够多的独立任务来塞满硬件。但Transformer和大模型改变了游戏规则自注意力机制中存在大量跨头的复杂数据依赖稀疏激活MoE带来了无法静态预测的分支行为张量并行、序列并行引入了频繁的跨计算单元同步点动态Shape、动态Batch在推理场景里造成执行时间高度不确定这些特性导致一个尴尬的局面静态调度器没法在编译期把所有情况都安排好运行时又缺乏动态调整执行顺序的机构。于是我不止一次地看到硬件利用率在真实大模型推理任务上的表现远低于纸面峰值。与其拼命堆算力不如在调度架构上想想办法让执行引擎自己具备“动态调整顺序”的能力。这就是我开始认真研究OoO NPU/GPGPU的原因。接下来我会把这套设计思路拆开揉碎从核心机制到实际工程落地一条一条讲清楚。本文的目标读者是对AI加速硬件有一定基础正在或者准备设计NPU/GPU微架构遇到利用率瓶颈想找新思路的工程师。2. 动态调度不等于全乱序OoO的边界在哪里2.1 从Tomasulo算法说起但别急着照搬OoO执行在CPU领域已经有几十年的成熟实践核心算法是Tomasulo算法和它的各种改进版本。Tomasulo的关键贡献在于通过寄存器重命名Register Renaming消除指令之间的假相关WAR和WAW并用保留站Reservation Station实现分布式动态调度让每个执行单元的待执行队列独立管理。但如果你直接在NPU/GPGPU上照搬CPU那套Tomasulo几乎是必死。原因很简单CPU的OoO窗口通常只有几十到几百条指令物理寄存器堆和重排缓冲区ROB的容量维持在百数量级。而一个现代GPGPU/NPU有几十到上百个计算单元每个单元内部可能还有几十个MAC通道。如果为每个线程/任务维护一个CPU级别的OoO窗口硬件开销会爆炸。正确的做法是认清一个现实NPU/GPGPU上的乱序绝大多数不是指令级Instruction-Level的乱序而是任务级Task-Level、线程块级CTA/Thread Block-Level的乱序。什么意思以我做的NPU设计为例顶层调度器维护的不是一条条指令而是成百上千个“任务”——每个任务可以是一个卷积操作、一个矩阵乘法、一个归一化层、一个地址搬运操作。任务之间的依赖关系用硬件同步计数器和依赖图表示。底层计算单元比如MAC阵列则通过任务队列来取活。当某个任务的前置依赖不满足时任务分派器直接跳过它分派下一个没有依赖冲突的任务给计算单元。这种粒度的OoO有个显而易见的好处调度资源消耗被摊薄了。一个任务可能包含数万个周期的工作量为任务级别维护一个几十项的表项完全在可接受范围内。相比之下指令级OoO要为几千条指令各维护一条记录开销截然不同。所以我在文章开始重新定义一下本文的OoO指硬件在执行时能够根据运行时的依赖信息和资源占用情况动态改变计算任务在计算单元上的执行顺序使其不必严格按照分配顺序完成从而隐藏长尾延迟。维度CPU OoONPU/GPGPU任务级OoO调度粒度指令级微操作任务级/线程块级OoO窗口大小几十~几百条指令几百~几千个任务调度开销高寄存器重命名、ROB中依赖跟踪、任务队列延迟隐藏对象缓存未命中、ALU延迟访存延迟、同步等待、直连通信核心难点消除假相关、精确异常任务依赖管理、资源冲突避免、编程模型适配2.2 到底混乱了多少OoO窗口设计的空间分析OoO窗口也叫调度窗口是一个核心参数硬件同时检查多少个候选任务来寻找可执行任务。窗口越大越容易找到可执行的独立任务延迟隐藏能力越强但存储开销、比较器复杂度、功耗也越大。构造一个简单的概率模型假设每个任务到达执行单元时已经有w个候选项在队列里每个任务当前可执行的概率是p由依赖占用比决定则至少找到一个可执行任务的概率为P_find 1 - (1 - p)^w当p 0.3只有30%的任务不被依赖阻塞时调度窗口 wP_find找到可执行任务概率475.99%894.24%1697.99%3299.87%你看看当窗口从4扩大到16利用率损失概率从24%降到了2%。算一笔账如果一个16 MAC阵列的NPU在典型负载下的利用率是70%把调度窗口从4扩大到16理论上可以把利用率推到96%左右——这几乎相当于多买了37%的算力而成本只是多了几十KB缓存和一组优先编码器。当然这个模型是高度简化的真实场景中p随时间和依赖链长度波动但给出的直觉是对的窗口大小是OoO性能最敏感的设计杠杆。CPU工程师听到16项窗口会觉得太小但在任务级OoO场景中16~64已经是很大的窗口了因为每个任务的计算量动辄几十上百周期实际数据量远大于指令数。3. 主网格阵列与MAC阵列的分工调度单元才是OoO的心脏3.1 NPU的物理架构基础谁是执行者谁是调度者刚才多次提到了MAC阵列在Intel NPU的资料里还有一个概念叫主网格阵列Main Grid Array其实很多NPU的顶层硬件结构都类似计算引擎Compute Engine内部组织成一组或多组PE阵列Processing Element Array每个PE包含多个MAC单元PE和PE之间通过片上网络NoC连接Global Memory和Local Scratchpad。组间通信的效率和调度逻辑往往比MAC阵列本身的乘累加吞吐更能决定真实性能。一句话概括分工MAC阵列是干活的调度器是派活的。传统设计里调度器通常用最简单的轮询Round-Robin或FIFO队列分发任务属于按顺序派活干完一个收回一个的模型。而OoO NPU的改动核心恰恰在这层把派活逻辑从按顺序派改成看谁先就绪谁先派。3.2 主网格阵列如何实现任务级OoO我建议的实现方案是给主网格阵列加三个关键硬件模块第一个是任务依赖矩阵Task Dependency Matrix。每个任务分配一个w位向量标记它依赖哪些尚未完成的任务的完成标记Done Bit。任务派发时硬件扫描依赖矩阵找出所有依赖项全部为Done的任务放入就绪队列。第二个是非阻塞任务分派器Non-blocking Dispatcher。传统分派器遇到依赖未满足的任务会直接stall整个流水线而非阻塞版本把未就绪任务放回等待表优先派发就绪队列中的任务。这和CPU中的顺序发射乱序执行思想一致只是粒度从指令换成了任务。第三个是完成顺序跟踪缓冲Completion Order Buffer。它记录所有已发射但未完成任务的原始顺序号只有按原始顺序号连续回收的任务才能释放其写回资源。为什么需要这玩意儿因为后端存储的写端口有限如果任务乱序完成还按顺序写回会造成大量空间碎片。这个缓冲保证执行是乱序的但写回/提交基本有序——规避了CPU中复杂的内存重排序和精确异常问题。这三个模块组合起来的效果是MAC阵列永远在执行当时最“应该”执行即无依赖的任务而不是机械地等待最早发射的那个任务完成。在BatchNorm、残差连接这类短延迟任务和全局内存传输这类长延迟任务混跑的场景短任务会被立刻执行完长任务还在慢悠悠搬运主计算单元的利用率能平均提高20%以上这在我们的仿真测试中是反复验证过的。3.3 网格阵列的工作原理补课从MAC到CIM看热搜词知道大家对MAC阵列的工作原理也感兴趣这里稍微展开说一下因为这也是理解OoO重要性的基础。一个MAC单元做的事非常简单计算accumulator input_a * input_b。一个PE通常有8~64个MAC一个CE有几十到几百个PE。做矩阵乘法时数据流组织方式直接决定吞吐率Weight Stationary权重固定权重先加载到PE内部寄存器输入数据流过所有PE适合复用权重如卷积网络。Output Stationary输出固定输出激活值固定在PE内累加输入数据流入PE适合矩阵乘法。Row Stationary行固定每行数据驻留在PE中减少数据搬移适合CNN。在算力需求不变的情况下MAC阵列希望每个周期所有MAC都在计算。但实际中如果MAC阵列等数据、等权重、等同步就会空转。乱序任务调度器能在多大程度上缓解“等”的问题决定了MAC阵列的真实利用率。更进一步有些探索性设计把算力直接嵌入存储器Computing-in-MemoryCIM在存储阵列内部完成乘加这需要更高层的调度器进行宏观排布——但无论底层怎么做上层OoO调度都以任务为单位不受影响。4. 让OoO真正跑起来设计权衡与工程化陷阱4.1 必须付出的代价面积、功耗、时序纸上谈兵结束说点工程上的苦账。给NPU/GPGPU加OoO能力首先要为三个模块买单任务依赖矩阵、非阻塞调度器、完成顺序跟踪缓冲。假设任务数为N依赖矩阵的大小是N × N比特。N 64时矩阵只有512字节显存带宽开销也还好但如果想做精细的字节级依赖跟踪N变成1024矩阵就是128KB条目的SRAM占了芯片面积一大块。因此设计上必须要做取舍只对粗粒度任务做依赖跟踪不对单条指令做。以我参与过的一个小规模NPU原型为例在SMIC 28nm工艺下部分参数估测如表模块面积开销占原芯片比例功耗开销频率影响64-entry依赖矩阵比较器3%~5%4%~6%无明显64-entry非阻塞调度器2%~3%3%~4%稍有完成顺序跟踪缓冲64项1%~2%1%~2%无明显合计大约8%~10%的面积和功耗开销换来了平均20%以上的实际利用率提升——摊到单位算力上看反而是省钱省电的。但如果你是塞到极低功耗边缘设备里的NPU这8%面积可能就不可接受了。这时候一个妥协方案是只在部分计算单元上启用OoO调度如负责稀疏计算的单元其他单元保持顺序FIFO。4.2 什么时候不要做OoO说过头了也不好。我在做设计评审时经常强调OoO不是银弹有几种特定情况做OoO是典型的负优化或者至少性价比很低。一是在流式规律型负载中不要做。CNN卷积、GEMM这类规则负载静态调度已经接近最优任务的依赖模式固定OoO动态调度器和顺序调度结果几乎一样却要多花面积和功耗。这种负载更值得做的优化是减少数据搬移、增大数据复用、提高存储带宽而不是搞乱序。二是在任务粒度太细的时候不要做。如果任务只有两三个周期而且一个操作符一行指令任务级OoO的调度开销反而会比省下来的等待时间还大。这种情况下大多数时间调度器都在“忙乱”而MAC阵列在等待新的任务到达——好事变坏事。合理的做法是先做粗颗粒度的指令融合Fusion把几十条细粒度指令合并成一个粗粒度任务再做任务级OoO。实测下来我们把一组包含8条指令的序列融合成一个任务后OoO调度器的吞吐提升了近4倍无效切换次数大大减少。三是在负载基本无依赖长链每个任务之间都是独立并行的环境中不要做。一个无限并行的负载你用FIFO调度器就能跑满硬件资源OoO全是白费电。这时候应该把精力放在访存带宽优化上因为瓶颈根本不在调度。4.3 动态Shape和稀疏性OoO真正的主场说实话我最看好的OoO发力点其实是动态Shape和稀疏计算场景。这两个场景里任务的执行时间完全无法静态预测。动态Shape典型的例子是NLP推理中的变长序列。一个batch里每个序列长度不同导致每个中间张量的形状不停地变。静态调度器被迫按最长序列预留时间结果短序列的任务提前完成了计算单元还是要傻等到下一个静态调度点为它分配新任务。OoO调度器呢短序列任务完成后调度器会立刻在窗口里挑出后面那些依赖已就绪的任务执行把等待时间填满。稀疏计算的逻辑类似稀疏矩阵里非零元素分布极不均匀PE的负载天然不平衡。在静态调度下负载重的PE要跑很久负载轻的PE早就闲置了。OoO可以动态地把任务从重负载PE迁移到轻负载PE——这实际上就是负载均衡的一种硬件化实现。我在这类设计中遇到过最“坑”的问题是稀疏任务和稠密任务混合时依赖关系极其复杂。我开始时天真地以为只要给每个输出矩阵块维护一个依赖计数器就够了结果到了两层操作符交替出现的场景依赖图直接乱套。最后采用的方案是给任务管理器加了一个稀疏感知的表项扩展每个任务带一个稀疏标志位稀疏任务在依赖矩阵中有特殊通道可以跳过部分阻塞条件。这个改动让混合负载场景的调度吞吐提升了差不多一倍。5. 从原型到流片前你应该这么验证设计5.1 别信仿真器的理想结果要加入时序和功耗约束很多团队做OoO验证时直接用Cycle-Accurate Simulator跑一遍看到利用率提升就开心得不得了。但仿真器默认的限制条件往往太理想化了——比如它默认任务依赖检查是零周期完成的、默认所有计算单元都是理想吞吐。实际工程中这个检查路径很可能落在关键路径上尤其在频率要求高的设计里。我的经验是在做性能仿真时一定把乱序调度的时序开销通常2~4个周期计入模型否则性能预估偏高流片回来会被打脸。给一个我常用的做法在性能模型里增加一个ooo_overhead参数表示每次调度器做一次全局扫描需要多少个周期。开始时设成1逐渐增大到8看性能曲线的斜率变化。如果性能下降很平缓说明设计对调度器时延不敏感如果斜率很陡说明调度器时序是整个OoO性能的关键瓶颈这时候应该优先优化扫描逻辑。5.2 用真实工作负载做回归测试而不是只跑合成基准性能验证的第二大坑是只用GEMM、卷积这类规则负载做基准。这些负载对OoO不公平——它们几乎是FIFO调度器的最优工况。真正应该测的负载包括Vision Transformer含大量动态Shape和跨头依赖MoE推理稀疏路由专家负载不均衡图神经网络采样数据依赖链随机性强混合精度训练前向反向的依赖依赖复杂控制流密集的自定义算子在真实负载测试中我发现一个高价值的衡量指标建议设计组都加上计算单元空闲周期占总周期的比例。你可能会看到合成基准测试空闲率只有5%但真实负载达到30%以上——这才是OoO调度器的价值所在。5.3 验证文件系统和仿真方法硬件验证上OoO调度器比顺序调度器难验证得多。因为任务的执行顺序不确定用传统的有序比较reference model逐周期比对根本对不上。这里我们有实战经验断言Assertion式验证主要检查不变性比如“完成的任务数不可能超过发射的任务数”、“依赖未就绪的任务不可能被派发”这类不变量。参考模型改为“事件驱动”验证而不是“周期驱动”验证只比较最终结果不比较中间时刻状态。随机序列注入加多种初始化种子OoO的bug很依赖初始状态种子太单一容易漏掉边界情况。形式化验证跑RTL用小窗口比如8-entry做完整形式化证明。记得有一次我们写了三个月都没查到的问题最后是用形式化验证工具在窗口范围内发现的——一个依赖矩阵的位宽截断错误导致两个不同任务被误判为有依赖。这个bug在随机验证里几乎不可能重现但在形式化验证里几分钟就暴露了。5.4 软件栈和编译器的配合最后我想强调一个经常被硬件党忽略的点OoO硬件再好也需要软件栈配合才能发挥实力。你不可能让一个编译器在编译时就输出一个需要乱序调度的标志因为OoO的意义就在于运行时自主决策。但编译器能做一件重要的事为硬件提供精确的任务依赖边。如果编译器输出的依赖信息是粗粒度的把整个算子都标成一个大任务硬件OoO就无米下炊。所以NPU的软件栈里编译器一定要把计算图拆分成足够细的异步任务流并且为每个任务标注最小依赖集合。我们在实验中对比过编译器输出粗粒度任务和细粒度异步任务两种方式真实模型的性能差异可达40%。编译器团队和架构团队必须一起工作才能发挥出OoO调度的最大价值。另外还有一个被反复踩坑的地方是内存别名Memory Aliasing判断。很多NPU编译器为了安全把所有内存访问都按依赖处理导致OoO调度器根本找不到可乱序的任务。这里需要编译器做指针分析和别名消岐把真正不冲突的访存标记为独立否则OoO的调度窗口再大也派不上用场。6. 我的取舍建议乱序调度和传统设计的组合拳说了这么多最后给出我在实际项目中的倾向性建议。任务级OoO我认为最适合优先落地的形态不是“全芯片大乱序”而是分层混合调度第一层是编译器/运行时静态调度把计算图切分成粗颗粒度的任务组并按优先级排列。第二层是硬件OoO任务调度器在任务组内动态调度子任务容忍由动态Shape和负载不均衡带来的长尾等待。第三层才是底层计算单元的固定顺序流水线确保每个子任务内部的执行效率最大化。这样做的好处是每一层的设计复杂度都被限制在可控范围同时能同时获得静态调度的低开销和动态调度的冗余容忍能力。具体到硬件规模上小规模NPU低于128 MAC我不建议做完整OoO任务调度器把预算花在加大SRAM带宽更实在。中等规模NPU256~1024 MAC是最适合做OoO的甜点区任务依赖矩阵和窗口大小可以做得很滋润比如32核每核配一个32-entry的任务调度器。超大规模GPGPU数千MAC以上不宜在单一大阵列上做深窗口OoO更适合在GPCGraphics Processing Cluster级别做分布式任务仲裁每个GPC维护自己的OoO队列组间用硬件同步单元协调。最后在设计评审时有一句我常挂在嘴边的话乱序执行不是一种具体的技术而是一种设计态度——承认静态世界的不完美并在运行时给予硬件自主调整的自由。如果你手头的负载特点明显受益于动态调度那真的值得认真考虑一下这条路。
返回列表