ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

乱序执行在AI加速器中的设计与实践:从NPU到GPGPU

乱序执行在AI加速器中的设计与实践:从NPU到GPGPU 1. 从严格按序到乱序AI加速器架构的范式转折点做 NPU 和 GPGPU 这些年我越来越意识到一个有意思的现象每当行业把芯片做到一定规模总会撞上同一堵墙——计算单元在等数据而数据在等总线总线上还堵着一堆没处理完的请求。传统的解法是堆流水线深度、加多级缓存、做软件流水编排但这些手段在 AI 工作负载日渐多样化的今天开始显得力不从心。所谓 Out-of-Order乱序执行在最朴素的层面理解就是让指令不按程序原本的顺序去执行而是谁的操作数先准备好谁就先进入计算单元。这个概念在 CPU 领域已经成熟了几十年从 Tomasulo 算法到寄存器重命名再到现代的乱序执行引擎都是围绕如何更充分地利用硬件资源这一核心命题展开的。但到了 NPU 和 GPGPU 领域乱序执行却一直不是主流选择原因很简单传统上AI 加速器面对的是高度规则的计算模式卷积、矩阵乘、激活函数这些算子的访存模式和数据依赖关系在编译期就能被精确分析软件调度就能把流水线安排得明明白白硬件为什么要多此一举去做动态调度但最近几年这个局面正在发生微妙的变化。做 NPU 设计的同行应该都有感触模型结构越来越野动态 shape、稀疏计算、控制流分支、多任务混跑逐渐成为常态编译期的静态调度开始撑不住实战需求。与此同时GPGPU 上的通用计算负载也越来越复杂单纯靠 warp 级的大规模并行已经不足以掩盖访存延迟和资源竞争的问题。在这样的背景下Out-of-Order NPU/GPGPU 设计从学术界的边缘话题一步步走到产业界的讨论中心也就不奇怪了。这篇文章我不打算写成一个教科书式的科普而是想从实际设计的角度把乱序执行引入 AI 加速器这件事掰开揉碎讲清楚为什么要乱序、乱序的收益到底在哪里、硬件上怎么实现、会遇到哪些坑、以及一条务实的落地路径是怎么样的。无论你是做 NPU 微架构的工程师、GPGPU 的软件栈开发者还是正在评估下一代 AI 芯片架构方向的技术管理者这篇文章应该都能给你一些不一样的参考。2. 为什么要乱序优先级在经典并行架构中的迁移与重建2.1 数据并行时代的隐性前提在展开乱序设计的讨论之前我们需要先回到一个更本源的问题NPU 和 GPGPU 的传统架构到底在什么假设下是高效的答案只有四个字数据并行。卷积神经网络里一个 3x3 的卷积核要在 feature map 上滑动成千上万次每一次滑动的计算过程几乎一模一样只是输入数据的位置不同Transformer 里的矩阵乘本质上也是把一个大规模矩阵乘拆成无数个相互独立的小规模乘加操作。这种计算模式的好处在于指令流极其规整内层循环的每一次迭代之间没有数据依赖编译器或是硬件调度器可以轻易地把这些并行任务分配到不同的计算单元上并且在编译期就规划好数据搬移的时序。这个假设在 AI 加速器的黄金十年里一直成立。无论是早期的 NPU 设计还是 GPGPU 的 SIMT 架构它们的核心思想都是用大量的计算单元去掩盖访存延迟用可预测的流水线去保证吞吐。在这种模式下乱序执行不仅没有必要反而是一种浪费——硬件调度逻辑本来可以换成更多的 MAC 单元或 ALU省下来的面积和功耗能带来更直观的性能提升。所以传统的 NPU 微架构几乎都是严格按序In-Order的指令从取指开始经过译码、发射、执行、写回每一步都按照固定的流水线节奏前进中间没有任何动态调度的环节。编译器负责把指令顺序安排好硬件只负责忠实地执行。2.2 静态调度的天花板当编译器不再全知全能问题在于编译器全知全能这个假设正在被真实世界的负载一点点瓦解。第一个冲击来自动态 shape。早期的 AI 模型输入尺寸和中间张量的形状都是编译期确定的但现在越来越多的模型会在运行时根据输入数据的实际情况动态调整计算图。比如 NLP 模型里的变长序列一句话是 20 个 token下一句话可能是 80 个 token再比如推荐系统里稀疏特征的数量每个用户都不一样。这些动态 shape 意味着编译器无法在编译期精确计算每一个循环的迭代次数、每一块 buffer 的分配大小、每一条访存指令的地址范围。当编译期的静态规划失去了精确性运行时就需要硬件来完成原本由软件完成的调度决策。第二个冲击来自稀疏计算。稀疏矩阵乘、稀疏注意力这些计算模式在 NLP 和多模态模型中越来越常见。稀疏计算的本质是大量的计算位置上是 0不需要实际的乘加操作但哪些位置是 0 在编译期并不知道只有在运行时刻读出稀疏掩码才能确定。传统的 SIMT 或脉动阵列在这种情况下会非常尴尬——如果老老实实做所有位置的乘加计算效率会大幅下降如果要做跳过skip操作就必须在运行时动态决定哪些指令真正需要发射到计算单元这就天然需要乱序的决策能力。第三个冲击来自多任务混跑。现代 NPU 和 GPGPU 的典型使用场景早就不只是跑一个独占的模型了。手机上的 NPU 可能要同时处理拍照的 ISP 任务和语音助手的推理任务云端 GPGPU 可能要并发调度多个不同大小的推理请求。这种混跑场景下不同任务对计算资源、带宽、内存容量的需求差异巨大硬性的按序调度会频繁出现资源空闲和排队阻塞的问题。2.3 一个生活化的类比后厨的备菜逻辑我特别喜欢用餐厅后厨来类比这个变化。早些年的餐厅厨师长会在早上把一天的菜单全部规划好几点切菜、几点炖汤、几点炒菜全部按时间表执行。这个模式在菜品固定、客流量稳定的情况下效率极高。但现实是客人点菜的时间是随机的有的菜需要炖一个小时有的菜三分钟就能出锅还有一些菜临时缺了食材需要换菜。如果后厨还死守那张时间表就会经常出现炖锅空着没人用、炒锅忙不过来的情况。一个聪明的后厨会怎么做答案是建立一块白板把所有待做的菜都写在上面厨师在完成一道菜之后去看下一道哪个菜的材料已经准备好了、哪个菜的空闲锅最合适然后去接那个活。这就是乱序执行的核心思想不按点单顺序做菜而是按材料就绪情况 资源空闲情况动态排优先级。回到芯片视角材料就是指令的操作数锅就是计算单元MAC 阵列、ALU、访存单元白板就是乱序窗口instruction window。设计一颗 Out-of-Order 的 NPU/GPGPU本质上就是在芯片上搭建一套自动化的后厨白板系统。3. 核心设计拆解窗口、调度器与依赖追踪机制3.1 指令窗口的资金池模型把乱序执行引入 NPU 或 GPGPU第一个要决定的架构问题是乱序窗口OOO Window设计多大放在哪个位置指令窗口在整个乱序执行架构里相当于一个资金池。指令被取指译码之后不会立刻发射而是先放进这个池子里等待调度器周期性地扫描池子里的指令找出那些操作数已经就绪也就是依赖已经满足且计算单元有空位的指令优先发射出去。窗口越大调度器能看到越远的未来指令越容易找到可以执行的指令来填补流水线气泡但代价是硬件开销——窗口里的每一条指令都需要为它保存操作数、状态标记、依赖信息这些都要占用物理寄存器堆和 SRAM 的容量。CPU 界的经验数据是窗口从 32 条目增长到 128 条目IPC每周期指令数收益非常明显但从 128 增长到 192 以上收益就开始递减而面积和功耗开销却几乎线性增长。NPU 的设计需要考虑一个关键差异NPU 的指令粒度比 CPU 大得多。CPU 的一条指令是把一个寄存器和另一个寄存器相加而 NPU 的一条指令可能是执行一个 8x8 的矩阵乘操作或从全局内存搬运一块 4KB 的数据到 SRAM。指令粒度大意味着窗口的条目数不需要像 CPU 那么大但每一条指令内部的资源占用和依赖关系要复杂得多。从我的设计经验来看面向 NPU 的乱序窗口条目数在 16 到 64 之间通常是合理的范围。这个数量级的设计可以用一个相对紧凑的 CAM内容寻址存储器结构来实现依赖追踪同时能把动态调度的收益吃到大部分。具体选多少取决于你的负载特征如果主要是大矩阵乘窗口可以偏小因为一条矩阵乘指令的执行时间很长窗口里能并行的指令本来就不多如果主要是小算子和控制流密集的负载窗口就要偏大否则调度器会因为看不到足够的就绪指令而频繁空转。3.2 依赖追踪的两条路线记分牌与 Tomasulo窗口设计好了下一步是实现依赖追踪。乱序执行最核心的机制就是判断哪条指令可以发射这背后需要一套严谨的依赖追踪系统。业界有两条经典路线记分牌Scoreboard和 Tomasulo 算法。记分牌的核心思想是以功能单元为中心每个计算单元旁边挂一个状态表记录它正在执行哪条指令、还需要哪些操作数、操作数什么时候到位。调度器每次扫描时依次检查每条指令的操作数是否已经被写入如果写入且功能单元空闲就发射该指令。这套机制实现简单硬件开销小但有一个致命缺点存在结构冒险structural hazard——两张本来没有数据依赖的指令因为都要用同一个寄存器可能会被错误地阻塞导致调度效率下降。Tomasulo 算法的核心思想是以数据为中心通过寄存器重命名把程序里的逻辑寄存器映射到物理寄存器上每条指令不再直接依赖逻辑寄存器而是依赖物理寄存器的生产者和消费者关系。这样一来不同指令之间即使使用同一个逻辑寄存器只要它们被重命名到不同的物理寄存器就不会互相阻塞数据依赖的追踪精度大大提高。代价是硬件复杂度显著上升——需要一套空闲物理寄存器列表、一张逻辑寄存器到物理寄存器的映射表、以及为每条在窗口里的指令维护源物理寄存器的就绪状态。对于 NPU 和 GPGPU 的设计我的建议是按需选择。如果你的加速器面对的是数据流比较规整的算子比如卷积和全连接层记分牌就够用了因为这类算子的寄存器使用模式非常规律结构冒险很少发生。但如果你的加速器要跑的是动态图模型、稀疏算子、多任务混跑Tomasulo 风格的寄存器重命名几乎是必选项——它会显著减少无关指令之间的伪依赖提高调度器看到可发射指令的概率。GPGPU 在这方面的实践经验更丰富一些现代 GPU 的 warp 调度器虽然不完全是乱序的但在处理内存依赖和同步操作时类似 Tomasulo 的思路已经被大量采用。3.3 访存系统的内存依赖预测讲完了指令之间的数据依赖还必须处理一个更麻烦的问题内存依赖。指令 A 往地址 0x1000 写了一个数据指令 B 要从地址 0x2000 读数据如果在编译期就能确定这两个地址不重叠那么指令 A 和指令 B 就可以放心乱序但如果地址是运行时动态计算的就没办法在编译期做出结论。CPU 乱序执行里有一个成熟的技术叫内存依赖预测memory dependence prediction硬件维护一张最近访问过的内存地址表当一条新的访存指令进来时通过查表来预测它和正在执行的其他访存指令是否可能重叠。如果预测不重叠就大胆乱序执行如果预测可能重叠就保守地阻塞直到前面的访存指令完成。预测错误时需要触发回滚或冲刷恢复代价是性能损失。NPU 和 GPGPU 的访存模式跟 CPU 有一个明显的差异对大块数据的搬运远比小粒度的随机访问更频繁。NPU 的访存指令通常是搬运一块连续的张量数据地址范围动辄几 KB 到几 MB。这种大粒度的访存模式让内存依赖预测变得更有挑战预测的粒度从字节级变成了块级需要追踪的不再是一个个地址而是一个个地址区间区间重叠的判断规则也更复杂——两个区间只要有一小段重叠就不能乱序执行。一个务实的做法是粗粒度标记 细粒度检查在窗口里的每条访存指令先按数据块的起始地址和大小做一个区间的粗分类分类相同即地址区间可能有重叠的指令之间保持程序顺序分类不同的指令可以自由乱序。这个方案会把重叠判断从 O(N^2) 的逐条比较降为 O(N) 的分类处理硬件实现上简单很多虽然会损失一些并行度但换来的是确定性和低开销。对于大多数 AI 工作负载来说这个权衡是划算的。4. 与经典并行架构的融合GPGPU 的 Warp 调度、抖抖式分派与多核对齐4.1 SIMT 的先天乱序Warp 调度器如何动态选发射很多人一听到乱序执行第一反应是这东西是不是要从零开始发明。实际上在 GPGPU 的 SIMT单指令多线程架构里乱序调度的思想早就以一种特殊的形式存在了——warp 调度器的动态选择。一个 GPGPU 有成千上万个线程这些线程按 32 个一组组成 warp。在每个时钟周期warp 调度器要决定让哪个 warp 发射指令到执行单元。最朴素的策略是按优先级顺序轮转但现代 GPU 的 warp 调度器几乎都是动态的它维护一个就绪队列只有那些当前指令的操作数已就绪、且没有访问冲突的 warp 才能进入候选集合调度器再从中选出一个或多个发射。这不就是乱序执行吗从动态选择就绪指令这个角度来说确实是的。只不过 GPGPU 乱序的粒度不是指令级别而是 warp 级别它通过海量的线程级并行来隐式地制造就绪候选而不是像 CPU 那样在单线程内通过解析依赖关系来发掘并行度。这个差异非常关键CPU 的乱序是窗口内挖潜力GPGPU 的乱序是池子里选模型。两者的目标一致但机制不同硬件的侧重点自然也不同。对于 NPU 的设计者来说这个对照很有启发。如果你的 NPU 走的是 SIMT 风格或者向量机风格那么你真正需要设计的是一个高效的线程块/warp 调度器让它能在多组并行任务之间动态切换而不是在单条指令流内部做复杂的依赖分析。4.2 与脉动阵列的调度对齐还有一种更传统、但近期热度持续攀升的 NPU 结构脉动阵列Systolic Array。Google TPU、很多边缘 NPU 都采用这种设计。脉动阵列的特点是数据像流水一样在计算单元网格里流动每个计算单元只和相邻的单元通信。这种结构天然地偏好规则的数据流模式——权重复用、输入特征图复用、输出累加每一步都是精心编排过的。那么脉动阵列能不能乱序理论上可以但实现方式别有韵味。脉动阵列的乱序不是让指令乱序而是让数据块乱序。举例来说一个脉动阵列要执行两层卷积传统做法是先完整做完第一层再做第二层但在乱序架构下调度器可以提前判断第一层的某些输出块已经生成完毕立即把它们作为第二层的输入送进阵列而不必等到第一层全部算完。这种块级的数据流重排对于层数很深的 CNN 和 Transformer 会带来可观的流水线效率提升。不过要付出代价脉动阵列的数据流动是固定方向的如果要做块级重排就必须在阵列周围增加灵活的数据缓冲和交换网络。这等于是在一个流水线机器外面套一个乱序调度层设计和验证复杂度都会显著上升。4.3 多核场景下的动态负载均衡真正的复杂度发生在片上有多个 NPU 核心或 GPU SM 的时候。多核意味着每个核心都需要独立的乱序窗口和调度器但它们之间又是互相耦合的——共享 DRAM 带宽、共享 LLC、甚至共享一部分中间结果比如 pipeline parallel 场景下的层间通信。这就带来了一个全局调度问题乱序窗口看到了就绪指令但如果执行这条指令需要的带宽被另一个核心占满了发射了也是白发射只会增加排队延迟。所以在多核场景下乱序调度的决策维度要从指令是否就绪扩展到指令所依赖的资源是否具备。我见过不少团队在做多核动态调度时一开始只做局部乱序结果发现核心之间的资源竞争反而加剧了。后来大家总结出来的经验是要在每个核心的调度器里引入一个资源感知层即该层周期性从全局资源管理单元如带宽管理器、共享缓存控制器获取资源占用情况并在调度决策时给每条待发射指令附加一个资源预算。这套机制本身不复杂复杂的是资源状态的同步延迟——全局信息传回核心需要几个周期在这几个周期内核心可能会基于过期信息做出错误的调度决策导致资源过载或闲置。对这种问题的务求解方案是在资源感知层和调度器之间引入滞后容忍允许核心基于本地估算做出调度决策但设置一个资源预算上限一旦核心发现自己的实际资源使用超过预算就主动降低发射率把资源让给其他核心。这个机制有点类似 TCP 的拥塞控制不需要全局精确同步以加减速的方式逼近最优资源分配。5. 隐含的战场编译器、运行时与硬件如何分工5.1 静态与动态的边界聊了这么多硬件机制一个很容易被忽略但极其重要的问题浮现出来乱序执行到底给编译器省了多少事又给编译器添了多少麻烦很多人有一个误区觉得做了乱序执行编译器就可以随便生成指令顺序了依赖关系全部交给硬件去处理。这个想法在纯 CPU 世界都不完全正确在 NPU/GPGPU 世界更是危险。乱序执行不是万能的它受窗口大小的限制只能看到未来一小段指令如果编译器生成的指令顺序横跨了一大段距离窗口根本看不到那么远动态调度再聪明也使不上劲。所以硬件的乱序能力与编译器的静态编排能力天然具有互补性。一个合理的分工方式是编译器负责全局调度——在基本块之间、算子之间、内核之间做好静态规划保证大尺度的数据流是合理的硬件负责局部乱序——在指令窗口范围内动态处理那些编译期难以预测的延迟和依赖填上流水线气泡。这种编译器做粗粒度、硬件做细粒度的混合策略能够既保留硬件动态调度的灵活性又避免动态调度带来的巨大硬件开销是务实之选。5.2 编译器的优化机会实测下来编译器的工作并不会因为引入乱序执行而变简单恰恰相反它为编译器打开了新的优化空间同时也要求编译器对硬件调度机制有更精细的刻画。一个典型的优化机会是延迟平衡调度。在没有乱序执行的情况下编译器必须把一条访存指令和它真正使用数据的计算指令拉开足够的距离才能保证数据到达时计算指令正好发射。这个距离取决于访存延迟编译器只能按保守的估计来排布。有了乱序执行编译器可以更加激进地提前发射访存指令不必刻意拉大指令间距因为如果数据提前到达硬件窗口可以暂存如果数据迟到调度器也会自动调整发射顺序把它后面的指令先发射出去。这给编译器的指令排布提供了更大的自由度。另一个优化机会是窗口压力管理。乱序窗口的容量是有限的编译器可以通过调整指令顺序来避免窗口溢出。例如把一条占用大量物理寄存器的指令和一条占用少量寄存器的指令交叉排列避免短时间内窗口塞满大寄存器指令导致后续指令无法进入窗口。这种窗口压力感知的调度在 CPU 编译器中已经相对成熟在 NPU/GPGPU 上还是相对稀缺的能力值得编译器团队重点投入。5.3 运行时的角色引入了乱序执行之后运行时runtime的作用也需要重新定位。传统的 NPU runtime 主要负责把编译好的二进制加载到设备上、分配内存、启动内核执行在乱序架构下runtime 需要承担更多动态决策的职责比如根据运行时已知的输入 shape 动态调整任务划分和调度参数、根据多任务混跑的实际资源占用情况调整优先级、以及收集硬件调度器的事件反馈用于性能调优。在我接触过的几个 NPU runtime 设计里做得好的团队都会在 runtime 层维护一个硬件调度器状态模型定期通过性能计数器读取乱序窗口的利用率、发射停顿的周期数、调度错误的回滚次数。这些数据可以帮助 runtime 动态调整向硬件下发的任务粒度。如果窗口利用率长期偏低说明任务粒度太小runtime 应该合并小任务如果调度回滚频繁说明运行时提供的内存依赖信息不够准确runtime 应该调整内存分配策略以减少地址重叠的风险。这种硬件反馈-运行时调整的闭环是乱序架构优点充分发挥的必要条件。5.4 编译与硬件的折中实例讲一个我实际经手的例子。那时我们在评估一个带有 32 条乱序窗口的 NPU 设计发现它在跑稀疏卷积时调度器经常因为窗口里没有足够多的就绪指令而空转。原因是编译器生成的循环结构太紧了内层循环每轮只有一条卷积计算指令和一条索引更新指令这两条指令之间存在循环进位依赖窗口里最多只有 2 条指令可以并行剩下 30 条窗口位置全被浪费了。后来编译器团队做了一个优化在循环展开的时候不再是简单地把一轮循环体复制 N 份而是把卷积计算指令和地址计算指令分别集中排列中间插入几条稀疏掩码加载指令。这样一来窗口里很快就有了多个互不依赖的卷积计算指令调度器的发射率从不足 10% 提升到了 70% 以上。这个优化不改变任何硬件逻辑纯粹是软件侧配合但性能收益远超我们在硬件上做得各种精细的微调。这个例子给我的感触很大乱序执行不是一个硬件做了软件就不用管的方案而是一个硬件提供可能性、软件负责用起来的协作方案。别指望做完乱序执行就能躺赢编译器和 runtime 的适配功夫往往决定了硬件的真实收益。6. 实战中的性能收益与陷阱以及验证的独特难度6.1 收益不做假设哪些负载真正受益花了大价钱把乱序执行做进 NPU/GPGPU 里性能收益到底能有多少这个问题很多团队会在立项时问但很少有人能给出一个客观的答案因为它高度依赖负载特征。根据我在几个项目上的实测大体可以分为三类负载第一类是强受益负载。这类负载的特征是指令间依赖关系稀疏、访存延迟波动大、有多条可独立执行的指令流。典型代表是动态 shape 的 NLP 推理、稀疏注意力、多路推荐模型。在这类负载上乱序执行配合资源感知调度端到端性能提升可以达到 20%-50%。这个提升不是来自单条指令的加速而是来自流水线气泡消除和资源利用率的提升。第二类是弱受益负载。特征是指令流规整、访存模式可预测、编译期的静态调度已经能把流水线安排得很好。典型的代表是大矩阵乘、标准卷积。在这类负载上乱序执行的收益非常有限很多时候只有 3%-8%甚至在某些极端情况下会有轻微的性能回退调度开销大于收益。这也是很多传统 NPU 团队对乱序执行犹豫不决的原因——他们的主力负载恰恰是这些规则计算。第三类是负收益负载。这些负载的特征是单条指令执行时间极长、依赖链极深。比如某些超长序列的注意力计算一条矩阵乘指令可能要执行几万个周期后续指令全都依赖它的输出。这种情况下乱序窗口里的指令只有极少数处于可发射状态调度器几乎无事可做而窗口本身占用的面积和功耗成了纯开销。所以做不做乱序执行首先要看你的目标负载画像。如果你的产品定位是通用 AI 加速需要同时跑各种稀奇古怪的模型乱序执行是值得投资的如果你的产品定位是某个垂类的算力加速比如固定跑某个大模型的矩阵乘那么乱序执行的优先级就要往后放。6.2 影响硬件面积与功耗的评估硬件工程师最关心的问题永远是这些乱序执行机制到底吃掉多少面积和功耗基于我接触过和参与过的设计可以给出一个大致的参考范围。一个 32 条目的乱序窗口连带物理寄存器堆扩展、依赖矩阵、调度器的优先级编码逻辑在 7nm 工艺下大约会占用 0.5-1.5 mm² 的核心面积功耗占整个核心动态功耗的 5%-10%。如果是 64 条目面积和功耗大约翻倍。对于一个动辄几十 mm² 的 NPU 或 GPGPU 核心来说这个开销不算小但也不是不可接受。不过要注意一个隐蔽的放大器效应乱序窗口的功耗大部分来自 CAM 搜索和优先级编码这两部分的功耗与窗口条目数近似线性相关但它们只在调度器真正扫描时才会动态消耗。如果设计得好的话可以通过时钟门控clock gating让窗口在指令流规整且无明显气泡时进入低功耗模式减少不必要的标签搜索操作。我在一个项目上试过给窗口加空闲检测逻辑一旦检测到连续 N 个周期没有可发射的指令就把调度器的扫描频率降一半只保留必要的唤醒逻辑。这个改动把调度器功耗降了约 30%而性能损失仅 1% 左右。这类低功耗细节往往比追求更大的窗口容量更有实际价值。6.3 验证挑战状态空间爆炸与随机调度乱序执行的验证难度是传统 In-Order 架构的好几倍这也是很多团队在评估阶段低估的部分。In-Order 架构的行为是完全确定的——同样的程序、同样的输入数据每一步流水线的状态都是可预测的验证的目标是状态是否和预期一致。乱序执行则完全不同同一段程序在两次运行中因为中断时序、访存延迟的细微差异指令的实际发射顺序可能完全不同。这带来两个问题第一传统的参考模型reference model对比验证变得困难因为逐周期的 RTL 状态比对不再适用只能做功能级的结果比对第二随机调度下的死锁检测和活锁检测变得必要因为你无法保证在某些极端的调度顺序下系统不会陷入某条指令永远不被发射的状态。我们在实际验证中采用的手段是分层验证第一层用形式化方法验证依赖追踪逻辑的正确性——即窗口里的每一条指令只有在它的所有操作数都就绪时才会被标记为可发射这是乱序执行的安全底座第二层用大量的随机程序在仿真器上跑功能比对覆盖各种依赖模式真依赖、伪依赖、内存依赖、跨窗口依赖第三层上 FPGA 跑真实负载重点观察调度器的性能行为——不是说功能正确就够了还要看动态调度的决策质量是否达到预期。6.4 性能反噬的排查调度抖动与优先级反转最后分享一个实战排查的经历。我们的乱序 NPU 在联调的时候出现过一个诡异的现象单跑任务 A 性能很好单跑任务 B 性能也很好但 A 和 B 混跑时整体吞吐居然比分开跑还低。这个问题排查了好几天最终定位到是在调度器的优先级设计上出了问题。任务 A 的指令主要是访存密集型的任务 B 的指令主要是计算密集型的。我们在调度器里给 A 任务设置了较高的优先级希望访存指令能更快地发出提前把数据搬到位。结果发现A 的高优先级指令经常把 B 的计算指令挤到后面而 B 的计算指令执行时所需要的中间数据恰恰是由 A 的访存指令预先搬进 SRAM 的。A 的访存指令被优先发射后B 的计算指令反而要等数据B 的计算单元空闲A 的访存单元也在等 B 释放缓冲区。这个现象有点类似经典操作系统的优先级反转问题高优先级任务依赖的操作恰恰是在低优先级任务的执行路径上。我们的解决方案调度器里引入了资源逆依赖检测每当一条高优先级指令被选择发射时检查它是否依赖某条低优先级指令正在执行的资源如果是则临时提升那条低优先级指令的优先级。这个机制有点粗糙但在我们遇到的场景下已经够用性能反噬问题消失了。这类问题在 In-Order 架构中几乎不会出现因为指令强制按序执行天然不会产生这种跨任务的优先级交错。这也印证了一个观点乱序执行带来的不是一个简单的新特性而是一整套新的系统性问题。做这块设计的时候一定要有足够的心理准备和时间预算。7. 渐进式策略从编译器后端的软件乱序开始硬件乱序执行架构落地的成本高、风险大是否每家做 AI 芯片的团队都必须一步到位我不这么认为。从我的经验来看有一条更稳妥的落地路径可以分三步渐进每一步都能带来真实的收益又不需要承担过大的硬件复杂度。第一步在编译器后端实现软件乱序编译器在不改变程序语义的前提下对指令序列进行重排序把访存指令提前、把计算指令尽量紧凑排列、在依赖链之间插入独立指令。这一层的优化完全不需要硬件改动只需要编译器的指令调度模块和硬件性能模型协作。实测下来做好软件乱序翻译能填补 30%-50% 的流水线气泡收益非常可观。第二步在硬件中加入一个小型乱序窗口目标不是全量乱序而是只对访存指令做乱序或者只对同一条指令内部的多个子操作做乱序。这类受限乱序的硬件复杂度比全乱序低一个数量级但能覆盖大部分动态延迟场景。举个例子如果你的 NPU 主流水线是取指→访存→计算→写回你可以先只让访存这一步是乱序的——访存指令进入一个小型队列只要地址不冲突就并行发出等待数据返回后再按原顺序喂给计算单元。这个方案把访存延迟的波动吸收掉了而计算单元的流水线仍然是按序的。第三步扩展窗口并引入重命名机制。当第二步的受限乱序架构跑通了积累了足够的验证经验和软件适配经验之后再逐步扩大乱序的覆盖范围加入寄存器重命名、物理寄存器堆扩展、更细粒度的依赖追踪。到这一步你就拥有了一颗成熟的乱序 NPU/GPGPU。这种三步走的价值在于每一步都带来独立可量化的收益每一步失败都不会伤筋动骨且每一步的软件适配经验都会为下一步做铺垫而不是像一步到位那样把全部风险集中在一个大版本里。8. 我在处理乱序设计问题时经常踩的坑与心得最后分享一些我在项目推进过程中积累的经验有些是踩过坑才懂的希望能帮后来者节省一些时间。第一个心得乱序执行的目标不是快而是不空转。很多团队做乱序设计时会把目光盯在 IPC 提升上但实际上乱序执行最大的收益来自让那些因为延迟不确定而闲置的计算单元转起来。做性能评估时不要只盯着理论 IPC更要看一个更接地气的指标计算单元的利用率utilization。如果你的 MAC 阵列利用率已经超过 70%乱序执行带来的提升空间就很有限了如果利用率长期低于 50%乱序执行可能是比扩大 MAC 阵列更划算的投资。第二个心得别被乱序这个词误导乱序不等于无依无据的乱来。合格的乱序调度器本质上是一个极度守规矩的调度器——它对每条指令的依赖关系都了如指掌只在绝对安全的情况下打乱发射顺序。如果你的设计经常触发回滚或者纠错说明你的依赖追踪逻辑不够精确问题大概率出在保守度不足而不是调度策略不够激进。宁可调度保守一点也别让错误恢复逻辑成为性能瓶颈——回滚的代价远比一次多余的等待高得多。第三个心得乱序架构需要独立的功耗管理策略。乱序调度器是一头功耗怪兽它本质上是每周期都在做决策在低负载时尤其浪费。我在设计中尝试过的思路是把乱序窗口分成多个分片低负载时只启用一个分片高负载时再逐步激活更多分片。这种细粒度的功耗管理比单纯依赖时钟门控要有效得多而且实现难度其实不高只是需要把窗口的控制逻辑从单一调度器改成主副协作的方式。第四个心得验证和调试工具的投资要趁早。乱序执行的可观测性比 In-Order 架构差一个量级——同一条指令可能在窗口里停留几十个周期调度决策的过程几乎不可见。如果你不给验证和调试团队配备硬件级追踪和波形可视化工具一旦出现问题定位时间会拖得非常长。我见过一个项目因为调度器的边界条件 bug在仿真阶段花了近三个月才定位到——如果当初在 RTL 里多埋几个性能计数器这个时间至少能砍掉一半。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进