第 21 章

乱序执行:CPU 比你聪明

乱序执行:CPU 比你聪明

想象你在食堂排队打饭。前面那个人在等厨师从后厨端红烧肉,这道菜还要三分钟。如果你傻等,什么都不干,那这三分钟就浪费了。聪明的做法是:先去旁边打一碗汤,顺手拿个馒头,等红烧肉来了再回来拿——最终结果一样,但总时间短了很多。

CPU 做的事情比这聪明得多:它会分析你的程序,找出哪些指令之间没有依赖关系,然后同时执行提前执行这些指令——程序员看到的是一个顺序执行的程序,CPU 内部执行的是另一个顺序,但保证输出结果完全相同。这就是乱序执行(Out-of-Order Execution, OoOE)

Level 1:建立直觉

顺序执行的瓶颈

假设有这样一段代码:

int a = data[0];          // 指令1:读内存(可能 cache miss,等 200 周期)
int b = data[1];          // 指令2:读内存(可能 cache miss,等 200 周期)
int c = a + 1;            // 指令3:依赖 a(必须等指令1完成)
int d = b + 2;            // 指令4:依赖 b(必须等指令2完成)
int result = c * d;       // 指令5:依赖 c 和 d

顺序执行

指令1 开始 → 等200周期 → 完成
指令2 开始 → 等200周期 → 完成
指令3 执行(1周期)
指令4 执行(1周期)
指令5 执行(1周期)
总计:≈ 403 周期

乱序执行

指令1 开始 → (等待中)
指令2 同时开始 → (等待中)       ← 1和2没有依赖关系,可以同时执行!
                   等200周期
指令3 执行(完成指令1后立刻)
指令4 执行(完成指令2后立刻)
指令5 执行(等待3和4)
总计:≈ 203 周期(快了一倍!)

这就是乱序执行的直觉:找到没有依赖关系的指令,并行执行它们

什么叫"没有依赖关系"

三种数据相关性:

RAW(Read After Write):真依赖,必须等:

int x = a + b;   // 写 x
int y = x + c;   // 读 x,必须等上面完成

WAW(Write After Write):假依赖,可以通过重命名消除:

int x = a + b;   // 写 x(第一次)
int x = c + d;   // 写 x(第二次)——可以写到不同的物理寄存器

WAR(Write After Read):假依赖,可以通过重命名消除:

int y = x + 1;   // 读 x
int x = a + b;   // 写 x——如果读/写用不同物理寄存器,两者可以并行

CPU 用**寄存器重命名(Register Renaming)**消除假依赖,只有真依赖(RAW)才会阻止乱序。

Level 2:原理剖析

乱序执行的完整流程

现代超标量乱序 CPU(如 Intel Core、Apple M 系列、AMD Zen)内部:

取指(Fetch)→ 解码(Decode)→ 重命名(Rename)→
发射(Dispatch)→ 乱序执行 → 顺序提交(Commit)

┌─────────────────────────────────────────────────────┐
│  前端(Frontend)                                     │
│  ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │
│  │  取指     │→│  解码    │→│  重命名/分配           │ │
│  │ (I-cache)│ │(μops)  │ │ (消除假依赖)          │ │
│  └──────────┘ └──────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────┘
           ↓
┌─────────────────────────────────────────────────────┐
│  乱序执行核心(OoO Engine)                           │
│  ┌──────────────────────────────────────────────┐   │
│  │  重排序缓冲区(ROB,Reorder Buffer)            │   │
│  │  [μop1] [μop2] [μop3] ... [μop200+]          │   │
│  └──────────────────────────────────────────────┘   │
│           ↓ (当操作数就绪时)                          │
│  ┌──────────────────────────────────────────────┐   │
│  │  保留站(RS,Reservation Station)             │   │
│  │  等待操作数就绪的 μop 在此排队               │   │
│  └──────────────────────────────────────────────┘   │
│           ↓                                          │
│  ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐       │
│  │ALU 0   │ │ALU 1   │ │Load   │ │Store   │       │
│  │(整数)│ │(整数)│ │单元   │ │单元    │       │
│  └────────┘ └────────┘ └────────┘ └────────┘       │
└─────────────────────────────────────────────────────┘
           ↓ (乱序完成后,顺序提交结果)
┌─────────────────────────────────────────────────────┐
│  退休(Retire):按程序顺序提交结果,更新架构状态        │
└─────────────────────────────────────────────────────┘

关键:乱序执行,顺序提交(In-Order Commit)。执行可以乱序,但结果必须按原始程序顺序提交——这保证了外部可见行为与顺序执行完全一致。

重排序缓冲区(ROB)

ROB 是乱序 CPU 的核心数据结构,一个循环队列:

Apple M4 Pro ROB 容量:约 600+ 条微操作
Intel Core i9-14900K ROB:约 512 条微操作
AMD Ryzen 9 7950X ROB:约 320 条微操作

ROB 越大 → 可以"看到"更远的未来指令 → 找到更多独立操作 → 更高 IPC
但 ROB 越大 → 面积和功耗越高 → 设计权衡

ROB 中的每条微操作有状态:

寄存器重命名

x86 架构只有 16 个通用寄存器(rax, rbx, rcx, rdx, rsi, rdi, r8-r15)。乱序 CPU 内部有物理寄存器文件,比架构寄存器多得多:

Intel Core:物理寄存器文件 ~280 个整数寄存器(架构寄存器只有 16 个)
AMD Zen 4:~224 个物理整数寄存器

重命名表(RAT,Register Alias Table):
架构寄存器名 → 当前映射的物理寄存器编号

例子:

; 程序写了三次 rax(WAW 相关)
mov rax, [addr1]   ; 指令1: rax → p10(物理寄存器10)
add rax, rbx       ; 指令2: rax → p11(重命名!现在 rax 映射到 p11)
add rax, rcx       ; 指令3: rax → p12(再重命名!)

; 指令1、2、3 写不同的物理寄存器,可以并行执行
; 最终 rax = p12(最后一次写的物理寄存器)

推测执行(Speculative Execution)

乱序执行的威力还不止于此——CPU 会推测性地执行尚未确定会执行的指令。

最常见的推测:分支预测之后的执行:

if (condition) {    ← CPU 预测 condition 为 true
    // 这里的指令被推测性地执行
    doA();
} else {
    // 这里不执行
    doB();
}
// 等 condition 真正算出来:
// 如果预测正确 → 推测执行的结果直接用,省了等待时间
// 如果预测错误 → 推测执行的结果全部丢弃(Flush),重新执行正确路径

推测执行 + 乱序执行 = 超强 IPC:CPU 可以提前执行几十到几百条未来的指令,只要预测大部分正确。

现代 CPU 的分支预测准确率:95-99%。错误时的代价:清空流水线,约 15-20 周期的惩罚。

Level 3 · 规范怎么定义的(资深)

乱序执行的理论与架构规范

乱序执行(Out-of-Order Execution, OoOE)的理论基础是 Robert Tomasulo 1967 年在 IBM 360/91 上提出的 Tomasulo 算法。该算法通过保留站(Reservation Station)和寄存器重命名实现了指令的动态调度——不按程序顺序发射指令,但保证结果按程序顺序提交(retire),从而在架构层面维持了"顺序执行"的假象。

现代 CPU 使用 ROB(Reorder Buffer) 来实现有序提交。ROB 的大小直接决定了 CPU 能"看多远"的指令——Intel Golden Cove 的 ROB 有 512 条目,意味着最多可以有 512 条指令同时处于飞行状态(in-flight)。Apple M4 的 ROB 更大,估计在 600 条以上。ROB 的大小受限于 SRAM 面积和时序约束——每个周期需要检查 ROB 头部的多条指令是否可以提交。

架构可见性方面,ISA 规范保证程序员看到的是"顺序执行"的模型——乱序执行是微架构的实现细节,对软件完全透明(除了性能影响)。但 Spectre(2018 年)的发现打破了这个假设:推测执行的微架构副作用(Cache 状态变化)可以通过侧信道被观测到,泄露程序不应该访问的数据。这迫使 ISA 规范进行修补——Intel 引入了 IBRS(Indirect Branch Restricted Speculation)等新的 MSR 控制位,ARM 引入了 CSV2/CSV3 安全特性标志,RISC-V 则在规范中预留了 Zifencei 等围栏指令。

Level 4 · 边界与陷阱(所有人)

陷阱 1:依赖链是乱序执行的死穴

乱序执行能重排独立的指令,但对于存在真数据依赖(RAW)的指令链,无论 ROB 多大都无法并行。例如:a = b + c; d = a + e; f = d + g;——每条指令都依赖前一条的结果,CPU 必须串行执行。加密算法(如 AES 的 CBC 模式、SHA 哈希链)天然存在这种长依赖链,这就是为什么 Intel 和 ARM 都提供了 AES-NI/ARMv8 Crypto 等专用硬件加速指令——用硬件流水线而非通用乱序引擎来处理这类计算。

陷阱 2:Store-to-Load Forwarding 失败导致意外延迟

当一条 store 指令后紧跟一条 load 指令读取同一地址时,CPU 通常可以直接从 Store Buffer 中"转发"数据给 load,而不需要等数据写入 Cache。但如果 store 和 load 的地址只是部分重叠(例如 store 写了 4 字节,load 读 8 字节覆盖了同一区域),CPU 无法转发,必须等 store 完成写入 Cache 后 load 才能执行——额外延迟约 10-20 个周期。这种"Store Forwarding Stall"在编译器将不同大小的类型强制转换(type punning)或使用 union 时容易触发。perf statld_blocks.store_forward 计数器可以检测到这一问题。

陷阱 3:Spectre/Meltdown 永久改变了性能基线

2018 年曝光的 Spectre(CVE-2017-5753/5715)和 Meltdown(CVE-2017-5754)漏洞,迫使所有操作系统部署了代价高昂的缓解措施:KPTI(内核页表隔离,每次系统调用多一次页表切换)、Retpoline(间接跳转变为返回指令,防止分支目标注入)、IBRS/STIBP(限制推测执行范围)。这些补丁在系统调用密集的工作负载(如数据库、网络服务器)上造成 5-30% 的性能下降,在 I/O 密集型场景下甚至更高。这意味着你看到的任何 2018 年之前的性能基准测试数据,都已经不再准确——当前的"真实"性能已经永久性地低于那些数字。

本章评分
4.7  / 5  (8 评分)

💬 留言讨论