CPU 内部真正执行的不是 x86 指令(本篇博客只讨论 x86),而是 µop(微操作,micro-operation)。一条变长的 x86 指令进入 CPU 后先被拆成一个或多个定长的 µop,之后取指、重命名、发射、执行、退休,每个环节都以 µop 为单位记账。
逐拍图是教学化的理想模型(资源空闲、缓存命中、无历史状态干扰),机制和量级真实,但具体拍数会因微状态的不同而漂移 1~2 拍。其他微架构机制相同、参数不同。
CPU 前端与后端
现代乱序处理器以 **IDQ (Instruction Decode Queue,译码后队列)**为界把流水线分成两半:IDQ 之前是前端,之后是后端。
-
前端:把 x86 指令字节 变成 严格按照程序顺序排列的 µop 流 进入 IDQ 排队,它只处理指令的地址和字节,不接触任何数据。包含
分支预测、取指(ITLB地址翻译 + L1I-cache16字节/拍读出指令字节)、译码(DSB(Decode Stream Buffer, µop Cache) /MITE传统微指令译码器 /MSROM(Microcode Sequencer ROM, 微码定序器ROM),把x86变长指令字节翻译成 µop 进入 IDQ)。 -
后端:把 µop 分配/重命名 调度、执行、退休。它包含分配/重命名(写
RAT寄存器别名表、分配PRF物理寄存器堆 和ROB表项(Reorder Buffer, 重排序缓冲区))、保留站(RS, Reservation Station)与调度器(Scheduler)、执行端口、LSU(Load-Store Unit, 加载存储单元, LB/SB)、ROB退休。后端看到的只有 µop、物理寄存器号、内存地址,从 IDQ 往后,指令集是 x86 还是 RISC 已经无关紧要。 -
Q: 为什么前端必须严格按序,后端可以乱序?
- A: x86 指令是变长指令,必须按顺序依次切分指令边界,因此取指和译码天然严格按序。后端乱序是因为各操作的延迟相差百倍,按序等待会造成巨大资源浪费,可以让无依赖的指令先执行。乱序带来的副作用问题(假依赖、内存序、异常现场)全部由后端最后的 ROB 按序退休收口。
-
Q: 怎么判断 CPU 的性能瓶颈在哪?
- A: 前后端是一条供需关系链:前端供不上,后端的执行单元就空转;后端吃不下(ROB/RS占满),前端停下等待,哪边长期是短板,哪边就是瓶颈。查分支预测失败率、I-cache/DSB miss、取指带宽不足、DSB/MITE/MSROM译码供给不足等等 导致 IDQ 分配阶段拿不到µop 是前端瓶颈;查数据缓存miss、依赖链过长、端口冲突、ROB/RS/LSU资源占满等等 导致 IDQ 里有 µop 但分配不出去是后端瓶颈。
0. CPU 性能靠什么提升?
历史:i386 多周期(1985) -> i486 5级流水线(1989) -> Pentium 超标量Superscalar(1993) -> P6 乱序执行(1995)
性能 ∝ IPC × 频率 / 指令数- IPC 取决于CPU微架构设计,每周期平均完成几条指令;
- 频率 靠更深的流水线、更快的晶体管;
- 指令数 在指令集和编译器确定的条件下,几乎不变;
-
从 功耗墙 中我们知道,只靠提高频率跑的更快是不行的,动态功耗 为了提频率,往往需要提高电压 ,导致功耗呈立方级上涨,发热密度超过散热极限。
-
从 5级流水线 中我们知道,单条流水线无论切得多深,理论 IPC 都最高为 1,且如果流水线中一条指令卡住了,后面的所有指令都只能“等”它,而后面被堵住的指令其实并不是所有都需要现在卡住的这条指令的数据。
- 所以我们来看看有什么方法可以提高 IPC 呢?
1. 加宽流水线(顺序超标量)
-
把执行阶段拆开:执行阶段(EX)可以按照 opcode 操作码,分配到不同的执行单元,即 FU (functional unit)。并由 issue 来控制发射的时机。但写回还是得要按顺序。
-
N 路流水线、N 发射超标量架构 的代价是什么?
- 为了提供足够的并发访问能力,
寄存器堆(Register File)需要设计为多端口,Cache也同理要设计为多端口。又因为多端口有字线和位线,所以寄存器堆和Cache的面积需要成平方膨胀; - 为了找出这多条源操作数已就绪且无数据依赖冲突的指令,
硬件调度器在每个周期内必须进行的两两依赖检测和唤醒匹配,开销为 ,也是平方级膨胀。 - forwarding
数据转发旁路通道也是平方级膨胀,全连接布线更是困难。
- 为了提供足够的并发访问能力,
故 加宽流水线的理论 IPC 最高为 宽度 N,但硬件代价成平方级上涨,且数据依赖的情况更严重不容易到理论上限。现代 CPU 中,一般加宽流水线只会做到 4~8 之间。
2. 乱序执行 out-of-order
让没数据依赖的指令越过前面卡住的指令去乱序执行:谁的数据先到齐,谁先去 functional unit (执行单元)。
-
顺序进 + 乱序执行 + 乱序写回 + 顺序出(commit到寄存器堆)
-
此时的性能主要和程序具体逻辑相关了:
ILP(Instruction-Level Parallelism) 指令级并行 = 指令条数 / 排完的周期数,ILP 的理论上限和数据流图(DFG, Data Flow Graph)中最长的依赖链长度相关。在 CPU 后端调度乱序执行 中,数据流图是局部为有向无环图(DAG)的有向图。
1. 分支预测:下一拍取哪个地址
取指的第一输入不是”下一条顺序指令的地址”,而是分支预测器给出的地址。每当遇到条件跳转、无条件跳转、call/ret,CPU 分支指令真正执行完毕(Resolved)前,提前决定下一拍的从哪里取指。
预测器要在每一拍内连续回答三个问题:
| 问题 | 硬件 |
|---|---|
| 方向:跳还是不跳 | bimodal(2-bit 饱和计数器)/ gshare、TAGE 类历史关联 |
| 目标:跳到哪里 | BTB(Branch Target Buffer,分支目标缓冲) |
| 返回地址:ret 回哪里 | RSB(Return Stack Buffer,返回栈缓冲) |
难点在间接分支(jmp reg / jmp [mem]、跳转表、虚函数调用等):目标每次运行都可能不同,只能靠历史规律猜。Skylake 在常规代码上预测准确率在 95% 以上、规律性强的代码接近 99%(量级)。
代价不对称:预测对,零开销;预测错,误取路径上所有 µop 全部作废、恢复 RAT 快照、从正确地址重新取指,约 16~17 拍。这就是字节码解释器里”巨大且随机的 switch”慢的根源——每次分发都在付一次 16 拍的代价。
2. 取指:每拍 16 字节的指令流
预测器给出地址后,取指单元做两件事:
- ITLB 地址翻译:取指地址是虚拟地址,先查 L1 ITLB(百余项量级,覆盖最近使用的代码页)翻译成物理地址;ITLB miss 查二级 STLB(约 1536 项),再 miss 触发页表遍历(访存导致前端直接停摆几十到上百拍)。
- L1 I-cache 读出:32KB、8 路、64 字节行,每拍最多读出一个 16 字节对齐窗口。这是取指路径的原始带宽上限。
变长指令给这一步加了两个限制:
- 指令边界依赖预译码标记。 16 字节窗口里可能是1条15字节的长指令,也可能是5条3字节的短指令,字节流本身不带边界信息。读出的窗口先经过预译码(
predecode)标出每条指令的起点,再交给译码器。 - 跨窗口的指令要等下一拍。 最长 15 字节的指令横跨两个 16 字节窗口时,取指拆成两拍完成;热点循环若因对齐或编译器填充(NOP)导致关键指令跨窗口,前端供给就打折。I-cache miss 同样停前端:去 L2 取指令约 12 拍。
3. 译码:把变长指令翻译成定长 µop
x86 指令 1~15 字节变长,在完整解析 前缀 和 ModRM 字段之前,不知道指令在哪里结束。译码阶段的任务就是把这些变长指令翻译成定长的 µop,后端乱序引擎只认识 µop,不再接触 x86。
Skylake 每拍最多译码 4 条指令,由 1 个复杂译码器 + 3 个简单译码器 组成:“全功能”复杂译码器负责需要拆解为 2~4 个 µop 的指令,简单译码器专为x86简单指令设计,每拍最多各产出 1 个 µop;再复杂的指令就要交给 微码ROM(MSROM)翻译了,速度会慢一个数量级。
译码结果有缓存。前端三条供给路径:
| 路径 | 带宽 | 何时生效 |
|---|---|---|
| DSB | 约 6 µop/拍 | 热循环,译码结果已缓存 |
| MITE | 16 字节/拍 | 冷代码,DSB 未命中 |
| MSROM | 约 1 µop/拍 | 兜底,复杂指令 |
三条路径的产出统一进入 IDQ,前端结束工作,下面是后端的工作。
4. 分配与重命名:消除假依赖
在 x86 架构中,指令集只暴露 16 个通用寄存器(因为指令集是最基础的规约,最小的CPU核也需要满足指令集中的要求),但硬件里不存在”rax”这个物理实体。因为数量有限,所以编译器在生成代码时,会不断“重复使用”同一个寄存器名字(比如到处都是 rax)。
这就导致了一个问题:明明两条毫无关系的指令,仅仅因为用了同一个寄存器名字,就被强行绑在了一起。这种现象叫假依赖(False Dependencies)。
寄存器重命名(Register Renaming)是现代CPU为了打破指令执行限制、实现乱序执行而发明的一项核心硬件技术。实现把软件层面的“名字”(架构寄存器,如rax、rbx)和硬件层面的“实体”(物理寄存器,如p42、p105)彻底解耦
现代 CPU 是流水线 + 多发射的,它并不是按顺序执行的,而是乱序执行的。在流水线中,同一时刻有成百上千条指令处于“飞行(in-flight)”状态。只要指令之间没有真正的因果关系(真依赖),CPU 就会让它们并行处理——谁先准备好,谁就先跑。从而形成:“顺序进 + 乱序执行 + 乱序写回 + 顺序出(commit到寄存器堆)”
假依赖:WAW(写后写)、WAR(读后写); 真依赖:RAW(Read After Write,写后读) 因为后面指令的数据完全取决于前面的写指令。
下面是一个便于理解的例子:
int compute(int a, int b, int c, int d) { int reg = a + b; // 语句 1:给 reg 赋值 int out = reg * c; // 语句 2:用 reg 的值 reg = b + d; // 语句 3:再次给 reg 赋值 return out + reg; // 语句 4:读取最终的 out 和 reg}假设我们用 RISC-V 架构;
| 程序顺序 | 原始指令 | 重命名后(分配物理寄存器) | 说明 |
|---|---|---|---|
| 指令1 | add x1, a, b | add p10, a, b | x1映射到p10 |
| 指令2 | mul x2, x1, c | mul p20, p10, c | 读x1即读p10旧值,x2映射到p20 |
| 指令3 | add x1, b, d | add p30, b, d | 新的x1映射到新的p30(与p10无关) |
| 指令4 | add x3, x2, x1 | add p40, p20, p30 | 读x2即读p20,读x1即读p30新值) |
指令1→2 是 RAW 真依赖指令1算出的x1;指令2↔3 是 WAR 假依赖;指令1↔3 是 WAW 假依赖
硬件发现:“你们只是名字都叫 x1,我给你们发不同的工牌(p10 和 p30),你们就完全可以去不同的房间干活,别互相挡路(假依赖)。”
- 寄存器重命名阶段维护一张 RAT寄存器别名表(记录架构寄存器ARF到物理寄存器号的映射),真正的值则存放在 PRF物理寄存器堆(一个多端口 SRAM 阵列)。
RAT和PRF均由 CPU 核心内的纯硬件电路实现,对软件完全透明。- 重命名的核心思路:硬件内部提供一个远大于架构寄存器数量(如 x86 的 16 个通用寄存器)的物理寄存器池(例如 Intel Skylake 整数侧拥有 168 项)。每当指令需要写回结果时,便从池中分配一个全新的物理寄存器号,并在 RAT 中建立新的映射;而旧的映射仍被之前已分发、尚未退休的指令所引用。这样一来,WAR 与 WAW 等“假依赖”被彻底消除,调度器只需关注真实的数据流依赖(RAW)。
- 寄存器重命名阶段是严格的顺序执行:同一拍分配的 µop 按程序顺序依次读写 RAT,确保后一个 µop 读到的映射能读到前一个 µop 刚刚更新后的映射结果。这一顺序特性在后续的逐周期模拟中会直接用到。
- 重命名/分配阶段的带宽是整机的吞吐上限——一旦 ROB、RS 或 PRF 空闲项中的任意一个耗尽,分配便会暂停,并通过流水线反压(back-pressure)传递到前端。
5. 保留站与调度器:等操作数,乱序发射
重命名后的 µop 会进入保留站(RS, Reservation Station)。(以 Skylake 为例,RS 共 97 项,采用统一调度器设计,整数、访存、浮点 µop 混在同一个 RS 里竞争表项和发射机会)
-
RS 表项结构:每个表项主要包含 µop 本身、每个源操作数的【有效位 + 值/Tag】。 如果源操作数已就绪:有效位置 1,字段里直接存 64 位数据值(来自 PRF 或 CDB); 如果源操作数未就绪:有效位清 0,字段里存 等待的物理寄存器号(Tag); (真正的值始终在 PRF 里,RS 只是在操作数就绪时“缓存”一份,以便发射时直接喂给执行单元,以此减轻 PRF 读端口压力)
-
RS 的工作流程:
- 就绪评估:当所有源寄存器号都已被标记”已产出”,有效位置 1;
- 唤醒:一条 µop 执行完成,通过 CDB 广播把 (目的物理寄存器号, 数据值) 广播到整个 RS,所有等这个号的表项就绪位清零等待解除;
- 选择:调度器扫描所有就绪 µop,按照最老优先(防饥饿)策略,并结合执行端口空闲情况,挑选 µop 发往对应执行单元。
6. 执行端口:最终的物理资源
µop 就绪并被选中后,还要送到具体的执行单元。
Skylake 有 8 个执行端口:
| 端口 | 功能 |
|---|---|
| p0 / p1 / p5 / p6 | 整数 ALU(各1拍);p0/p1 兼乘法与浮点FMA;p6 兼分支 |
| p2 / p3 | load 地址生成(AGU),每拍最多2个load |
| p4 | store 数据写回 |
| p7 | store 地址计算 |
这里要区分两个独立的概念:延迟(latency,一条指令从发射到结果可用所需的周期数)和 吞吐(throughput,连续发射同类指令的最小间隔周期数)。
-
延迟决定依赖链关键路径的长度,吞吐决定无依赖代码的理论上限。优化前要先分清代码瓶颈属于哪一类。
-
端口冲突:同一拍内多个就绪 µop 若被调度到同一个端口,只有其中一个能执行,其余的必须回 RS 等待下一拍。
- 在编写热点循环时,刻意让访存、乘法、移位、纯 ALU 操作分散到不同端口,就是最基本的端口平衡(Port Balancing)优化。
7. LSU:访存单元
LSU(Load-Store Unit) 是是流水线中唯一有权访问数据缓存(L1D)的部件,LSU 由三块组成:地址计算单元AGU、Load Buffer、Store Buffer。访存是流水线中最大的延迟源——ALU 运算仅需 1 拍,L1 命中约 4 拍,L2 约 12 拍,L3 约 40 拍,访问内存更是高达上百拍——因此必须有独立的缓冲把慢操作和执行端口解耦。
- Load的执行路径::AGU 计算出虚拟地址(base + index × scale + disp)→ 经 DTLB 转换为物理地址 → 按 L1D 组索引读出 tag 和 数据并行比对 → 命中则将 64 位数据写回 PRF 并通过 CDB 广播。对于简单的 base+disp 寻址,整条管线实现 load-to-use 延迟为 4 拍。若 L1 miss,则分配一个 LFB(Line Fill Buffer) 向 L2 发起行填充,此时该 load 占用的 LB 条目及相关资源会保持锁定,直至数据返回。
Store 被拆解为两个 µop,这是乱序访存体系中最核心的设计:
- STA(store address):算地址,写入 SB 条目的地址字段;
- STD(store data):把要写的数据写入 SB 条目的数据字段。
拆分为二的三个原因:
- 地址常常早于数据就绪:先算好可以提前做地址冲突检查;
- 数据可能有长依赖:STD 可能依赖一条尚未完成的 load 结果,拆分后 STA 和 STD 可作为独立 µop 各自在 RS 中等待调度,互不阻塞;
- 保证内存序:x86 强内存模型要求 store 按程序顺序对其他核可见。Store Buffer 按 FIFO 顺序排空,恰好实现了这一点。因此,store 的数据进入 SB 后并不立即写入缓存,而是等待指令在 ROB 中按序退休(Retire)后,由 SB 异步排空、最终写入 L1D。
补充两个关键机制:
- STLF(store-to-load forwarding),若后面的 load 读取的地址,恰好落在 SB 中某个尚未排空的 store 范围内,数据可直接从 SB 转发给 load(约 5~7 拍完成),无需等待其真正写入 L1D。该转发要求地址完全匹配;若出现部分重叠或类型大小不匹配,会触发校验失败,惩罚高达 10 拍以上。
- 内存歧义消除:load load 为了不阻塞 ILP,会“赌”自己与前面未执行的 store 地址不重叠,从而越过 store 先执行。硬件通过 Store Bypass Predictor 记录历史来预测。若赌错(地址实际重叠),则触发流水线冲刷(Flush),将该 load 及其后续所有依赖指令作废并重放。
8. ROB:乱序执行,按序退休
**ROB(Reorder Buffer,重排序缓冲区)**是一块连续的硬件存储,逻辑上作为环形队列,有一个分配指针和一个退休指针。每条 µop 在分配阶段按程序顺序占据一个 ROB 表项,表项内记录:Busy/Valid、当前状态(State: 分配→发射→执行中→已完成→已退休)、目的物理寄存器号dst_preg、源PC、异常标记、类型标记等信息。
µop 执行完成后仅在 ROB 中标记“已完成”,完成顺序是乱序的;但退休必须从队头按序进行(Skylake每拍最多退休4项,与前端发射宽度匹配,避免 ROB 堆积)。队头的 µop 没完成,后面完成得再多也不能退休——队头阻塞。
按序退休买来的是精确异常:当第 N 条指令触发异常(或分支预测失败)时,硬件能保证前 N-1 条指令的全部效果已生效,而第 N 条及之后的指令如同从未执行过。乱序执行的 µop 虽然已经修改了 PRF 中的物理寄存器并通过 RAT 建立了映射,但这些改动仅存在于推测状态中,尚未成为架构可见的“最终事实”。一旦第 N 条指令出错,硬件只需恢复 RAT 的快照、冲刷 RS 和 ROB 中后续所有 µop、并从出错指令的 PC 重新取指即可。
退休同时做资源回收(同一拍内并行完成):
- 释放 ROB 表项:清空 Busy 位,退休指针推进;
- 物理寄存器堆PRF:目的寄存器的旧映射引用计数减一,归零后物理寄存器回空闲表;
- Load Buffer:释放对应的 LB 表项,解除对 Line Fill Buffer 等资源的占用;
- Store Buffer:退休动作仅将其数据从“推测执行(可撤销)”转换为“提交确定(不可撤销)”,真正的缓存写入由 SB 在后台异步排空完成,不阻塞退休流水。