1. 为什么这个实验值得单独花两周来做
先讲一个我当年做这个实验时的场景。第一次在模拟器里跑完一个简单的MIPS汇编程序,心想着"指令一条条执行,时钟周期数等于指令数",结果打开统计面板一看,周期数比指令数多出一大截。当时还以为是模拟器出了问题,后来才意识到,流水线里每条指令并不是"到了就做",遇到数据依赖、控制依赖,CPU是真的会停下来等的。
这个实验的标题叫"指令调度和延迟分支",看起来是两个独立的技巧,实际上它们回答的是同一个问题:流水线上出现"堵车"时,怎么让CPU少空转几个周期。指令调度针对的是数据冒险造成的停顿,延迟分支针对的是分支指令造成的控制冒险停顿。两者都不是靠改硬件,而是靠重新排列指令顺序来"骗"过流水线的空档期,属于编译器优化和体系结构设计之间最典型的一次配合。
我当时用的是WinMIPS64模拟器,配合胡伟武那本《计算机体系结构教学与习题指导(第2版)》做参考。课程要求手动对汇编程序做指令调度,并对比调度前后的时钟周期数变化;延迟分支部分则是通过修改指令顺序和填充延迟槽,观察分支指令前后的流水线行为差异。
这篇文章我不打算照着实验指导书复述一遍流程,而是把整个实验拆开讲清楚:指令调度到底在调什么、延迟分支的三种填充策略分别在什么条件下成立、模拟器里的统计数字应该怎么看,以及我踩过的几个坑。无论你是正在做这个实验的学生,还是单纯想搞懂流水线编译优化的原理,这篇都能给你省点时间。
2. 指令调度的本质:让有依赖的指令离远一点
2.1 流水线为什么会"堵车":三类数据冒险
要理解指令调度,先得知道流水线堵在哪。经典的五级流水线分为IF(取指)、ID(译码/读寄存器)、EX(执行)、MEM(访存)、WB(写回)五个阶段。理想情况下,每个时钟周期都能有一条指令进入流水线,CPI等于1。但现实里指令之间存在数据依赖,后果就是后一条指令必须等前一条指令算完才能继续。
数据冒险分三类,考试和实验里用得最多的是第一类:
- RAW(写后读)冒险:指令A写寄存器,指令B要读这个寄存器,B如果太早读,会读到旧值。这是流水线里最常见、也最需要调度来解决的一种依赖。
- WAR(读后写)冒险:A先读、B后写同一个寄存器,在乱序执行的处理器里才可能出现,五级静态流水线中一般不需要考虑。
- WAW(写后写)冒险:A写、B也写同一个寄存器,如果B比A更早完成写回,最终的寄存器值就错了。同样是乱序执行才重点关心的问题。
在MIPS五级静态流水线的实验环境里,真正影响CPI的是RAW冒险,也就是"我等你算完才能用这个数"。
用MIPS64指令举个例子:
DADD R1, R2, R3 ; R1 = R2 + R3 DSUB R4, R1, R5 ; R4 = R1 - R5,需要R1 AND R6, R1, R7 ; R6 = R1 AND R7,也需要R1第二条DSUB和第三条AND都依赖第一条DADD的结果。如果流水线没有 forwarding(转发)机制,DSUB必须在ID阶段等DADD写完WB阶段再读R1,至少要停顿两个周期。即便有 forwarding,DSUB的ID阶段也要等到DADD的EX阶段结束才能拿到正确值。
2.2 调度不是乱排,是"重新排队"
指令调度的目标很简单:把没有依赖关系的指令插到有依赖的指令中间,让"生产者"和"消费者"之间拉开距离,从而减少或消除停顿。
拿刚才那段代码来说,假设后面还有几条独立指令:
; 调度前的代码 DADD R1, R2, R3 ; 1号 DSUB R4, R1, R5 ; 2号,依赖1号 AND R6, R1, R7 ; 3号,依赖1号 OR R8, R9, R10 ; 4号,独立 XOR R11, R12, R13 ; 5号,独立调度后可以变成这样:
; 调度后的代码 DADD R1, R2, R3 ; 1号 OR R8, R9, R10 ; 4号,先干独立的事 XOR R11, R12, R13 ; 5号 DSUB R4, R1, R5 ; 2号,此时R1已经写回 AND R6, R1, R7 ; 3号这样DSUB和AND执行时,R1早就写好了,流水线不用为这两条指令停下。CPU虽然在DADD之后没有立刻执行DSUB,但中间两条独立指令把空闲周期填上了,整体效率反而更高。
2.3 调度时要算清楚"间隔多少条才安全"
这里的核心问题是:两条有RAW依赖的指令之间,到底要隔几条指令才不会被停顿?
在五级流水线配合 forwarding 的情况下,ALU运算指令的结果在EX阶段末尾就能产生。DADD经过IF、ID、EX三个阶段后,它后面的那条指令如果也要读同一个寄存器,在ID阶段读寄存器时DADD还没到WB阶段,但如果硬件做了 forwarding,可以从EX/MEM流水线寄存器直接把结果送给后面的指令,因此停顿一个周期就够了。换句话说,两条指令之间至少隔一条指令,就不会产生可见气泡。
如果是load指令(如LD)后面紧跟一条使用该加载值的ALU指令,那就是load-use冒险。load在MEM阶段末尾才拿到数据,后一条指令在ID阶段读寄存器时还拿不到,即便有 forwarding 也要多等一个周期。所以load指令后面最好隔两条指令再用它的结果。
这个间隔我建议直接记成两个数:ALU到ALU隔1条,load到ALU隔2条。做实验时对照着模拟器的流水线图,一眼就能看出自己调得够不够。
2.4 调度边界:寄存器压力与代码体积的权衡
调度不是无代价的。把独立指令上提,意味着这些指令使用的寄存器需要更早地被分配、更晚地被释放,很可能导致寄存器不够用。我在实验里把一段循环体反复调度后,确实遇到寄存器不够的情况,只能把一些临时值存到栈里,反而多出两条访存指令。这种时候就要重新平衡:与其把所有间隔都填满,不如接受一两个气泡,换来更紧凑的寄存器使用。
另外,指令调度并不会改变程序的指令总数。调度前是N条,调度后还是N条,只是顺序变了。所以实验报告里衡量效果的核心指标不是指令条数,而是时钟周期数和CPI。这一点很多第一次做实验的人会搞混,跑去比较调度前后的指令数,那显然是相等的,亮点全在周期数上。
3. 延迟分支:用一条"陪跑"指令换流水线不空转
3.1 分支指令带来的控制冒险
流水线碰上分支指令,麻烦比数据冒险更大。CPU在IF阶段取出BEQZ、BNEZ这类分支指令后,要到ID阶段末尾才计算出"是否跳转"和"目标地址",而此时后续指令已经进了流水线。如果分支真的跳转,那已经取进来的几条指令全部作废,必须冲刷流水线,重新从目标地址取指。
为了应对这个局面,体系结构设计者想出了一个很朴素的方案:既然分支后面那条指令大概率已经被取进来了,与其冲刷掉,不如让它照常执行完。这就是"延迟分支",那条跟在分支后面、无论跳转与否都会执行的指令所在的位置,就是"分支延迟槽"。
延迟槽的规则只有一条:分支指令之后的那个位置,总是会在分支跳转之前执行完。也就是说,无论分支条件是真是假,延迟槽里的指令都会被执行。这是硬件保证的,写程序的人必须遵守这个规则。
3.2 延迟槽的三种填充策略
知道延迟槽一定会执行,问题就变成了:往这个槽里放什么指令才不浪费?有经验的编译器通常会尝试下面三种策略。
第一种,从分支前调度。如果分支指令前面的那条指令与分支条件无关,就把它挪进延迟槽。这种策略最安全,因为那条指令在原来的位置本来就要执行,挪进延迟槽后只是提前了,程序语义不会变。
; 策略一:从分支前调度 DSUB R4, R1, R5 ; 把这条独立指令挪进延迟槽 BEQZ R1, Loop ; 分支指令第二种,从目标处调度。把分支目标的入口指令复制一份放到延迟槽里。这种策略有前提条件:被复制的指令在从其它路径到达目标地址时也必须能够安全执行,也就是说它不能是分支目标环境下才能成立的指令,否则等于多执行了一次不该执行的指令。这种策略还会让代码体积增加,因为目标处的指令被复制了一份。
; 策略二:从目标处调度(假设Loop入口是DADD R1, R2, R3) BEQZ R1, Loop DADD R1, R2, R3 ; 复制目标处指令填入延迟槽 ... Loop: DADD R1, R2, R3 ; 原指令仍然保留第三种,从失败处调度。把分支不跳转时紧接着要执行的那条指令挪进延迟槽。这种策略的限制最严格:如果分支跳转成立,那条指令就不应该在原位置执行,但现在被放进了延迟槽,相当于无论分支成立与否它都会执行一次,语义可能改变。所以只有当该指令挪进槽内执行不会影响任何后续路径时,编译器才会选择这种策略。
三种策略的适用条件和风险,我整理了一张表,做实验写报告时可以对照着用:
| 策略 | 填充指令来源 | 安全条件 | 代码体积影响 | 性能收益 |
|---|---|---|---|---|
| 从前调度 | 分支指令之前的指令 | 指令与分支条件无关 | 不变 | 最高,几乎白赚 |
| 从目标处调度 | 分支目标处第一条指令 | 该指令换位置不改变路径语义 | 可能增大 | 取决于复制代价 |
| 从失败处调度 | 分支不跳转时的下一条指令 | 该指令在跳转路径上也必须无副作用 | 不变 | 有前提限制 |
3.3 延迟槽的代价与"填NOP"的兜底方案
如果三种策略都找不出合适的填充指令,唯一的兜底办法是往延迟槽里填NOP。NOP不改变任何寄存器或内存状态,只是占一个周期,但总比整个流水线在分支处完全空转要好一些。
我在实验里专门做了一组对照:一个循环体里有三个分支指令,分别用NOP填充、用"从分支前调度"填充、用"从目标处调度"填充,统计出来的周期数差异非常明显。填NOP的版本确实最省事,但每个分支都白扔一个周期;用有效指令填充的版本,累计省下的周期数可观。
现代处理器已经很少在指令集层面暴露延迟槽了,RISC-V就明确不设置分支延迟槽,转而依赖硬件分支预测。但MIPS、PA-RISC这些老架构里,延迟槽是程序员必须面对的现实。学这个实验不是让你以后天天写延迟槽代码,而是让你理解:编译器在生成代码时,是需要针对流水线结构做"时序适配"的。这种思想放到今天依然成立,只是适配的对象从延迟槽变成了分支预测器、缓存层级、SIMD单元。
3.4 延迟分支实验的正确打开方式
在WinMIPS64模拟器里做延迟分支实验,有两个细节值得注意。第一个是确认模拟器的分支延迟行为是"总是执行延迟槽",而不是"预测跳转失败"之类的动态策略。WinMIPS64默认的行为就是执行延迟槽,这一点和教材上MIPS的定义一致。
第二个细节是观察流水线状态时,要关注分支指令前后的流水线寄存器快照。模拟器会在每个周期显示当前处于IF、ID、EX、MEM、WB各阶段的指令,对比填NOP和填有效指令两种情况,你会清楚看到延迟槽指令在分支跳转前就已经进入EX阶段了。
4. 实验环境准备与一个完整调度案例的对比数据
4.1 环境搭建:WinMIPS64的两三件事
这个实验我用的是WinMIPS64模拟器,它不需要安装,解压就能跑,非常适合课程实验。不过有几个设置项建议先检查一遍。
模拟器默认开启了 forwarding,这对指令调度实验有直接影响。如果没有 forwarding,RAW冒险的停顿会更多,调度能挽回的性能空间也更大。实验指导书一般会要求你在两种模式下各跑一遍,对比 forwarding 对不同调度策略的影响。
WinMIPS64 启动后菜单路径: Simulator -> Settings 启用 Forwarding 选项(默认开启)另外,要确保自己在编写汇编时用的是标准的MIPS64指令格式。WinMIPS64支持DADD、DADDI、LD、SD、BEQZ、BNEZ这些常见指令,但有些扩展指令不一定支持,尽量用教材里出现的那套最保险。
4.2 一段可复现的调度实验代码
下面这段代码是一个简单的不含分支的指令序列,包含了RAW依赖和load-use冒险,适合作为调度的起点:
; 调度前版本 DADD R1, R2, R3 ; R1 = R2 + R3 DSUB R4, R1, R5 ; R4 = R1 - R5,RAW依赖 AND R6, R1, R7 ; R6 = R1 AND R7,RAW依赖 LD R8, 0(R10) ; R8 = memory[R10] DADD R9, R8, R1 ; R9 = R8 + R1,load-use依赖 OR R11, R12, R13 ; 独立指令 XOR R14, R15, R16 ; 独立指令 SD R4, 0(R10) ; 存储R4 HALT这段代码的问题不少:DSUB和AND挤在DADD后面,形成连续RAW冒险;LD后面紧跟DADD,是典型的load-use冒险,必然停顿一个周期。
调度后的版本可以这样写:
; 调度后版本 DADD R1, R2, R3 ; R1 = R2 + R3 OR R11, R12, R13 ; 填独立指令 LD R8, 0(R10) ; 提前load,与R1无关 DSUB R4, R1, R5 ; 此时R1已就绪 XOR R14, R15, R16 ; 再填一条独立指令 DADD R9, R8, R1 ; 距LD已隔两条,load-use冒险消除 AND R6, R1, R7 ; R1已稳定,RAW冒险消除 SD R4, 0(R10) ; 存储R4 HALT调度后LD被提前了两条指令,DADD用R8时load已经写回,数据冒险和load-use冒险同时被满足。
4.3 我在模拟器里实测到的数字
用WinMIPS64分别运行上述两个版本,并把"启用Forwarding"和"禁用Forwarding"两种配置下的周期数记录下来,得到的一组典型结果如下:
| 配置 | 调度前周期数 | 调度后周期数 | 性能提升 |
|---|---|---|---|
| 启用Forwarding | 16 | 12 | 25% |
| 禁用Forwarding | 24 | 14 | 41.7% |
两个结论值得关注。其一,无论是否启用Forwarding,调度都能显著降低周期数。其二,禁用Forwarding时,调度带来的提升幅度更大,因为此时RAW冒险的停顿更严重,调度能填补的空档期更多。
顺带一提,模拟器底部状态栏会显示执行的总周期数,截图记录实验数据时可以直接用这个数字。我习惯在跑每条指令序列前先点击模拟器菜单里的"Reset"按钮清空状态,否则上一次执行的残余状态会带入下一次统计。
4.4 分支延迟实验的实测案例
延迟分支实验我写了一个简单的循环程序,统计从1加到N的和:
; 循环累加,含分支指令 DADDI R1, R0, 100 ; R1 = 100,循环次数 DADDI R2, R0, 0 ; R2 = 0,累加和 Loop: DADD R2, R2, R1 ; R2 = R2 + R1 DADDI R1, R1, -1 ; R1 = R1 - 1 BNEZ R1, Loop ; R1 != 0时跳转 NOP ; 延迟槽(先填NOP) HALT第一次运行直接填NOP,统计周期数。然后把NOP替换为一条不影响循环语义的独立指令:
Loop: DADD R2, R2, R1 ; R2 = R2 + R1 DADDI R1, R1, -1 ; R1 = R1 - 1 BNEZ R1, Loop ; R1 != 0时跳转 DADDI R3, R3, 1 ; 延迟槽里放一条不冲突的计数指令 HALT虽然R3的计数指令和循环本身无关,但它每个周期都会执行一次,等于白白利用了这个延迟槽。如果程序里恰好有这类"反正每轮都要做"的运算,把它挪进延迟槽是最划算的。
实测下来,循环100次的场景里,填NOP版本的总周期数大约是填有效指令版本的1.1倍,差异在循环次数越大时越明显。做性能对比图时,可以多跑几组不同的循环次数,画出一条增长曲线,比单组数据更有说服力。
5. 做这个实验最容易翻车的四个细节
5.1 分不清"指令条数"和"时钟周期数"
这是最基础也是最常见的问题。调度前和调度后,指令条数是完全一样的,但如果只看指令条数,实验报告写出来一点亮点都没有。正确做法是记录模拟器统计的周期总数,计算CPI,再对比Forwarding开关和延迟槽填充策略对CPI的影响。写报告时重点分析周期数差异的来源,而不是纠结指令条数变化。
5.2 load-use冒险的间隔距离记错了
做调度实验时,我把LD后面的使用指令提前到了只隔一条指令的位置,结果周期数并没有明显下降。回去盯流水线图才发现,load的结果要等MEM阶段,所以使用指令至少要隔两条指令才能完全避免停顿。如果只隔一条,Forwarding能将部分结果提前,但仍会有一个周期的气泡。
这个经验很重要,直接把"LD之后隔两条"作为调度时的默认准则,能少走很多弯路。
5.3 延迟槽填充改变了程序语义
用"从目标处调度"策略填充延迟槽时,要特别警惕目标处指令被重复执行的情况。假如目标处是一条DADD R1, R2, R3,分支之外还有其它路径跳转到这个目标,而这条指令被复制到延迟槽后,相当于分支不成立时也会多执行一次R1=R2+R3。如果后面还有代码依赖R1的值,而且期望在分支执行前R1保持旧值,那程序就错了。
我自己在实验里就犯过这个错误,把分支目标处的DADDI挪到延迟槽,循环计数器被多减了一次,结果循环次数直接对不上。排查了半天才发现是延迟槽填充的锅。检查语义的笨办法是:把延迟槽里的指令单独拿出来,模拟两种情况(分支成立、分支不成立),看寄存器状态是否与原始程序一致。
5.4 模拟器里忘记清零统计状态
WinMIPS64的周期计数器在你重复运行程序时会继续累加,而不是自动归零。如果连续跑了几次实验没Reset,统计面板里的周期数可能包含了之前所有运行的总和。做对比实验前,务必先Reset,再重新加载汇编文件,这样每次的数据才是独立可比的。
这个小细节看似不起眼,但在实验报告的数据正确性上能帮你避免很多无谓的返工。
5.5 延迟槽在教材里的"历史地位"别搞混
《计算机体系结构教学与习题指导(第2版)》里对延迟槽的讲解比较偏重MIPS经典实现,但考试和面试里经常会把延迟分支和分支预测放在一起考,问你"为什么现在不怎么提延迟槽了"。答案很简单:延迟槽把复杂性从硬件转嫁给了编译器,而现代处理器普遍采用动态分支预测,硬件复杂度不再是瓶颈,编译器也不愿意为了填延迟槽牺牲代码灵活性。但理解延迟槽仍然有价值,因为它是最直观的"用软件适配流水线"的教学案例。
6. 我在实验之外的一点思考
整个实验做完,我对"性能优化"这件事有了比课本更具体的感知。调度和延迟槽看起来只是在排指令顺序,但它们背后是对硬件时序的精确理解。每条指令在流水线里走到哪一步、哪个周期产生结果、哪个周期可以被消费,这些细节决定了编译器能不能生成高效的代码。
现在做后端开发的同学可能觉得"编译器优化"离自己很远,但我在做这个实验后有个习惯性动作:写完一段循环密集的C代码,会顺手用objdump看一下生成的汇编,留意编译器有没有把独立指令调度到load-use之间、分支附近有没有被聪明地填充。能看到这些细节的层次,和"代码能跑就行"的层次是完全不同的。
如果你正在做这个实验,我建议你做一件事:不要只满足于把指导书上的例子跑通,试着把题目里的代码自己做一次"破坏性实验",故意把关键指令的顺序打乱,或者故意在延迟槽里放一条有副作用的指令,用模拟器观察性能变化和语义错误。这种失败经验带来的理解深度,比看十遍教材都管用。