CSAPP - 07.PIPE-02
计算机组成原理笔记:流水线实现 II (Pipelined Implementation Part II)
主要内容:
上一节我们构建了 PIPE-(基础流水线),但它遇到冒险时只能通过“暂停”或“插入气泡”来解决,效率极其低下。
本章的目标是将 PIPE- 升级为最终的 PIPE 处理器,核心手段是:
- 数据转发 (Data Forwarding):解决大部分数据冒险,不需要暂停,极大提升吞吐量。
- 加载/使用冒险处理 (Load/Use Handling):处理转发也无法解决的特殊数据冒险(必须暂停 1 周期)。
- 控制冒险处理 (Control Hazard Handling):高效处理分支预测错误和
ret指令。 - 异常与组合逻辑:处理多种冒险同时发生时的优先级问题。
Motivation:
如果流水线充满了“气泡”(空指令),那它就失去了意义。通过硬件层面的优化(转发),我们可以让指令像接力赛一样紧密衔接,让 CPI(每指令周期数)无限接近于 1.0,从而获得极致的性能。
1. 复习与现状 (Review & Status)
1.1 处理器演进路线
- SEQ:顺序执行,简单但太慢。
- SEQ+:调整 PC 更新阶段,为流水线做准备。
- PIPE-:
- 特点:插入了流水线寄存器 (F, D, E, M, W)。
- 问题:无法自动处理 数据冒险 (Data Hazards) 和 控制冒险 (Control Hazards)。
- 临时方案:依赖编译器插入
nop指令(软件气泡),导致代码体积膨胀且性能低下。
- PIPE:
- 目标:完整的、高效的流水线处理器。
- 手段:引入 数据转发 (Data Forwarding) 和高级控制逻辑。
1.2 PIPE- 的困境
- 数据冒险:如果指令 A 写寄存器
%rax,紧接着指令 B 读%rax。在 PIPE- 中,B 必须等 A 写回(Write-back)后才能读,中间需要插入 3 个气泡。 - 控制冒险:分支预测错误或
ret指令导致取指错误,需要清除流水线。
数据冒险的解决方案:气泡指令(Bubble)
定义:
“Bubble” 并不是程序员在汇编代码中写的一条真实指令,而是处理器硬件自动产生的一种特殊状态。它在功能上完全等同于 nop (No Operation) 指令。
本质:
当流水线控制逻辑向某个阶段(如 Execute 阶段)注入 Bubble 时,该阶段的流水线寄存器会被重置为默认状态(通常对应 nop)。这会导致该阶段在当前时钟周期内:
- 不执行任何有效操作。
- 不改变任何程序状态(不写寄存器、不写内存、不改条件码)。
- 像一个“空气泡”一样流向下游阶段。
为什么要用它?(解决冒险)
它是处理流水线冒险 (Hazards) 的关键机制,就像在紧凑的传送带上故意留出一个“空位”:
- 抵消错误 (Squashing):当分支预测错误时,之前错误取入流水线的指令必须被“杀掉”,方法就是把它们变成 Bubble。
- 制造延迟 (Stalling):当发生加载/使用冒险 (Load/Use Hazard) 时,需要在执行阶段插入 Bubble,从而推迟后续指令的执行,给内存读取留出时间。
气泡(Bubble)本质上是一个空操作(NOP)指令。通过在流水线中插入气泡,可以暂停流水线的某些阶段,从而解决冒险问题。
对于上图,处理器自动检测前面指令在W(写回)阶段与当前指令的F(访存)阶段是否有冲突。如果是,插入bubble指令。
为了实现“暂停”和“插入空指令”,硬件设计者修改了流水线寄存器(F, D, E, M, W),让它们支持三种操作模式:
| 模式 (Mode) | 行为描述 (Behavior) | 作用 (Function) |
|---|---|---|
| Normal (正常) | Output = Input |
正常传递信号,流水线流畅运行。 |
| Stall (暂停) | Output = Old Output |
“卡住”:寄存器保持上一周期的值不变,忽略输入。这会让当前阶段的指令“停”在原地。 |
| Bubble (气泡) | Output = NOP |
“喷出气泡”:寄存器的输出被强制重置为 nop 指令的状态。这相当于在流水线中凭空变出一条空指令。 |
当译码阶段(Decode)的指令需要读取寄存器,而前面的指令(处于 Execute, Memory, Write-back 阶段)还没有把结果写回去时,就发生了数据冒险。
处理流程:
-
检测冒险 (Detection):
- 检查 Decode 阶段指令的源寄存器 (
d_srcA,d_srcB)。 - 检查 Execute / Memory / Write-back 阶段指令的目的寄存器 (
e_dstE,M_dstE,W_dstE等)。 - 如果有重叠(Read-After-Write),说明数据还没准备好。
- 检查 Decode 阶段指令的源寄存器 (
-
控制动作 (Action):
- 对于 Decode 阶段的指令(依赖者):
- 对 D 寄存器 发送 Stall 信号 -> 让这条指令留在译码阶段,下一周期继续尝试读取。
- 对 F 寄存器 发送 Stall 信号 -> 让取指阶段也停下,防止取入新指令覆盖当前指令。
- 对于 Execute 阶段(空隙):
- 对 E 寄存器 发送 Bubble 信号 -> 因为 D 阶段没送来新指令(被暂停了),E 阶段必须执行一个“空操作” (
nop) 来填补时间空隙。
- 对 E 寄存器 发送 Bubble 信号 -> 因为 D 阶段没送来新指令(被暂停了),E 阶段必须执行一个“空操作” (
- 对于 Decode 阶段的指令(依赖者):
-
效果:
- 就像在两条指令之间动态插入了
nop指令。 - 这种状态会持续(多个周期),直到前面的指令通过 Write-back 阶段,数据真正写入寄存器文件。此时冒险消除,流水线恢复 Normal 状态。
- 就像在两条指令之间动态插入了
为了实现这一逻辑,引入了一个名为 Pipeline Control Logic (流水线控制逻辑) 的组合逻辑块。
- 输入:所有阶段的寄存器 ID、状态码、指令代码。
- 输出:控制 5 个流水线寄存器的
stall和bubble信号(例如F_stall,D_stall,E_bubble等)。
检测逻辑示例 (简化版):
1 | |
- 优点:
- 简单:硬件逻辑相对容易实现。
- 正确:能够保证程序逻辑的绝对正确性,不会产生数据竞争。
- 缺点:
- 性能极差 (Poor Performance):
- 每次遇到数据依赖,流水线都要停顿 2-3 个周期。
- 由于数据依赖在程序中非常常见,这会导致 CPI (每指令周期数) 大幅上升,吞吐量暴跌。
- 这使得流水线处理器退化回了 SEQ 处理器的效率,甚至更差(因为有寄存器延迟)。
- 性能极差 (Poor Performance):
下一步:为了解决这个问题,我们需要引入 数据转发 (Data Forwarding),让数据不需要等待写回,直接在流水线内部“传递”,从而消除这些不必要的 Bubble。
2. 数据冒险的解决方案:数据转发 (Data Forwarding)
这是本章最核心的概念。
2.1 什么是数据转发?
- 朴素思想 (Naive Pipeline):指令必须等到 写回 (Write-back) 阶段结束,数据真正写入寄存器文件后,后续指令才能读取。
- 观察 (Observation):其实数据在 执行 (Execute) 或 访存 (Memory) 阶段就已经计算出来了!
- 转发 (Forwarding):
- 原理:不等待数据写回寄存器文件,而是直接将流水线寄存器(E, M, W 阶段)中的中间结果,“抄近道”发送给 译码 (Decode) 阶段。
- 效果:消除了大部分数据冒险带来的暂停,流水线可以满负荷运行。
2.2 转发源与目的 (Forwarding Sources & Destinations)
-
源 (Sources) - 数据从哪里来?
- ALU 输出 (e_valE):在 Execute 阶段刚算出来的结果。
- 内存输出 (m_valM):在 Memory 阶段刚读出来的结果。
- M 阶段的 ALU 结果 (M_valE):上条指令算出来,正在过 Memory 阶段的值。
- W 阶段的写入值 (W_valM, W_valE):即将写入寄存器文件,但还没写进去的值。
-
目的 (Destinations) - 数据到哪里去?
- Decode 阶段:
valA和valB的选择逻辑(即d_valA和d_valB)。
- Decode 阶段:
2.3 转发优先级 (Forwarding Priority)
当流水线中有多条指令同时写同一个寄存器时(例如:连续三条指令都修改 %rax),应该转发哪一个?
这段 HCL 代码的作用就是: 决定 valA 到底是从寄存器堆里读旧值,还是从流水线前面的阶段“窃取”(Forward)最新的值。
- 原则:选择 “最新” (Earliest Pipeline Stage) 的数据。
- 逻辑顺序:
- E 阶段 (Execute):最年轻的指令,数据最新鲜。
- M 阶段 (Memory)。
- W 阶段 (Write Back):最老的指令。
- 实现 (HCL):在
Case表达式中,先检查 E 阶段,再检查 M 阶段,最后检查 W 阶段。匹配即停止。
1 | |
3. 加载/使用冒险 (Load/Use Hazard)
3.1 转发的局限性
有一种情况是转发也救不了的:加载/使用冒险。
- 场景:
mrmovq (%rcx), %rax(从内存读数据到%rax)addq %rax, %rbx(下一条指令立马用%rax)
- 原因:
- 指令 1 在 Memory 阶段结束时(周期 8)才能拿到数据。
- 指令 2 需要在 Execute 阶段开始时(周期 8)使用数据。
- 时序冲突:数据还没读出来,ALU 就要用了。这是一个“时光倒流”的需求,硬件做不到。
3.2 解决方案:暂停 + 转发
- 策略:必须暂停 1 个时钟周期。
- 具体操作:
- Stall (暂停):保持 Fetch 和 Decode 阶段的指令不变(F 和 D 寄存器不更新)。PC不更新。
- Bubble (气泡):向 Execute 阶段插入一个气泡(
nop)。
- 结果:指令 2 晚一步进入 E 阶段,此时指令 1 已经拿到了内存数据,可以通过转发机制(从 W 阶段转发到 E 阶段前)解决问题。
3.3 检测逻辑 (Detection Logic)
1 | |
ret指令也会访问内存,但是写入的是PC(硬件寄存器)而不是寄存器文件,所以不会出现加载/使用冒险。
在PIPE处理器中,一般的数据冲突并不会增加时钟周期,只有加载/使用冒险会增加1个时钟周期。
pop指令在写回阶段时候,要写两个寄存器,R[%rsp] <- valE和R[%rA] <- valM,在这里将栈顶元素值和新的栈地址“都写入”%rsp寄存器。事实上,写入哪个依赖于下面的顺序:
- HCL 逻辑开始判断第一行:
d_srcA(%rsp) 是否等于M_dstE(%rsp)? - 答案是 YES! 因为
popq指令确实会更新栈指针(dstE设为了%rsp)。 - 匹配命中,立即停止。
- 输出结果:选择
M_valE。
4. 控制冒险的解决方案 (Handling Control Hazards)
控制冒险主要来自 分支预测错误 和 返回指令 (ret)。
4.1 分支预测错误 (Branch Misprediction)
在流水线中,处理器为了保持高速运转,遇到条件跳转指令(如 je, jne)时,不能等到算出结果再决定下一条指令取哪里的。它必须猜测(通常 CS:APP 的 Y86 设计默认采用“预测跳转 (Always Taken)”策略,或者简单的静态预测)。
-
Fetch 阶段:看到了
je target,直接假设条件成立,开始取target处的指令。 -
Decode 阶段:继续处理
target处的指令。 -
Execute 阶段:真相大白时刻。ALU 终于算出了条件码(CC),我们知道了条件到底成不成立。
-
现状:我们默认预测条件跳转 (
jXX) 是 Taken (跳转) 的。 -
问题:如果实际不应该跳转 (Not Taken),流水线已经错误地取入了目标地址的两条指令。
-
解决:Cancel (取消/冲刷)。
- 当
jXX到达 Execute 阶段时,ALU 算出条件不满足。 - 注入气泡 (Inject Bubbles):在下一个周期,将Decode 和 Execute 阶段的指令替换为气泡。
- 修正 PC:Fetch 阶段重新从正确地址 (
valP) 取指。
- 当
-
代价:浪费 2 个 时钟周期。
Trigger: E_icode = IJXX & !e_Cnd
这个公式的意思是:“如果在 Execute 阶段发现这是一条跳转指令,并且条件竟然不成立!”
我们逐项拆解:
-
E_icode = IJXX:- 表示当前在 Execute 阶段执行的指令是条件跳转指令(Jump if XX,例如
je,jg,jl等)。 - 注:
jmp是无条件跳转,不需要判断条件,所以不算在这里。
- 表示当前在 Execute 阶段执行的指令是条件跳转指令(Jump if XX,例如
-
!e_Cnd:e_Cnd是图中蓝色小方块输出的信号 (Condition Holds)。它根据 ALU 刚刚算出的标志位(CC)判断跳转条件是否为真。!e_Cnd表示条件为假 (False)。- 关键隐含前提:因为我们的处理器(Y86-64 Pipeline)默认策略通常是预测跳转 (Predicted Taken)。
- 如果我们预测它会跳(已经去取目标地址的指令了),结果现在发现条件不成立(不该跳),这就说明我们预测错了吗 (Mispredicted)。
-
ALU & CC (Condition Codes):
- ALU 执行计算(比如
subq %rax, %rbx),产生 Zero Flag (ZF), Sign Flag (SF) 等。 - 这些标志位保存在 CC 寄存器中。
- ALU 执行计算(比如
-
e_Cnd 逻辑块:
- 它读取当前的跳转类型(比如
je需要 ZF=1)和当前的 CC 值。 - 如果匹配,输出 1 (
e_Cndis true);否则输出 0。
- 它读取当前的跳转类型(比如
虽然这张图只讲了“检测”,但检测到之后的动作才是关键,这通常被称为流水线清洗 (Pipeline Flushing):
- 废除指令:
因为预测错了,所以目前紧跟在跳转指令后面的两条指令(分别在 Fetch 和 Decode 阶段)都是错误的指令(原本不该执行的)。
处理器必须向 D 和 E 流水线寄存器中插入气泡 (Bubble),或者把它们变为 nop,从而“杀掉”这两条错误指令。 - 修正 PC:
处理器必须把 PC 重新指向正确的地址(即跳转指令的下一条指令,也就是“不跳转”的路径)。
4.2 返回指令 (Ret Instruction)
- 现状:
ret的返回地址在栈里,必须等到 Memory 阶段才能拿到。 - 解决:Stall until complete (一直暂停)。
- 当译码阶段发现
ret指令时。 - 对 Fetch 阶段进行 Stall (暂停),只要
ret还在 D, E, M 阶段。 - 对 Decode 阶段进行 Bubble (气泡),防止错误指令进入。
- 直到
ret到达 Write-back 阶段,拿到了valM(返回地址),并将 PC 设置为该地址。
- 当译码阶段发现
- 代价:浪费 3 个 时钟周期。
分三次每次插一个bubble
5. 流水线控制逻辑总结 (Pipeline Control Logic)
我们需要一个集中的逻辑块来控制所有流水线寄存器的状态(Normal, Stall, Bubble)。
5.1 特殊情况组合 (Control Combinations)
有些罕见情况会同时触发多种控制逻辑,需要设定优先级。
-
组合 A (Mispredict + Ret):
- 分支预测错误,且跳转目标刚好是
ret。 - 处理:按 分支预测错误 处理。
- 分支预测错误,且跳转目标刚好是
-
组合 B (Load/Use + Ret):
- 加载/使用冒险,且下一条是
ret。 - 处理:Load/Use 优先级更高。因为如果不暂停让数据读出来,
ret根本不知道回哪去。
- 加载/使用冒险,且下一条是
5.2 最终控制表 (Pipeline Control Actions)
| 条件 (Condition) | F Reg | D Reg | E Reg | M Reg | W Reg |
|---|---|---|---|---|---|
| 正常 (Normal) | Normal | Normal | Normal | Normal | Normal |
| 加载/使用冒险 (Load/Use) | Stall | Stall | Bubble | Normal | Normal |
| 分支预测错误 (Mispredict) | Normal | Bubble | Bubble | Normal | Normal |
| Ret 指令处理 | Stall | Bubble | Normal | Normal | Normal |
6. 性能分析 (Performance Analysis)
6.1 CPI (Cycles Per Instruction)
衡量流水线效率的核心指标。
- 理想 CPI:1.0 (每个周期完成一条指令)。
- 实际 CPI:。
-
6.2 惩罚项 (Penalties)
- LP (Load Penalty): 加载/使用冒险导致的气泡 (1个)。
- MP (Misprediction Penalty): 分支预测错误导致的气泡 (2个)。
- RP (Return Penalty):
ret指令导致的气泡 (3个)。
典型性能计算:
假设:Load占25% (其中20%发生冲突), 分支占20% (40%预测错), Ret占2%。
- 结论:虽然有暂停和预测错误,CPI 1.27 依然远好于非流水线的 CPI (如 > 5.0)。
7. 现代处理器展望 (Modern CPU Design)
课件最后简要提及了真实世界的高性能 CPU(如 Intel Haswell)是如何设计的:
- 超深流水线:不止 5 级,可能有 15-20 级,以提高主频。
- 多发射 (Multiple Issue):每个周期取指、译码、执行多条指令 (Superscalar)。
- 乱序执行 (Out-of-Order Execution):
- 指令不一定按顺序执行。
- 有一个指令池,谁的数据准备好了谁先跑。
- 退役单元 (Retirement Unit):确保指令按程序顺序写回结果(保证正确性)。
- 分支预测 (Branch Prediction):
- 使用 BTB (Branch Target Buffer) 和复杂的历史记录表。
- 预测准确率极高 (>95%),极大减少了 MP 惩罚。
总结:从 SEQ 到 PIPE 是计算机体系结构的一次飞跃。通过转发解决了数据等待,通过预测解决了控制等待,虽然引入了复杂性,但获得了巨大的性能回报。