引言
五级顺序流水线的 CPI 下限是 1——每周期最多完成一条指令。但顺序流水线的实际 CPI 通常远高于 1,因为只要有一条指令 stall(cache miss、长延迟除法、访存依赖),后面所有指令都被堵住。而程序里天然存在大量可并行的指令:不同寄存器、不同功能单元、互不依赖,硬件却因为「必须按程序顺序」而无法利用它们。
超标量与乱序执行就是为打破这个限制而生的:每周期取多条、发射多条指令(超标量),并且允许后发的指令先执行完(乱序),只要最终提交时恢复程序顺序的语义即可。这两件事加起来,把单核 CPI 从 1 压到 0.30.5(即 IPC 23),是现代高性能核性能的主要来源。
代价是复杂度爆炸。乱序核的面积可以是同工艺顺序核的 5~10 倍,验证难度高一个数量级,功耗也大幅上升。因此 RISC-V 生态里出现了清晰的分层:MCU 用顺序单发射,嵌入式应用核用顺序双发射,服务器核才上深度乱序。本文按「为什么乱序 → 怎么重命名 → 怎么提交 → 怎么预测 → 怎么恢复 → 怎么消歧」的顺序展开,前置知识是 RISC-V 流水线 CPU 里的冒险与分支预测基础,本文不再重复那部分,而是往深处走。
目录
- 从标量到超标量:多发射的动机
- 取指带宽与发射宽度
- 顺序多发射与乱序执行的分野
- 寄存器重命名与数据流
- 保留站与发射队列
- 重排序缓冲与顺序提交
- 分支预测器进阶:从 2 位到 TAGE
- 推测执行与精确异常恢复
- 访存消歧与加载队列
- 同时多线程 SMT 的取舍
- 面积、功耗与频率的三角
- 开源乱序核案例与实现要点
1. 从标量到超标量:多发射的动机
顺序单发射流水线的瓶颈可以从 CPI 分解看出:
CPI = 1 + 停顿/指令数
停顿来源:分支错误预测(每次 2~15 拍)、cache miss(30~300 拍)、
功能单元冲突(除法 20 拍)、数据依赖(load-use 1 拍)
典型数值(分支 20%,预测正确率 90%;访存 30%,L1 缺失 5%):
分支停顿 = 0.20 × 0.10 × 15 = 0.30
访存停顿 = 0.30 × 0.05 × 60 = 0.90 ← 主导项
其他 ≈ 0.10
CPI ≈ 2.30 → IPC 0.43
访存停顿是最大头,而它的本质是「一条 load 卡住,后面几十条与它无关的指令都动不了」。乱序执行的价值就在于此:把后续的独立指令拉到前面执行,用计算掩盖访存延迟。
顺序执行(load 卡 60 拍):
load r1, [r2] ← 等 60 拍
add r3, r4, r5 ← 被堵住
add r6, r7, r8 ← 被堵住
... 后续 20 条无关指令全被堵住
乱序执行:
load r1, [r2] ← 发出后挂起,不阻塞窗口
add r3, r4, r5 ← 立刻发射
add r6, r7, r8 ← 立刻发射
... 20 条无关指令全部执行完,load 才返回 → 停顿被完全隐藏
要能这样做,硬件必须能「看到」足够多的后续指令,这由指令窗口(instruction window) 大小决定。窗口 = 发射宽度 × 能提前看的周期数,现代核的窗口在 200~600 条指令之间。
2. 取指带宽与发射宽度
发射宽度(issue width)指每周期最多发射几条指令。4 发射意味着每周期要取 4 条、译码 4 条、重命名 4 条、发射 4 条、提交 4 条——整条流水线都要按这个宽度设计,任何一级窄了都会成为瓶颈。
取指带宽是第一个瓶颈:指令不是连续对齐的。RISC-V 有 16 位压缩指令(C 扩展),一条 4 字节的指令可能跨 cache line 边界,也可能与 16 位指令混排。4 发射要在一个周期内从 cache line 里解析出 4 条指令,需要复杂的对齐与拼接逻辑。
| 取值方案 | 做法 | 代价 |
|---|---|---|
| 固定宽度 | 每周期取 16 字节,按需拼接 | 实现简单、有效指令数少 |
| 指令队列缓存 | 预译码后存进队列,从队列取 | 队列本身是瓶颈 |
| 循环缓冲(loop buffer) | 小循环整体驻留,重复取 | 循环命中率高、面积小 |
| 预译码位(predecode bits) | 取指时标好指令边界 | 需要额外存储位 |
发射宽度并非越宽越好。实测表明,从 1 发射提到 2 发射能带来约 40% 的性能提升,2→4 约 20%,4→6 只剩 5%10%,而面积和功耗线性甚至超线性增长。原因是程序里的指令级并行度(ILP)本身有限,且分支、依赖、资源冲突会限制可同时发射的指令数。这就是为什么主流设计停在 46 发射,而不是做 8 发射。
// 4 发射的发射选择:从就绪队列里挑前 4 条
// 关键约束:同周期发射的多条指令之间不能有资源冲突
always_comb begin
issue_cnt = 0;
for (int i = 0; i < ENTRIES; i++) begin
if (issue_cnt < 4 && ready[i] && !conflict_with_issued(i)) begin
issue_valid[issue_cnt] = 1'b1;
issue_idx[issue_cnt] = i;
issue_cnt++;
end
end
end
3. 顺序多发射与乱序执行的分野
「超标量」和「乱序」是两件独立的事,组合出四种架构:
| 架构 | 发射 | 执行 | 例子 |
|---|---|---|---|
| 单发射顺序 | 1 | 顺序 | Cortex-M, 多数 RISC-V MCU |
| 多发射顺序 | 2~4 | 顺序 | 部分 DSP、VLIW |
| 单发射乱序 | 1 | 乱序 | 少见(乱序开销不值得) |
| 多发射乱序 | 2~6 | 乱序 | Cortex-A, BOOM, 服务器核 |
顺序多发射的实现简单得多:指令按顺序发射到各自的功能单元,只需要检测同周期发射的指令之间的资源冲突与依赖。缺点是遇到长延迟操作时,后面的指令仍然被堵住。VLIW 是顺序多发射的极端——把调度完全交给编译器,硬件不做依赖检测,但编译器很难处理动态延迟(cache miss)。
乱序执行把依赖检测和调度搬到硬件,靠重命名 + 发射队列 + ROB 三件套。它需要处理的关键问题比顺序多发射多得多:精确异常、推测恢复、访存消歧、内存顺序,每一项都有大量细节。RISC-V 的 RVWMO 内存模型给乱序核的访存重排划定了边界,实现必须严格遵守(见 RISC-V 指令集架构详解 )。
4. 寄存器重命名与数据流
乱序执行的第一块基石是寄存器重命名:把架构寄存器(32 个)映射到大量的物理寄存器(通常 128~256 个),消除假依赖(WAR 写后读、WAW 写后写),只保留真依赖(RAW 读后写)。
程序顺序: 重命名后(P = 物理寄存器):
add x1, x2, x3 add P10, P1, P2 ← x1 → P10
sub x4, x1, x5 sub P11, P10, P3 ← 真依赖 P10
add x1, x6, x7 add P12, P4, P5 ← x1 → P12,与 P10 无关
xor x8, x1, x9 xor P13, P12, P9 ← 真依赖 P12
原始代码里第二条 add 对 x1 的写与第三条 xor 对 x1 的读
构成 WAW/WAR 假依赖;重命名后它们指向不同物理寄存器,
可以完全并行执行。
重命名表(RAT, Register Alias Table)记录「架构寄存器 → 当前物理寄存器」的映射。每条指令译码后:
- 查 RAT 得到源操作数的物理寄存器号(读映射)。
- 分配一个新的物理寄存器作为目的(写映射)。
- 更新 RAT 的目的映射。
物理寄存器的回收靠 ROB:只有当指令提交(不再可能被冲刷)时,它占用的旧物理寄存器才能回到空闲列表。因此物理寄存器数量必须大于「架构寄存器 + 在途指令数」。
// 重命名 + 空闲列表分配(简化)
wire [PHYS_W-1:0] ps1 = rat[rs1]; // 读映射
wire [PHYS_W-1:0] ps2 = rat[rs2];
wire [PHYS_W-1:0] pd = freelist_pop(); // 分配新物理寄存器
wire [PHYS_W-1:0] old_pd = rat[rd]; // 旧映射,提交后回收
// 提交时:freelist_push(old_pd_of_committed_instr)
重命名的实现有两种主流做法:统一物理寄存器堆(PRF,Intel/BOOM 风格,物理寄存器同时存架构值和推测值)与数据在 ROB 中(重命名只改标签,数据存在 ROB 条目里,AMD/部分 ARM 风格)。前者面积小但需要额外的恢复机制(用 architectural RAT 恢复),后者恢复简单但 ROB 要存数据。
5. 保留站与发射队列
重命名之后,指令进入发射队列(issue queue / 保留站 reservation station),等待操作数就绪。
保留站的经典结构(Tomasulo 算法)是每条目存「操作数 + 标签」,标签指向产生该操作数的保留站条目;当产生者执行完,它把结果广播到公共数据总线(CDB),所有等待该标签的条目同时捕获数据。
保留站条目:
{ op, pdest, psrc1_ready, psrc1_value/tag, psrc2_ready, psrc2_value/tag }
发射条件:两个源都 ready 且功能单元空闲
发射后:功能单元执行 → 结果广播到 CDB → 等待者捕获
现代设计多改用统一的发射队列 + 唤醒/选择分离:
- 唤醒(wakeup):结果产生的周期,把所有等待该物理寄存器的条目标记为 ready。
- 选择(select):从所有 ready 的条目里按优先级挑出 N 条发射。
唤醒是时序关键路径:从「功能单元算完」到「标记 ready」到「选择发射」必须在同一个周期内完成,这条路径限制了发射队列的规模和频率。因此现代核把发射队列分体(按功能单元分组,如整数队列、浮点队列、访存队列),每个队列更小、唤醒路径更短。
单一大队列:唤醒延迟长、面积大,但调度灵活
分体队列: 唤醒快、面积小,但跨队列依赖需要额外转发
→ 主流做法:整数 2 队列 + 浮点 1 队列 + 访存 1 队列
6. 重排序缓冲与顺序提交
ROB(Reordering Buffer)是乱序核的「真相之源」:所有指令按程序顺序进入 ROB,乱序执行,但必须按程序顺序提交(commit/retire)。提交意味着结果写回架构状态(架构寄存器堆、内存),此后不可撤销。
ROB 条目记录:
{ valid, pc, pdest, old_pdest, done, exception, 访存信息, 分支信息 }
提交逻辑每周期从 ROB 头部取出最多 N 条「已完成」的指令,依次提交。任何一条没完成,后面的都不能提交(这就是「顺序提交」的含义)。
ROB 的作用有三个:
- 恢复程序顺序语义:乱序执行的结果在提交前对软件不可见。
- 支持精确异常:只有提交时才可能抛异常,此时 ROB 里全是「已提交的」和「未提交的」清晰分界。
- 回收物理寄存器:提交时才能把
old_pdest推回空闲列表。
ROB 大小直接决定乱序能力。ROB 有 200 条意味着最多 200 条指令同时在途;ROB 满了以后取指停顿,前端被堵住。这就是为什么「ROB 大小」是处理器规格表里的关键参数,也是为什么现代核的 ROB 从 128(2010 年代)涨到 500+(现在)。
ROB 满载 → 取指停顿 → 前端空转 → 后端也拿不到新指令
实测:ROB 从 128 提到 256,内存密集型负载性能提升 15%~25%
7. 分支预测器进阶:从 2 位到 TAGE
RISC-V 流水线 CPU 与冒险处理 里讲过 2 位饱和计数器与 GShare。在乱序核里,分支预测的重要性被进一步放大:流水线越深、窗口越大,一次错误预测要冲刷的指令越多,代价从 2 拍涨到 15~20 拍。
现代预测器的核心思想是用多条不同长度的历史来预测,因为不同分支的可预测性来自不同长度的上下文:
| 预测器 | 索引方式 | 正确率 |
|---|---|---|
| 2 位计数器 | PC | 85%~90% |
| GShare | PC XOR 全局历史 | 93%~95% |
| 局部/全局混合(Tournament) | 两个预测器 + 选择器 | 95%~96% |
| TAGE | 多张表,历史长度几何递增 | 97%~99% |
| 感知机预测器 | 历史位的线性组合 | 97%~98%,训练慢 |
TAGE(TAgged GEometric) 维护多张预测表,第 i 张表用最近 L(i) 位历史做索引,L(i) 按几何级数增长(如 5, 15, 45, 135 位)。每张表存「带标签的预测 + 置信度」。预测时用所有命中表中最长历史的那张的预测;如果那张置信度不够,回退到较短历史的表。
TAGE 结构:
表0: 历史 5 位 → 预测短模式(如固定循环)
表1: 历史 15 位 → 中等模式
表2: 历史 45 位 → 长模式(如嵌套循环的退出条件)
表3: 历史 135 位 → 极长模式
基础预测器(2 位)兜底
预测正确率:97%+ ;对「循环退出」这类最难的分支也有明显优势
配套还有 BTB(分支目标缓冲) 预测跳转目标、RAS(返回地址栈) 预测函数返回、ITTAGE 预测间接跳转。函数返回如果靠 BTB 预测,递归和虚函数场景会大量误判,RAS 用「调用时压栈、返回时弹栈」精确预测,正确率接近 100%。
8. 推测执行与精确异常恢复
分支预测错误时,已经进入流水线的错误路径指令必须被冲刷,同时恢复架构状态。恢复的难度取决于「什么时候发现预测错误」:
| 发现位置 | 冲刷范围 | 惩罚周期 |
|---|---|---|
| 取指后立即(BTB 命中) | 无 | 0 |
| 译码/重命名阶段 | 前端 | 3~6 |
| 执行阶段 | 前端 + 已发射的后续指令 | 10~15 |
| 提交阶段(访存/异常) | 整个 ROB | 15~25 |
恢复机制有两种:
- ROB 扫描式恢复:冲刷时遍历 ROB,把所有未提交指令的物理寄存器回收。简单但慢(ROB 越大越慢)。
- 检查点(checkpoint)式恢复:分支时保存一份 RAT 快照,预测错误时直接恢复 RAT,不需要扫描。快,但每个检查点要存一整张 RAT(几十个条目),面积大。现代核通常在预测正确率高的分支类型上打检查点,其他走扫描。
// 检查点恢复:分支处保存 RAT,误预测时直接恢复
always @(posedge clk) begin
if (branch_rename) begin
ckpt_rat[ckpt_ptr] <= rat; // 快照
ckpt_free_cnt[ckpt_ptr] <= free_cnt; // 空闲列表水位
ckpt_ptr <= ckpt_ptr + 1;
end
if (mispredict) begin
rat <= ckpt_rat[ckpt_id]; // 恢复映射
free_cnt <= ckpt_free_cnt[ckpt_id]; // 恢复空闲列表
flush_rob(ckpt_id); // 冲刷 ROB 中更新的条目
end
end
精确异常是乱序核必须满足的语义:异常发生时,所有在异常指令之前的指令都已完成,之后的一条都没执行。靠 ROB 顺序提交天然满足——异常在提交点才被处理,此时 ROB 里的分界清晰。这也是乱序核能做「页错误后重新执行该指令」的前提。
9. 访存消歧与加载队列
访存是乱序执行最难的部分,因为内存没有寄存器重命名——两个访存指令是否冲突,只能靠地址比较。
访存消歧(memory disambiguation) 要回答:一条 load 能不能越过前面地址未知的 store 执行?
| 策略 | 做法 | 风险 |
|---|---|---|
| 保守 | load 必须等所有前面的 store 地址确定 | 安全、性能差 |
| 地址预测 | 预测 load 不与前面 store 冲突,直接执行 | 需验证、错了要重放 |
| 全推测 | 无条件让 load 先跑 | 需回滚机制,实现复杂 |
主流做法是带验证的推测:load 先执行,同时记录它越过了哪些 store;等那些 store 的地址算出来后,比较是否与 load 地址重叠。重叠则重放(replay) load。
加载队列(Load Queue)记录:
{ load 地址, 数据, 状态(已执行/待重放), 越过的 store 掩码 }
存储队列(Store Queue)记录:
{ store 地址, 数据, 状态(地址已算/数据已到/已提交) }
冲突检测:load 提交前,检查是否有更早的 store 与它地址重叠
→ 有则标记 load 需要重放(清掉数据,重新执行)
访存顺序(memory ordering) 在 RISC-V 上由 RVWMO 定义:默认允许 load 越过更早的 store(弱序),但 fence 与 .aq/.rl 后缀会插入顺序约束。乱序核必须正确实现这些约束,否则多核程序会出错。这部分与 Cache 一致性协议协同工作——一致性保证单个地址的可见性,内存模型保证跨地址的顺序。
10. 同时多线程 SMT 的取舍
SMT(Simultaneous Multithreading) 让一个物理核同时执行多个线程的指令,共享所有执行资源。它的前提是「单线程用不满发射宽度」——实测单线程在 4 发射核上平均只用 1.5~2 个发射槽,剩下的是被依赖和停顿浪费的。
| 方案 | 共享 | 私有 | 复杂度 |
|---|---|---|---|
| SMT-2 | 执行单元、Cache、ROB | 寄存器堆、RAT、PC | 高 |
| SMT-4 | 同上 | 同上 | 很高 |
| 多核 | 无(除 LLC) | 全部 | 中 |
SMT 的收益:吞吐提升 20%~40%(内存密集型负载收益更大,因为两个线程可以互相填补停顿)。代价:面积增加 5%~15%(主要是寄存器堆和重命名逻辑),单线程性能可能下降 5%(资源共享),安全风险(侧信道攻击,Spectre 类漏洞常靠 SMT 放大)。
RISC-V 生态里 SMT 的实现不多(BOOM 有实验性支持),因为多数 RISC-V 核的目标是嵌入式与能效,SMT 的收益不足以抵消复杂度。RISC-V 的 hart 概念为 SMT 预留了抽象:一个 hart 对应一个硬件线程上下文,多 hart 可以共享执行单元。
11. 面积、功耗与频率的三角
乱序核的复杂度最终体现为三个数字的博弈:
| 参数 | 顺序单发射 | 4 发射乱序 |
|---|---|---|
| 典型频率(同工艺) | 1.5~2.0 GHz | 1.0~1.5 GHz |
| 面积 | 1x | 5~10x |
| 动态功耗 | 1x | 4~8x |
| IPC | 0.6~0.8 | 2~3 |
| 能效(每指令能耗) | 1x | 2~3x |
乱序的能效更差——它靠「多干活」换性能,而重命名、发射选择、唤醒广播都是纯开销电路,不做任何有用计算。这就是为什么移动端与嵌入式几乎全是顺序核:在功耗预算固定时,顺序核能用更低功耗跑完同样的工作,或者用更少的核跑更多任务。
提高 IPC 的三条路及其成本:
1. 加宽发射(4 → 6):面积 +30%,IPC +5%~10%,不划算
2. 加深 ROB(128 → 256):面积 +15%,IPC +15%~25%,最划算
3. 加大多级 Cache:面积 +40%,IPC +10%~20%,看负载
结论:现代核的优化重心是「窗口大小与预测准确率」,
而不是「发射宽度」,因为前者的边际收益高得多。
12. 开源乱序核案例与实现要点
RISC-V 生态里有几个有代表性的乱序实现:
| 核 | 发射宽度 | ROB | 特点 |
|---|---|---|---|
| BOOM(伯克利) | 2~4 | 64~128 | Chisel 生成,可配置,教学与研究首选 |
| Rocket(伯克利) | 1(顺序) | — | 作为对照基线 |
| CVA6(原 Ariane) | 1~2(顺序为主) | — | 工业级、验证完善 |
| XiangShan(香山) | 6 | 256+ | 高性能、开源、接近商用水平 |
| NaxRiscv | 2~6 | 可配 | SpinalHDL,生成式 |
实现顺序建议(从零做乱序核的路线):
1. 顺序流水线 + 完整冒险处理(已完成 → 见流水线篇)
2. 加寄存器重命名(先不做乱序,验证映射正确)
3. 加 ROB + 顺序提交(仍按序执行,验证提交逻辑)
4. 加发射队列 + 乱序发射(先只乱序整数,访存仍保守)
5. 加分支预测 + 检查点恢复
6. 加访存消歧与加载重放
7. 加多发射(宽度从 2 提到 4)
每一步都用同一套测试程序(如 riscv-tests + CoreMark + 自建的依赖密集微基准)验证,任何回归都能定位到具体一步。乱序核的 bug 通常是「时序相关的偶发错误」,必须靠大量随机测试 + 形式验证(对重命名、ROB、一致性协议做模型检验)来收敛。
# 用 Spike 做黄金参考,与 RTL 逐指令比对(co-simulation)
spike --isa=rv64gc -l --log-commits prog.elf > golden.log
# 用 Verilator 跑 RTL,导出提交日志
./Vtb_top +commit_log=rtl.log
# 比对:两者提交的 (pc, rd, value) 序列必须完全一致
diff <(normalize golden.log) <(normalize rtl.log)
权衡取舍
| 决策点 | 选项 A | 选项 B |
|---|---|---|
| 发射宽度 | 2 发射:面积小、IPC 提升明显 | 6 发射:边际收益低、面积大 |
| 窗口大小 | 小 ROB:频率高、并行度低 | 大 ROB:并行度高、时序难 |
| 恢复机制 | ROB 扫描:面积小、恢复慢 | 检查点:恢复快、面积大 |
| 访存消歧 | 保守:正确性易保证、性能差 | 推测 + 重放:快、需验证逻辑 |
| 预测器 | GShare:面积小、95% | TAGE:99%、面积大延迟长 |
| 物理寄存器 | 统一 PRF:面积小、恢复复杂 | 数据在 ROB:恢复简单、ROB 大 |
| SMT | 开:吞吐 +30%、安全风险 | 关:单线程快、能效好 |
| 发射队列 | 统一大队列:灵活、唤醒慢 | 分体队列:唤醒快、转发复杂 |
常见坑清单
- WAR/WAW 未消除:只重命名目的寄存器而源仍用架构寄存器号,假依赖残留,乱序能力归零。
- 物理寄存器回收过早:在指令提交前就把
old_pdest放回空闲列表,被覆盖后恢复出错。 - ROB 提交不看 done 位:头部指令未完成就提交,读到垃圾数据。
- 误预测恢复漏恢复空闲列表:只恢复 RAT 不恢复 free list 水位,物理寄存器永久泄漏,最终死锁。
- load 越过 store 未做验证:直接推测执行且不检查冲突,读到陈旧数据且无法发现。
- store 数据未进 store queue:store 在提交时才取数据,此时源寄存器已被后续指令覆盖,写错值。
- fence 语义实现不完整:
fence只做了流水线排空,没有约束访存顺序,多核下出错。 - 唤醒路径过长:发射队列太大导致唤醒 + 选择无法在一个周期完成,被迫降频。
- 异常在非提交点处理:乱序阶段就抛异常,破坏了精确异常语义。
- RAS 未处理溢出/下溢:递归过深或返回地址栈空时预测错误,函数返回跳飞。
- 同周期发射的指令有写后写冲突:两条指令写同一物理寄存器(重命名漏分配),结果不确定。
- BTB 与 I-Cache 不一致:代码修改后 BTB 里仍是旧目标,需
fence.i同步。
小结
乱序执行可以概括为一条主线:用重命名消除假依赖,用发射队列让就绪的指令随时执行,用 ROB 保证提交时恢复程序顺序,用分支预测和访存消歧让推测尽量正确,用检查点和重放机制处理推测失败的恢复。 这五件事互为支撑,缺一件整个机制就不成立。
工程上的核心洞察是:性能的主要来源是「窗口大小 + 预测准确率」,而不是「发射宽度」。把 ROB 从 128 加到 256、把预测器从 GShare 换成 TAGE,收益远大于把发射宽度从 4 提到 6,而面积代价小得多。这也是为什么现代核的规格表里 ROB 条目数和预测器描述越来越详细,而发射宽度基本稳定在 4~6。
下一步建议阅读 RISC-V 工具链与裸机开发 ,学习如何用 Spike 与 Verilator 做协同仿真、逐指令比对提交日志;如果关心数据级并行与指令级并行的互补关系,可以进入 高性能计算 专题。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。