ARTICLE DETAIL

资讯详情

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

乱序执行如何拯救AI芯片利用率:NPU与GPGPU的Out-of-Order设计实践

乱序执行如何拯救AI芯片利用率:NPU与GPGPU的Out-of-Order设计实践 你可能刚从一段难熬的性能分析会出来新设计的NPU跑Transformer解码峰值算力标得漂亮实测利用率却只有四成。指令里塞满了NOPDMA搬运结果迟迟不回来一堆本可以算的指令堵在流水线门口。这时候做过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往往很小因为单请求的延迟约束非常苛。一批请求可能只构造出两三个warpSM上的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块因为权重全是零”的判断。这些判断在编译期是可以静态分析的如果那部分权重在模型里确实是零编译器完全可以把对应计算循环删掉。但问题在于现在很多稀疏化的方式是“微结构化”的权重里哪些位置是零在推理阶段才由输入决定。更典型的是MoEMixture 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, R4I2: ADD R5, R1, #8I3: FMA R1, R6, R7, R8I1写R1I2读R1I3又写R1。如果只有一个叫R1的物理寄存器I3必须等I2读完R1才能执行因为I3一写就把数据覆盖了。这个约束叫“写后读依赖”的逆过程——准确说是“写后写WAW依赖”和“读后写WAR依赖”它们不是真正的数据依赖只是同一个寄存器名字造成的假冲突。重命名的做法是给I1分配物理寄存器P10I3分配物理寄存器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, R6I3: IADD R7, R8, #1I4: 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内部挖ILPGPGPU最经典的执行模型是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时代的遗物不如把它当成一个工具箱访存乱序解决延迟执行乱序开发ILPwarp级动态调度解决资源不均引擎间乱序解耦同步开销。按需取用分级落地才能在面积、功耗、验证成本和实际负载之间找到属于自己的那条曲线。我从一开始就没有把Out-of-Order当成一个全有或全无的东西。这也是做芯片最有意思的地方——没有银弹只有每一步取舍之后剩下的、刚好能解决问题的逻辑。
返回列表