为了搞懂 CPU 为什么慢,我模拟了一遍乱序执行
疑惑:我的程序去哪儿了?
写代码的时候,你以为 CPU 是在老老实实地一行一行执行你的指令吗?
错了。当你写下 a = b + c; d = e * f; 时,现代 CPU 可能会先算 d,再算 a,甚至会预测 a 的结果是多少先跑下去再说。
这就是 Out-of-Order Execution (乱序执行)。它是现代 CPU 性能的基石,也是 Spectre/Meltdown 漏洞的万恶之源。
为了彻底搞懂它,我决定参照经典的 Tomasulo 算法,写一个简单的 Python 模拟器,看看指令到底是怎么在 CPU 肚子里“赛跑”的。
1. 流水线 (Pipeline) 的瓶颈
最简单的 CPU 是顺序执行的:取指 -> 译码 -> 执行 -> 写回。
但这有个大问题:依赖阻塞。
1 | |
在顺序 CPU 里,SUB 指令必须等 ADD 甚至 DIV 退休了才能上场。这简直是暴殄天物!SUB 的加法器明明空着在睡觉。
2. 乱序执行的核心组件
为了让 SUB 能够超车,我们需要引入几个黑科技组件:
2.1 Reservation Stations (保留站)
这是加法器、乘法器门口的“候诊室”。指令译码后,不直接进计算单元,而是先在候诊室领个号排队。如果操作数没准备好(比如 F0 还在算),保留站会监听总线,一旦 F0 算好了,它立马抓过来。
2.2 Reorder Buffer (ROB, 重排序缓冲区)
这是 CPU 的“悔棋本”。虽然后面的指令可能先算完了,但它们不能先提交 (Commit)。必须按照程序原本的顺序提交结果。如果中间发生了异常(比如除零),或者预测失败,ROB 可以把后面的指令全部撤销。
2.3 Common Data Bus (CDB, 公共数据总线)
这是 CPU 内部的广播大喇叭。一旦某个计算单元算出了结果,它不写回寄存器堆,而是对着 CDB 大喊:“F0 算好了!结果是 3.14!”
所有在保留站里等 F0 的指令听到后,立马把 3.14 抄下来。
3. 模拟实战
我们定义几条指令:
1 | |
状态机模拟
模拟每一时钟周期的变化:
Cycle 1:
LD F6发射 (Issue) 进 Load Buffer。
Cycle 2:
LD F6开始执行。LD F2发射。
Cycle 3:
LD F6执行完毕,广播结果 34。LD F2开始执行。MUL F0发射进乘法保留站。发现 F2 没好,F4 没好(假设 F4 初始值已知)。它在保留站里标记:Wait(ROB_idx_of_F2)。
Cycle 5 (关键时刻):
LD F2执行完,广播 F2=45。MUL F0听到了!把 45 抄进来,开始做乘法。SUB F8(早已在加法保留站里等 F2) 也听到了!它 F6 早就好了,F2 刚到,立刻开始执行。
注意到了吗?SUB 指令虽然排在 MUL 后面,但因为它需要的资源到齐得早(加法单元空闲,操作数就位),它可能会比 MUL 先算完。
4. Register Renaming (寄存器重命名)
再看最后那条 ADD F6, ...。它要写 F6。而第一条指令是 LD F6。
中间的 DIV 用到了 F6。
如果 ADD 跑得太快,把 F6 改写了,那 DIV 岂不是读到了错误的新值?这叫 WAR (Write-After-Read) 冒险。
Tomasulo 算法通过 重命名 解决了这个问题。
在 CPU 内部,物理寄存器 (Physical Register) 远比架构寄存器 (F0-F31) 多。
- 第一条指令的
F6映射到P1。 - 最后一条指令的
F6映射到P2。 DIV读的是P1,ADD写的是P2。
大家互不干扰,并行度拉满。
5. 假如没有乱序执行…
如果没有乱序执行,我们的旗舰 i9 处理器可能跑得像 10 年前的 Atom 一样慢。
所有的 Cache Miss 都会导致流水线彻底停摆。而在乱序执行的加持下,CPU 可以在等待内存数据(几百个周期)的同时,把后面几百条不相关的加减乘除全算完。
这也解释了为什么 CPU 的频率卡在 5GHz 上不去了,但 IPC (Instructions Per Clock) 还在每一代稳定提升。工程师们在压榨晶体管的极限,试图预测未来的每一个分支,消除每一个气泡。
6. 总结
写完这个模拟器,我对底层硬件的敬畏又多了一分。
我们在高层语言里写的每一个简单的赋值,底层都在上演着一场精密绝伦的交响乐。
无数的 Buffer、Queue、ALU 在纳秒级别配合,只为了让你少等那 0.0001 秒。
这就是计算机体系结构的浪漫。