数据与结构冒险:为什么正确的指令会读到错误的值

下面四条指令顺序执行时,x2 应为 14,x3 应为 21,地址 160 最后保存 21。把它们直接放进五级流水线,若没有前递和互锁,第二条却可能用初始的 x1=0 算出 x2=0。

1
2
3
4
addi x1, x0, 7
add x2, x1, x1
add x3, x2, x1
sw x3, 160(x0)

中心问题是:一个源寄存器在当前拍应从哪里获得数值,又在什么时候必须等待?本篇在第 09 篇的执行器中故意关闭保护,保存真实错误输出,再用前递与停顿恢复正确行为。全部周期与数据均属于“教学时序模型”,没有使用真机 PMU 或 gem5。

RAW 依赖需要确定最新的生产者

RAW 指先写后读的真实数据依赖。第一条 addi 在第三拍 EX 已经计算出 7,但要第五拍 WB 才写入 x1。第二条 add 在第三拍 ID 读 x1,读到的仍是旧值。它在第四拍 EX 使用这个旧值,就会产生错误结果。

CS61C 数据冒险材料分别讨论了停顿、同拍先写后读寄存器堆,以及向 EX 前递的路径。本模型采用 WB 先写、ID 后读的约定:距离较远的依赖可能因此直接读到新值;相邻依赖仍需要额外机制。

故障模式可直接运行:

1
2
3
python3 examples/computer-architecture/pipeline/src/run.py \
examples/computer-architecture/pipeline/programs/raw_chain.hex \
--broken-raw

此参数同时关闭前递与 RAW 互锁,是用于观察错误的配置。保存的 raw-broken.txt 中,第二次提交的 value 为 0;raw-repaired.txt 中对应值为 14。这个故障不是手写“预期输出”,而是执行器真的读了旧操作数后产生的提交事件。

前递的选择顺序不能颠倒

当 EX 需要寄存器 r,前递逻辑先检查 EX/MEM 中较新的结果,再检查 MEM/WB 中较老的结果,最后才使用 ID 捕获的值。匹配条件还要排除 x0、无效包、不写寄存器的指令以及尚未产生数据的 load。

1
2
3
4
5
EX 的源寄存器 r
r == 0 -> 0
EX/MEM 写 r 且结果可用 -> 较新的 ALU 结果
MEM/WB 写 r -> 已完成的结果
其余 -> ID 捕获值

例如连续两条指令都写 x1,后面的读者需要第二次写入。若 MEM/WB 匹配优先于 EX/MEM,就可能把第一次结果转发过去。回归中的 newest_writer 先写 1,再写 9,随后执行 add x3,x1,x1,要求提交 18;它专门区分这两个选择顺序。

x0 也不能被当作一般目标寄存器。addi x0,x0,9 的编码中 rd 确实为 0,但架构 x0 不会变成 9;后续读 x0 必须直接得到 0。对 lw x0,... 也不能制造一个“等待 x0 新值”的 RAW 停顿,不过这个 load 的访存本身仍然会执行和检查地址。

修复后的四条 raw_chain 指令共八拍,达到这个短序列的 N+4。若用 --interlocked 关闭前递、保留检测,则等待生产者完成 WB 后再放行,结果相同但共十四拍。前递节省了等待周期,并不改变原程序的依赖关系。

load-use 的值来得更晚

ALU 结果在 EX 末已经可用,load 的数据却要在 MEM 末才可用。对相邻的 lw x2,0(x1); add x3,x2,x2,load 的 MEM 与 add 的 EX 原本落在同一拍。按本模型的边沿和组合路径划分,不能把本拍末才读出的数据送回同拍已开始的 EX。

因此检测到 ID 的源寄存器依赖 EX 中的 load 时,模型保持 PC 与 ID 包,向下一个 EX 位置插入一个空槽。load 继续前进,下一拍它进入 MEM,消费者仍在 ID;再下一拍消费者进入 EX,从 MEM/WB 得到 load 数据。

周期 ID EX MEM WB 动作
4 add lw addi 保持 add,插入气泡
5 add lw addi load 读取数据
6 sw add lw add 使用前递数据

完整记录见 load-use-stages.txt。该案例四条指令需要九拍,比 N+4 多一拍。这里的“一个气泡”依赖一拍 MEM 和这套前递路径;若缓存未命中让 MEM 占四拍,等待就不再只有一个周期。

store 同时有地址与数据依赖

sw x2,0(x1) 的 x1 参与有效地址计算,x2 是将要写入的数值。只对 ALU 的两个数值输入做表面匹配,容易漏掉 store 数据;S 型指令中的部分位属于立即数,也不能误当作 rd。

本模型在 EX 获得 store 的基址和数据,两者都参与前递与依赖检测。因而 lw x2,...; sw x2,... 同样需要一个 load-use 气泡。另一种数据通路可以增加 MEM 阶段的 store-data 旁路,让某些这种相关不必在 EX 等待;那是另一套时序约定,需要单独画清楚旁路终点。

store_sources 案例分别生成基址 160 和数值 37,再把 37 写到 160,随后 load 读回。load_store_data 则直接检测 load 的结果能否正确成为下一条 store 的数据。这些案例与 x0、最新生产者案例共同参加 C/Python 逐提交差分。

结构冒险来自同一拍的资源竞争

数据已经准备好也不代表能前进。如果指令取指和数据访存只能使用同一个端口,MEM 里的 load/store 就会占掉当拍 IF 的机会。这是资源冲突,添加寄存器前递路径不能制造第二个端口。

ports.hex 有七条指令。分离端口时十一拍,单端口时十三拍。单端口模式明确记录两次实际丢失的 IF 机会;程序末尾已经没有指令要取时,MEM 占端口不会再计一次虚假的 IF 停顿。对照记录见 ports-comparison.txt

1
2
3
python3 examples/computer-architecture/pipeline/src/run.py \
examples/computer-architecture/pipeline/programs/ports.hex \
--single-port --cycles /tmp/ports.jsonl

这只是端口数量的教学对照,不等同于测量某种缓存的命中率、bank 冲突或真实带宽。后续缓存模型可以经 memory_latency 回调返回总 MEM 占用拍数,但多个并行 miss、MSHR 和独立请求队列不在当前接口中。

MEM 等待时还要保护已经进入流水线的操作数。较老 WB 可以在等待期间退出;被冻结的 EX 包必须接收并保留这次前递,不能等 MEM 解除等待以后再寻找一个已经消失的生产者。回归同时覆盖长延迟、单端口、预测和无前递互锁的组合,防止只在理想一拍内存中正确。

复跑与验收

下载 pipeline-lab.zip,执行 python3 examples/computer-architecture/pipeline/src/verify.py。记录 verification.json 保存 287 次五级差分、故障反例和计数检查;源码哈希、输入哈希和配置一起保留。模型支持本系列 13 条指令,完整 RV32I 与硬件时序没有被验证。

练习一:在 addi x1,x0,1; addi x1,x0,9; add x3,x1,x1 中故意把前递优先级反转,指出哪个阶段包含旧值、哪个阶段包含新值。验收答案必须定位首次错误的第三次提交,不能只说“结果不对”。

练习二:把 lw x2,128(x0) 改为 lw x0,128(x0),后接读取 x0 的 add。RAW 停顿应消失,数据访存仍存在;若使用单端口,其结构冲突仍可能保留。能够同时解释这两点,才能区分数据依赖与资源竞争。

本篇验收要求先复现错误,再说明前递来源和优先级,指出 load-use 在何处等待,并追踪 store 的两个源。只有最终总和正确,不足以通过该项验收。

阅读衔接

前一篇:计算机体系结构 09:五级流水线

参考资料