15.1 流水线与数据冒险
把一条指令拆成多个阶段,并让不同指令同时占据不同阶段,就能提高吞吐。流水线没有让某一条指令凭空更快;它缩短的是连续指令完成之间的间隔。
经典五级 RISC 流水线适合建立模型,现代 CPU 则可能拥有更深、更宽且动态调度的前后端。先把简单模型学准,再进入分支预测与乱序执行。
1. 延迟与吞吐不是一回事
假设洗衣需要洗、烘、叠三步,每步 30 分钟,三个工位可同时处理不同篮子:
- 一篮衣服从开始到完成,延迟仍是 90 分钟;
- 流水线填满后,每 30 分钟可以完成一篮,吞吐提高。
指令流水线同理。经典五级划分是:
| 阶段 | 名称 | 主要工作 |
|---|---|---|
| IF | Instruction Fetch | 根据 PC 取指令 |
| ID | Instruction Decode | 译码并读取寄存器 |
| EX | Execute | ALU 运算或地址生成 |
| MEM | Memory Access | 访问数据存储层次 |
| WB | Write Back | 写回寄存器结果 |
理想时间线:
cycle 1 2 3 4 5 6 7
instruction A IF ID EX MEM WB
instruction B IF ID EX MEM WB
instruction C IF ID EX MEM WBA 仍经过五级,B 却可以在 A 完成前开始。稳定状态下,理想的单发射流水线每周期完成一条指令,IPC 接近 1。
“分成五级就快五倍”不成立。实际加速受以下因素限制:
- 最慢一级决定时钟周期;
- 级间寄存器本身有延迟与功耗;
- 流水线需要填充和排空;
- 依赖、分支和资源冲突会插入空泡;
- cache miss 可能远长于一个流水级。
2. 三类流水线冒险
结构冒险
两条在途指令同一周期需要同一个硬件资源。例如统一单端口存储器既要取下一条指令,又要为当前 load 读数据。可增加端口、分开指令与数据存储、复制资源,或者停顿;并行硬件也要付面积、功耗与布线成本。
数据冒险
指令之间存在寄存器或内存依赖:
RAW(Read After Write) 是真依赖:
add x5, x1, x2
sub x6, x5, x3 # 必须读取 add 产生的新 x5WAR(Write After Read) 是反依赖:
read x5
write x5WAW(Write After Write) 是输出依赖:
write x5
write x5这里 read/write 只是依赖示意,不是 RISC-V 指令。假设寄存器都在 ID 读、WB 按序写的简单五级流水线,主要面对 RAW。WAR/WAW 在允许指令乱序执行或完成时才会阻碍调度,现代 CPU 用 register renaming 消除它们。内存依赖更难,因为两个地址可能直到执行阶段才知道是否相同。
控制冒险
遇到条件分支或间接跳转时,取指单元尚不知道下一条 PC。若等条件完全解析再取指,会留下空闲周期;若先猜,猜错后就要丢弃错误路径上尚未提交的工作,并从正确地址重新开始。
预测越早、前端越深、发射越宽,错误路径可能占用的资源就越多。下一节会单独展开。
3. 转发让结果提前抵达
在经典五级流水线里,ALU 结果在 EX 末尾已经产生,却可能到 WB 才写入寄存器堆。若下一条指令等到 WB 后再读,会浪费周期。
forwarding/bypassing 把中间流水寄存器中的结果直接送到后续 ALU 输入:
add: IF ─ ID ─ EX ─ MEM ─ WB
╲
sub: IF ─ ID ─ EX ─ MEM ─ WB
↑
forwarded value控制逻辑比较后续指令源寄存器与前面在途指令目的寄存器,再选择寄存器堆值、EX/MEM 结果或 MEM/WB 结果。
转发网络是微架构细节,反汇编里看不到“这一条走了旁路”。汇编只能暴露数据依赖,具体 CPU 决定是否以及从哪一级转发。
4. load-use 为什么仍可能停顿
考虑:
lw x5, 0(x10)
add x6, x5, x7在经典模型中,lw 的数据到 MEM 末尾才产生,紧随其后的 add 却在同一周期的 EX 开始就需要它。普通 EX-to-EX 转发来不及,hazard unit 会让依赖指令停一个周期:
lw : IF ─ ID ─ EX ─ MEM ─ WB
add: IF ─ ID ─ stall ─ EX ─ MEM ─ WB“一定停一周期”只属于这套具体流水级与 cache 命中假设。不同微架构可能调度、重放或以不同延迟提供 load 结果;cache miss 更会把等待拉长很多周期。
编译器可以在中间安排一条独立指令,现代乱序 CPU 也可以执行其他已就绪工作来隐藏部分延迟。没有独立工作时,真依赖链仍然决定下限。
5. 依赖链限制指令级并行
下面两个函数使用无符号整数,结果在模 2^64 意义下相同;本例数据未超出范围,因此也等于数学总和。浮点加法不满足同样的重分组等价性。第一版只有一个累加器,每次加法依赖上一次;第二版使用四个独立累加器,最后再合并:
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
static uint64_t sum_one(const uint32_t *values, size_t length) {
uint64_t sum = 0;
for (size_t i = 0; i < length; ++i) {
sum += values[i];
}
return sum;
}
static uint64_t sum_four(const uint32_t *values, size_t length) {
uint64_t a = 0;
uint64_t b = 0;
uint64_t c = 0;
uint64_t d = 0;
size_t i = 0;
size_t bulk_end = length - length % 4;
for (; i < bulk_end; i += 4) {
a += values[i];
b += values[i + 1];
c += values[i + 2];
d += values[i + 3];
}
for (; i < length; ++i) {
a += values[i];
}
return a + b + c + d;
}
int main(void) {
enum { COUNT = 100000 };
uint32_t *values = malloc(sizeof(*values) * COUNT);
if (values == NULL) {
return EXIT_FAILURE;
}
for (size_t i = 0; i < COUNT; ++i) {
values[i] = (uint32_t)(i % 97);
}
uint64_t first = sum_one(values, COUNT);
uint64_t second = sum_four(values, COUNT);
printf("%" PRIu64 " %" PRIu64 "\n", first, second);
free(values);
return first == second ? EXIT_SUCCESS : EXIT_FAILURE;
}cc -std=c17 -O2 -Wall -Wextra accumulators.c
./a.out多个累加器提供了独立加法,给超标量或乱序 CPU 更多可并行工作。不过不能直接把运行时间差归因于流水线:编译器可能自动展开、向量化或把两个函数优化成不同指令。分析时应同时查看汇编,并用适合目标 CPU 的性能计数器测量。
6. 编译器、ISA 与微架构的边界
一段 C 循环经过多层转换:
C 源码
↓ compiler optimization
ISA instructions
↓ decode / rename / schedule
micro-operations and execution units编译器知道目标 ISA、指令延迟模型和静态依赖,可以:
- 删除无用工作;
- 展开循环;
- 选择 branch 或条件移动;
- 自动向量化;
- 调整独立指令顺序;
- 通过别名分析证明两个内存访问不冲突。
CPU 在运行时看到真实 cache miss、分支历史和操作数就绪状态,可以动态调度。两者合作,但不能互相替代。
C 的 volatile 不是“禁止 CPU 乱序”的通用屏障。它主要约束抽象机中对 volatile object 的访问,常用于内存映射 I/O 等特定接口;线程同步应使用原子操作、mutex 和语言内存模型。Java volatile 则有语言规范定义的跨线程可见性与顺序语义,两者不能按名字类比。
7. cache miss 会把短流水线问题放大
ALU 操作可能很快,内存层次延迟却相差很大。load miss 后,CPU 可能:
- 在 cache 层次继续查找;
- 生成并跟踪未完成内存请求;
- 执行与该 load 无关的后续指令;
- 在资源耗尽或遇到依赖时停住;
- 最终从更远层次取得数据并唤醒依赖指令。
memory-level parallelism 允许多个独立 miss 同时在途。链表遍历的下一个地址依赖当前节点内容,通常难以产生这种并行;数组顺序访问则更容易被预取并发起多个请求。
因此“优化流水线”常常落到数据布局:连续访问、减少指针追逐、压缩工作集和避免伪共享。
8. 精确异常要求看起来按顺序
即使简单流水线让多条指令同时在途,架构状态也要呈现可理解的边界。若一条 load 发生页故障:
- 它之前的指令应表现为已经完成;
- 对本例的单次 load,它及之后的指令不应留下不可撤销的目的寄存器或普通内存结果;
- 异常处理结束后可以从正确位置继续或终止。
ISA 定义的异常记录、页表 accessed 位或可重启复合指令可能有各自的部分进度规则,不能概括成“故障时什么都没改变”。顺序流水线可以通过控制各级写入维护规定的边界。乱序 CPU 则需要更明确的 retirement/commit 机制,下一节会介绍 reorder buffer 如何维护精确异常。
9. 分析一道依赖题
给定:
add x5, x1, x2
lw x6, 0(x5)
xor x7, x6, x3
add x8, x4, x9可以按顺序标注:
lw计算地址依赖add的 x5,是 RAW;ALU 转发可能解决;xor依赖lw的 x6,是 load-use RAW,在经典五级模型中要停顿;- 最后一条
add与前三条寄存器独立,可在更先进的调度器里越过等待执行; - 仅凭汇编不能断言真实 CPU 停了几周期,还要知道微架构与 cache 状态。
正确的分析语言是“依赖允许什么”,不是为所有处理器背一张固定周期表。
10. 小结
流水线通过重叠不同指令的阶段提高吞吐:
- 五级 RISC 模型把取指、译码、执行、访存和写回分开;
- 结构 hazard 来自资源争用,数据 hazard 来自依赖,控制 hazard 来自下一条 PC 的不确定性;
- forwarding 缩短结果传递路径,但不能消除所有 load-use 等待;
- 多个独立累加器能暴露更多指令级并行;
- cache miss、内存依赖和数据布局常比理想五级时间线更重要;
volatile不是线程同步或硬件排序的万能开关。
下一节进入分支预测、超标量与乱序执行,看现代 CPU 如何在保持架构结果有序的同时,让内部执行大幅越序。