超标量与乱序执行

系统讲解 RISC-V 超标量与乱序执行微架构:多发射的动机与取指带宽瓶颈、寄存器重命名如何消除假依赖、保留站与重排序缓冲的顺序提交、TAGE 分支预测、推测执行的精确异常与检查点恢复、访存消歧与加载重放,并给出面积功耗频率三角的量化取舍与开源乱序核实现路线。

引言

五级顺序流水线的 CPI 下限是 1——每周期最多完成一条指令。但顺序流水线的实际 CPI 通常远高于 1,因为只要有一条指令 stall(cache miss、长延迟除法、访存依赖),后面所有指令都被堵住。而程序里天然存在大量可并行的指令:不同寄存器、不同功能单元、互不依赖,硬件却因为「必须按程序顺序」而无法利用它们。

超标量与乱序执行就是为打破这个限制而生的:每周期取多条、发射多条指令(超标量),并且允许后发的指令先执行完(乱序),只要最终提交时恢复程序顺序的语义即可。这两件事加起来,把单核 CPI 从 1 压到 0.30.5(即 IPC 23),是现代高性能核性能的主要来源。

代价是复杂度爆炸。乱序核的面积可以是同工艺顺序核的 5~10 倍,验证难度高一个数量级,功耗也大幅上升。因此 RISC-V 生态里出现了清晰的分层:MCU 用顺序单发射,嵌入式应用核用顺序双发射,服务器核才上深度乱序。本文按「为什么乱序 → 怎么重命名 → 怎么提交 → 怎么预测 → 怎么恢复 → 怎么消歧」的顺序展开,前置知识是 RISC-V 流水线 CPU 里的冒险与分支预测基础,本文不再重复那部分,而是往深处走。

目录

  1. 从标量到超标量:多发射的动机
  2. 取指带宽与发射宽度
  3. 顺序多发射与乱序执行的分野
  4. 寄存器重命名与数据流
  5. 保留站与发射队列
  6. 重排序缓冲与顺序提交
  7. 分支预测器进阶:从 2 位到 TAGE
  8. 推测执行与精确异常恢复
  9. 访存消歧与加载队列
  10. 同时多线程 SMT 的取舍
  11. 面积、功耗与频率的三角
  12. 开源乱序核案例与实现要点

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)记录「架构寄存器 → 当前物理寄存器」的映射。每条指令译码后:

  1. 查 RAT 得到源操作数的物理寄存器号(读映射)。
  2. 分配一个新的物理寄存器作为目的(写映射)。
  3. 更新 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 的作用有三个:

  1. 恢复程序顺序语义:乱序执行的结果在提交前对软件不可见。
  2. 支持精确异常:只有提交时才可能抛异常,此时 ROB 里全是「已提交的」和「未提交的」清晰分界。
  3. 回收物理寄存器:提交时才能把 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 位计数器PC85%~90%
GSharePC 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
提交阶段(访存/异常)整个 ROB15~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 GHz1.0~1.5 GHz
面积1x5~10x
动态功耗1x4~8x
IPC0.6~0.82~3
能效(每指令能耗)1x2~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~464~128Chisel 生成,可配置,教学与研究首选
Rocket(伯克利)1(顺序)—作为对照基线
CVA6(原 Ariane)1~2(顺序为主)—工业级、验证完善
XiangShan(香山)6256+高性能、开源、接近商用水平
NaxRiscv2~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 做协同仿真、逐指令比对提交日志;如果关心数据级并行与指令级并行的互补关系,可以进入 高性能计算 专题。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「芯片与体系结构」更多文章

  1. 可测性设计与测试
  2. 低功耗数字设计
  3. RISC-V 向量扩展 RVV