
指令并行和流水线这两个词刚接触计算机体系结构的朋友很容易把它们当成一回事。实际上指令并行是目标流水线是实现这个目标最经典的手段之一。我在做RISC-V CPU设计的那段时间最开始也以为把五级流水搭出来就万事大吉了结果跑基准测试的时候发现CPI每指令周期数压根没降下来多少甚至在某些指令序列下比单周期还慢。后来才明白流水线不是简单地切段加寄存器就完事里面的冲突处理、指令调度策略才是真正决定性能的地方。这篇内容适合正在学体系结构、准备做RISC-V CPU设计、或者想搞明白CPI到底怎么算的朋友我会从流水线的基本原理讲到冲突处理再结合指令调度的实操经验把这条链路彻底讲透。1. 从单周期到流水线为什么CPI降不下去1.1 单周期CPU的瓶颈到底在哪先说说单周期CPU的问题。单周期设计里每条指令都在一个时钟周期内完成时钟周期长度由最慢的那条指令决定。通常最慢的是加载指令load因为它要经过取指、译码、执行、访存、写回五个阶段。假设每个阶段耗时分别是200ps、100ps、200ps、200ps、100ps那时钟周期就得定在800ps。这意味着一条简单的加法指令本来只需要400ps就能跑完却被迫等了800ps。这就是单周期设计的核心浪费所有指令都被最慢的指令拖了后腿。从CPI角度看单周期CPU的CPI恒定为1看起来很美但这是以超长的时钟周期为代价换来的。实际性能要看的是指令执行时间公式是执行时间 指令数 × CPI × 时钟周期。单周期CPI1但时钟周期是800ps如果流水线能把时钟周期降到200ps即使CPI涨到1.2执行时间也会大幅缩短。这就是流水线的价值所在。1.2 五级流水线的切分逻辑流水线的思路很朴素既然每条指令都要经过五个阶段那为什么不让五条指令同时处于不同阶段呢就像工厂流水线一样每个工位只负责一道工序工件依次流过。经典的五级流水线划分是取指IF、译码ID、执行EX、访存MEM、写回WB。每个阶段之间插入流水线寄存器用来暂存中间结果。时钟周期由最慢的那个阶段决定理想情况下可以降到200ps。这样理论上吞吐率提升5倍但实际远远达不到原因就是流水线冲突。我当初设计的时候犯过一个错误把寄存器堆的读写放在了同一个周期里。后来发现如果写回阶段在周期前半段写、译码阶段在周期后半段读确实可以在一个周期内完成但这要求寄存器堆支持前半周期写、后半周期读。很多教学用的RISC-V核就是这么处理的但实际流片时这种时序约束很紧容易出问题。更稳妥的做法是写回在时钟上升沿完成译码在下降沿读或者干脆加一个旁路。1.3 CPI的理论值与实际值的差距理想流水线的CPI趋近于1意思是每个周期都能完成一条指令。但实际中流水线冲突会导致停顿stallCPI会上升到1.2到1.5甚至更高。我实测过一个简单的五级流水RISC-V核跑CoreMark的时候CPI大概在1.35左右跑Dhrystone的时候能到1.2。差距主要来自数据冲突和控制冲突。这里有个容易混淆的点CPI是动态指标跟程序行为强相关。同样一个CPU跑不同程序CPI完全不同。所以评估流水线性能时不能只看一个CPI数字要结合具体的基准测试程序。另外CPI的倒数就是IPC每周期指令数有时候用IPC更直观因为IPC越高越好而CPI是越低越好。2. 流水线中的三类冲突结构、数据、控制2.1 结构冲突硬件资源不够用结构冲突的本质是硬件资源在同一周期被多条指令争抢。最典型的就是存储器冲突取指阶段要读指令存储器访存阶段要读数据存储器。如果指令和数据共用一个存储器那IF和MEM就会打架。解决办法有两个一是采用哈佛结构指令存储器和数据存储器分开二是加指令缓存和数据缓存。我在RISC-V核设计里用的是分离的I-Cache和D-Cache虽然面积大一点但结构冲突基本消除了。还有一种情况是寄存器堆的写端口冲突如果多条指令同时要写回就需要多端口寄存器堆但多端口寄存器堆面积和功耗都上去了一般不会为了这个加端口而是通过调度避免。结构冲突的排查有个简单方法看综合报告里的资源利用率。如果某个资源利用率接近100%那大概率就是瓶颈。我当初发现乘法器利用率特别高因为连续几条乘法指令挤在一起后来在编译器层面做了指令调度把乘法指令拉开距离利用率就降下来了。2.2 数据冲突RAW、WAR、WAW的区分数据冲突分三种写后读RAW、读后写WAR、写后写WAW。在按序流水线里WAR和WAW其实不会发生因为指令按顺序流动读操作总在写操作之前。真正要处理的是RAW也就是后面的指令要读前面指令还没写回的结果。举个例子add x1, x2, x3 # x1 x2 x3 sub x4, x1, x5 # x4 x1 - x5sub指令在EX阶段要用x1但add指令要到WB阶段才写回x1。如果什么都不做sub读到的就是旧值。这就是典型的RAW冲突。解决RAW冲突有三种主流方法停顿stall、旁路forwarding/bypassing、指令调度。停顿最简单但性能损失大旁路是硬件层面把EX阶段的结果直接送给需要的地方指令调度是编译器层面把无关指令插到冲突指令之间。实际设计中通常是旁路为主、停顿兜底、调度辅助。2.3 控制冲突分支指令的代价控制冲突来自分支指令。分支指令在EX阶段才能算出跳转目标但取指阶段已经取了下一条指令。如果分支跳转了那取到的指令就是错的要冲刷掉。这个冲刷的代价就是控制冲突的代价。分支预测是缓解控制冲突的核心手段。最简单的静态预测是“总是不跳转”或“总是跳转”准确率大概60%到70%。动态预测用两位饱和计数器准确率能到85%以上。我在RISC-V核里用的是两位饱和计数器加分支历史表BHT实测准确率在90%左右。但分支预测失败时冲刷流水线的代价是2到3个周期这个开销在分支密集的程序里很可观。还有一种方法是延迟槽delay slot把分支指令后面的一条指令提前执行不管分支是否跳转。MIPS用了这个方案但RISC-V没有采用因为延迟槽对编译器不友好而且和乱序执行配合起来很麻烦。3. 旁路与停顿数据冲突的硬件解法3.1 旁路路径的设计细节旁路的核心思想是结果在EX阶段算出来后不等写回直接送给需要它的指令。旁路路径通常有三条EX/MEM到EX、MEM/WB到EX、以及MEM/WB到MEM。为什么是这三条因为ALU的结果在EX末尾产生load的结果在MEM末尾产生而使用这些结果的地方通常是下一条指令的EX阶段。具体来说如果add在EX阶段算出x1sub在下一个周期进入EX阶段要用x1那就可以把add的EX结果直接旁路到sub的EX输入。这需要比较目的寄存器和源寄存器如果匹配就选择旁路数据而不是寄存器堆的输出。我踩过一个坑旁路比较逻辑里忘了排除x0寄存器。RISC-V的x0恒为0如果目的寄存器是x0不应该触发旁路。当时跑测试的时候发现有些指令结果不对查了好久才发现是x0被误旁路了。这个细节在教科书里经常一笔带过但实际写RTL的时候很容易漏。3.2 停顿插入的判断条件旁路不是万能的。有一种情况旁路解决不了load指令后面紧跟一条要用load结果的指令。因为load的结果要到MEM阶段末尾才出来而下一条指令的EX阶段在同一个周期时间上来不及旁路。这时候只能停顿一个周期。判断条件是这样的如果EX阶段的指令是load且它的目的寄存器等于ID阶段指令的源寄存器那就插入一个气泡bubble。这个判断逻辑要放在流水线控制模块里输出一个stall信号冻结PC和IF/ID寄存器同时清空ID/EX寄存器。停顿的代价是一个周期看起来不大但如果load指令很密集累积起来就很可观。所以编译器优化里有一项就是load延迟调度把load指令尽量提前让后面的指令有时间等。3.3 旁路和停顿的优先级旁路和停顿不是互斥的而是配合使用。优先级是先看能不能旁路能旁路就不停顿不能旁路才停顿。这个优先级逻辑要写清楚否则会出现该旁路的时候停顿了或者该停顿的时候旁路了导致数据错误。我在RTL里是这么实现的先算旁路条件如果旁路条件满足就把旁路数据选通到ALU输入如果旁路条件不满足且是load-use冲突就拉高stall信号。两个信号互斥用优先级编码器保证不会同时有效。4. 指令调度编译器层面的优化4.1 静态调度与动态调度的区别指令调度分静态和动态。静态调度是编译器在编译时决定指令顺序动态调度是CPU在运行时决定指令执行顺序。静态调度简单不需要额外的硬件但受限于编译时能获取的信息。动态调度灵活能根据运行时情况调整但硬件复杂度高。RISC-V的按序流水线通常用静态调度因为硬件简单。但如果要做高性能核动态调度比如Tomasulo算法就很有必要。我在项目里先用静态调度后来发现分支密集的场景下性能上不去才加了简单的动态调度。静态调度的核心是重排指令顺序把有冲突的指令拉开距离。比如lw x1, 0(x2) add x3, x1, x4这两条指令有load-use冲突编译器可以重排成lw x1, 0(x2) add x5, x6, x7 # 无关指令 add x3, x1, x4这样load的结果在add执行前已经可用了不需要停顿。4.2 循环展开与寄存器重命名循环展开是静态调度的常用手段。把循环体复制几份减少分支指令的比例同时给调度器更多重排空间。比如一个循环体有10条指令展开4次后变成40条分支指令从每10条一次变成每40条一次控制冲突大幅减少。寄存器重命名解决的是假冲突WAR和WAW。虽然按序流水线里假冲突不会实际发生但在调度时如果两条指令用了同一个寄存器调度器会保守地认为它们有依赖不敢重排。重命名后不同的指令用不同的物理寄存器调度器就能自由重排了。我在项目里做过一个实验同样的代码不展开循环CPI是1.4展开4次后降到1.15。但展开也有代价代码体积变大I-Cache命中率可能下降。所以展开次数要权衡一般2到4次比较合适。4.3 调度对CPI的实际影响调度对CPI的影响是实打实的。我做过一组对比测试跑同一段矩阵乘法代码调度策略CPI执行周期数无调度1.5215200基本调度1.2812800循环展开调度1.1211200展开调度重命名1.0810800从1.52降到1.08性能提升超过40%。这个提升不需要改硬件纯粹靠编译器优化。所以做CPU设计的时候编译器和硬件的协同设计非常重要。我后来跟编译器组的同事一起调把一些硬件特性暴露给编译器比如告诉编译器哪些指令可以双发射效果更好。5. RISC-V流水线设计中的实操坑点5.1 流水线寄存器的复位与使能流水线寄存器不是简单的D触发器要带复位和使能。复位用于冲刷流水线使能用于停顿。我当初设计的时候忘了给IF/ID寄存器加使能结果停顿的时候PC冻结了但IF/ID还在更新导致指令重复执行。正确的做法是每个流水线寄存器都有clk、rst_n、en三个控制信号。rst_n拉低时清空寄存器en拉低时保持当前值。冲刷和停顿的区别是冲刷是清空插入气泡停顿是保持。控制冲突用冲刷数据冲突用停顿。5.2 分支预测失败时的冲刷范围分支预测失败时要冲刷掉错误路径上的指令。冲刷范围取决于分支在哪个阶段解析。如果分支在EX阶段解析那IF和ID阶段的指令都是错的要冲刷IF/ID和ID/EX两个寄存器。如果分支在ID阶段解析需要额外的比较器那只用冲刷IF/ID一个寄存器代价小一个周期。我在设计里把分支解析提前到了ID阶段加了一个专用的比较器。虽然多了一点硬件但分支代价从2个周期降到1个周期整体性能提升明显。这个取舍要看分支指令的比例如果分支占比高提前解析就值得。5.3 访存指令的对齐与异常处理RISC-V的load/store指令要求地址对齐。如果地址不对齐会触发异常。在流水线里异常的处理要特别小心因为异常指令后面的指令可能已经进入流水线了。正确的做法是异常在MEM阶段检测到后冲刷掉后面所有指令把异常原因和异常地址写入CSR寄存器然后跳转到异常处理程序。我踩过一个坑异常处理程序里用了浮点指令但浮点单元还没初始化导致二次异常。后来在异常处理程序开头加了浮点单元初始化的代码才解决。这个坑在RISC-V手册里没有明确说是实际调试中发现的。6. 从CPI反推流水线设计的改进方向6.1 CPI分解理想CPI与实际CPI的差距把CPI分解开来看能清楚知道性能损失在哪。公式是实际CPI 理想CPI 数据冲突停顿CPI 控制冲突停顿CPI 结构冲突停顿CPI。理想CPI是1单发射或0.5双发射。我实测的数据是理想CPI1数据冲突停顿CPI0.18控制冲突停顿CPI0.12结构冲突停顿CPI0.05总CPI1.35。数据冲突是大头所以优化重点应该放在数据冲突上。后来加了旁路和调度数据冲突停顿CPI降到0.08总CPI降到1.15。6.2 双发射与超标量的取舍双发射能把理想CPI降到0.5但硬件复杂度大幅上升。需要两个ALU、两个译码器、更复杂的旁路网络。而且双发射对指令级并行度ILP要求高如果程序本身ILP低双发射也发挥不出来。我做过一个估算双发射的硬件面积大概是单发射的1.8倍功耗是1.6倍但性能提升只有30%到40%。所以双发射适合对性能要求高的场景比如服务器CPU。嵌入式场景下单发射加优化调度可能更划算。6.3 流水线深度与频率的权衡流水线越深时钟频率越高但冲突代价也越大。五级流水时分支失败代价是2个周期十级流水时分支失败代价可能到5个周期。所以深流水线需要更准确的分支预测来抵消冲突代价。我试过把五级流水改成七级把EX拆成EX1和EX2频率从200MHz提到280MHz但CPI从1.15涨到1.35。算下来执行时间反而变长了。所以流水线深度不是越深越好要找到频率和CPI的平衡点。一般来说五到八级是比较甜点的范围。7. 一些调试流水线的土办法调试流水线最头疼的是波形对不上。我常用的办法是在每个流水线寄存器上打标记用不同的颜色区分不同阶段的指令。这样在波形图里一眼就能看出哪条指令在哪个阶段冲突和停顿一目了然。还有一个办法是加性能计数器。在RTL里加几个计数器分别统计停顿周期数、分支失败次数、load-use冲突次数。跑完测试程序后读这些计数器就知道瓶颈在哪。这个方法比看波形高效得多尤其适合跑长测试程序。最后说一个容易忽略的点流水线的验证要充分。我当初只跑了简单的指令序列没跑随机测试结果上线后发现某些指令组合会出错。后来用随机指令生成器跑了上百万条随机指令才把边角情况覆盖全。流水线的bug往往藏在指令组合里单条指令测试是测不出来的。