二十一、流水线的冒险与处理
流水线的冒险(Hazard)是破坏流水线顺畅执行,导致流水线不得不停顿(Stall)或清空(Flush)的主要因素。处理这些冒险是流水线设计的核心挑战。我们将详细探讨三类冒险及其处理方法。
总览:冒险的类型
- 结构冒险 (Structural Hazard)
- 原因: 硬件资源竞争。两条指令在同一时钟周期需要访问同一个硬件部件。
- 数据冒险 (Data Hazard)
- 原因: 数据依赖性。一条指令需要另一条指令的计算结果,但该结果尚未产生或写回。
- 控制冒险 (Control Hazard)
- 原因: 指令流改变。主要由分支指令(如条件跳转、循环)引起,导致预取的指令无效。
1. 结构冒险 (Structural Hazard)
问题描述: 由于处理器资源不足,无法支持所有指令组合的重叠执行。
经典例子: 指令和数据共享单一存储器(冯·诺依曼结构)。
- 指令
I1在MEM阶段访问数据存储器。 - 指令
I2在IF阶段需要访问指令存储器。 - 如果两者是同一个存储器,就会发生访问冲突。
时空图表现(冲突时):
| 指令 \ 周期 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| I1 (load) | IF | ID | EX | MEM | WB |
| I2 | IF | ID | IF (Stall!) | EX | |
| I3 | IF | ID (Stall!) | IF |
在周期4,I1和I2都需要访问存储器,硬件无法同时满足,导致I2的IF阶段必须停顿一个周期。
解决方案:
- 资源重复 (Resource Replication):这是最根本的解决方法。
- 使用分离的指令Cache和数据Cache(哈佛结构):现代处理器普遍采用此方法,从根本上解决了存储器访问的结构冒险。
- 使用多端口存储器:成本较高,但可以允许同时进行多次访问。
- 流水线停顿 (Stalling):也称为产生一个“气泡(Bubble)”。当检测到冲突时,让后续指令停顿一个周期。简单但效率低下,现代高性能处理器通常通过精心设计来避免结构冒险。
2. 数据冒险 (Data Hazard)
问题描述: 指令之间存在数据依赖关系,下一条指令需要用到上一条指令的结果。
三种类型(以两条指令 I1 和 I2 为例):
- RAW (Read After Write) - 写后读(真依赖)
I1写入寄存器,I2要读取该寄存器。这是最常见的数据冒险。add s0, t0, t1
sub t2, s0, t3# 需要s0的新值
- WAR (Write After Read) - 读后写(反依赖)
I1读取寄存器,I2要写入该寄存器。- 在按序发射的简单流水线中不会发生,因为读操作(ID阶段)总是在写操作(WB阶段)之前。
- WAW (Write After Write) - 写后写(输出依赖)
I1和I2都要写入同一个寄存器。- 在按序发射的简单流水线中也不会发生,因为WB按顺序进行。
我们主要处理RAW冒险。 时空图表现如下,I2的ID阶段需要等待I1的结果:
| 指令 \ 周期 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| I1 (add) | IF | ID | EX | MEM | WB |
| I2 (sub) | IF | ID (需要s0) | EX | MEM |
解决方案:
-
流水线停顿 (Stalling) / 插入气泡 (Bubbling)
- 机制: 检测到数据依赖后,让硬件控制器暂停后续指令一个或多个周期(让它们重复ID阶段或插入空操作NOP),直到所需数据就绪。
- 缺点: 严重降低性能。
- 工具: 通常使用“直通”或“互锁”单元(Forwarding/Interlock Unit)来检测和产生停顿信号。
-
操作数前递 (Operand Forwarding / Bypassing)
-
核心思想 (Core Idea) : 打破“结果必须写回寄存器后才能被后续指令使用”的约束。 通过增加额外的硬件通路,将指令的执行结果从其产生后的第一时间(即刚被计算出并存入流水线寄存器时)就直接“绕道”传递给需要该结果的指令,从而避免流水线停顿。
-
机制 (Mechanism):增加额外的数据通路和多路选择器(MUX)。
- 来源: 数据可以从多个“上游”流水线寄存器中获取:
- EX/MEM 寄存器: 存放的是刚在ALU中计算出的结果。
- MEM/WB 寄存器: 存放的是刚从数据存储器读出或要写回的结果。
- 目的地: ALU的输入端口。
- 控制: 一个前递单元 (Forwarding Unit) 会持续监测流水线中的指令。如果它发现:
- 当前正在EX阶段的指令(
I2)的源操作数寄存器编号 - 与之前正在MEM或WB阶段的指令(
I1)的目的寄存器编号匹配 - 并且之前的指令确实会写回该寄存器(由它的控制信号判断)
那么前递单元就会产生控制信号,控制ALU输入端的MUX选择来自EX/MEM或MEM/WB寄存器的值,而不是选择从ID阶段读出的寄存器堆的值。
- 当前正在EX阶段的指令(
- 来源: 数据可以从多个“上游”流水线寄存器中获取:
-
时空图与解释
让我们看一个最经典的、需要前递的场景:
I1(add) 和I2(sub) 存在RAW依赖。指令 \ 时钟周期 1 2 3 4 5 6 I1 (add s0,t0,t1)IF ID EX MEM WB I2 (sub t2,s0,t3)IF ID EX MEM WB 关键时间点分析(假设每个阶段工作在一个时钟周期内):
- 周期3开始:
I1进入EX阶段,它的操作数t0,t1被送入ALU进行计算。I2进入ID阶段,从寄存器堆中读取t3的值,并尝试读取s0的值。此时,I1的结果还未算出,所以I2读到的s0是旧值(错误的)。
- 周期3结束:
I1的ALU计算出s0的新值,这个值在时钟上升沿被保存到EX/MEM寄存器中。I2译码完成,它的操作数(陈旧的s0和t3)和控制信号在时钟上升沿被保存到ID/EX寄存器中。- 注意: 在周期3内,
I2无法获得正确的s0值。
- 周期4开始:
I1进入MEM阶段,EX/MEM寄存器中的值(包括正确的s0)保持稳定。I2进入EX阶段,ID/EX寄存器中的值(包括陈旧的s0)被送到ALU的输入端。- 就在此时,前递单元发挥作用!
- 它检测到
I2在EX阶段的源寄存器是s0。 - 它检测到
I1在MEM阶段的目的寄存器也是s0,并且会写回。 - 它立即产生控制信号,让ALU的输入MUX选择来自EX/MEM寄存器(即
I1的结果)的值,而不是选择ID/EX传来的陈旧值。
- 它检测到
- 因此,
I2的ALU实际使用的是刚从EX/MEM寄存器前递来的、正确的s0值进行计算。
结论: 前递并没有消除数据冒险,它依然存在(
I2在ID阶段读到了错误的值)。但它通过增加一条“捷径”,在错误的数据即将被使用的那一刻,及时地替换为正确的数据,从而避免了因冒险而导致的流水线停顿。代价是增加了额外的硬件复杂度(通路、MUX、控制逻辑),但收益是巨大的性能提升。 - 周期3开始:
-
Load-Use Hazard的特殊性
- 对于一条Load指令(如
lw s0, 0(t0)),数据必须等到周期4结束时(MEM阶段结束)才会从存储器中读出并存入MEM/WB寄存器。 - 而依赖该数据的指令(如
add t2, s0, t3)在周期4开始时就需要这个数据作为ALU的输入。 - 即使使用前递,最早也只能在周期4结束时才能拿到数据,已经来不及在周期4的EX阶段使用了。 因此,处理器必须为这种特殊情况插入一个流水线气泡(停顿一个周期),然后在周期5开始时,才能通过前递将MEM/WB寄存器中的数据送给ALU。
- 对于一条Load指令(如
-
-
编译器调度 (Compiler Scheduling)
- 机制: 由编译器重新排列指令顺序,在存在依赖的指令之间插入不相关的指令。
- 例子:
原始代码(有冒险):
编译器调度后:lw s0, 0(t0) # Load数据,周期长 add t2, s0, t3 # 必须等待lw addi t4, t4, 1lw s0, 0(t0) addi t4, t4, 1 # 不相关指令,填充延迟槽 add t2, s0, t3 # 此时lw的数据已就绪
3. 控制冒险 (Control Hazard)
问题描述: 分支指令(如beq, bne, j)改变程序流,导致已经预取并进入流水线的指令(分支目标之后的指令)可能无效。
问题根源: 分支指令的结果(是否跳转?跳转到哪里?)通常在ID阶段末期或EX阶段初期才能确定,但处理器在IF阶段就已经取回了下一条顺序指令。
时空图表现:
| 指令 \ 周期 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| I1 (beq ...) | IF | ID (确定跳转) | EX | MEM | WB |
| I2 (下一条指令) | IF | (无效指令!) | |||
| I3 (目标地址指令) | IF | ID |
在周期2,I1在ID阶段计算出应该跳转,但此时I2已经被取指并进入流水线,必须被丢弃。
解决方案:
-
流水线停顿 (Stalling)
- 机制: 一旦遇到分支指令,就暂停其后所有指令的取指,直到分支方向确定。这会产生固定的延迟,称为分支延迟槽(Branch Delay Slot)。
- 缺点: 效率低。如果分支很多,性能损失严重。
-
分支预测 (Branch Prediction) ★★★ (现代处理器关键技术)
- 核心思想: 猜!预测分支是否会跳转,并基于预测结果继续取指。
- 类型:
- 静态预测 (Static Prediction): 编译器或硬件采用简单策略。
- 预测永远不跳转: 继续取顺序指令。简单,成功率约50%。
- 预测总是跳转: 立即开始取目标地址的指令。
- 基于操作码预测: 例如,循环结尾的
bne通常预测为“跳转”。
- 动态预测 (Dynamic Prediction): 硬件根据运行时历史行为进行预测。
- 1位预测器: 记录上一次该分支是否跳转,这次就按上次的结果猜。
- 2位饱和计数器预测器: 需要两次预测错误才会改变预测方向,更健壮。这是现代处理器的基本技术。
- 分支目标缓冲区 (BTB): 一个缓存,存储分支指令的地址及其上次跳转的目标地址,可以同时预测分支方向和目标地址。
- 静态预测 (Static Prediction): 编译器或硬件采用简单策略。
- 预测错误恢复: 如果预测错误,必须清空(Flush) 流水线中所有错误的指令,并从正确的地址重新开始取指。这会带来惩罚(Penalty)。
-
延迟分支 (Delayed Branch)
- 机制: 一种编译器技术。分支指令的效果(是否跳转)并不是立即生效,而是允许它后面的一条指令(位于“分支延迟槽”中的指令)总是被执行,无论分支是否发生。
- 例子:
beq t0, t1, label # 分支指令 add t2, t3, t4 # 延迟槽指令,总会被执行 ... # 从这里开始,才是分支的目标或顺序下一条 - 现状: 在现代复杂的超标量处理器中管理困难,已较少使用,但其思想影响深远。
总结表
| 冒险类型 | 根本原因 | 关键解决方案 |
|---|---|---|
| 结构冒险 | 硬件资源冲突 | 资源复制(分离Cache)、多端口硬件 |
| 数据冒险 | 数据依赖(RAW) | 前递/旁路(主要)、停顿、编译器调度 |
| 控制冒险 | 指令流改变(分支) | 分支预测(主要)、停顿 |
现代高性能处理器通过精妙的硬件设计(如前递网络、复杂的分支预测器、深缓冲区)和编译器优化,极大地缓解了这些冒险带来的性能损失,使得流水线能够接近理想的高吞吐率状态运行。

浙公网安备 33010602011771号