你可能刚从一段难熬的性能分析会出来:新设计的NPU跑Transformer解码,峰值算力标得漂亮,实测利用率却只有四成。指令里塞满了NOP,DMA搬运结果迟迟不回来,一堆本可以算的指令堵在流水线门口。这时候,做过CPU微架构的同事会云淡风轻地提一句——“要不试试点Out-of-Order?”别急着觉得他在开倒车。乱序执行从来就不是CPU的专利,它只是“屏蔽动态延迟”这套方法论里最成熟的一支。最近两年,关于Out-of-Order NPU和GPGPU架构设计的讨论明显多了起来,很多团队都在思考同一个问题:当线程切换掩盖不了延迟、编译器静态编排摸不清访存节奏的时候,是不是该把乱序引擎请回来。
这篇文章不是课程讲义,也不是论文复述。我想以一个做过乱序CPU内核、也做过AI加速器调度器的工程师视角,把Out-of-Order NPU/GPGPU的设计思路拆开揉碎,讲清楚几个问题:为什么静态调度开始失灵,乱序里最核心的三个机制到底在干什么,在面积和功耗预算极紧的AI芯片里该怎么分级落地,以及NPU和GPGPU的乱序路线为什么从根本上就不一样。适合正在做AI芯片微架构、搞编译器调度、或者纯粹对“为什么专用芯片也开始搞乱序”好奇的读者。
1. 乱序不是逆向进化:专用芯片为什么开始借CPU的老办法
1.1 三条技术路线的历史岔路
CPU、GPU、NPU在“怎么隐藏延迟”这个问题上,走了三条完全不同的路。
CPU走的是乱序执行(Out-of-Order Execution),从Pentium Pro开始,靠硬件的重命名、保留站、重排序缓冲把动态延迟藏起来。CPU面对的是通用应用,分支预测错误、缓存缺失、多级访存延迟,这些都是运行期才知道的事,编译器再聪明也预判不了,所以硬件必须自己扛。
GPU走的是线程切换路线。GPGPU的基本调度单位是warp(或者说wavefront),一个warp在等全局内存返回时,调度器马上切换另一个warp来跑。只要同时驻留的warp够多,访存延迟可以被大批量的线程掩盖掉。这是GPU不需要乱序执行的经典理由——也是很多人直到今天还在说的话。
NPU走的是静态数据流路线。早期NPU大多采用脉动阵列或者带复杂编译器的数据流架构,计算模式非常规整,一个矩阵乘法的数据什么时候从哪来、结果什么时候流到哪,编译器都能算得很清楚。既然图是静态的,调度就可以做成静态的,硬件越简单越好。
这三条路线在各自的“理想负载”下都非常成立。问题是,AI负载正在脱离理想情况。
1.2 CPU老办法的真正价值:容忍不可预测
乱序执行的核心价值,不是把指令变快,而是把“不可预测”变成“可容忍”。它不预测访存延迟会有多长,也不猜测分支到底跳不跳,而是构造一个足够大的指令窗口,让任何一条指令只要条件满足就立即执行,不需要等前面的指令全部走完。
这个思路对今天的AI加速器有多重要?看一下AI负载的变化趋势就知道了。
GPU遇到的是控制流的动态化。图形和早期GPGPU计算里的warp锁步执行(lockstep)假设,在Transformer解码、稀疏计算、动态形状面前越来越失效。一个warp里32条线程,如果某个样本的循环次数比另一个多,锁步执行会把利用率直接砍半。更麻烦的是,warp之间切换并不能解决warp内部的依赖链延迟——如果每个warp里都有一条超级长的串行计算链,那么切换再多warp也只是同时执行多条长链,单条链的性能没有任何提高。
NPU遇到的是数据流时序的动态化。矩阵运算本身还是规整的,但整颗芯片早就不是“一个矩阵乘到底”了。现在的主流AI芯片里,有Matrix计算引擎、向量引擎、标量引擎、DMA引擎、多级缓存,这些引擎之间的依赖和同步关系越来越复杂。DMA什么时候返回结果,取决于存储层次、地址是否冲突、总线占用,这些在编译期根本定不准。
这时候如果还坚持纯静态调度,就只有一个办法:按最坏情况打拍子。每隔几百周期加一个障碍同步(barrier),所有引擎停下来等最慢的那个。这种设计在编译期可以证明是正确的,但平均下来每一次同步都在浪费算力。
1.3 低占用率:warp切换失效的时刻
有个场景特别能说明问题:低占用率推理。
在云端的实时推理场景里,batch size往往很小,因为单请求的延迟约束非常苛。一批请求可能只构造出两三个warp,SM上的warp数量根本不够轮流切换。这时候GPU传统的延迟掩盖手段基本失灵,一个访存返回400周期,这两三个warp全都得干等。
乱序执行在这种情况下就有了独特的价值:它不依赖“别人家的线程”来填坑,而是从同一个warp的指令流里挖掘可以提前执行的独立指令。同一个线程里的指令之间,往往会有相当比例的非依赖指令,乱序可以绕过那条卡住的访问,先把无关的整数运算、地址计算、甚至某些FMA给做掉。这就是为什么近年几代主流GPU微架构,其实已经悄悄地引入了类似乱序的机制——比如分支跳跃取指、多指令发给、寄存器级别的数据旁路。工业界嘴上没喊“OOO GPU”,身体已经动起来了。
2. 静态调度失能现场:哪些负载正在逼近编译器编排的极限
如果说上一节讲的是原理,这节就是赤膊上阵的实战分析。我在看一个AI算子能不能从乱序里受益的时候,从来不看峰值算力,就看编译器在目标代码里插了多少条NOP、放了多少次强制同步。这些就是静态调度失能的证据。
2.1 Transformer解码阶段:一条又长又深的依赖链
以自回归解码为例。对于每一个生成出来的token,你都要干一件事:拿到前面所有token的K和V缓存,计算attention score,再更新输出。这个过程天然是串行的——不生成完当前token,下一个token的query就出不来。
在计算图层面这是一条漫长的串行链。但注意,这只是宏观层面的串行。深入到单条指令流里,一个token的处理内部有大量可以并行的子计算:多个attention头的score计算互相独立,不同的KV缓存块可以并行读取,softmax之后的加权求和也可以部分并行。问题是,这些独立指令在编译后的静态指令序里,是排在一条长链之后的。编译器不能提前发射它们,因为硬件没有动态调度能力;编译器也不敢把它们的顺序打得太散,因为寄存器压力会爆。
乱序的价值在这里非常明显:硬件在指令窗口里看到一条长链卡住,但窗口里同时还有几十条独立的乘法指令,于是动态地把这些独立指令提前发射出去。执行单元不再因为那条“关键路径”而全线空转。
现代GPGPU乱序研究里有个关键结论:在低占用率解码场景,一个带有浅乱序窗口(比如每warp 24-48条指令窗口)的调度器,能把有效执行周期降低20%到40%。这不是理论推演,很多基于模拟器的公开研究都有类似数字。
2.2 稀疏计算和MoE路由:控制流成了常量?
稀疏计算带来的是另一种动态性——循环边界不定。
结构化稀疏里经常出现“跳过这个2x2块,因为权重全是零”的判断。这些判断在编译期是可以静态分析的,如果那部分权重在模型里确实是零,编译器完全可以把对应计算循环删掉。但问题在于,现在很多稀疏化的方式是“微结构化”的,权重里哪些位置是零在推理阶段才由输入决定。更典型的是MoE(Mixture of Experts)结构,路由器根据当前token的内容选择激活哪些专家,这是一个完全动态的分支。
一旦出现动态分支,静态调度的保守策略是:把所有专家的代码都放在一个巨大的switch-case里,等路由结果出来了,再取指执行对应分支。问题是,被选中的只是其中一两个专家,其他专家的代码逻辑不会执行,但指令缓存里可能已经被占满,分支结果延迟又高,整个warp只能停在分支指令处等待。乱序机制里的分支预测和跳跃取指(branch divergence handling)在GPGPU上已经有成熟的硬件支持,在NPU里反而讨论得少,但设计思路是一样的:不要让控制流依赖变成执行流依赖,该跳就跳,跳错就重放。
2.3 动态形状与多租户混跑:编译器不可能提前知道的事
云上AI芯片还有一重考验:你的芯片每时每刻跑的模型可能不是同一个。有的是大batch的训练、有的是小batch的推理、有的是稀疏推荐模型。每个kernel的网格大小、线程块尺寸、共享内存用量都不一样。
经典VLIW式或静态调度式NPU,在部署前需要对每个模型做一次深度编译优化,生成一个确定性的指令序列。模型一换,优化就要重来。更麻烦的是多租户:两个模型同时跑在一个NPU上,它们的访存行为会互相干扰。编译器做优化时假设的访存延迟,在混跑场景里根本不成立,所有精心的静态编排全部作废。
乱序在这里的价值有点“老火慢炖”的意思——它可以把硬件从“必须信任编译器”中释放出来。硬件动态调度器实时观察访存返回情况、执行单元占用情况,哪条指令能执行就马上执行,天然就适应多租户带来的不确定性。当然,代价是面积和功耗,这个后面细说。
2.4 编译器塞NOP的账怎么算
很多人没有直观概念,编译器插NOP到底有多伤。给你一个简单的量化思路。
假设某段代码编译后静态指令数是1000条,其中200条是NOP。这200条NOP不是编译器闲得慌,而是因为某一条关键指令的结果还没到,后面的几条指令依赖它,硬件又没有动态调度能力,只能原地等待。那么这段代码的实际执行周期是1000拍,而理想情况下只要800拍。
也就是说,NOP比例直接对应利用率损失。如果一个NPU的平均NOP比例是15%,那无论峰值算力标多高,理论上限就是85%,这是架构层面的天花板,再优化电路也没用。乱序执行就是把这种编译器为了迁就硬件而插入的等待时间找回来。硬件看到NOP后面的指令可以执行,就不会傻等。
看完这几类负载,结论很明确:触发器不是逻辑电路,而是动态性本身。静态调度在编制一个在完全可预测环境下的列车时刻表;现实情况是列车经常晚点,乘客经常中途改目的地。乱序执行,本质上就是把“列车调度”从一条笔直的单轨改成了一张可以动态变道的轨道网。
3. 移植乱序前先吃透三个机制:重命名、保留站与提交的分工
如果你已经决定在NPU/GPGPU里加点乱序,先别急着画框图。得先把乱序引擎里最核心的三个机制在AI语境下的意义吃透。CPU里的经典三件套——寄存器重命名、保留站/调度器、重排序缓冲——在AI芯片上的存在价值完全不同,有些甚至可以砍掉或做简化。
3.1 寄存器重命名:消除“假依赖”
先看一个最基础的概念:为什么需要重命名?
在指令集架构的模型里,程序员看到的寄存器叫架构寄存器,数量有限,比如R0到R31。但在乱序执行时,硬件内部维护的是物理寄存器,数量比架构寄存器多得多。重命名就是把架构寄存器映射到物理寄存器。
重命名到底解决了什么问题?看这段假想指令:
- I1: FMA R1, R2, R3, R4
- I2: ADD R5, R1, #8
- I3: FMA R1, R6, R7, R8
I1写R1,I2读R1,I3又写R1。如果只有一个叫R1的物理寄存器,I3必须等I2读完R1才能执行,因为I3一写就把数据覆盖了。这个约束叫“写后读依赖”的逆过程——准确说是“写后写(WAW)依赖”和“读后写(WAR)依赖”,它们不是真正的数据依赖,只是同一个寄存器名字造成的假冲突。
重命名的做法是:给I1分配物理寄存器P10,I3分配物理寄存器P11,硬件里维护一个映射表,告诉后续指令“现在R1对应P11,但I2读到的应该是P10里的旧值”。于是I2和I3就可以完全并行执行,谁都不需要等谁。
在AI芯片里,这种假依赖有多严重?非常多。神经网络算子里经常出现同一个累加寄存器被反复写来写去,比如做卷积的累加器,每个输出通道都要累加很多次。编译器在静态调度时会被这些同名寄存器拖住,不得不把无关指令硬插到依赖链之后。有了重命名,累加器上的多条独立累加链就能并行飞起来。
当然,重命名不是免费的。物理寄存器堆的面积和功耗都很大,每个寄存器要多宽的数据位,取决于你的数据精度。FP16需要16位,加上累加的FP32需要32位。如果你的物理寄存器堆要做成每个周期支持多读多写,那成本更会飙升。后面我会讲怎么控制规模。
3.2 保留站与调度器:真正“做决策”的地方
保留了站和调度器才是乱序的“大脑”。指令经过重命名后,会被放入一个统一的缓冲池里,等待它的所有源操作数都准备好。操作数一到齐,调度器就会检查执行端口是否空闲,如果空闲就立刻发射。
这个机制有两层关键:唤醒(wakeup)和选择(select)。
唤醒是这样一个过程:FMA指令要写回物理寄存器P10,这条指令在cycle 5执行完。那么所有源操作数是P10的指令,都需要在cycle 5被告知“你的数据好了”。硬件上是一个大的比较器矩阵,执行单元写回的物理寄存器号会广播给所有正在等待的指令。选择则是说,如果同一个周期有8条指令都准备好了,但执行端口只有两个,该让谁先上?
经典调度器是给每条等待指令一个年龄标记,优先选择最老的指令。这样做能够保证最终的结果与程序顺序一致。但在AI芯片里,我们不一定需要严格按年龄来选。比如有些低优先级的填充指令、有些可丢弃的预测指令,完全可以靠后。这算是一个在NPU设计里可以做文章的地方。
保留站的大小基本上决定了乱序窗口的大小。你窗口越大,能看到的独立指令就越多,隐藏延迟的本事就越强,但面积和功耗也越大。CPU里一个乱序窗口通常是几百条指令;GPGPU和NPU的面积预算没这么宽裕,做64条甚至32条就已经非常大胆了。
这里有个经验直觉:乱序收益的边际递减非常快。窗口从0增加到32,收益往往很大;从32增加到64,收益可能只有前者的三分之一;从64增加到128,收益就很小了。所以在AI芯片上,一个浅层乱序窗口可能就足够吃下大部分收益。
3.3 提交与精确异常:AI芯片可以偷懒的地方
CPU非常较真“精确异常”——无论在乱序执行的第几步快进,都要保证中断或异常发生时,处理器的状态和严格按程序顺序执行到该点时的状态完全一致。为此需要重排序缓冲(ROB)记录每一条指令的原始顺序,并保证指令按照这个顺序提交(commit)或取消(cancel)。这是乱序引擎里另一个昂贵的部件。
AI芯片真的需要这个吗?我的结论是:大部分场景不需要。原因有三条:
第一,AI加速器的指令主要是向量和矩阵指令,它们的执行是幂等的。所谓幂等,就是如果一条矩阵乘法指令执行到一半被打断了,最简单粗暴的恢复策略是丢掉整个kernel,重新从头执行一遍,结果不会变。这与CPU不同——CPU上可能已经执行了有副作用的系统调用,不能随便重放。
第二,NPU/GPGPU的计算过程有明确的kernel边界。一个GPU kernel的执行时间从几十微秒到几毫秒,比CPU上单条指令的时间长得多。在最坏情况下,直接把整个kernel重放一次,代价是可以接受的。
第三,AI任务对“精确状态”的定义和通用计算完全不同。我们不需要知道“此刻R3是什么值”,只需要知道“整块输出张量是不是完整算完了”。中断时残破的中间状态没有外部观察者,不需要被恢复。
基于这三点,AI芯片在乱序设计里可以砍掉ROB,或者大幅简化提交逻辑。比较常见的做法是:执行完成就是完成,写入寄存器堆后直接对后续指令可见;如果发生异常,就停止发射新指令,等已经在飞的指令全部执行完,然后把整个kernel标记为失败,交给软件重跑。这等于用“kernel级重放”替代了“指令级精确状态恢复”,既保留了乱序带来的性能,又省掉了ROB的面积和功耗。
这个设计取舍,是所有Out-of-Order NPU设计里我认为最关键的一条。它决定了你能省掉多少开销,也决定了乱序执行在AI加速器里是否真正可行。
4. 分级乱序路线图:按面积预算决定做哪一级
聊完机制,落到工程实操。你不可能一次性把整个芯片改成完整乱序,那就是给自己挖坑。更实际的做法是分级执行——每一级对应不同的面积预算、设计复杂度和收益。以下是我推荐的路线图,由浅入深。
4.1 第0级:访存乱序
这一级做得最少的硬件改动:只把访存指令和计算指令之间的顺序打乱。
具体做法是给访存队列加一个独立的调度器。局存(比如SMEM/本地缓存)的load指令,在这个队列里等待地址计算完成,一旦地址可用,不管前面的访存指令是否完成,都可以尝试发射。同时,计算指令不需要像现在这样傻等“DMA完成”事件,而是在数据到达寄存器堆时被唤醒。
这一级的成本相对低。你不需要做寄存器重命名,只需要给每个访存队列增加几项地址相关性检查和一套简单的就绪追踪。最大的难点反而在store-load之间的内存依赖:如果后面的load读的地址正是前面store要写的地址,那必须先等store执行完,否则读到脏数据。
4.2 第1级:执行端口乱序
这是真正引入乱序调度的一级。各个执行单元(FMA、ALU、SFU、地址计算单元)前面的指令队列互不独立,而是一个统一的保留站。指令在保留站里等待操作数,哪个执行端口有空闲,就由调度器把对应的指令派发过去。
举一个实际例子。假设有一个warp,前一条指令是从全局内存load一个值到R1,紧接着下面三条指令是:
- I2: FMA R3, R4, R5, R6
- I3: IADD R7, R8, #1
- I4: FMA R9, R1, R10, R11
如果顺序执行,I2、I3都得排队;而I4依赖I1的结果,必须等到访存返回。但在乱序保留站眼里,I2、I3的操作数早就准备好了,即使I4卡住,I2、I3照样可以发射到对应的FMA单元和整数ALU上。这就在访存等待的几百个周期里,把原本空转的执行单元喂饱了。
执行端口乱序需要一个基础的重命名机制。不需要像CPU那样映射几十个物理寄存器,一般来说每warp配备16到32个物理寄存器槽位就能支撑这个窗口了,这会是一个比较合适的起步配置。此处的成本大头是保留站和调度器,因为它需要每周期做多位操作数的比较。
4.3 第2级:warp级动态优先级调度
三级里最容易被忽略、但工业界最常做的,其实是这一级:warp调度器从“轮流固定轮询”变成“谁有料谁上”。
严格来说,这不叫指令级乱序,但它实现了乱序想达到的效果——动态隐藏延迟。核心是给每个warp一个状态位:这个warp的下一跳指令是否所有源操作数都已就绪。调度器每个周期从“已就绪”的warp里挑一个来发射,如果某个warp在等待访存,就暂时不选它。
听上去很像传统GPU的调度策略对吧?区别在于:传统GPU判断“一个warp能否发射”往往只看它是停顿在访存等待还是可以继续执行;而加上TLB缺失、地址冲突等细粒度事件后,就形成了一个非常敏感的动态调度器。
这一级的好处是不改指令集,编译器也几乎不需要改。它其实是在调度器内部做文章。工业界好多已知的GPGPU都在这个范围里演进,比如支持从指令缓存里跳过一个依赖链、做下一指令预取等,行为上已经接近“局部乱序”。
4.4 第3级:完整乱序的裁剪形态
如果前三级做了之后,你的性能分析还是显示执行单元空转严重、低占用率下延迟掩盖不够,那就得上这一级:完整乱序的AI定制版。
这个版本的特征是:完整的物理寄存器堆映射 + 分布式保留站 + 无ROB的提交简化。
物理寄存器堆大小需要根据你的目标延迟隐藏需求来定。怎么算?这里可以用一个小公式估算:你希望隐藏的访存延迟是L拍,执行单元的发射率是R条/拍,那么至少需要L×R条在飞的独立指令才能把流水线喂满。举例,访存延迟400拍,发射率是每拍1条,那你至少需要400条在飞指令;如果窗口只有64条,那最多只能隐藏64拍的延迟,剩下336拍依然要等。所以小窗口并不是万灵药,你得先搞清楚自己的延迟分布再决定寄存器堆做多大。
这也是为什么兼顾“低占用率场景”的AI芯片,会在局部采用完整乱序。比如推理卡为了在只有1-2个活跃warp时也能榨干算力,可能会牺牲面积给一个足够大的指令窗口。这个取舍每个团队有自己的算账方式,但我建议把“目标延迟隐藏曲线”画出来之后再做决定。
4.5 各级别的收益与成本对比
| 乱序级别 | 主要硬件改动 | 面积开销估计 | 典型收益场景 | 实现难度 |
|---|---|---|---|---|
| L0 访存乱序 | 访存队列调度器 | 较低(<5%) | 访存延迟高、DMA等待严重 | 中 |
| L1 执行端口乱序 | 保留站+局部重命名 | 中等(8%-15%) | 执行单元空转、依赖链混行 | 较高 |
| L2 Warp动态调度 | 调度器状态追踪 | 低(<3%) | 多warp负载不均衡 | 低 |
| L3 完整裁剪版乱序 | 物理寄存器堆+分布式调度 | 高(20%以上) | 极低占用率、强依赖链 | 很高 |
注意,这些百分比不是从哪个手册抄来的,而是基于不同类型AI芯片项目的粗略经验。实际数字跟你的数据位宽、发射宽度、频率目标直接相关。但趋势非常明确:乱序深度每加一级,收益和成本都同步上升,所以“分级落地”不是偷懒,而是对工程负责。
5. NPU和GPGPU的根本分歧:乱序动机与执行模型不在一个次元
很多人会把NPU和GPGPU的乱序当成同一个问题,其实它们的出发点和落点很不一样。简单类比一下:GPGPU是在一个本来就是“通用计算引擎”的身子上,给乱序思维补课;而NPU是在一个为算子特化的流水线机器里,想办法让控制流和数据流重新会合。
5.1 GPGPU乱序:从warp内部挖ILP
GPGPU最经典的执行模型是SIMT(单指令多线程)。一个warp里的32条线程执行同一指令,天然就带32个不同数据。因为同一指令、不同数据,所以执行单元通常不需要为“同一个代码逻辑里多个互相独立的运算”费心,因为指令本身已经是并行的了。
乱序对GPGPU的意义,更多集中在“warp内指令级并行(ILP)很低的时候把它开发出来”。比如一个warp里有一条长依赖链:load A -> FMA -> FMA -> FMA,这些FMA只能串行等待。而同一warp的指令流里还有其他一些独立指令,只是被卡在这条链后面。乱序窗口可以把链后面的独立指令搬到前面。
但这里有个GPGPU特有的限制:SIMT锁步。一个warp内的一条指令在32条线程上执行,必须占用一个完整的指令槽。如果执行单元一次只支持一条warp指令,那么即便窗口里有两条独立的指令,也得用两个周期发射,不能在一个周期同时发射到不同单元——除非架构支持双发射,甚至多发射。这带来了另一个设计参数:多发射宽度。多发射宽度越高,乱序收益越大,但调度器面积也越大。所以在GPGPU里,乱序设计经常和发射宽度一起做,而不是单点优化。
另外,GPGPU遇到分支发散时,warp分裂成几个分支路径,每条路径都要分别执行。乱序调度器可以把不同分支路径上的指令混合发射,从而进一步减少空转。我看到过的很多GPGPU乱序研究,重点都在这个点。
5.2 NPU乱序:引擎之间的依赖解耦
NPU的乱序思考维度完全不同。
以比较典型的新一代NPU架构为例,这类芯片里有几个明显不同的执行引擎:矩阵乘引擎(比如可配置的MMU阵列)、向量引擎、标量引擎、DMA引擎。它们之间的数据流依赖是这样的:DMA先把数据搬进片上SRAM,矩阵引擎从SRAM取数计算,结果送给向量引擎做激活/归一化,标量引擎则处理地址计算和控制流。
这种架构在静态调度里最痛苦的地方是什么?是引擎之间的同步。因为谁都没法精确预测DMA是否按时完成、向量引擎的负载是否会因为数据形状变化而波动,编译器最保险的做法是在每个引擎交接处放一个大的同步点。一个kernel跑下来可能有十几个这样的同步点,每个同步点平均浪费几十拍,一个算子跑完浪费上千拍。
NPU的乱序应该解决的是这个层面的问题:不是单条warp指令间的ILP开发,而是多个独立执行线程(在不同引擎上跑的不同指令流)之间的动态同步。假设一个矩阵指令不依赖DMA_B的结果,而DMA_B还在排队,完全可以让矩阵引擎先执行另一块独立计算。这个决策需要硬件调度器能跨越引擎边界,看到一个更大的数据依赖图。
5.3 工业实践里的真相:混合方案
所以,真实的工业设计几乎都是混合的:在GPGPU里,warp调度器做L2级的动态选择,再加上小窗口的执行端口乱序;在NPU里,每个引擎内部可能做浅层乱序,引擎之间用轻量级的动态依赖追踪机制代替笨重的全芯片同步。
以Intel新一代NPU的公开资料为例,其架构同样包含矩阵、向量、标量等不同计算引擎。要在这种架构里引入Out-of-Order,最合理的方式不是做一个统一的大调度器,而是每个引擎内部各自做小窗口乱序,引擎之间的依赖关系通过硬件同步原语(比如数据就绪标志、防火墙寄存器)动态解析。这样既避免了统一调度器的面积灾难,又能把引擎间交错等待的时间捡回来。
这里也回应了热词里“intel npu开发”相关的一些讨论:NPU的乱序和CPU/GPGPU乱序相比,更强调“流水线粒度”或者说“任务粒度”的乱序,而不是单纯的指令粒度。这个区别很关键。
5.4 一张表说清两个物种的差异
| 维度 | GPGPU乱序 | NPU乱序 |
|---|---|---|
| 执行粒模型 | SIMT锁步warp | 多异构引擎流水线 |
| 乱序主要目标 | 开发warp内ILP、容忍访存延迟 | 解耦引擎间依赖、容忍动态形状 |
| 调度粒度 | 指令级+部分分支路径 | 指令级+子任务级 |
| 典型窗口/队列 | 每warp 24-64条 | 每引擎16-32条+跨引擎同步队列 |
| 主要面积风险 | 调度器面积、多端口寄存器堆 | 跨引擎依赖矩阵、同步逻辑 |
| 编译器配合程度 | 中,需要吞吐率优化 | 高,编译器依然负责主要排布 |
6. 乱序真正难啃的骨头:验证、功耗与存储一致性
如果你看完前面的分析,决定“那就做个L1执行端口乱序吧”,那么恭喜你,真正难啃的骨头现在刚到。乱序执行的性能收益在纸面上算得相当好看,但落地时有三块硬骨头,处理不好会让整个项目回炉重造。
6.1 验证灾难:为何“受控乱序”是好设计
乱序引擎是CPU微架构里验证难度最高、错误最多发的地方之一。在CPU业内,做乱序核心的验证团队人数往往是做执行单元团队的数倍。为什么?因为乱序状态下,同一个架构状态可以对应无数种微架构状态。一个bug可能只在你把特定三条指令安排在特定发射顺序、特定缓存未命中组合下才出现。组合空间是指数爆炸的。
NAS的应对方法一条是“受控乱序”,也是我做项目特别偏爱的方式:不要做完全自由度的乱序,而是规定好乱序窗口的大小、发射规则、可重命名寄存器的数量,并且把这个规则固化成类似“伪随机可预测”的确定性行为。这样验证时可以用形式化方法直接证明,调度器在有限窗口内满足“数据依赖不被破坏”的约束,而不是做海量随机验证。
另一个非常实用的简化是:既然AI芯片允许kernel级重放,那就把验证重心放在“错误能不能必然被发现”上,而不是“错误会不会发生”上。也就是说,设计一个便宜的完整性校验机制(比如最终的张量校验和),一旦校验失败就整核重放。这种方式在航天和通信系统里很常见,在AI芯片乱序里同样适用。
6.2 功耗不确定性与热设计
乱序的功耗像个“坏脾气的演员”:正常跑小负载时功耗低,一旦跑到计算密集又访存密集的重度负载,调度器会疯狂切换指令,寄存器堆读端口全开,功耗可能比顺序执行高出30%到50%。
这里需要特别警惕功耗密度。NPU的卖点往往是每瓦特算力,你的基准测试如果只是跑理想矩阵乘,那乱序几乎不工作,功耗很好看;但一旦跑低占用率动态负载,乱序调度器的活动会导致局部热点。热设计如果按顺序执行的功耗模型来,高温降频时性能反而更差。
好在设计阶段可以做的优化很多:保留站条目可以按需关闭(power gating)、调度器在指令队列空时不重启时钟、寄存器堆的读写端口可以按端口组动态关闭。说到根上,你得在设计早期就把乱序活动因子的功耗模型建出来,不能等流片后再救火。
6.3 存储一致性:同一地址的顺序谁来保证
乱序执行遇到最实际的问题是:访存指令乱序执行,两个访问同一地址的指令怎么办?
比如一条store指令和一条load指令访问同一个地址,如果load先执行,读到的就是旧值,违背程序语义;反过来如果两条store访问同一地址,后执行的先写,前一个会覆盖它,最终结果也会错。CPU里解决这个问题的是load-store队列(LSQ),里面有内容寻址存储器(CAM)结构,每周期对所有在飞访存指令做地址比较,一旦发现冲突就做相应处理。
在AI芯片里,这个问题同样存在,但可以大幅简化:因为我们有kernel边界。如果你在NPU里做L0访存乱序,可以规定一个严格的范围——比如只在访问互不重叠的buffer之间乱序,编译器在编译期通过别名分析把不冲突的全局内存访问打上“可乱序”标记,硬件只在带这个标记的指令间做动态调度。这是一个非常有效的“编译器辅助乱序”方案,既拿到了乱序性能,又避开了CAM的功耗和面积。
如果不想让编译器背锅,那就只能在访存队列里做完整的地址相关性检测。很多GPGPU架构其实就是这么干的,但代价很大:CAM结构在先进工艺下非常费电,而且每个地址比较器的面积不小。所以工业界更倾向的是折中方案——只做同一warp或同一thread block内的相关性检测,跨块的访存乱序直接禁用。
6.4 设计早期就应该画的几条曲线
最后给你一个特别实在的建议:任何乱序设计开始之前,先从你的真实负载里抓三组数据。
第一,访存延迟分布曲线。你的负载访存延迟是稳定的几十拍,还是从几十拍到上千拍剧烈抖动?如果稳定,乱序收益有限;如果剧烈抖动,乱序的收益空间巨大。
第二,指令依赖链长度分布。把目标算子反汇编出来,统计每个基本块里的最长依赖链长度。如果普遍是2到3拍的短链,那浅乱序就够了;如果经常出现几十拍的强依赖链,你需要更大的窗口。
第三,执行端口利用率曲线。在你的理想benchmark上,FMA单元、向量单元、DMA单元各自的空闲周期分布是什么样的?如果FMA单元已经很忙了,乱序并不能凭空增加算力;但如果是DMA回不来导致FMA空转,那乱序的每一分面积都花在刀刃上。
有了这三条曲线,你基本就能判断该做哪一级乱序。我做过的几个项目里,多数最终落在这儿:访存级乱序必做,执行端口乱序看曲线,完整乱序除非有非常强烈的低占用率需求,否则不碰。
说了这么多,回到最开始那个四成利用率的问题。乱序执行并不是银弹——它没法把一个“算法本身串行”的问题变并行,也没法拯救一个执行单元已经跑满的流水线。但在那些编译器看不住动态性的现场,它是一个被验证过无数次的成熟工程手段。做AI芯片设计的人,与其把乱序当成CPU时代的遗物,不如把它当成一个工具箱:访存乱序解决延迟,执行乱序开发ILP,warp级动态调度解决资源不均,引擎间乱序解耦同步开销。按需取用,分级落地,才能在面积、功耗、验证成本和实际负载之间找到属于自己的那条曲线。
我从一开始就没有把Out-of-Order当成一个全有或全无的东西。这也是做芯片最有意思的地方——没有银弹,只有每一步取舍之后剩下的、刚好能解决问题的逻辑。