如果你接触过 CPU 相关的内容,一定听过“超标量”(Superscalar)这个说法。它听起来很玄,但本质上回答了一个非常朴素的问题:既然一个时钟周期内处理器只能执行一条指令,那能不能同时执行好几条?超标量处理器就是因为这个朴素的问题而生的。现代高性能处理器单核性能能从几十兆赫一路推到几GHz,IPC(每周期执行的指令数)从不到1提升到平均2~3甚至更高,靠的核心机制就是超标量。
这篇是系列的第一篇,我先把骨架讲清楚:超标量解决什么问题、一条指令在超标量处理器里怎么走完一生、寄存器重命名和乱序执行的关系、以及设计时最关键的几个参数怎么权衡。适合正在学体系结构的学生、做嵌入式和编译器想回头理解硬件的开发者,也适合刚接触 CPU 设计准备看数据通路和 RTL 的新人。看完你会对“宽发射、乱序执行、顺序提交”这三件事形成整体认识,也知道下一步该去补哪块细节。
1. 先说清楚:超标量到底解决什么问题
1.1 一个时钟周期能发几条指令,这就是差距
早期处理器是典型的标量流水线。标量的意思是:每个时钟周期最多取出并执行一条指令,IPC 上限是 1,哪怕主频再高,单核每秒能完成的指令数也受限于主频乘以 1。主频的时代过去之后,设计者意识到必须从“一个周期干更多事”里要性能,这就是超标量的出发点:每个周期取出、译码、发射、执行多条指令,让 IPC 的理论上限直接变成发射宽度。
举个例子。假设一段代码里连续三条加法指令之间没有任何依赖,标量流水线需要三个周期才能执行完,而一个三发射的超标量处理器理想情况下可以在一个周期内同时执行完三条。这里的“理想情况”很关键,因为现实中指令之间总是有依赖,而且依赖关系发生在流水线已经跑起来之后,不是写代码时就能静态确定的。
厨房做菜是一个特别贴切的类比。标量处理器像一个厨师,同一时间只能兼顾一口锅;超标量处理器像一整个后厨,多个厨师同时处理不同的菜。但后厨能不能真正跑满,取决于菜谱里各道菜之间有没有先后依赖——如果每道菜都必须要等上一道菜出锅才能开工,再多的厨师也白搭。超标量处理器设计的全部核心,就是尽量让后厨里的厨师们别闲着。
1.2 编译器静态调度和硬件动态调度,两条路线
既然指令之间有依赖,挖掘指令级并行(ILP)就有两条路线。一条是把依赖分析交给编译器,在编译期就把互不相关的指令排在一起,做成一条超宽指令,每个周期由硬件按部就班地执行整包指令。这就是 VLIW 的思路。另一条是硬件自己动态判断哪些指令可以并行执行,在运行时把就绪的指令发射到空闲的执行单元上,这就是超标量的思路。
VLIW 看起来逻辑更简单,省掉了复杂的硬件调度逻辑。但它有一个致命问题:编译期不知道运行时的动态变化。比如分支走哪条路径、缓存是否命中、函数调用返回地址是什么,这些都会影响实际指令流,编译器只能做最保守的假设。更麻烦的是,VLIW 的二进制和具体硬件宽度强绑定,芯片出了新版本,旧程序可能就没法完全发挥新硬件的并行能力。
超标量选择把调度这件事交给硬件,代价是前端、重命名、调度器、唤醒逻辑全都变得异常复杂,收益却是程序不用重新编译、硬件可以随时根据运行状态动态调整。现代高性能处理器几乎全部选择了硬件动态调度这条路线,这不是偶然。
1.3 超标量核心三件事:宽取指、乱序执行、顺序提交
超标量设计可以浓缩成三句话:取指要宽、执行可以乱、提交必须稳。
“宽”指流水线每一级都能同时处理多条指令,从取指、译码、重命名到发射宽度都要配套,任何一级宽度不够都会成为瓶颈。“乱”指指令进入执行阶段后不再严格按程序顺序执行,一条指令只要操作数准备好了,就可以先跑,不管它后面还有多少条更老的指令在等待。“稳”指执行虽然乱了,但对外表现得像顺序执行一样:指令提交时严格按照程序顺序来,发生异常或中断时能回到精确的指令边界。
很多初学者以为乱序执行就是乱来,其实恰恰相反。乱序只是把“计算过程”打乱,最终结果和异常行为必须和顺序执行保持一致。为了做到这一点,超标量处理器里最重要的数据结构就是重排序缓冲区(ROB),后面我会详细展开。
2. 整体骨架:一条指令在超标量处理器里怎么走完一生
2.1 从五级流水线到超标量流水线
经典的 RISC 五级流水线是 IF(取指)、ID(译码)、EX(执行)、MEM(访存)、WB(写回)。超标量流水线的本质,是把这五级扩充成三个大段:前端负责取指和译码,执行端负责重命名、调度和运算,提交端负责按序提交。
前端的核心任务是持续不断地向流水线供指令,包括分支预测、指令缓存访问、译码、指令队列缓存。执行端的核心任务是消除伪依赖、动态调度指令发射、通过多个执行单元并行计算。提交端的核心任务是用 ROB 记录每一条指令的程序顺序,在指令完成后按顺序更新体系结构状态,并回收资源。
这三个大段之间存在异步缓冲,比如前端取到的指令可以不立刻进入执行端,而是在指令队列里等一等;执行完的指令也不必立刻提交,可以在 ROB 里排队。正是这些缓冲的存在,让超标量处理器各段之间可以独立运转,不至于因为某一级停顿而整个流水线空转。
2.2 前端职责:取指、预测、送指令进流水
前端最重要的组件是分支预测器。如果没有分支预测,遇到分支指令时流水线只能等分支结果出来再取指,白白浪费十几个周期的取指深度。有了预测器,前端可以直接猜一个方向继续取指,等到执行端算出真实分支结果,只要猜对了就完全不损失性能。
分支预测通常由多个部件协同工作。BTB(分支目标缓冲)记住分支指令的地址和下一条指令的地址,用来快速定位跳转目标;方向预测器根据分支历史判断跳不跳;返回地址栈专门处理函数调用返回,因为返回地址是动态压栈的,普通预测器很难准确猜中。现代处理器的分支预测准确率在常见负载下可以达到 95% 甚至更高,但预测失败造成的性能损失依然不可忽视,这一块我后面会单独算一笔账。
前端还有一个容易被忽略的细节:指令缓存。每个周期前端要取好几条指令,指令缓存必须支持多字节的跨行访问,取出可能横跨两个缓存行的指令块,再拼接成若干条完整指令送给译码器。缓存缺失时前端完全停摆,这段时间所有后续阶段都会因为没有新指令而空转,所以指令预取、缓存替换策略在前端设计里同样重要。
2.3 执行端:重命名、分发、调度、执行
指令经过译码后,进入重命名阶段。重命名要做的第一件事是给每条指令的目的寄存器分配一个物理寄存器,同时分配一个 ROB 条目。然后指令被分发到对应的发射队列或者保留站,等待操作数就绪。这里要注意,指令一旦进入发射队列,它的源寄存器已经被换算成物理寄存器编号,而且这些物理寄存器可能还在被更早的指令使用,这时候需要一套“操作数捕捉”机制:要么在分发时就读取寄存器值,要么等值被写回时再通过旁路网络直接送来。
调度器是执行端的心脏。它每周期要扫描发射队列里所有等待中的指令,找出操作数已经就绪、执行单元空闲的指令,按年龄规则发射。如果多个就绪指令竞争同一个执行单元,必须让更老的那条先执行,这个选择逻辑直接决定了乱序执行的公平性和性能。
执行单元通常是多个不同类型的运算单元组合,包括整数 ALU、乘法器、浮点运算单元、访存地址计算单元、分支执行单元等。不同指令的执行延迟差异很大,这也是乱序调度的一大意义:短延迟的指令可以插在长延迟指令之前完成,而不是干等。
2.4 提交端:ROB 与顺序提交
ROB 是一个环形缓冲区,每条指令进入重命名阶段时就分配一个条目。ROB 条目里记录了指令的类型、目的逻辑寄存器编号、旧物理寄存器编号、完成状态等信息。指令执行完成后,结果写到物理寄存器文件,并把 ROB 对应条目标记为完成。提交逻辑每周期检查 ROB 队头,只有队头的指令完成,才能提交这条指令;提交时把新物理寄存器的映射正式变成体系结构状态,同时释放这条指令“覆盖”掉的旧物理寄存器。
这就实现了乱序执行和精确异常的完美统一:指令执行可以乱序,但体系结构状态的更新严格按照程序顺序进行。如果某条指令发生异常,只需要把 ROB 中该指令之前的所有条目提交,之后的所有条目全部作废,再把映射表恢复到异常指令之前的状态,就得到了一个精确的异常现场。
3. 重命名与乱序执行:为什么做、怎么做、代价是什么
3.1 三种数据冒险,哪两种是纸老虎
流水线设计里,数据冒险分为三种:RAW(写后读)、WAR(读后写)、WAW(写后写)。以这三条指令为例:
I1: R1 <- R2 + R3 I2: R4 <- R1 + R5 I3: R1 <- R6 + R7I2 要读 R1,而 I1 要写 R1,这是 RAW 冒险,人话叫“真依赖”——I2 必须等 I1 算出来才能执行,没法取巧,只能靠等待或者数据转发来缩短延迟。
I3 要写 R1,而 I2 要读 R1,如果 I3 先执行,I2 就会读到错误的新值,这叫 WAR 冒险。I3 写完 R1 后 I1 的结果就没用了,如果 I3 和 I1 都要写 R1,谁先写都行但必须保证最后保留的是 I3 的结果,这叫 WAW 冒险。后面两种看着可怕,但它们并不是计算逻辑上的真依赖,只是因为寄存器编号不够用,两条指令不得不复用同一个寄存器。
超标量处理器解决 WAR 和 WAW 的标准办法是寄存器重命名:别再让每条指令都对着同一个逻辑寄存器写,给每个新值分配一个独立的物理寄存器,问题就消失了。
3.2 寄存器重命名的实现机制
现代处理器的重命名本质是建立一套“逻辑寄存器到物理寄存器”的动态映射。逻辑寄存器就是指令集架构里定义的那些寄存器,比如 32 个通用寄存器;物理寄存器则是芯片内部寄存器文件里实际存储数据的编号,数量通常远多于逻辑寄存器,可以是逻辑寄存器的两三倍甚至四五倍。
实现重命名需要三个核心结构。第一是映射表(RAT,也叫寄存器别名表),记录每个逻辑寄存器当前对应哪个物理寄存器。第二是空闲列表,记录哪些物理寄存器还没有被使用。第三是物理寄存器文件本身,所有指令的结果都写到物理寄存器里,不再写回逻辑寄存器。
重命名一条指令的逻辑用伪代码写出来非常清晰:
// 指令进入重命名阶段,假设指令格式为 op rd, rs1, rs2 rd = 指令的目的逻辑寄存器编号 new_pr = free_list.pop() // 从空闲列表取一个新的物理寄存器 old_pr = map_table[rd] // 读取逻辑寄存器 rd 当前的映射 map_table[rd] = new_pr // 建立新映射,rd 现在指向新的物理寄存器 rob_entry = { rd, old_pr, status = 未执行 } // 源操作数 rs1, rs2 直接查 map_table 转成物理寄存器编号注意 old_pr 并不会立刻释放。因为之前还有一些更老的指令可能已经读取了 old_pr 的值,它们还没执行完。只有等到当前这条指令提交时,确认所有更老指令都不再需要 old_pr 了,才能把 old_pr 归还给空闲列表。ROB 条目里保存 old_pr,就是为了在提交阶段能够安全释放它。
3.3 乱序执行与精确提交为什么能同时成立
有了重命名和 ROB,乱序执行就变得很自然了。指令进入发射队列后,调度器只关心物理操作数是否就绪,完全不管程序顺序。比如上面的 I3 在重命名阶段已经把 R1 映射到了新的物理寄存器 P3,那么它对逻辑 R1 的写入就不会影响 I2 对 R1 的读取,因为 I2 的源操作数在重命名时已经被翻译成了 P1(I1 分配给 R1 的物理寄存器)。
指令执行完成后,结果直接写进物理寄存器文件。它能不能把映射表里 R1 指向 P3 这件事真正“落实”?不行,要等 ROB 队头的指令提交。ROB 保证了体系结构状态只按程序顺序更新,因此哪怕执行期间各种乱序、各种预测失败,任何时刻处理器都能回到精确的指令边界状态。
分支预测失败时的恢复也是靠 ROB 和映射表快照来实现的。常见的做法是对分支指令重命名后的映射表做检查点备份,预测失败时直接恢复检查点;也可以在 ROB 里保存每条指令覆盖的旧映射,失败时从 ROB 尾部往回逐条恢复。检查点恢复快但占用面积,回滚恢复省面积但速度慢,实际设计里常常两者结合。
3.4 乱序带来的设计代价
乱序执行不是免费的午餐,最大的代价在功耗和时序。物理寄存器文件必须提供足够多的读写端口,因为每个周期可能有好几条指令要读取源操作数,同时好几条指令要写入结果。端口数一多,寄存器文件的面积和访问功耗会迅速膨胀。
调度器的唤醒和选择逻辑同样凶险。每个周期,发射队列里每条正在等待的指令都要和所有新写回的结果比较,看自己的操作数是否就绪,这个比较网络是典型的 CAM(内容寻址存储器)结构。发射宽度和队列深度越大,比较器的数量按乘积增长,布线长度、时序、功耗都跟着暴涨。这也是为什么高性能处理器的发射宽度普遍停留在 6~8,而不是直接做到 32。宽度翻倍带来的性能收益远小于硬件代价,这是后文要讲的权衡核心。
4. 关键设计参数怎么权衡:宽、深、多的背后
4.1 发射宽度不是越大越好
发射宽度决定了处理器每个周期最多能执行多少条指令,看起来当然是越大越好。实际设计里,宽度从 2 增到 4 性能改善很明显,从 4 增到 6 还有收益,再往上去就越来越不划算了。原因在于真实程序里的可用指令级并行度有限,尤其是整数负载,平均窗口内能并行的指令往往只有几条,盲目加宽只会让大量执行单元空转。
让宽度真正跑满,还需要一系列配套能力。取指阶段要每周期取出宽度条指令,译码器要每周期译出这么宽,重命名逻辑要每周期处理这么多条指令的映射表更新,寄存器文件要有足够的读写端口,ROB 和发射队列要能容纳这么多在飞行指令。任何一个环节跟不上,发射宽度就只是个摆设。这就是设计中常说的“带宽匹配”:各阶段宽度必须大致相等,否则瓶颈必然出现在最窄的那一级。
4.2 乱序窗口和 ROB 深度
乱序窗口可以理解为“处理器同时能看到多少条正在执行的指令”。ROB 深度直接决定这个窗口的大小。窗口太小,调度器没有足够的候选指令去挖掘并行性,遇到长延迟操作(比如缓存未命中的加载)时只能干等;窗口太大,调度器、唤醒逻辑和物理寄存器文件的压力都会急剧增加。
这里有一个很常见的误解:ROB 越大性能一定越好。实际上,乱序窗口的收益高度依赖负载类型。对整数密集型程序,窗口超过一定大小后性能收益就趋平了;对缓存缺失较多的程序,大窗口确实能隐藏更多访存延迟,这也是为什么服务器和数据库负载的处理器愿意把 ROB 做得很大。设计者必须在面积、功耗、时序和实际负载特性之间做取舍。
4.3 物理寄存器数量
物理寄存器数量要和 ROB 深度、发射宽度一起定,不能单独拍脑袋。每条尚未提交的指令如果带一个目的寄存器,就会占用一个物理寄存器。物理寄存器不够,处理器的重命名阶段就会被堵住,即使 ROB 还有空位、发射队列还有空间,新指令也进不了流水线,这种现象叫重命名停顿。
物理寄存器文件必须支持每个周期多条写、多条读。写端口数量取决于每周期最多能有多少指令完成写回,读端口数量取决于每周期发射的指令最多需要读多少个源操作数。端口、面积、功耗、访问延迟四个因素互相撕扯,最终选出来的物理寄存器数量通常是逻辑寄存器数量的两到四倍,具体数值因微架构而异。
4.4 访存队列与内存消歧
访存指令是超标量设计里最棘手的一类指令,因为它们除了要算地址、访问缓存,还存在一个指令级并行的大麻烦:内存消歧。如果一条 load 和一条更早的 store 可能访问同一个地址,load 就不能提前执行,否则可能读到旧值。可问题是,执行 load 的当下,前面 store 的地址可能还没算出来,处理器怎么知道它们是否冲突?
实际设计使用 Load Queue 和 Store Queue 来动态判断。load 执行时会检查队列中更早的 store,如果发现地址匹配,就直接把 store 的数据转发过来,不用等 store 写进缓存。如果地址还没算出来,load 可以先执行,等 store 地址算出来后发现有冲突,再通过冲刷机制把 load 及其后续指令作废重来。这就是 store-to-load forwarding 和 load 推测执行的基本逻辑。访存顺序处理不好,ILP 挖掘得再好也会被卡在这一环,所以访存子系统在超标量设计里往往是复杂度最高的部分之一。
我把前面几个关键参数整理成一张速查表,方便对照理解:
| 设计参数 | 作用 | 增大后的收益 | 增大后的代价 |
|---|---|---|---|
| 发射宽度 | 每周期最多发射指令数 | 峰值 IPC 提升 | 前端带宽、寄存器端口、调度复杂度同步增长 |
| ROB 深度 | 乱序窗口大小 | 隐藏访存延迟、挖掘更多 ILP | 唤醒比较网络、面积功耗大增 |
| 物理寄存器数 | 重命名可用新名字数量 | 减少重命名停顿 | 寄存器文件端口数和面积膨胀 |
| Load/Store 队列深度 | 访存指令跟踪与转发能力 | 提高访存并行度 | 地址比较逻辑复杂,时序压力大 |
5. 前端和访存:分支预测与 store forwarding 两个大头
5.1 分支预测不准的代价怎么算
分支预测失败的惩罚可以用一个很简单的公式估算:预测失败代价约等于前端深度乘以每周期取指宽度。假设前端有 20 级流水线,每周期取 4 条指令,一次预测失败就要清空这 20 级里总计 80 条还没提交的指令,重新开始取指。高性能处理器的分支预测准确率虽然已经很高,但哪怕只有 1% 的失准率,在大负载下单看这一项就足以吃掉好几成的性能提升。
现代分支预测器早已不是单一的表,而是分成多个层次。方向预测上,常见思路是把分支历史和分支地址进行哈希,去索引一张模式历史表,这就是 gshare 的思想;更先进的预测器还会用多个不同长度的历史表进行预测,最后用带元数据的投票器选一个结果,TAGE 就是这类设计的代表。目标预测上,BTB 记录跳转目标地址,而返回地址栈专门处理函数调用返回。这堆预测器加起来面积不小,功耗也很可观,但相对于它们节省下来的流水线冲刷开销,依然是值得的。
5.2 store-to-load forwarding 为什么重要
来看一个最常见的代码模式:
I1: store R1 -> [R2] I2: load R3 <- [R2]I2 和 I1 访问同一个地址,而且 I2 要读 I1 刚写进去的数据。如果没有 forwarding,I2 必须等 I1 真正把数据写进缓存才能读,这会强制把两个本来可以紧挨着执行的访存操作串行化。有了 Store Queue 的转发机制,load 可以直接从 store 拿数据,延迟比访问缓存还要低,RAW 冒险在访存子系统里就被巧妙消除了。
实现上有一个难点:load 执行时可能前面有好几个 store,怎么找到目标 store?Store Queue 里通常按程序顺序存储,load 的地址出来后和队列里的 store 顺序比较,取最新且不晚于该 load 的匹配项。比较宽度有限时,处理器甚至需要多个周期来完成这个查找,而查找期间 load 又必须停下来等,所以 Store Queue 的组织结构在真实设计里非常扣细节。
5.3 缓存与带宽:每个周期要喂饱那么多执行单元
超标量处理器对缓存的带宽要求远超标量处理器。取指阶段要求每周期拿到宽度条指令,数据访问阶段要求每周期同时支持多个 load/store 的地址计算和数据读写。一个单端口缓存根本忙不过来,所以高带宽缓存通常采用多端口化、存储体分组或者复制缓存结构来提升并行访问能力。
写回也容易被忽略。多个执行单元同时完成写回,要求物理寄存器文件每个周期有足够多的写口。如果写口不够,完成时间相近的指令就得排队写回,这会直接拖慢调度器的唤醒速度。所以物理寄存器文件、旁路网络、缓存带宽、ROB 写口这些都必须在设计早期一起规划,任何一个短板都会成为整条流水线的瓶颈。
6. 学习与调试阶段最常踩的坑
6.1 常见误区汇总
我在学习和调试过程中见过不少容易混淆的点,整理如下:
| 误区 | 实际情况 |
|---|---|
| 超标量 = 多核 | 超标量是单核内部提升指令级并行,多核是线程级并行,二者解决不同层面的性能问题 |
| 乱序执行 = 没有顺序 | 执行乱序,提交严格按序,这是精确异常的前提 |
| 重命名就是给寄存器换个名字 | 重命名涉及映射表、空闲列表、ROB 之间的协作,名字分配和释放都需要精确管理 |
| ROB 越大越好 | 窗口收益有边际递减,物理寄存器、调度器、分支恢复代价都会上涨 |
| 分支预测主要靠 BTB | BTB 只解决目标地址,方向预测靠模式历史表,返回地址要靠专门的返回栈 |
| store forwarding 很简单 | 地址比较顺序、冲突检测、load 推测失败处理都是容易出错的地方 |
6.2 用模拟器做实验的体会
我强烈建议学超标量的朋友尽早找开源模拟器上手,把参数改来改去看 IPC 变化。实际做实验时,一次只动一个参数,比如把 ROB 深度从 32 调到 64,记录 IPC 增量;再把发射宽度从 4 调到 6,单独记录。如果同时改多个参数,最后很难定位是哪个改动带来了收益,或者哪个改动掩盖了另一个改动的损失。
做实验时还有一个很容易犯的错:只盯平均 IPC,不关注瓶颈在哪一级。效率更高的做法是先跑出每个阶段的活动率:重命名阶段是不是经常停顿?发射队列是不是经常为空?ROB 是不是经常满?这些活动率能告诉你瓶颈到底在取指、重命名、调度还是访存,再针对瓶颈调整对应参数。我实测过很多次,最终结论往往是“某个参数看似是短板,改完收益很小,真正卡脖子的是另一个没人注意的队列”。
6.3 检查数据通路和 RTL 的实际经验
如果你已经到了写 RTL 或者阅读别人 RTL 的阶段,我建议先画一张数据通路图再动手。画图的重点是:把这个周期里映射表、空闲列表、ROB、发射队列、物理寄存器文件之间有哪些连线画清楚,每条连线上的数据在哪个阶段产生、哪个阶段消费。很多刚开始写乱序核的人都会在“提交时释放物理寄存器”这个逻辑上出错:要么提前释放了还有更老指令在引用的物理寄存器,要么一直不释放导致空闲列表耗尽。要避免这类问题,最好的办法是画清楚谁在引用旧映射、谁负责把它归还给空闲列表。
分支预测失败恢复的边界情况也是重灾区。最好在模拟波形里逐一检查:预测失败信号来了之后,映射表是否恢复到正确的检查点;ROB 里预测点之后的指令是否全部作废;物理寄存器文件里这些作废指令写的值是否被浪费,空闲列表会不会因此出现不一致。这些边界场景在正常指令流里很难触发,一旦触发就是稳定复现的 bug,调试起来特别费劲。
访存子系统的验证也要特别注意 load 推测失败的场景。处理器允许 load 在不知道前序 store 地址的情况下先执行,但如果后面发现冲突,不仅 load 自身要作废,所有依赖这个 load 结果的指令都要连坐。这个依赖链的取消在 RTL 里实现起来很容易漏,很多团队验证这块都会花掉大量时间。
我在实际学习中最深的体会是:超标量处理器的每个部件都能单独讲明白,难的是它们在同一拍里协同工作时暴露出的问题。寄存器重命名和分支恢复联动的时序、调度器唤醒和旁路网络的时序、ROB 提交和空闲列表回收的时序,这些才是真正的硬骨头。这篇先把骨架立住,下一篇我打算专门展开分支预测的细节,包括预测器结构、恢复机制、以及用模拟器验证预测准确率的具体方法,到时候见。