
体系结构学习笔记写到第六篇这一篇聊超标量处理器。我知道很多人第一次听到“超标量”三个字的时候第一反应是这不就是把流水线加宽一点每周期多跑几条指令吗如果你也这样想那这篇笔记正好适合你。真正把超标量处理器拆开看会发现它牵涉到的寄存器重命名、乱序执行、精确异常、分支预测恢复每一个模块单独拎出来都是一门课。这篇文章适合正在学体系结构的学生、做编译器或内核优化的工程师以及所有想搞明白“CPU 内部到底怎么把一条条指令并行塞进去执行”的人。我尽量用做过实际项目的人的口吻来讲不堆公式但会把关键机制讲透。学超标量不是记住几个术语缩写就完事而是要理解每个设计选择背后的约束和代价。我会从单发射流水线为什么会撞墙讲起一路走到现代处理器的取指、译码、重命名、调度、提交最后聊聊从学理到芯片实现之间那些没法写在教科书里的权衡。1. 为什么超标量是体系结构学习里绕不开的坎1.1 单发射流水线到底卡在哪先回到最基础的经典五级流水线取指、译码、执行、访存、写回。单发射的意思是每个时钟周期最多取一条、译一条、执行一条、提交一条。它虽然靠流水线重叠把吞吐率提上去了但天花板非常明确IPC每周期指令数的理论上限是 1。你可能觉得 IPC 上限是 1 也没啥不好反正主频可以拉高。可问题在于单纯拉主频会遇到功耗墙而且程序里大量指令之间有依赖流水线经常要停顿。比如ADD R1, R2, R3 SUB R4, R1, R5第二条 SUB 要等第一条 ADD 把 R1 写出来才能读这叫 RAW 相关是真正没法绕开的数据依赖。因为这种依赖流水线即使没有结构冲突也常常出现气泡。单发射结构下IPC 连 1 都很难稳定达到平均下来可能只有 0.3 到 0.6。也就是说CPU 大部分时间都在等。所以体系结构课程里讲超标量首先解决的动机就是单发射 IPC 上限太低而程序内部明明还存在不少可以并行的指令只是硬件没有手段把它们同时抓出来执行。超标量处理器把“每周期一条”放宽成“每周期 N 条”目标就是提高指令级并行度ILP而不是主要靠提高频率。1.2 超标量的核心定义一次不止一条超标量处理器的核心特征是每个周期可以取多条指令、译多条指令、发射多条指令到不同功能单元执行并且可能乱序完成。它和 VLIW超长指令字的关键区别在于超标量的并行性是由硬件动态发掘的VLIW 是把并行性交给编译器静态打包。举一个生活化的类比一桌人吃饭单发射有点像只有一个服务员一次只能上一道菜后厨出菜再快也没用。超标量相当于多加几个服务员一次端好几道菜。但这里有个隐含问题桌上的人点菜如果有先后依赖比如“先要蒜蓉再要酱油”服务员端错顺序就会被骂。VLIW 是让点菜的人事先把必须按顺序吃的菜打包成一捆服务员照单全收超标量则是服务员自己很聪明看一眼哪些菜能先上、哪些必须等自己动态安排顺序。这个区别直接影响软件设计。VLIW 需要编译器精确知道硬件资源一旦硬件型号变了二进制就得重新编译。超标量则对软件更宽容同一个二进制在宽窄不同的实现上都能跑只是性能不同。现代主流 CPU 基本都走超标量这条路因为它和既有软件生态的兼容性最好。这一点在我后面讲 x86 译码时会反复出现。2. 前端取指与译码带宽、预测和分组的三角关系2.1 取指带宽为什么不是想加就加很多人以为超标量设计最难的部分在乱序执行实际上面向前端的取指问题一天不解决后面所有部件都是空转。超标量一旦设计成 4 发射前端每周期至少要提供 4 条指令如果你设计的是 8 发射前端每周期要供 8 条。取指不是简单地把指令缓存的数据接口加宽就完事它还面临两个天然障碍。第一个障碍是对齐与跨越。指令在内存里是按顺序排放的但一条分支指令可能让控制流跳到任意地址取指块不一定和指令边界对齐。为了每周期取满 N 条指令你需要在取指缓冲区里做复杂的对齐/拼接逻辑把多个可能不对齐的取指块合并成完整的指令组。第二个障碍是分支。遇到分支指令时处理器不能等分支执行完再取下一条否则流水线就空转。所以前端必须依赖分支预测器猜一个方向沿着预测地址继续取指。一旦猜错整个后面的指令全部作废重新回到正确的目标地址取指。这个代价在宽超标量下非常大如果你每周期取了 6 条指令而分支在第三条取错了等于浪费了整整一轮前端的带宽。现代处理器前端通常每周期取 16 字节到 32 字节的指令块配合 BTB分支目标缓冲和高级分支预测器。拿最近几年 ARM 和 x86 的设计看4 发射到 8 发射已经成为主流前端压力是非常真实的工程问题。2.2 译码的复杂性x86 的困境和 RISC 的优势不同指令集的译码复杂度天差地别。RISC 指令比如 ARMv8、RISC-V指令定长 32 位译码逻辑可以做得又简单又快前端几乎可以线性地把一条条指令切开。x86 就惨了指令长度从 1 字节到 15 字节不等还带各种前缀、操作码、ModR/M、SIB、立即数。你拿到一段字节流不看完整个指令根本不知道下一条从哪开始。x86 处理器的做法是把硬件译码器当成一个“内部编译器”每条复杂 x86 指令进来先被拆成一个或多个微操作uop后面的乱序执行引擎只认识 uop不认识 x86 指令。比如一条add [mem], reg可能会被拆成 load、add、store 三个 uop。这套东西让微架构设计多了一层抽象但也带来了可观的译码复杂度和功耗。也正因为这样现代 x86 处理器通常配一个很大的 uop 队列和循环缓冲Loop Buffer把频繁执行的循环体以 uop 形式缓存起来减少反复译码的功耗和延迟。学超标量时很多人忽略“译码宽度”这个参数实际上后端发射宽度再大译码宽度跟不上整机性能依然受限。软件设计与体系结构在这里交汇指令集的设计决定了硬件要花多大代价才能把它跑宽。2.3 分支预测在前端的真正作用止损而不是猜中分支预测经常被初学者误解以为目标是“猜中越多越好”。从体系结构角度看分支预测的真正价值在于减少误预测带来的流水线冲刷损失。你猜对了只是不亏你猜错了前面取进来的所有投机执行的指令全部得清掉重新取指。在宽超标量里误预测代价 流水线深度 × 发射宽度。一个 20 级流水线、6 发射的处理器每次误预测可能要浪费上百个指令槽位。所以现代分支预测器的研究方向比如 TAGE、ITTAGE、感知机预测器核心目标是把误预测率从 5% 压到 1% 甚至更低而不是追求单点预测的“聪明”。现代处理器的分支预测器通常包含几个层级简单快速的 BTB、基于历史的分支历史表BHT、以及 TAGE 这类基于部分历史模式匹配的预测器。它们和缓存一样都是“局部性”原理的应用最近执行过的分支模式很可能再次出现。你在做内核或编译器优化时如果把一个频繁跳转的热点循环改成无分支代码其实就是在帮硬件降低分支预测压力这也是软件设计和体系结构协同的一个典型例子。3. 寄存器重命名乱序执行的第一块基石3.1 三种数据相关RAW/WAR/WAW要理解乱序执行先得把指令之间的相关关系分清楚。教科书上通常会讲三种RAW读后写后一条指令要读的寄存器恰好是前一条指令要写的寄存器。这是真正的数据依赖没法通过改名字绕开只能等。WAR写后读前一条指令要读某个寄存器后一条指令却要写同一个寄存器。如果后一条先执行前一条就读不到旧值了。WAW写后写两条指令都要写同一个寄存器如果顺序颠倒最终留下的值就不对。WAR 和 WAW 有个共同特点它们不是真正的数据依赖而是因为程序里复用了同一个寄存器名字造成的“名字相关”。就像两个不同的人共用一个写字台A 用完把名字写在黑板上B 也写在黑板上B 不能等 A 写完就开始写自己的内容吗换一块黑板不就行了。寄存器重命名做的事情就是给每个逻辑寄存器一个物理寄存器别名让本来要写同一个名字的指令各写各的物理寄存器从根上消除 WAR 和 WAW。RAW 依然存在因为那是数据流本身的要求。这一下就打开了乱序执行的大门指令只要数据就绪就可以不按程序顺序执行。3.2 逻辑寄存器到物理寄存器的映射重命名的硬件实现核心是一张映射表 RATRegister Alias Table把体系结构定义的逻辑寄存器比如 x86 里的 RAX、RBXARM 里的 X0-X30映射到物理寄存器文件中的某个寄存器。物理寄存器文件的数量比逻辑寄存器多得多现代处理器通常有 100 到 300 多个物理寄存器。每条指令在译码后进入重命名阶段时会做下面几件事对每个要写的目的逻辑寄存器从物理寄存器池里分配一个空闲物理寄存器。在 RAT 里把该逻辑寄存器的新映射更新为新分配的物理寄存器。把旧映射信息记录下来方便以后恢复。对每个要读的源逻辑寄存器查 RAT 得到当前最新的物理寄存器编号。举个例子两条指令都写 R5ADD R5, R1, R2 SUB R3, R5, R4第一条 ADD 分配物理寄存器 P10 作为 R5 的新映射第二条 SUB 读 R5 时查 RAT 发现当前映射是 P10所以它读的就是 P10 这个物理寄存器。如果第二条之前还有一条指令也写了 R5但已经提交那么它的结果存在 P8RAT 里此时已经换成 P10 了两条写指令之间不再有冲突。物理寄存器文件是多端口的因为发射宽度 N 意味着每周期可能有 N 条指令同时要读 2 到 3 个源操作数。端口越多芯片面积和功耗越大所以现代处理器的物理寄存器文件设计都极其讲究。3.3 重命名宽度和恢复机制重命名的宽度是每周期能处理多少条指令进入乱序引擎的硬指标。如果前端每周期能译 4 条指令那么重命名阶段最好也能每周期处理 4 条否则前面排队的指令会把队列塞满。现代处理器常把译码和重命名放在同一个宽度下设计。重命名还有一个隐藏的重担恢复。当分支预测失败或发生异常时RAT 里的映射关系需要恢复到某个历史点。ROB重排序缓冲里会保存每条指令重命名之前的旧映射表项。恢复方式有两种一种是做检查点checkpoint对分支点定期保存整张 RAT另一种是沿着 ROB 逐条回滚把旧映射逐个恢复。前者恢复快但占面积后者省面积但恢复慢。实际处理器常常两种结合对分支预测器给出的预测点做少量检查点对异常则从 ROB 精确恢复。这里值得注意乱序执行本质上是一种“投机”所有投机都要有后悔药而这个后悔药的设计难度经常被低估。4. 调度与发射Tomasulo 算法和保留站的现代变体4.1 记分牌和 Tomasulo 的本质区别经典教材里指令调度的两个代表性机制是记分牌Scoreboard和 Tomasulo 算法。它们都能实现乱序执行但思路完全不同。记分牌采用集中式控制所有功能单元的执行状态都由一张记分牌表统一记录。每条指令要发射时由中央逻辑检查源操作数是否就绪就绪才发射。这种方式结构简单但它本质上还是集中判断而且它不能解决 WAR/WAW 冲突造成的停顿因为它没有做寄存器重命名。Tomasulo 算法用保留站Reservation Station实现了分布式控制。每个功能单元前面挂一组保留站每条指令在里面“等待操作数”。源操作数一旦被产生出来就会通过公共数据总线CDB广播给所有等待它的保留站保留站捕获到对应数据后标记就绪。由于每份数据都由物理寄存器或保留站单独存放WAR/WAW 的“名字冲突”彻底没有了。下表是我自己对比这两个方案时整理的维度记分牌Tomasulo控制方式集中式状态表分布式保留站寄存器重命名无隐含在保留站中消除 WAR/WAW不能能数据转发简单通过 CDB 广播硬件复杂度低高Tomasulo 是现代乱序超标量处理器的鼻祖。你去看现在任何一个乱序 CPU 的调度器本质上都是 Tomasulo 思路的变体指令在保留站里等待数据数据就绪后竞争发射端口。4.2 统一保留站 vs 分布式保留站纯 Tomasulo 设计里保留站可以看作一个统一的池子任何功能单元都可以从中选指令。但随着发射宽度增加统一的保留站会带来巨大的比较器扇出和连线延迟。比如 6 发射处理器里每周期要同时唤醒 6 条指令每个保留站都要和自己的邻居比较优先级全局互连非常恐怖。所以现代处理器的调度器通常是分簇的Clustered。整数、浮点、访存各自有独立的保留站和调度队列甚至整数内部还能拆成多个簇。每个簇内部只有少数几个发射端口这样唤醒逻辑可以做得更快、功耗更低。代价是指令从 A 簇发射后如果目标功能单元在 B 簇需要跨簇传递数据可能多一个周期的延迟。设计取舍永远是这样简单整齐的电路性能差性能好的电路都是东拼西凑出来的。你看到某款 CPU 的发射宽度是 6 还是 8不能只看数字还得看它是怎么分簇的、每簇几个端口、跨簇延迟多少。这些都是软件感知不到、但对性能影响很大的微架构细节。4.3 发射宽度与唤醒逻辑的实际约束发射窗口Scheduler Window的大小决定了乱序引擎最多能同时“看着”多少条指令。现代处理器发射窗口动辄几十条到上百条。窗口越大越能跨越长延迟指令比如缓存缺失的 load找到可以执行的指令但唤醒逻辑的功耗随窗口大小超线性增长。具体来说发射选择器要满足两个条件操作数全部就绪并且选择了正确的执行端口。为了做到这一点每条指令进入调度器时就要和它所依赖的指令建立物理连接或者通过“生产者 tag 比较器”的方式跟踪。当某个物理寄存器被写回时广播 tag 给所有正在等待的调度器条目条目匹配就把自己的操作数就绪标志置位。这在电路上是一大堆并行比较器。之前做性能分析时我见过一个典型现象一个程序理论上 ILP 很高但实际 IPC 上不去。用 perf 一看发现大量周期耗在调度器满或发射槽空闲上。原因往往是调度器窗口不够大、无法跨过某个 cache miss 的 load 去调度后续指令或者指令之间虽然无依赖但所有指令都集中在同一个发射端口。这说明超标量的“宽”不是万能药调度资源的组织方式才决定实际利用效率。5. 提交与异常ROB 如何让乱序世界变回有序5.1 ROB 的基本结构乱序执行让指令的完成顺序变得乱七八糟但程序员的直觉里指令是严格按照顺序执行的。为了在外部世界维持这个假象处理器必须有一个机制把乱序的结果重新按程序顺序提交。这个机制就是重排序缓冲也就是 ROB。ROB 是一个循环队列每条指令在译码/重命名时被分配一个 ROB 项。ROB 项里记录着这条指令的状态、目的逻辑寄存器、计算结果。指令乱序执行完成后结果先写进 ROB而不是直接写进体系结构寄存器。只有当 ROB 队头的指令状态是“已完成”且它前面所有指令都已提交时它才能从 ROB 离开把结果真正提交到体系结构寄存器文件。这样即使后面有指令执行得更快提交顺序始终是程序顺序。ROB 的大小非常关键。它本质上限制了乱序执行可以“超前”多少条指令。如果 ROB 满了前端必须停顿不能发射新指令进入流水线。这就是为什么即使处理器可以在 cache miss 时继续执行后续指令这个“继续执行”的能力也不是无限的——窗口上限就是 ROB 的大小。5.2 精确异常与推测执行恢复为什么要这么麻烦地通过 ROB 提交一个重要原因是精确异常。如果一条指令在程序中间发生了缺页、除零、非法访问操作系统希望处理器能报告一个“精确状态”异常指令之前的所有指令已经完成之后的所有指令都没有改变任何状态。这样操作系统才能正确保存上下文、处理异常、恢复执行。没有 ROB要实现精确异常要么得把流水线停到某个精确边界要么得用复杂得多的恢复逻辑。有了 ROB异常的恢复就很直接把 ROB 里异常指令之后的那些项清掉把 RAT 恢复成异常指令前的状态把 PC 指到异常指令。这就是上一条笔记里说的“后悔药”的完整版。分支预测失败也是类似的恢复流程只是它没有异常那么严重只需要把错误路径上的指令全部作废。如果前面采用了 RAT 检查点恢复可以在一个周期内完成如果只靠 ROB 回滚可能要多花几十个周期。所以现代处理器对分支误预测的恢复快对异常处理的恢复慢一点这个不对称是合理的——异常本来就很稀少分支却每几条指令就来一次。5.3 现代处理器中的提交宽度与退役率如果前端每周期能取 8 条指令发射也能达到 8那么 ROB 每周期通常也能提交最多 8 条比如 Apple M1 的退休宽度大约是 8。提交宽度和发射宽度、取指宽度要匹配否则前端再宽提交端也会成为瓶颈。但提交端并不是简单地把 ROB 头部的几条已完成指令一起放行。它需要同时检查中断、异常、调试事件、缓存一致性要求等等。有些指令即使执行完了也不能立刻提交比如 store 指令要等它真正写到缓存层级之后才能安全退役load 指令要等它所读的内存位置没有其他 store 在前面的冲突。因此你在现代 CPU 的性能计数器中经常能看到uops_retired和uops_issued两个数字。如果 issued 远大于 retired说明有很多指令因为预测错误白干了如果两者差距不大但 IPC 依然低那瓶颈很可能在依赖链或者缓存。提交端虽然是流水线最后一段但它的设计质量直接影响整机的稳定性。乱序执行把所有复杂度都揽到了自己身上而提交逻辑负责把这份复杂度藏起来还原成程序员看到的顺序模型。6. 现代超标量处理器里的关键取舍从学理到硅片6.1 ISA、软件设计与微架构的关系学到这里很多人会意识到一个问题指令集体系结构ISA是一套接口微架构是实现。ISA 规定了软件能看到什么比如寄存器数量、指令编码、内存模型微架构决定这些指令在每个周期里怎么被执行。ISA 一旦定了软件就可以在上面编译运行微架构则可以每两三年换一代只要还能执行同样的 ISA。这种“接口与实现分离”的思想和软件里的分层设计非常像。这也是为什么软件设计与体系结构经常被放在一起讨论的原因ISA 像软件的 ABI/API 契约微架构像被隐藏的内部实现。编译器不需要知道流水线有几级只需要遵守 ISA 规则微架构也不能随便改变 ISA 语义否则所有软件都会崩。但两者不是完全孤立。x86 能长盛不衰很大程度是因为微架构拼命把复杂指令翻译成内部简单 uop使得旧软件的二进制兼容性得到保留。而 RISC-V 这种新指令集设计时就直接考虑了现代超标量前端的译码需求指令尽量定长、编码尽量规整。指令集的设计风格真的会影响硬件要多花多少晶体管才能把它跑宽。6.2 功耗、面积和频率的三角博弈超标量宽度不是越大越好。每加宽一档物理寄存器文件、调度器、ROB、旁路网络Bypass Network的端口数和比较器数量都会增加。旁路网络尤其要命指令执行完结果要能直接转发给还在流水线里的其他指令而不是先写回寄存器再读。发射宽度越大旁路网络需要的多路选择器越多连线延迟越高最终直接拖慢时钟频率。这就是为什么高性能处理器的主频近几年很难再往上冲的原因之一。Apple 的 M1 系列把发射宽度做得很宽执行宽度超过 8但它牺牲了频率换来的是中低功耗下极高的 IPC。反过来很多服务器芯片追求高频率发射宽度相对保守通过更深的流水线和更高级的分支预测来弥补。没有标准答案只有针对应用场景的取舍。功耗方面乱序执行引擎是处理器里最耗电的模块之一。你可以看到移动处理器的核心设计往往采用“大核 小核”混合架构大核是超标量乱序执行追求峰值性能小核是简单的顺序流水线功耗极低。操作系统的调度器会根据任务负载在两者间切换。这已经超出了纯微架构的范畴变成系统级软硬件协同设计。6.3 实测中的体会IPC 不是你看到的 IPC最后分享一点我自己的实测心得。刚学超标量时我总以为 IPC 是衡量 CPU 微架构好坏的直接指标后来发现同样一个程序在 A 微架构上跑 IPC 3.0在 B 微架构上可能只有 2.0但 B 的主频比 A 高 50%最终两者性能几乎一样。更隐蔽的问题是IPC 会受指令混合、内存访问模式、分支行为、TLB 命中率等影响。一个纯加法循环任何处理器的 IPC 都能逼近理论峰值一个随机访问链表的程序IPC 甚至能跌到 1 以下。所以用perf stat看 IPC 时我建议配合看这几个计数器cycles、instructions、branch-misses、cache-misses、stalled-cycles-frontend和stalled-cycles-backend。如果前端停顿多问题在取指/译码/分支预测如果后端停顿多问题在调度器/功能单元/访存系统。我自己的调试流程一般是先看 IPC 是否远低于该微架构的典型值再分前端/后端定位最后针对瓶颈做代码调整。比如把高频分支改成查表、把随机访问改成顺序访问、把长依赖链拆成多条并行链。这些优化技巧本质上都是在迎合处理器内部的超标量结构。很多黑科技优化其实不是玄学而是你理解超标量之后自然而然能想到的。学习超标量处理器纸上谈兵很容易真正动手做一次实验才记得牢。我用 gem5 模拟器跑过一段乱序执行和顺序执行的对比分别统计 IPC 和功耗再拿真实 CPU 上的perf数据做对照收获比单纯看书大得多。如果你有条件我建议你也试试手画一遍数据通路标出每个部件的宽度、延迟、容量然后挑一个熟悉的 benchmark看看它到底卡在哪个环节。每一步做完你对“超标量”这三个字的理解都会比现在深一层。