RAW(ALU→ALU):真依赖与前推旁路 —— 寄存器域里最优雅的一拍消解
本篇是数据冒险拆分的 RAW 篇(寄存器域):先立好 µop 视角(CISC 只到译码器,冒险 = µop 之间的物理寄存器依赖),再把 RAW 三种形态定下来,随后集中解剖最快的一种——ALU→ALU:靠前推旁路把依赖代价压到"背靠背 1 拍"。访存域的两种 RAW(Load→Use、Store→Load)见 访存 RAW 篇。前提:冒险总览见数据冒险总纲;假依赖见假依赖篇;L1 miss 连锁见miss 连锁反应篇。
一、先立好 µop 视角
x86 是 CISC 指令集,但到了硬件层面,CISC 只暴露到译码器为止。译码器的职责就是把一条复杂的 x86 指令拆成一到多条 µop,之后的所有流水线部件——重命名、保留站、执行单元、LSU、ROB——处理的全是 RISC 风格的 µop,与它们来自 x86 还是 ARM 指令集毫无关系:
- 汇编层:程序员看到的是
add eax, [rsi+8],一条 CISC 指令同时干了"读内存"和"做加法" - 译码器(CISC→µop 边界):拆成
LD tmp, [rsi+8]+ADD eax, tmp,一条变两条 - 执行层(全部 RISC):µop 固定三操作数
dest, src1, src2,只做一件事(load 只 load,ALU 只算),寄存器全部是物理寄存器编号,再也没有"累加器既是源又是目的"这回事
所以冒险本质上发生在 µop 与 µop 之间,而不是 x86 指令与指令之间:
┌─────────────────────────────────────────────────────────────────┐
│ 一条 x86 指令可能拆成多条 µop: │
│ │
│ mov [rbx], eax → STA [rbx] + STD eax │
│ (Store Addr) (Store Data) │
│ µop₁ 无目的PR µop₂ 无目的PR │
│ │
│ add eax, [rsi+8] → LD tmp,[rsi+8] + ADD eax,tmp │
│ µop₁ 写目的PR µop₂ 读µop₁的目的PR │
│ ↑ 这两条 µop 之间有 RAW! │
│ │
│ addsd xmm0, [rdi+rcx*8] → LD tmp,[rdi+...] + ADDSD xmm0,tmp │
│ µop₁ 在 INT RS µop₂ 在 FP RS │
│ ↑ 跨集群 RAW! │
└─────────────────────────────────────────────────────────────────┘冒险 = µop 之间的物理寄存器依赖。程序里写的是
eax,µop 之间传递的是物理寄存器号(如 PR12→PR37)。冒险检测和消解都在物理寄存器域完成。
二、RAW 是什么:真依赖,硬边界
一条 µop 读的物理寄存器,恰好是前面一条 µop 要写的物理寄存器。消费者的输入 = 生产者的输出——这是真实的、无法消除的数据流依赖。
µop₁: ADD P37, P12, P23 ; 写目的 PR = P37(结果)
µop₂: SUB P45, P37, P30 ; 读源 PR = P37 ← 必须等 µop₁ 写完µop₂ 读 P37 不是碰巧同名——µop₂ 就是要用 µop₁ 算出来的值。这是真依赖,硬件必须保证 µop₂ 读到的是 µop₁ 写入后的值。
但"前面一条"的生产者身份不同,RAW 的严重程度也完全不同。按数据从哪里来,RAW 分三种:
| 类型 | 生产者 | 数据延迟 | 前推能不能救 | 典型场景 |
|---|---|---|---|---|
| ALU→ALU | ALU 计算结果 | 1 拍 | ✅ 前推旁路直接截到 | a=b+c; d=a+e |
| Load→Use | L1 D-Cache / 下级缓存 | 4~200+ 拍 | ⚠️ 只能减 1~2 拍,大头在 cache 延迟 | a = *p; b = a + 1 |
| Store→Load | Store Buffer forwarding | ~3~5 拍 | ✅ SB forwarding 等价于前推 | *p=1; x=*p(同地址) |
三种 RAW 的本质都是"消费者等生产者",但等的时长差了两个数量级:
- ALU→ALU:生产者 1 拍就算完了,前推网络在数据"还没落位 PRF"时就截走了,consumer 几乎不用等——这是硬件设计最优雅的部分,本篇 §三 全解剖
- Load→Use:生产者的数据不在流水线里——它在外的缓存层次,4 拍是起跑线、200 拍不稀奇。前推只能省 PRF 读那 1 拍,杯水车薪;其完整流水线见 访存 RAW 篇
- Store→Load:特殊在"生产者"不是 ALU 也不是 cache,而是 store buffer 里还没落位的 store 数据——地址比对命中了直接给。机制见 访存 RAW 篇
下面只看前推能完美解决的 ALU→ALU——它速度最快、机制最纯,是理解"RAW 怎么被硬件消解"的最佳入口。
三、最简单的 RAW:ALU → ALU(前推旁路,差 1 拍)
两条纯算术 µop,生产者和消费者紧挨着:µop₁: ADD P37, P12, P23 → µop₂: SUB P45, P37, P30

图上三条泳道完整呈现了 µop₂ 从取指到写回的 6 个步骤,核心问题只有三个:
P37 还没算出来,µop₂ 就要用——硬件怎么知道"还没好"? 重命名阶段,RAT(寄存器别名表)查到 P37 对应的目的 PR 还在流水线里没写回 → 给 µop₂ 的源操作数标记
tag≠0。µop₂ 带着这个 tag 进入保留站。µop₂ 怎么知道 P37"什么时候好"? 保留站逐拍监听结果总线的广播——当 µop₁ 的 EX 级算完,结果总线广播
PR37 就绪,保留站里的 tag 比较器匹配到tag=37,立即唤醒 µop₂。µop₂ 怎么"拿到" P37 的值——数据从哪来? 不是等 µop₁ WB 写回 PRF 再读(那要等到拍6),而是 ALU 输入端的 MUX 在发射时直接比较:µop₂ 的源 PR tag 和 EX/MEM、MEM/WB 的目的 PR tag 做匹配——命中 EX/MEM → MUX 选中级间寄存器的输出,数据"插队"进入 ALU,省 1 拍。
三个问题对应三个硬件机制:RAT(判断依赖)→ 结果总线广播(唤醒等待者)→ 前推 MUX(截胡数据)。缺一个,RAW 惩罚都会扩大。
前推能消除几乎所有 ALU→ALU 的 RAW 代价——从差 2 拍压到差 1 拍(背靠背),保留站里的 tag 匹配 + 前推 MUX 组合是标准解法。
3.1 前推的硬件是怎么做的——tag 匹配 + MUX 选路
ALU→ALU RAW 能在 1 拍内消解,靠的是两件事同时发生:
① 保留站的 tag 唤醒。每条 µop 在发射到保留站时,源操作数如果是未就绪的(RAT 查到目的 PR 还没算出来),保留站槽位里写的是"等哪个 PR"——比如 µop₂ 的源 P37 未就绪,槽位里记 tag=37。当 µop₁ 在 EX 级结束、结果锁存到 EX/MEM 级间寄存器的同时,结果总线广播 PR37 就绪。保留站里的 tag 比较器逐拍监听这条广播,匹配到 tag=37 → 立即唤醒槽位里的 µop₂,调度器在同一拍或下一拍把 µop₂ 发射到 ALU。
② ALU 输入端的 MUX 选路。µop₂ 发射进 ALU 时,ALU 的输入不是只能从 PRF(物理寄存器文件)读——它前面有一排 MUX,三路来源:
| 来源 | 延迟 | 命中条件 |
|---|---|---|
| PRF 常规读 | 0(基准) | 默认路径,tag 匹配不上时走这路 |
| EX/MEM 前推 | -1 拍 | 正在执行的指令(上一条)的目的 PR 匹配 |
| MEM/WB 前推 | -1 拍 | 上上条指令的目的 PR 匹配 |
µop₂ 发射时,比较器同时把 µop₂ 的源 PR tag 和 EX/MEM、MEM/WB 的目的 PR tag 做对比——匹配到 EX/MEM,则 MUX 直接选 EX/MEM 级间寄存器的输出,跳过 PRF 读。这就是图里 EXMEM 到 ALU 的虚线箭头——数据在级间寄存器里"插队"送到下一条 ALU 输入端。
为什么前推网络只向前看 1~2 条? 因为更早的指令早就 WB 写回 PRF 了,直接走 PRF 常规读就行。前推的本质是"截胡还没落位的数据"——只截最近 1~2 条,足够了。
四、代码层实践:依赖链与独立链,IPC 差了一个数量级
前推把 ALU→ALU 压到背靠背 1 拍,但前提是依赖链上只有一条。实际程序里的加法/乘法链,往往一条挨一条,前推救的是"相邻两条",救不了"一整串":
; 依赖链(串行累加):每条都等上一条算完
① add rax, rbx
② add rax, rcx ; 等① 的结果
③ add rax, rdx ; 等② 的结果
; IPC ≈ 1/3 ~ 1/4(加法链延迟约 3~4 拍),乱序也挖不出并行
; 独立链(多路累加):四条链互不相干
a += x; b += y; c += z; d += w;
; IPC ≈ 接近端口数(现代 x86 有 4 个整数 ALU 端口)同一份数据、同样的运算次数,写法差一层循环展开,IPC 能差 3~4 倍。这正是 ipc-bottlenecks 里"瓶颈从哪来"的微架构根:依赖链把每条指令的延迟串起来,独立链把延迟重叠掉。
验证方法很简单:
perf stat -e cycles,instructions ./chain # 依赖链:IPC ≈ 0.25~0.33
perf stat -e cycles,instructions ./par # 独立链:IPC ≈ 1.5~2.5实践要点:能拆的链就拆——循环展开 + 多路累加是编译器
-O2的常见优化,也是手写热点循环时的基本功。但别为了拆链把代码改得可读性崩掉,先跑perf确认瓶颈确实在整数依赖链上再说(怎么定位见 tools/code/perf)。
五、一句话总结
ALU→ALU 是 RAW 里最优雅的一种:生产者 1 拍就算完,前推旁路在数据落位 PRF 之前就截走,把依赖代价压到背靠背 1 拍。 机制三件套——RAT 判依赖(打 tag)、结果总线广播(唤醒等待者)、前推 MUX(截胡数据),缺一个惩罚都会扩大。但它救的是"相邻两条"而非"一整串":代码里的依赖链照样让 IPC 掉到 1/3~1/4,只有拆链(多路累加)才能把延迟重叠掉。访存域的两种 RAW(Load→Use 的 5~200 拍、Store→Load 的 SB forwarding)延迟完全不是一个量级,见访存 RAW 篇。