Iron Law
Time/Program = Instructions/Program × CPI × Cycle Time
CS152 · 第 4 课
第 3 课已经证明"复杂指令集会阻碍并行和流水线实现"。这一课正式引入贯穿全课程的性能分析框架——Iron Law——然后用经典的 5 级 RISC 流水线(Fetch/Decode/Execute/Memory/Writeback)把它落地成具体电路,并开始讨论指令之间在流水线里如何互相干扰(hazard)。
本课程反复使用的核心性能分解公式:
\[ \frac{\text{Time}}{\text{Program}} = \frac{\text{Instructions}}{\text{Program}} \times \frac{\text{Cycles}}{\text{Instruction}} \times \frac{\text{Time}}{\text{Cycle}} \]
| 微架构 | CPI | 时钟周期 |
|---|---|---|
| 微编码(Microcoded) | >1 | 短 |
| 单周期不流水(Single-cycle unpipelined) | 1 | 长 |
| 流水线(Pipelined) | 1 | 短 |
这张表正是三种设计风格的"一句话总结":微编码用短周期换来了灵活性,但代价是 CPI>1;单周期不流水的机器 CPI=1,但每个周期必须长到能容纳最慢的指令(例如访存),周期时间被拖累;流水线设计的目标,是同时拿到"CPI≈1"和"短周期"——这正是它相对前两种风格的根本优势所在。
假设执行 3 条指令:
一个常见的直觉误区:"5 级流水线的 CPI 是不是等于 5?" ——不是!CPI 衡量的是"稳态下平均每条指令消耗多少个周期",而不是"一条指令要经过多少个流水级"。只要流水线能够每个周期发射一条新指令、没有被打断,CPI 就趋近于 1,无论流水级数是 5 级还是 500 级。这是本课程中最容易混淆、也最重要的一个概念澄清。
把单周期数据通路按功能切成 5 段,每段之间插入流水线寄存器:
| 阶段 | 缩写 | 主要工作 |
|---|---|---|
| Fetch(取指) | F | 用 PC 访问指令缓存,取出指令 |
| Decode(译码) | D | 译码指令、从寄存器堆读出操作数 A/B、生成立即数 Imm |
| Execute(执行) | X | ALU 计算 |
| Memory(访存) | M | 访问数据缓存(load/store) |
| Writeback(写回) | W | 把结果写回寄存器堆 |
这一版设计假设寄存器堆/存储器是"同步写、异步读"的:写入在时钟边沿完成,读出则是纯组合逻辑,同一周期内写入的值不能被同一周期的读出直接看到(这个假设直接决定了后面 hazard 的产生方式)。
当多条指令同时存在于流水线的不同阶段时,可能出现三类冲突(hazard):
处理这些冒险通常需要在流水线中插入气泡(bubble),使理想情况下 CPI=1 的流水线实际 CPI > 1。
气泡如何影响实际 CPI(重新验算):若 3 条指令原本能在 3 个周期内完成(CPI=1),插入 1 个气泡后变成 4 个周期完成(CPI=4/3≈1.33),插入 2 个气泡后变成 5 个周期完成(CPI=5/3≈1.67)。这里 CPI 的计算方式是:从第一条指令完成的那一刻,到序列中最后一条指令完成的那一刻,所经过的周期数除以指令数。
结构冒险发生在两条指令同时需要同一个硬件资源时。硬件上可以通过让较新的指令在原地等待(stall)来解决;也永远可以通过增加硬件资源来彻底避免(例如给内存增加第二个访问端口)。经典 RISC 5 级整数流水线在设计上没有结构冒险——但许多真实 RISC 实现在乘法器、除法器、浮点单元等多周期部件上,以及寄存器写回端口上,仍然可能出现结构冒险。
考虑一串寄存器-寄存器指令 r_k ← r_i op r_j:
| 类型 | 例子 | 名称 |
|---|---|---|
| 数据依赖 | r3 ← r1 op r2r5 ← r3 op r4 | 写后读(Read-after-Write, RAW) |
| 反依赖 | r3 ← r1 op r2r1 ← r4 op r5 | 读后写(Write-after-Read, WAR) |
| 输出依赖 | r3 ← r1 op r2r3 ← r6 op r7 | 写后写(Write-after-Write, WAW) |
Interlock vs. Bypass 的具体对比(add x1,x3,x5 后紧跟 sub x2,x1,x4):
纯用互锁:sub 需要在译码阶段插入 2 个气泡,等到 add 的写回阶段完成、寄存器堆里确实有更新的值之后才能真正译码——五级流水线因此退化成实际上更长的执行时间。
加上从 ALU 输出直接到 ALU 输入的旁路路径后:sub 在自己的执行阶段直接拿到 add 刚刚算出、还未写回的结果,完全不需要气泡,两条指令背靠背顺畅执行。这正是现代流水线几乎必然采用"全旁路数据通路"(fully bypassed datapath)的原因——把执行、访存、写回阶段的结果都直接接回译码阶段的操作数选择器。
数据值的推测(value speculation)目前只在少数场景下被证明有效:分支预测、栈指针更新、访存地址消歧——这几个话题会在后续课程(分支预测、乱序执行)中展开。
不同类型的控制流指令,需要在流水线的不同阶段才能确定下一条指令地址:
| 指令类型 | 计算下一个 PC 需要的信息 |
|---|---|
| 无条件跳转(Unconditional Jump) | opcode、PC、偏移量——译码阶段即可确定 |
| 寄存器跳转(Jump Register) | opcode、寄存器值、偏移量——要等到执行阶段读出寄存器值 |
| 条件分支(Conditional Branch) | opcode、(用于判断条件的)寄存器、PC、偏移量——同样要等到执行阶段算出条件 |
| 其他所有指令 | opcode、PC(还需要确认它不属于以上几类) |
RISC-V 中,无条件的 PC 相对跳转(如 j target)在译码阶段就能确定跳转目标,因此只需要杀掉(kill,转换成气泡)译码阶段之后已经被错误取出的 1 条指令;而条件分支和寄存器跳转要等到执行阶段才能确定目标或跳转与否,因此需要杀掉两条已经被取出/译码的指令,产生 2 个气泡。
三种情形的流水线气泡数(重新核对):
j target(无条件跳转,译码阶段确定):产生 1 个气泡jr x1(寄存器跳转,执行阶段确定):产生 2 个气泡beq x1,x2,target(条件分支,执行阶段确定):产生 2 个气泡早期 RISC 借鉴了流水化微码引擎的思路,把 ISA 语义改成"分支/跳转指令之后的那条指令,无论分支是否发生,总是会被执行":
0x100 j target
0x104 add x1, x2, x3 // 无论如何都会被执行
...
0x205 target: xori x1, x1, 7
软件必须用有用的指令填满这个"延迟槽",或者显式插入一条 NOP。这样一来,硬件就不需要为无条件跳转插入气泡——延迟槽指令本来就该执行。
1990 年后的 RISC ISA 大多放弃了延迟槽,原因包括:延迟槽把微架构细节固化进了 ISA 语义(类似 IBM 650 让程序员手动排列磁鼓指令顺序以减少延迟);当延迟槽被 NOP 填充时会增加指令缓存的压力和缺失率;在更深的流水线(例如 30 级流水、每周期发射 4 条指令)中,"只有 1 个延迟槽"这个假设根本无法维持;更重要的是,后续课程会讲到的分支预测大幅降低了对延迟槽的需求。
凡是带有软件可见延迟槽的机器,编译器可能会插入大量 NOP 指令:NOP 降低了 CPI,却增加了每个程序的指令数——这正好提醒我们 Iron Law 三项之间常常存在此消彼长的权衡,不能只孤立地优化其中一项。
本课程采用的术语约定:
历史上,Babbage 的分析机已经有溢出异常;第一台带陷阱机制的系统是 1951 年的 Univac-I(算术溢出可以触发一段两条指令的修复例程,或者按程序员的选择直接停机;后续的 Univac 1103 于 1955 年加入了外部中断,用于实时采集风洞数据);第一台带 I/O 中断的系统是 1954 年的 DYSEAC——它有两个程序计数器,I/O 信号会触发两者之间的切换,同时它也是第一台支持 DMA(直接内存访问)的系统,甚至是第一台"移动"计算机:整机分装在两辆拖车里(共 20 吨),为美国陆军通信兵团建造。
异步中断的标准处理流程:处理器在指令 I_i 处停止当前程序(完成所有 I_i 之前的指令——这就是精确中断,precise interrupt的含义),把 I_i 的 PC 存入专用寄存器 EPC,关闭中断并把控制权转交给运行在监管模式下的中断处理程序。
在 5 级流水线中,不同类型的异常可能同时在不同阶段被检测到(Fetch 阶段的 PC 地址异常、Decode 阶段的非法操作码、Execute 阶段的溢出、Memory 阶段的数据地址异常),还要处理来自外部、随时可能到达的异步中断。解决方案:
把异常处理看作一种"推测执行":预测机制——异常极其罕见,所以简单地"预测不会发生异常"已经非常准确;检查机制——异常在指令执行流水线的末端才被真正检测出来,各类异常各有专门的检测硬件;恢复机制——只有到达提交点才真正写入架构状态,因此提交点之前的部分执行结果可以被直接丢弃,然后清空流水线、跳转到异常处理程序。旁路(bypass)机制则允许后续指令提前使用尚未提交的指令结果,而不必等到它真正写回。
问题 1:一条经典的 5 级 RISC 流水线,在没有任何冒险的理想情况下,其 CPI 大约是多少?
问题 2:add x1,x3,x5 紧跟 sub x2,x1,x4,如果数据通路提供了从 ALU 输出直接到 ALU 输入的旁路路径,需要插入几个气泡?
问题 3:在支持精确异常的 5 级流水线中,为什么需要把异常标志一直携带到"提交点"(通常是 Memory 阶段)才处理,而不是检测到异常就立刻处理?
Time/Program = Instructions/Program × CPI × Cycle Time
理想流水线 CPI 趋近 1,与流水线深度无关
结构、数据(RAW/WAR/WAW)、控制冒险
提交点之前可安全丢弃,之后才写入架构状态
sp23/lecture_slides/L04_Pipelining_I.pdf(UC Berkeley CS152/CS252A, Spring 2023, Sophia Shao)