拓冰建站拓冰建站
首页 / 资讯中心 / 正文

AI芯片乱序执行:从CPU到NPU/GPGPU的工程实践

做AI芯片的人这两年肯定被“Out-of-Order”这个词反复轰炸过。CPU里的乱序执行早就不是什么新鲜事但轮到NPU和GPGPU开始认真谈乱序说明一个趋势已经非常明确靠编译器静态排布指令、靠in-order流水线硬怼算力的时代在复杂真实负载面前已经越来越吃力了。我不是做理论研究的这些年主要是在NPU和类GPGPU的架构设计、仿真验证和实际部署之间反复横跳这篇东西就是从实战角度聊聊我对“Out-of-Order NPU/GPGPU设计”的理解包括为什么需要它、怎么从CPU那边学、怎么把它落到MAC阵列和主网格阵列上以及一堆只有踩过坑才知道的细节。这篇文章适合正在做AI加速器架构、做编译器与硬件协同设计、或者单纯想搞明白“乱序执行怎么从CPU搬到AI芯片”的工程师和研究生看。有些内容可能偏底层但我会尽量用大白话把逻辑讲透。1. 为什么NPU和GPGPU必须开始考虑乱序执行1.1 顺序执行在真实负载下的痛点传统NPU通常会采用比较保守的调度方式指令窗口里就那么几条指令编译器提前把依赖关系分析好硬件按部就班地发出去执行。这种做法在算力密集、访存规整的Conv、MatMul上确实表现不错因为计算模式太规律了编译器做静态排布就够了。但真实部署场景远没有这么温和。我去年调一个稀疏卷积加速器的时候就发现硬件利用率始终上不去调度器明明没闲着MAC阵列却经常在等数据。问题出在哪卷积窗口扫到稀疏区域时有的通道压根不需要加载数据可load指令已经发出去了后面的计算指令却被依赖关系卡死整个流水线就空转。顺序执行对“非常规访存”和“数据依赖动态变化”的容忍度太低这是最核心的痛点。另一个痛点来自多kernel并发和动态shape。现在NPU越来越多的场景是多个任务共享同一个加速器每个任务的输入shape可能运行时才确定编译期根本没法定死调度方案。编译器做不了的事情硬件就必须兜底而兜底最直接的手段就是乱序执行。1.2 乱序执行到底解决了什么问题乱序执行的核心价值不是“把指令颠倒了跑”这么简单而是把“依赖关系分析”从编译期部分转移到运行期用硬件去容忍动态的不确定性。具体到NPU/GPGPU语境的收益我总结有三点。第一访存延迟隐藏。当一个PEProcessing Element在等Global Memory或DRAM数据时调度器可以立刻切换到其他没有依赖冲突的指令或者warp让MAC阵列一直有活干这和CPU隐藏cache miss延迟一个道理。第二动态资源分配。某些数据准备好得早乱序调度器可以优先发射而不是被前面的阻塞指令拖死。第三编译器瘦身。编译器不需要做那么激进的依赖分析和调度排布硬件乱序窗口兜住一部分编译速度和二进制兼容性都有改善。可能有人会说那GPGPU本来就是靠大量并发线程来隐藏延迟的硬件调度器只要看到warp能跑就发射这不已经是乱序了吗其实不是传统的GPGPU在单个线程wavefront/warp内部指令仍基本按序发射只是靠线程之间的切换来填满流水线。真正的“乱序”还要更进一步它允许单个线程内的指令动态重排允许跨线程共享调度窗口。这才是我要讨论的范畴。1.3 什么负载从中受益最明显我实测下来有三类负载对Out-of-Order的收益最敏感稀疏计算。稀疏Transformer、稀疏CNN、推荐系统里的Embedding聚合数据分布极其不规则编译期静态调度基本抓瞎乱序窗口能极大提高MAC利用率。多分支动态控制流。比如动态神经网络、有条件的循环体不同分支之间路径长度差别很大顺序执行等于白白等待最慢的分支。多租户混合调度。多个任务共享一个加速器任务间没有固定的依赖关系乱序调度器可以跨任务挖掘并行性这个在云侧AI推理芯片上作用非常大。顺序执行不是不能用而是遇到这些负载你会肉眼可见地看到MAC阵列利用率往下掉功耗倒是没减多少因为控制逻辑和存储都在空转。乱序执行就是在这些场景里体现出真正的价值。2. 从CPU乱序到NPU/GPGPU乱序不是照搬是重构2.1 CPU乱序的核心机制与AI加速器的映射关系经典的CPU乱序执行核心是三件套调度窗口Issue Queue、保留站Reservation Station、重排序缓冲ROB。指令进入调度窗口等待操作数就绪后由保留站发射执行完的结果写回ROB最终按原始程序顺序提交Commit。这套机制搬到NPU/GPGPU上听起来很顺理成章但直接照搬会死得很惨。原因在于CPU的指令粒度是“一条算术/访存指令”而NPU的指令粒度是一整条张量指令一次可能驱动整个MAC阵列跑好几个周期。粒度不同调度器的宽度、复杂度、存储成本都完全不同。我对NPU乱序的设计思路做了一个映射CPU乱序部件NPU/GPGPU里对应的东西核心差异指令调度窗口张量指令队列Tensor Instruction Queue队列深度不用很大指令少但重每条约几百个周期保留站微指令分发器 寄存器就绪表需要跟踪的是张量寄存器/片上buffer而不是标量寄存器ROB完成记录表分数板记录的内容不是结果数据而是“哪个buffer的哪段数据已就绪”操作数就绪检测片上buffer/内存依赖跟踪粒度是整块buffer甚至buffer段不是单个字节我个人的观点是NPU/GPGPU的乱序应该做在“张量指令”这一层而不是像CPU那样深入到微操作级别。内部即使要把一条大指令拆成多个微操作也不需要用真正的out-of-order微操作调度那样面积和功耗直接爆炸。做成有限度的乱序窗口调度粒度到“block/指令组”级别是最符合实际的做法。2.2 为什么不能简单复用CPU的ROB和保留站有人会问现有CPU IP成熟把RISC-V或ARM核里的乱序逻辑抽出来不就行了问题不止出在指令粒度上还有下面几个原因。第一资源依赖结构完全不同。CPU容易发生的是寄存器WAR写后读和WAW写后写冒险但NPU更怕的是缓冲区别名问题——两条张量指令写同一块片上SRAM的不同偏移硬件很难快速判断是否冲突。CPU的寄存器重命名机制在这里很难直接用因为重命名的对象是“地址空间片段”而不是固定编号的寄存器。第二提交语义不同。CPU的提交要保证精确异常每条指令都可能有页错误、算术异常。NPU的张量指令执行时间长达数百周期中途任何异常都意味着巨大的回滚代价。真正实用的做法是让指令“先提交效果、后报告异常”或者用软流水配合栅栏Fence保证边界一致性。这样ROB里根本不需要存整条指令的结果只需要记录“谁完成了”。第三面积和功耗预算吃紧。NPU出来本来就是要堆算力的控制开销占太多面积就是浪费。CPU乱序调度器动辄几十KB的存储和一堆比较器放到NPU里这些成本可是会和MAC阵列抢面积的。所以NPU/GPGPU的乱序必须做“轻量化”我对轻量化的理解是窗口不大32128条张量指令就够调度逻辑以“矩阵依赖”快速判断为目标而不是逐条细粒度比较。2.3 纵深思考乱序的边界在哪里很多团队讨论乱序时容易走极端——要么觉得完全不需要要么想把CPU那套全搬过来。我的建议是先想清楚乱序要解的是“延迟隐藏”还是“吞吐提升”。如果目标是延迟隐藏那么大部分问题可以用更深的流水线、更大的软件流水、多线程并发来解决并不真的需要乱序。如果目标是吞吐提升而且负载里存在动态且无法预测的长延迟依赖那么乱序才值得上。所以实践中最好做一个“分级设计”基础in-order流水线保持精简遇到长延迟事件比如远端内存访问再进入乱序模式。这也是现在很多AI加速器设计里比较现实的折中路线。3. 调度器、主网格阵列与MAC阵列的配合3.1 主网格阵列与MAC阵列的工作原理要理解NPU乱序不得不先把NPU最核心的计算阵列讲清楚。主网格阵列Main Grid Array简称MGA是现在很多AI加速器采用的计算组织方式它把成百上千个MAC单元排列成一个二维网格每个MAC单元在时钟节拍内完成一次乘累加操作。主网格阵列的工作方式就像一块“算力地毯”数据从左侧和上方流入乘积和累加结果从右侧或下方流出。以典型的脉动阵列Systolic Array为例权重从上方广播或者逐行流入激活值从左侧流入每个MAC单元把输入乘上权重并累加来自上方MAC单元的部分和然后传给下方MAC单元。这种结构的好处是数据复用率极高坏处是数据必须严格对齐哪个阶段的数据晚到一拍后续好几行MAC都可能跟着空转。主网格阵列的调度本质就是“数据流编排”什么时候喂哪个数据、什么时候收集哪个部分和都会直接影响阵列利用率。在乱序执行的设计里主网格阵列的调度器不能再像in-order那样“一次只盯着一整条指令的数据流”了。它需要同时追踪多个准备就绪的“数据块”判断哪个块可以被推进MAC阵列。我做过的一个原型里就在MGA前端加了一张“数据块就绪表”每个条目对应一个tile级的输入表里记录了地址、尺寸、依赖关系以及是否就绪调度器每一拍扫描这张表挑出所有就绪且没有bank冲突的数据块尽量多地塞进MGA。3.2 乱序调度器与MAC阵列的交互方式很多人会把乱序调度器想成一个大而全的中央调度器但现实中主网格阵列并不适合一个“中央大脑”全管。原因很简单MAC阵列的数据路径非常固定你不可能任意调度每一行MAC的工作那样布线就炸了。我比较推荐的做法是“两级调度”第一级是粗粒度乱序调度负责把张量指令乱序地派发到各个计算子阵列的输入队列第二级是每行或者每个子阵列内部的顺序调度严格按照数据流顺序消费队列。这样既享受了乱序带来的延迟容忍又保住了MGA内部数据流的确定性。实际操作中我给调度器定义了三个优先级信号依赖就绪、数据在片上、目标MAC子阵列空闲。三个条件同时满足的指令优先发射。依赖就绪和数据片上这两点很容易理解但“目标子阵列空闲”很多人会忽略——如果指令乱序发射过去了结果到达时目标子阵列还在处理前一条指令那它就会在输入队列里排队反而造成新的排队延迟。所以调度器适当地做“后向压力感知”让指令宁可晚一点发射也不要扎堆到同一个子阵列。3.3 片上网络如何处理乱序完成的数据乱序执行最直观的问题就是“完成顺序和发射顺序不一样”。CPU有ROB重排结果NPU里也得有类似机制但由于数据块很大不可能做逐数据重排更多是靠“存储标签”和“指针追踪”。我把主网格阵列各个子阵列的输出统一发到一个“结果汇合器”Result Aggregator汇合器根据张量指令携带的目标buffer地址和偏移把数据直接写入对应的缓冲段写入完成后再更新一个完成标记。乱序没关系只要完成标记与依赖表对得上就行。这样下游消费者拿到的始终是完整buffer而不是物理时间序上先到先得的数据。然后是片上网络NoC的问题。乱序模式下不同子阵列写回同一个目标buffer的不同区域时顺序可能交错。传统做法是对这个buffer加锁但锁会让乱序收益大打折扣。更好的方案是“地址切片”——把目标buffer按子阵列数量切成若干独立slice每个slice只允许一个子阵列写入写完之后由汇合器统一做一次组合。这样既保住乱序自由度又避免了数据竞争。代价是多了一点点聚合逻辑在MAC阵列面积面前几乎可以忽略。3.4 主网格阵列MAC阵列的数据复用与乱序的冲突这里必须泼一盆冷水乱序执行和MAC阵列的数据复用优化本质上是有一点冲突的。因为在in-order下编译器可以精心安排数据流向让同一个输入数据被多个输出权重复用而乱序执行一旦打乱指令顺序就可能破坏这种复用导致从片上缓存或DRAM重复搬运数据。我的解决办法是不让硬件层面完全自由乱序而是让编译器在指令里显式标注“这个buffer可能被复用”的属性硬件调度器对带有复用属性的buffer给予“尽量连续调度”的约束而对没有复用需求的数据则放心大胆乱序。相当于在“乱序窗口最前端”加了一个非常小的软流水约束既保住了大部分复用收益又保留了乱序的动态灵活性。4. 存储、同步与一致性设计4.1 部分和缓存与结果重排张量计算里多组MAC子阵列计算同一个输出tile时会产生多个部分和。乱序执行下这些部分和可能以任意顺序到达累加单元如何保证最终加法结果正确是个实打实的工程问题。保守方案是等所有部分和到齐后再做一次排序累加。这个方法简单但会显著增加延迟。我实际用的方案是“树形部分和合并”每个输出位置维护一个累加器组部分和到了就加到对应位置等所有贡献部分和的指令全部完成标记置位时整个累加器组的数值就是正确的。本质上这是一个硬件支持的“分段事务”只要你跟踪好了完成计数乱序到达的部分和不会影响最终结果。这里有个小trick要给“完成计数清零”设置一个明确的点否则调试的时候非常痛苦。我建议在architected state里提供一个“tile同步计数器”软件可以显式查询某个tile的部分和是否已经完整这样可以绕过很多难查的竞态问题。4.2 同步屏障在乱序体系中的变形传统同步屏障在乱序执行下会面临两难屏障太频繁会退化成顺序执行屏障太稀疏又容易造成数据覆盖和依赖错误。我现在的做法是区分“强屏障”和“弱屏障”。强屏障要求之前所有指令全部完成并落盘一般在kernel切换、buffer重用或者多核同步点才用。弱屏障只要求依赖链上的指令完成它更像一个“数据就绪事件”由调度器动态等待。弱屏障配合乱序窗口能把同步开销减少一半以上。还有一个要点不要依赖“写完buffer再等屏障”这种粗粒度同步。设计上最好把buffer生命周期管理直接融合到调度器里调度器知道某块buffer已经被后续指令覆盖之后就再也不调度读它的旧指令了。这一步做好了屏障数量能大幅下降。4.3 一致性模型选择为什么几乎总是弱一致性NPU/GPGPU领域几乎不会采用CPU里的强一致性内存模型原因很简单大量片上buffer和散布的计算单元之间做全局一致性开销完全不可接受。乱序执行环境下一致性只会更弱。实践中我遵循的准则是每个线程/每个kernel上下文只能看到自己显式写入或读入的buffer跨kernel、跨上下文的数据传递必须通过“明确的发布-等待”原语比如semaphore或者fence指令。这种模型给架构师极大的自由度——调度器可以在任意顺序执行指令只要在软件约定的同步边界上做呈现即可。缺点是对编译器和编程模型要求高不过对于AI芯片而言绝大多数计算图都是静态的编译器完全可以在编译期把必要的同步都插好运行时基本无感知。坦白说如果哪家NPU敢在内存模型上做太强的保证那它的性能一定不太好看因为到处都在等待同步。弱一致性是乱序NPU/GPGPU的必然选择这不是“性能取向”而是“不做就活不下去”的物理现实。5. 常见问题与排查技巧实录5.1 数据冒险的典型误诊乱序执行下最容易被误诊的问题是WAW写写冒险。在in-order里两条指令写同一个buffer先发射的先写后发射的后写天然有序但乱序执行下后发射的指令可能先执行完正确结果是哪条指令“最后提交”而不是“最后执行完”。我在一个稀疏卷积加速器上排查性能异常时发现调度器没有对WAW做完整处理导致同一块输出buffer被不同偏差的指令覆盖最终输出的feature map错了一半。最后的方法是在依赖表里对每个buffer维护一个“写版本号”后到的写指令必须等待前序写指令的版本号被消费完才算真正完成。简单说写写冲突必须用版本追踪来判定否则乱序收益再高正确性也是零。5.2 性能回退和死锁排查乱序执行最气人的是你以为自己在优化性能结果跑出来的结果比in-order还慢。我遇到过几次典型的性能回退场景总结下来主要是“发射过于激进”导致下游队列被撑爆或者“乱序窗口太小”根本挖不到可并行的指令。排查这类问题我的经验是先把调度器改成“半顺序”模式——只允许最多乱序两条相邻指令如果性能还上不去那说明问题不在乱序深度而在数据复用、访存带宽、或者分区策略上。通过“最小乱序深度二分法”我甚至能在几个小时内定位到性能瓶颈在哪一层。死锁则常见于“调度器等buffer生产者等调度器”的循环等待。这种死锁最难排查因为它不总是出现只在特定数据和指令组合下触发。我最后的防线是在调度器里实现“看门狗机制”假如某个指令在调度窗口里待的周期数超过阈值就强制清空窗口把所有部分完成的buffer状态回滚到最近的强屏障点然后顺序重放。这个方法虽然损失了一点性能但保证了系统不会僵死线上安全最重要。5.3 常见问题速查表现象可能原因排查思路与解决方案MAC利用率骤降乱序窗口太小依赖链长导致无指令可发射加大调度窗口深度测量指令平均等待周期输出数据错乱WAR/WAW冒险未完整追踪引入buffer版本号对写写冲突做显式等待性能比in-order还差发射过于激进下游队列溢出增加后向压力感知降低发射宽度偶发死锁调度器与buffer生命周期形成循环等待加入看门狗强制回滚机制部分和结果不对部分和合并的完成计数错误用tile同步计数器确认所有贡献指令已完成片外带宽暴涨乱序破坏了数据复用对复用buffer做连续调度约束5.4 仿真验证中的独家技巧乱序NPU/GPGPU验证比in-order麻烦得多。常规RTL仿真跑几个cycle你就得手动去对波形乱序下波形跳来跳去人工追基本追不动。我强烈建议在架构层面先搭一个“指令级乱序模型”把所有依赖、同步、buffer生命周期抽象进去先用这个模型跑大量随机激励确认调度逻辑本身没问题再往下做RTL。另外我会在硬件里留一个“乱序记录寄存器”每完成一条指令就记录一条日志发射时间、完成时间、依赖命中情况、被谁阻塞了多少周期。这个日志对性能分析和功能调试都是无价之宝甚至可以直接喂给脚本生成热力图直观看到哪个MAC子阵列负载失衡。6. 实测调参与工具链心得6.1 关键参数怎么调乱序调度器最核心的可调参数是调度窗口深度、发射宽度、最大未完成指令数。就以我的流片前仿真经验来说窗口深度太小挖不出并行性太大会增加比较器和存储面积。我常用的起点是64条张量指令再根据平均访存延迟和MAC阵列执行周期调整。公式简单估算一下调度窗口深度约等于“平均指令延迟”时钟周期除以“平均发射间隔”再乘上你期望的并行度。比如平均指令延迟300周期发射间隔20周期想达到4路并行窗口就需要至少60条左右。发射宽度在NPU上很少需要超过4因为张量指令本身很重发射宽度再多MAC阵列也接不住。6.2 编译器和运行时该怎么配合硬件再聪明没有编译器配合也白搭。我建议在编译器的指令调度阶段把“依赖链信息”和“buffer复用属性”显式编码进指令字段硬件调度器直接用这些信息做启发式判断比硬件自己猜要精准得多。运行时方面需要一个轻量级的“同步感知调度器”它知道当前kernel的资源占用、buffer使用情况在启动下一个kernel之前判断是否要等待当前kernel完全结束。这个逻辑不能全交给硬件因为硬件窗口有限看不到kernel之外的信息。6.3 后续扩展方向我目前最看好的方向是“编译器和硬件联合的乱序调度”也就是编译器不是傻傻地生成顺序指令而是把可能的并行分组、候选乱序路径直接告诉硬件。硬件在运行时根据实际情况选择路径这比纯硬件动态调度更高效也比纯编译器静态调度更鲁棒。另外多核NPU之间的乱序协同调度也是一个充满挑战的深水区值得专门开一篇聊。根据我这段时间的实际体验乱序NPU/GPGPU设计最大的坑不是算法和调度而是“你以为你理解了乱序其实你只是把CPU的概念换了个名字”。真正落地时张量粒度带来的指令重、buffer依赖复杂、同步代价高、验证难度大每一项都需要专门的设计来化解。如果让我给后来者一句建议那就是先从性能瓶颈数据出发确认乱序真的能解决你的具体负载问题再动手改架构否则优化半天可能只是在纸面上提升了数据实际部署里一点变化都没有。架构设计最怕的不是做不出来而是做出来的东西负载根本不需要。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门