资讯动态

NPU/GPGPU乱序执行设计:从指令级到任务级的微架构实践

发布时间:2026/9/8 13:14:21 来源:尧图企业网站定制
最近在架构组内部做了一次技术分享主题是Out-of-Order NPU/GPGPU设计思路。准备资料的几天里我越发觉得这个话题被严重低估了一提到乱序执行很多人第一反应是IBM、Intel、ARM这些CPU厂商的老古董和NPU/GPGPU这种以吞吐见长的加速器没多大关系。但实际情况恰好相反——从NVIDIA这些年Hopper/Blackwell的调度器设计、到Intel NPU这类AI加速器里的任务级流水线再到各类GPGPU研究里反复出现的warp级乱序发言乱序这件事已经在加速器领域长出了完全不同的形态而且水很深。这篇文章不打算写教科书式的概念科普我想从实际做微架构设计的角度把NPU/GPGPU里的乱序执行拆开讲清楚到底有哪些乱序粒度、为什么非做不可、落地时寄存器堆和调度器怎么取舍、访存子系统怎么配合以及验证阶段最容易被坑的地方。内容主要来自我自己做GPGPU模拟器和AI加速器微架构的经历以及和同行交流时踩过、见人踩过的坑希望对正在做相关设计的人有帮助。1. 先厘清概念NPU/GPGPU里的乱序远不止一种1.1 指令乱序、访存乱序、任务乱序是三个层面很多人聊乱序时只想到CPU里的register renaming out-of-order execution那套。但在NPU/GPGPU语境下乱序至少可以拆成三个粒度而且这三个粒度经常被混在一起说导致讨论效率极低。第一是指令级乱序。这是CPU的老本行指令进入流水线后被重命名在保留站里等待操作数就绪哪个就绪了就先执行最后再按原始顺序提交。这种机制在真实GPU里其实并不普遍——GPU更依赖高并行度来遮蔽延迟主流架构仍然是in-order发射。但在很多GPGPU研究里比如把warp调度器换成乱序发射、或者对warp内指令做乱序issue的工作近几年论文数量还是很多的。第二是访存乱序。这是GPU里最普遍、也最容易被忽视的一种乱序。现代GPU的load/store unit完全可以乱序发出访存请求L1 miss的请求不一定按指令顺序进入L2返回的数据也可能乱序到达。对于GPGPU的memory pipeline来说乱序访存几乎是默认能力。乱序完成这四个字说的更多是访存指令的执行结果而非CPU式全乱序执行。第三是任务级乱序。这个粒度在NPU里尤其关键。一个NPU要跑一个神经网络会拆成很多算子、很多layer、很多block。算子之间虽然有数据依赖但同一层里多个block往往完全独立不同layer之间也可能因为多个计算单元的独立性而部分并行。硬件任务调度器把这些task按依赖关系发给不同计算单元谁准备好了谁先跑先完成的后完成的无所谓——这就是典型的任务级乱序。我在项目里经常看到一种争论有人说我们架构不支持乱序所以延迟高另一个说我们明明有乱序发射。吵了半天其实一个在说任务调度一个在说指令发射。所以先把概念切分清楚后面所有分析才有共同语言。1.2 CPU乱序的遗产重命名、ROB、保留站既然聊乱序难免要回到CPU那套经典结构。简单回顾一下CPU的乱序执行核心是三件套重命名把架构寄存器映射到物理寄存器消除WAR/WAW伪相关。重命名表一般叫RATRegister Alias Table提交时再恢复。保留站/调度器每条指令等待所有源操作数就绪后才能被选择执行。调度器内部一般有多个entry按操作数状态和优先级做仲裁。ROBReOrder Buffer用环形缓冲保存未提交指令的顺序信息。执行可以乱序完成但提交必须按程序顺序ROB里的ID就是顺序依据。这套机制在CPU里玩了几十年成熟度很高。但如果你直接把它照搬到GPGPU/NPU大概率会死得很难看。原因后面细说简单提一句CPU的乱序是为了单线程性能加速器的乱序是为了吞吐目标是完全不同的所以结构细节必须改。1.3 为什么顺序提交在加速器里没那么重要CPU必须有顺序提交是因为要支持精确异常precise exception——中断来了软件要能恢复到指令边界状态。但GPU/NPU是面向吞吐的加速器它非常不喜欢在热路径上做精确恢复。GPU一般以warp或者整个kernel为单位做错误处理NPU更粗暴通常以task或者整个推理请求为单位做错误上报。这带来了一个巨大的架构红利加速器可以不做严格意义的ROB或者把ROB做得非常浅。没有顺序提交约束指令执行完直接释放资源调度器的压力小很多也不需要commit阶段的大带宽写回。很多所谓的GPGPU乱序设计其实是重命名 乱序发射 乱序完成后直接写回本质上跳过了ROB。这个点在后面设计章节里会展开。2. 为什么GPU和NPU最终都绕不开乱序执行2.1 延迟隐藏的两条路线堆并行度还是堆调度逻辑加速器要隐藏内存延迟传统思路是堆并行度GPU通过大量warp切换来掩盖DRAM的几百个周期延迟TPU这种NPU则通过大量dataflow和流水线掩盖。这种方法简单直接但到一定规模后边际收益骤降。原因在于并行度需要资源支撑。每个warp要占寄存器文件、占scheduler slot、占scoreboard位宽。一个SM能容纳的warp数量有上限寄存器堆面积和功耗在那里卡着。当你没法继续增加常驻warp数时想隐藏剩余延迟就只能让已发射的指令更高效地利用处理器资源——这就必须靠乱序。我打个比方一个餐厅出菜慢最简单的方法是增加厨师数量。但厨房就这么大厨师已经站不下了这时再提高出餐效率只能让厨师不等上一道菜做完就直接处理下一道菜的配菜甚至哪个菜的材料先齐就炒哪个——这就是乱序厨房。2.2 资源碎片NPU里最容易被低估的问题NPU里有个特别典型的碎片问题矩阵乘法单元MMA和向量单元Vector通常是独立的硬件块。一个算子可能只用矩阵单元另一个算子可能只用向量单元。如果所有layer都严格按顺序执行矩阵单元在跑向量算子的时候只能干等利用率惨不忍睹。我做过一个真实案例一个小型神经网络里有两个支路支路A是卷积支路B是大量的elementwise操作。按原始拓扑顺序跑卷积单元和向量单元的实际利用率大概只有四成。后来在我们自己的调度器里加入了任务级乱序——只要数据依赖允许把支路B的向量算子插到支路A的矩阵算子中间执行。同样的硬件端到端延迟降了接近三成没有增加任何计算单元。这就是任务级乱序的核心收益把不同硬件资源的空闲片段填满本质上是时间维度的资源复用。看似是个算法问题实际牵动微架构。2.3 Warp发散和尾部效应带来的调度空隙GPGPU里也有类似现象。一个warp内部如果发生分支发散部分lane执行的指令另一些lane在等待执行单元会出现部分空闲。硬件调度器如果能在这些空档插入其他warp的指令就能把SIMD单元的吞吐拉满。还有尾部效应一个block里最后几个线程因为负载不均衡迟迟不结束后面的block只能等着。传统GPU的GigaThread引擎其实已经在做block级别的乱序分配——哪个SM空出来了就发新block而不是严格按block ID顺序执行。这也是乱序只不过没叫这个名字。所以你会发现一个很有趣的事实GPU/NPU从诞生那天起就在很多层面做了乱序调度只不过长期以来大家意识里乱序专指CPU指令窗口。真正的问题不是要不要乱序而是在哪些层级、用什么形式乱序最划算。3. 乱序执行微架构落地的关键设计寄存器堆、调度器、提交3.1 物理寄存器堆大小先做容量推演再谈其他如果要做真正的指令级乱序执行寄存器堆必须从架构寄存器文件改成物理寄存器文件。物理寄存器堆的容量直接决定乱序窗口能开多大这里有一个很实用的估算公式。假设目标隐藏的内存延迟是L周期每个warp期望的在途指令数是ISM里常驻warp数是W每条指令最多会往多少个目标寄存器写数记为D。那么物理寄存器数量至少是PhysRegCount W * I * D * LaneCount举个例子一个SM计划支持32个warp每个warp希望在途8条指令每条指令最多写2个寄存器比如FMA写1个但有些双发指令写2个一个warp是32个lane。那么需要的物理寄存器数量就是32 * 8 * 2 * 32 16384也就是每SM需要16K个物理寄存器。这个数字相当惊人因为一个寄存器通常是32bit16K寄存器就是64KB寄存器文件还只是保守估计。要是把warp数提到48、在途指令提到12直接翻到2304个entry面积立刻爆掉。所以做乱序GPGPU第一个要做的不是画流水线图而是用性能模型把寄存器堆面积和延迟隐藏效果扫一遍。我的经验是物理寄存器堆容量通常是你真正能实现的乱序窗口上限的硬约束调度器宽度反而是软的。3.2 调度器组织集中式保留站还是分布式warp队列CPU的保留站是全局统一的无数条指令在一个池子里面等操作数哪个就绪了选哪个。但在GPU/NPU里真正意义上的全局保留站几乎不现实因为指令总量太大、仲裁带宽要求太高。工程上更常见的是分布式warp队列 每周期多warp仲裁的做法。每个warp维护自己的一小段指令队列调度器按某种策略选出若干个warp然后从这些warp的队列里各取一条能发射的指令。这样做的好处是仲裁逻辑简单缺点是乱序窗口被限制在warp内部——一个warp内部的指令可以乱序发射但不同warp之间依然靠多线程并行来掩盖延迟。如果你追求更强的乱序能力可以做成每个warp内部有指令级乱序同时发射端支持多warp交错。这种结构的调度器潜规则是优先选择操作数已经就绪的warp而不是机械轮转。很多学术项目包括我自己做过的一个基于GPGPU-sim的修改版就是在warp队列前加了一个就绪过滤级从一堆warp里挑出ready warp再仲裁。实测在某些访存密集的kernel上能提升10%~15%的IPC但代价是调度器面积增加20%以上而且功耗明显上涨。3.3 提交策略跳过ROB的代价是什么前面说了加速器可以不用精确异常因此很多人索性连ROB都不用了。但我要提醒一个坑没有ROB你的分支预测恢复会非常难受。GPU/NPU的SIMT执行分支预测其实不像CPU那么激进但依然存在。如果warp内分支发生预测错误而那些乱序执行的指令已经写回了物理寄存器你要恢复分支错误前的状态——没有ROB你怎么知道哪些指令该撤销这是很多简化版乱序GPGPU翻车的重灾区。我的建议是保留一个轻量ROB但不需要记录完整寄存器快照只记录指令序号和分支标签就行。一旦发生flush按序号范围把物理寄存器做一次统一释放而不是逐条回滚。这个做法基本兼顾了面积和功能。NPU里的情况稍微不同。NPU的任务级乱序一般不涉及分支恢复而是涉及任务ID的跟踪。每个任务执行完把结果写回指定的SRAM地址或直接累加到结果缓冲。因为中间结果都在缓存里天然支持回滚反而比GPGPU简单。所以在NPU里任务级ROB可以做成一个很小的完成队列唯一要保证的是同一个输出地址的写操作不能乱序——这个靠地址冲突检测解决。3.4 一个典型乱序GPGPU流水线草图我用文字给一个典型的乱序GPGPU流水线做个画像方便大家对照。取指(按warp) - 译码 - 重命名(物理寄存器分配) - 写入warp指令队列 - 调度器(多warp仲裁 就绪检查) - 发射 - 执行单元 - 写回(乱序) - 轻量ROB(记录提交顺序) - 释放物理寄存器这跟CPU流水线最大的区别在于多了一个warp维度重命名和物理寄存器分配都是按warp分组的调度器既要做指令是否就绪的判断还要做选择哪些warp的仲裁写回不要求按序释放资源也更快。我第一次把这套流水线放到RTL里仿真的时候发现最意外的瓶颈不是调度逻辑而是重命名阶段的端口冲突。多个warp同时分配物理寄存器分配器端口不够导致每周期只能重命名少数指令取指宽度再大也白搭。这个问题在CPU里也存在但GPU的warp并发数更大端口冲突更明显。后来我们的办法是给分配器加了一个简单的bank机制不同warp映射到不同bank减轻端口压力。4. 调度策略与访存子系统的联动顺序之外的关键战场4.1 调度策略不能只盯吞吐公平性和活锁做调度器最诱惑的一点是永远挑选执行最快的指令但这会带来公平性问题。假设有两个warp一个访存延迟低、指令准备快另一个总是慢半拍。如果调度器永远选前者后者的指令可能在队列里等很久甚至饿死。CPU里经典的解决方法是GTOGreedy-Then-Oldest策略平时尽量让同一个线程/warp连续发射指令一旦它遇到长延迟操作立刻切换给最老的等待者。NVIDIA公开资料里提到过类似思路。我在自己测试过的一个warp调度器里复现过GTO公平性指数用warp IPC的方差来衡量比纯轮转提高很多总吞吐反而基本不降。这是很反直觉的地方——公平性和吞吐在多数情况下不是对立的只要策略选对。另外要警惕活锁如果调度器总是取消看起来不紧急的任务某些任务永远跑不完。任务级调度尤其如此。我对活锁的工程建议是给每个warp/task一个age计数器超过阈值的任务获得绝对优先权这一条必须写死在仲裁逻辑里不能靠策略软件保证。4.2 访存乱序与内存模型GPGPU比看起来更宽松当指令乱序发射、访存请求乱序完成存储系统的行为就变成一个宽松内存模型relaxed memory model。这对软件来说是个头疼的问题但对硬件设计反而是个窗口——你可以更自由地重排访存。不过有两个底线不能碰第一个是同一地址的读写顺序。即便内存模型宽松两个访问同一地址的指令也不能无限重排否则程序语义崩溃。硬件上一般靠地址比较器做检测命中冲突就把后一条指令卡住直到前一条完成。第二个是同步指令的隔离性。GPGPU里有barrier、NPU里有依赖边。syncthreads这类的同步指令一旦发射它前后的访存必须严格划分前面的全部结束后后面的才能开始。这个操作在硬件上通常实现为LSU里的一个fence标记所有已发出的访存请求完成后才允许后续请求出队。我见过一次很典型的bug某个自研GPGPU核为了追求性能把fence指令也当成普通访存指令乱序执行了结果syncthreads直接被跳过大量kernel算出了错误结果。排查了快两周最后用形式化验证工具找到了LSU状态机里缺少fence检查分支。这类问题在乱序系统里尤其隐蔽因为不是每个指令流都会触发只有特定的访存竞争场景才会。4.3 原子操作和写缓冲NPU乱序里最实际的坑NPU里做注意力机制时softmax和Attention需要大量跨计算单元的归约。硬件上通常会提供原子累加器或者专用的归约网络。在任务级乱序下多个task可能同时往同一个输出地址做原子操作。这里有个特别实际的坑如果原子操作的执行顺序和task完成顺序不一致最终累加结果就会出错而且错误不固定——依赖于任务调度的实时状态复现极难。我们的工程方案是给每个任务分配一个唯一的task ID在原子操作的地址后面附加一个ID比较逻辑只有最大ID即逻辑上最后完成的那个任务的原子结果才被写回最终地址。这其实就是在硬件层面实现了乱序执行、顺序归约。GPGPU里的写缓冲区也一样。乱序执行的store指令会先进入write buffer再由写地址和顺序策略决定什么时候刷到L2。如果写缓冲是严格FIFO乱序窗口会被堵死如果完全乱序刷出又可能违反一致性。常见的做法是把写缓冲按cacheline分桶同line内保持顺序不同line之间可以乱序刷出这是一个很均衡的折中。4.4 死锁乱序系统里最不能忽视的系统性问题乱序 资源互等 死锁温床。我举个例子两个warp都要执行一条访存指令但执行之前都必须占用一个scheduler slot。Warp A占着slot等数据Warp B被调度器选中后要占slot却被拒绝因为A不退。如果调度算法是只有拿到了slot才停在这个状态两个warp就互相卡死了。死锁的工程解法有几种资源预留每个warp至少保留一个scheduler slot任何时候不会被完全占用。非阻塞分配拿不到资源就cancel释放已有资源重试用随机退避避免活锁。全局进度检查系统层面周期性检查是否存在长期无进展的任务强制flush部分资源。我个人的经验是死锁问题在仿真阶段几乎很难完全测完一定会有极低概率的路径只在真实负载上爆发。因此RTL里必须加死锁检测计数器某资源等待超过N个周期自动触发一次全局恢复操作宁可损失一点性能也别让整个系统挂死。5. 验证和调试乱序系统最折磨人的地方以及工程落地建议5.1 验证随机测试是主力但必须有黄金参考模型乱序系统的验证难度比顺序系统高出一个量级。我见过很多团队在顺序核上验证做得很顺一改造乱序第一周就炸——因为指令完成顺序是乱的逐个比较输出变成不可能。我们自己的做法是搭一个行为级顺序参考模型纯C写的模拟器按照最原始的指令顺序执行输出每个warp每个寄存器的最终值。DUT被测硬件无论怎么乱序执行最终寄存器状态要和参考模型一致。每次RTL跑完把寄存器堆和内存dump下来对比。这个过程有个隐藏考点中间状态不要比只比最终状态。乱序系统的中间状态必然和顺序模型不同如果你比中间状态一天能报几百个假错而且全是因为乱序本身而不是真bug。另外一个很有效的技巧是约束随机指令流。不要把指令完全随机化而是在指令流里故意插入难解的相关性同一寄存器的WAW冲突、两个地址的WAR冲突、边界上的写后读相关这样更容易触发重命名和乱序窗口的边界问题。我在实际测试里发现大部分严重的乱序bug都是在相关性密集的指令序列里迸出来的随机纯流反而测不出东西。5.2 调试复现是最大的敌人乱序系统的bug有个特性你不去动它它下一次也不一定复现因为调度时序稍微变了结果就变了。我印象最深的是一个寄存器重命名bug在某个特定的warp数、特定的指令分布下物理寄存器回收早了导致另一个warp把还没读完的旧值覆盖掉。这个bug在RTL仿真里复现概率大概只有千分之一最后是靠加了物理寄存器引用计数调试模块把每次引用/释放的日志打到外部才定位到。所以调试乱序系统我强烈建议从一开始就设计好tracer。最少要有指令发射日志每条指令的warp ID、指令序号、物理寄存器分配情况。访存请求日志地址、类型、发出的周期、完成的周期。调度器决策日志每个周期选了哪个warp、哪个entry、为什么。这些日志不用全量保留可以做成环形缓冲触发错误时自动dump。没有这个机制乱序系统出了bug你大概率只能干瞪眼。5.3 性能反直觉乱序窗口越大不一定越好我在不同项目里测量过很多次乱序窗口和性能之间是典型的非线性关系窗口太小提升有限窗口太大调度开销会吃掉收益甚至出现性能下降。原因很好理解调度器每个周期要在更多候选指令里选出合适的仲裁时间变长、功耗上升物理寄存器堆变大导致访问延迟上升指令队列变深分支错误的flush代价急剧上升。这三个因素叠加窗口过大的边际收益是负数。我的一般建议是先做一轮性能仿真用gem5或者自研的trace-driven model扫一条窗口大小 vs 性能提升曲线。通常曲线会在某个点趋于平坦那个点就是工程上的甜点。不要嘴硬非要做到32 entry乱序窗口很多时候8~16已经够了多出来的面积留给你更需要的缓存。5.4 工程落地分三步走别一口吃成胖子如果一个新项目要从零开始做NPU/GPGPU并且想加入乱序能力我个人推荐的落地顺序是第一步做访存乱序LSU可以乱序发请求、乱序完成但指令发射仍然按顺序。这一步对性能提升非常可观而且对架构改动相对小。这里乱序的收益主要来自访存级并行基本不涉及寄存器重命名复杂度。第二步做轻量指令乱序只对访存指令和计算指令混合排序用轻量ROB管理提交物理寄存器堆按估算公式的中间值来定。这一步收益可能不如第一步大但对碎片化负载提升明显。第三步做任务级乱序这一步对NPU/GPGPU的系统级调度收益最大但需要硬件任务队列、依赖跟踪和完成顺序逻辑投入也最大。我的个人体会是如果目标负载里并行任务很多第三步收益往往远超第二步可以优先考虑。这套顺序的依据是风险控制每步都有独立收益且不会在架构层面留下短期无法回退的坑。很多团队上来就要做全乱序结果RTL延期一两年还是先把访存乱序做扎实更划算。最后说一个我自己的习惯乱序设计一定要在性能模型阶段把乱序度当参数量化而不是当口号。每次有人跟我说我们这版要支持乱序我都会追问一句你指的乱序是访存乱序、指令乱序还是任务乱序窗口打算开多大物理寄存器算过没有能把这三个问题明确回答出来乱序设计才算是真正开始了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价