做芯片架构的朋友应该都有这种感受:乱序执行(Out-of-Order)在CPU领域已经被研究了几十年,从IBM的POWER到ARM Cortex-A,再到苹果M系列,乱序流水线几乎成了高性能处理器的标配。但要是把话题换成NPU和GPGPU,很多人第一反应是“没必要”——GPU靠几千个线程并行藏延迟,NPU靠矩阵算子和片上SRAM批量吞吐,顺序执行不是挺好?我在做AI加速器项目的时候也是这么想的,直到碰上稀疏计算、动态Shape、访存发散这些不规则负载,顺序流水线频繁空转,才开始认真研究把乱序设计思路搬进NPU/GPGPU。这篇博文不吹概念,把我从需求判断、架构选型到模块实现、问题排查的一整套思路拆开讲,适合正在做AI芯片、GPGPU架构,或者对高性能计算感兴趣的朋友参考。
1. 场景判断:NPU/GPGPU为什么需要乱序执行
1.1 乱序执行解决的是哪一类问题
先说清楚一个底层逻辑:乱序执行解决的从来不是“算得慢”,而是“等得久”。在顺序执行处理器里,指令严格按程序顺序进入流水线,只要其中一条指令因为操作数未就绪、访存未返回等原因被阻塞,后面所有指令都得跟着堵住。而真实程序里的指令依赖关系是稀疏的——一条除法指令等待几百个周期,同时它后面的加减法、逻辑运算可能早就具备执行条件了。
乱序执行的核心思想是:取指和译码保持程序顺序,但指令一旦译码完成就进入一个“指令窗口”,调度器在窗口内找那些源操作数已经准备好的指令先发出去执行,等所有指令执行完毕后再按程序顺序提交(Commit)。这样做的结果是,一条长延迟指令后面的短指令可以“绕过”它先执行,流水线的有效利用率大幅提升。
对NPU和GPGPU来说,问题本质上一样。你别看NPU算矩阵的时候流水线哗哗跑,一旦指令序列里混入访存、同步、控制流,尤其是稀疏化处理和动态Shape场景,指令之间经常出现“某计算必须等DMA搬完数据才能启动”的情况。在顺序执行架构里,数据没到位就只能整个流水线空等。
1.2 吞吐处理器眼前的三堵墙
我把阻碍NPU/GPGPU流水线流动的因素归纳成三类,设计思路基本就是针对这三堵墙做优化:
第一堵墙是访存延迟。片上SRAM的访问延迟大概几个周期,片外DDR/HBM延迟动辄几百周期,即使有了多级缓存,一次Miss仍然可能让计算管线空转。GPU的老办法是靠线程切换:一个Warp等内存,赶紧切换别的Warp上计算单元,靠超大规模线程把“等待”摊薄。但这个办法在Warp数量不够多、或者所有Warp都在等同一块内存时会失效。
第二堵墙是数据依赖。NPU的计算指令之间经常有依赖,比如卷积累加、矩阵乘法的归约链;GPGPU里则是共享内存、全局内存上的读写依赖。依赖链越长,顺序执行的暴露延迟越大。
第三堵墙是同步和转发。GPGPU里的barrier指令、NPU里的DMA同步信号,本质上都是把并行打回串行的“闸门”。同步太多,吞吐率断崖式下跌。
这三堵墙里,访存延迟靠乱序可以躲;数据依赖靠乱序不能完全消除——你最多只能从后面找独立指令先做;同步则需要对同步机制本身重新设计。理解这点很重要,很多团队一上来就堆乱序窗口,结果访存延迟藏住了,依赖链还是卡死,性能没上去反而面积翻倍。
2. 设计定位:先想清楚“乱”在哪一层
2.1 和CPU乱序的根本差异
一上来就照搬CPU的乱序结构,是新手最容易踩的坑。CPU是延迟导向(Latency-oriented)的处理器,单个线程的延迟直接决定程序跑多快;GPGPU和NPU是吞吐导向(Throughput-oriented)的处理器,核心指标是单位时间完成多少TOPS,单条指令晚几十个周期,只要吞吐不掉就不算损失。
这就带来几个设计差异:
- CPU的乱序窗口动辄几百条指令(ROB通常128~512项),为了压榨单线程ILP;GPGPU如果给每个线程都保留这么大的窗口,片上存储直接爆掉。合理做法是缩小窗口、提高调度宽度,用多个线程或指令流来填满计算单元。
- CPU绕不开精确异常(Precise Exception),因为操作系统依赖异常现场恢复;NPU/GPGPU运行的多是显式并行程序,异常语义弱很多,提交逻辑可以简化。
- CPU的访存模型是弱一致加多级缓存,还要处理缓存一致性协议;GPGPU/NPU的片上存储很多是软件管理的SRAM(比如Shared Memory、片上Buffer),没有缓存一致性负担,但访存重排序要更加小心——软件明确管理数据视角,顺序乱了可能直接出脏数据。
一句话总结:NPU/GPGPU的乱序不能追求“高大上”,要追求“正好够用”。你做的是补充TLP的机制,而不是替代TLP。
2.2 窗口大小与发射宽度的量化取舍
这里给一个我自己常用的量化思路。假设访存延迟L是200周期,指令窗口有W条指令,平均每条指令能提供P条独立指令,那么流水线能掩盖的访存等待能力大约正比于W×P/L。W和P越大,掩盖效果越好,但硬件代价也越大。
一堆经验值供参考:
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| 指令窗口 | 64~128项 | 向量型NPU够用,标量密集部分可适当加大 |
| 发射宽度 | 2~4发射 | 再往上仲裁和总线功耗增长快,收益有限 |
| Load/Store队列 | 32~64项 | 满足常见访存窗口即可 |
| 保留站/发射队列 | 发射宽度的4~8倍 | 太浅调度器容易空转,太深面积失控 |
我实测过一组数据:在一个16核NPU设计里,指令窗口从32扩大到128,IR利用率(每个时钟实际发射的指令数与理论最大值的比)能从62%提高到83%,但面积增加约40%;再往上扩到256,利用率只到86%,面积却翻了近一倍。所以窗口“够用”就行,别贪。
2.3 寄存器重命名到底要不要做
寄存器重命名的目的是消除WAR(写后读)和WAW(写后写)伪相关。这两类伪相关不改变程序语义,纯粹是物理寄存器复用造成的顺序约束。CPU里寄存器数量少、指令密集,伪相关很普遍,基本都要重命名;NPU/GPGPU则要分场景看。
- 向量寄存器(比如512位向量寄存器组):寄存器数量一般不少,但向量指令执行周期长,WAW/WAR的风险不低。如果直接上完整的重命名表(RAT)和物理寄存器堆(PRF),面积会非常可观。我见过一种折中:只对长延迟指令做重命名,短指令共用物理寄存器,靠记分牌(Scoreboard)管理。
- 累加器:NPU里卷积和矩阵乘经常用累加器,这种寄存器往往是专用且路径清晰的,伪相关链路很容易分析,靠编译器插入同步指令往往比重命名更划算。
- 标量寄存器:这部分指令逻辑控制密集,是整个NPU执行的“主心骨”,建议做完整重命名。标量指令延迟短、依赖多,性能敏感度高,重名命收益明显。
我的经验是“分而治之”:标量域全量重命名,向量域轻量重命名,累加器靠同步。这样性能不牺牲,面积也压得住。
3. 核心模块拆解:从取指到提交的完整闭环
3.1 指令获取与译码:乱序的入口
NPU/GPGPU的指令集普遍不长,很多是定长指令(32位或64位),译码逻辑比x86简单很多,但这不意味着入口设计没讲究。
第一要处理指令格式的可变性和立即数扩展。向量指令通常带掩码、纵横(Swizzle)控制,译码复杂度集中在解析出“这条指令需要哪些物理寄存器、消耗哪些执行单元”。我建议在译码阶段就把操作数依赖关系显式解析出来,生成一个类似微操作(UOP)的数据结构,包含源操作数标签、目标寄存器、执行单元类型、功能位等。这样调度器后续的压力会小很多。
第二是分支与控制流的处理。GPGPU里分支不少,循环里套条件判断很常见;乱序指令窗口如果混入分支,就要有分支预测,或者采用“全部分支都执行再做选择”的策略。NPU的指令流往往很直,循环主要在DMA描述符里展开,这部分压力不大;但一旦引入动态控制流,分支预测器就免不了。
第三是取指带宽。窗口再大,如果取指带宽跟不上,发射队列永远是空的。一般建议取指带宽至少等于发射宽度,最好有1.5到2倍的裕量。
3.2 保留站与调度器:决策最密集的地方
调度器是乱序执行里最“卷”的模块:每个周期都要从窗口里挑出“源操作数就绪且执行单元空闲”的指令去发射,还要考虑优先级和能耗。常见结构有两种:
- 统一保留站(Unified Reservation Station):所有执行单元共享一个发射队列,每周期做集中仲裁,面积小,但仲裁器路数多,时序压力大。
- 分布式保留站(Distributed Reservation Station):按执行单元分队列,比如浮点、向量、访存各一个队列,仲裁简单、时序好,但队列利用率低。
我个人的习惯是混合方案:向量ALU和矩阵单元共用一个发射队列,访存指令单独一个队列,标量指令单独一个队列。这样访存发射不需要跟矩阵计算挤在一起,又不至于让寄存器堆的读端口爆炸。
调度策略上,最常用的是“最老指令优先”(Oldest-First),配合少量随机扰动避免饥饿。实测下来,在NPU负载里加一点“关键路径优先”的加权,比如给访存等待链上的指令加分,能再提升2到4个百分点的IR利用率。但这个收益跟负载相关性很大,建议仿真阶段多测几组权重再做决定。
3.3 Load/Store队列:最容易翻车的模块
访存重排序是乱序设计里面最复杂、最容易出Bug的部分,因为Load/Store一旦乱掉,后果是数据错误,不是性能降低。NPU/GPGPU的访存特殊之处在于很多访问是“宽访问”,一个Load可能搬运几百甚至几千字节,而且有大量DMA参与,队列条目不能按CPU那种单字粒度来处理。
我推荐的做法是给每个访存指令分配一个“范围描述”,也就是起始地址加长度,在访存队列里做地址范围比较(Range Check)。两条指令范围重叠时,判定存在内存相关性,后执行的Load不能越过前面的Store,除非做Store-to-Load Forwarding。由于NPU访存大多是规则的批处理模式,范围重叠检测其实可以做很快。
还有个细节值得单独说:片上SRAM的别名问题。NPU的片上Buffer常常被多个引擎复用,同一物理地址在不同时间扮演不同角色,可能是输入、权重,也可能是输出。乱序访存队列必须和Buffer分配器联动,一旦软件释放了一块Buffer,队列里还没提交的访存指令会访问到“新主人”的数据。我在实际项目中踩过这个坑:一条Load指令在窗口里躺了几十个周期,期间DMA已经把目标Buffer重新分配了,结果读回来的全是垃圾数据。后来我在访存队列里加了“缓冲区代际标签”,每个Buffer分配时打上一个递增的Generation ID,队列里的指令只有Generation和当前分配一致才允许执行,问题才彻底解决。
3.4 提交逻辑:和同步屏障的配合
提交(Commit)是乱序的“收口”:指令执行完不算数,必须按程序序提交后,改动才对软件可见。CPU用ROB实现提交和精确异常;NPU/GPGPU的提交设计要跟自己的同步机制结合。
NPU里有一类特殊的屏障指令(Barrier),它本身不干活,但要求前面所有计算和DMA都完成之后,后面的指令才能开始。在乱序方案里,屏障指令天然是提交的“分水岭”:所有比屏障老的指令必须先提交,比屏障新的指令不能越过它提交。实现上可以把屏障当成一条特殊的ROB条目,ROB指针滑动到它之前,必须等待老指令全部完成。
GPGPU的barrier更麻烦:它不仅是单线程内的顺序约束,还是Warp间的同步点,需要把多个Warp的提交动作对齐。我建议把“Warp内提交”和“跨Warp同步”分层处理:Warp内部照常乱序提交,但到达barrier时,硬件只允许在所有相关Warp都到达barrier之后,再统一放行后续指令。相当于把GPU的barrier语义叠加在乱序提交之上。
4. NPU里的乱序执行:执行依赖与DMA的编排
4.1 卷积/矩阵运算的依赖分析
NPU的主战场是卷积和矩阵乘,很多人以为这类负载规则性强、不需要乱序,但实际拆开看,指令流里同样塞满了依赖。比如卷积层中,一个输出Tile的计算需要从片上SRAM读入输入块和权重块,读入完成前计算指令无法发射;多个输出通道之间的累加回写也可能相互覆盖。在顺序执行下,这些依赖会形成很长的气泡。
乱序的价值在于:当一个Tile的输入数据还没到的时候,调度器可以先去执行另一个Tile里已经就绪的指令。相当于用指令窗口把“Tile间的并行”显式地做成“指令间的并行”。我在做卷积加速器时,把每个Tile的依赖关系分析做成一张依赖DAG,由调度器动态跟踪;相比编译器静态调度,动态方案在输入Shape变化、稀疏率不同时适应性强很多。
4.2 多引擎协同与同步屏障
NPU SoC里通常有多个执行引擎:向量引擎、矩阵引擎、DMA引擎、查表引擎等。乱序执行如果只做在单个引擎内部,跨引擎的协同还是靠软件插入同步信号,全局调度效率提升有限。
一个可行的思路是做一个“跨引擎命令调度器”:把各个引擎的指令流统一汇入一个乱序指令窗口,引擎共享同一个提交域(Commit Domain)。这样,DMA搬运完成的事件可以直接触发依赖于它的矩阵引擎指令发射,不需要经过中断和软件轮询。代价是跨引擎的依赖分析和同步信号逻辑变得复杂,时序上要特别注意。
我在一个项目里试过这种统一提交域方案,矩阵引擎利用率从67%提到了79%,多出约8%的逻辑面积,验证复杂度也明显增加——跨引擎的死锁问题直到仿真后期才暴露出来,最后是靠给关键队列加反压机制才收敛。
4.3 与DMA流水线的配合
DMA和计算单元的配合方式,直接影响NPU整体效率。传统方案是“双缓冲”:一块Buffer在计算,另一块在DMA搬运,软件显式切换。这个方案在规则负载下效率不错,但动态Shape场景里,软件很难精确预判什么时候切Buffer最合适。
如果做了乱序,硬件可以自己决定DMA指令的相对顺序:只要访存队列里确认“这块Buffer的计算已经提交完成”,立刻发射下一轮DMA指令去填充它,不需要软件干预。这本质上是一种硬件层的流水线气泡消除,比软件双缓冲灵活得多。
但有一个前提要牢记:硬件自动发射DMA的能力必须受保护区域的约束。实际项目里我一般做成“可编程策略”:默认顺序发射,需要时通过控制寄存器切换成“依赖触发发射”模式,避免硬件自作主张影响软件预期。
5. GPGPU里的乱序执行:SIMT模型下的新思路
5.1 SIMT与线程级并行的局限
GPGPU的SIMT(单指令多线程)模型,核心是靠Warp(通常32个线程)和成百上千个活动Warp来掩盖延迟。一个Warp等内存,硬件立刻切换到另一个Warp。这套方案在图形和传统通用计算负载上很好用,因为它天然有海量可调度的线程。
但SIMT模型有几个死角。一是Warp内分支发散,一个Warp里一半线程走A路、一半走B路,执行单元利用率直接掉一半;二是跨Warp的共享内存依赖,多个Warp同时读写同一块Shared Memory时,如果软件不加同步,数据竞争很难由硬件驱动解决;三是Warp数量受寄存器文件大小限制,每个线程占的寄存器越多,能同时调度的Warp越少,延迟掩盖能力越差。
乱序执行在这几个死角上都能帮上忙:Warp内分支发散后的两路可以在乱序窗口里交错发射,尽量复用不冲突的执行单元;Shared Memory访问的等待时间可以通过独立指令的提前发射来隐藏。不过这类收益通常没有CPU那么明显,因为GPU的负载并行度本来就高。我的建议是“先饱和TLP,再补乱序”,别本末倒置。
5.2 Warp调度与轻量乱序的共存
GPGPU的Warp调度器和乱序执行之间是“合作”关系,不是替代关系。常见做法是:Warp调度器以Warp为粒度从多个Warp里选一个发射,选中的Warp内部再做乱序调度,也就是二级调度。
这种两级设计要注意匹配:Warp调度器发射的每条指令进入该Warp的指令窗口,窗口满了就暂停该Warp的发射,让其他Warp先上。这样既能保持Warp间的高并行度,又能在Warp内部藏一点延迟。实现上,指令窗口可以做成每Warp独立的“小ROB加发射队列”,总容量受制于寄存器堆端口。
我实测过一个简化版方案:每个Warp只保留8到16条指令的乱序窗口,双发射,整体性能比纯顺序Warp调度提高5%到11%,而且对寄存器文件容量要求不高。关键是别想着给每个Warp做几十条指令的大窗口,面积和端口压力会直接失控。
5.3 结合Tensor Core的乱序设计
Tensor Core这类矩阵单元,天然是“一条指令干一大票活”,执行时间长、依赖相对少。把它纳入乱序窗口后,调度器会发现矩阵指令占据执行单元好几十个周期,后面一堆轻量指令在排队。这未必是坏事——轻量指令可以插进矩阵指令的间隙执行。
更进一步的思路是“多指令融合发射”:把矩阵指令、GEMM地址生成、累加器初始化等强相关的指令打包成一个复合指令组,组内还是顺序执行,组间乱序调度。这样既保持了矩阵计算的高效规律性,又让不同矩阵指令组之间可以重叠执行——前一个GEMM在矩阵引擎里跑,后一个GEMM的地址计算已经在访存单元里提前完成。
这个方案我在一个AI芯片项目里用过,矩阵引擎利用率提升明显,尤其是在小矩阵、多Batch的场景下。但要注意复合指令组的粒度不能太大,太大就把乱序调度的灵活性给锁死了。
6. 常见设计坑与排查经验实录
6.1 死锁与活锁
乱序设计最常见的坑是死锁。典型场景:调度器一直把窗口里的资源分配给“等访存”的指令,但访存指令依赖的Load/Store队列满了,而Load/Store队列的条目又需要已经发射的指令执行完才能释放——循环等待,流水线卡死。
排查经验是:给每个关键队列,包括发射队列、Load队列、Store队列、ROB,加上“资源水线”监视器。一旦某个队列占用率超过90%,启动反压,暂停窗口内新指令的译码和分配,优先消化老指令。仿真环境里也要加死锁检测断言:如果连续N个周期发射数为0且还有未提交指令,立刻报错并Dump波形。
活锁则来自仲裁策略不公。比如总是让新到的低地址指令优先,老指令永远抢不过,只能在队列里一直排队。解决方法是加入老化位(Aging Bit),指令每过一个固定周期就提升一级优先级,保证老指令最终能被调度到。
6.2 精确中断与流水线冲刷
如果NPU/GPGPU需要支持虚拟内存、缺页异常,或者需要调试器单步执行,精确中断绕不开。乱序执行下,指令的“执行完成”和“提交”分离,硬件必须保证异常处理时能准确知道哪条指令是异常点。
通常做法是利用ROB:异常发生时,ROB里所有比异常指令老的指令全部提交完,比它新的指令全部取消,然后把异常入口地址设为异常指令的PC。NPU的指令宽度大、提交吞吐高,一旦触发冲刷,损失比CPU更严重,几十条在飞的向量指令可能全部白干。
我的建议是:如果业务不需要虚拟内存,就明说“不支持精确异常”,大幅简化硬件和验证;如果必须要,那就在异常路径上做“整块回滚”。把异常点前的分摊指针、片上Buffer状态统一做快照,异常处理完后整体恢复,而不是逐指令恢复,这在纯硬件实现里更省事。
6.3 功耗、面积折中与验证
乱序模块是功耗大户,主要来自三部分:调度器的比较逻辑、寄存器堆的多端口读取、队列的Tag匹配。实测数据:同一个NPU核,加乱序后性能提升大概10%到15%,功耗增加20%到30%,面积增加15%到25%。这个“性价比”取决于产品定位——训练芯片、数据中心推理芯片可以接受,边缘端低功耗设备可能就不划算。
验证方面,乱序设计最大的难点是组合爆炸:指令间相对顺序任意,任何两条指令的组合都可能出现异常交互。建议做三件事:一是面向依赖的随机生成器,专门生成WAR、WAW、RAW密集的指令流;二是搭一个黄金参考模型,用乱序模拟器输出作为比对基准;三是做形式化验证,针对访存队列和调度器的核心性质——无死锁、无数据竞争、提交保序——做断言证明。这部分投入不小,但回报实在,越早引入越好。
我在几个项目里最深的体会是:乱序执行对NPU/GPGPU不是银弹,不要因为它能提升CPU性能就认定它在AI芯片里一定有效。设计之前,先用性能模型把负载的访存延迟、依赖链、同步开销拆清楚,找到真正的瓶颈。如果瓶颈是访存延迟,乱序值得投入;如果瓶颈是依赖链或者同步屏障,那要先从算法和编译器入手。而且乱序一旦引入,整个验证体系和性能分析工具都要跟着改,这是一个牵一发动全身的决定。做完项目回头看,我反而觉得“轻量乱序”和“跨引擎依赖触发”这种折中方案,才是NPU/GPGPU里最实用的形态。希望这篇整理出的思路,能帮你少踩几个坑。