前面的知识只是一个理论基础铺垫,下面我们就结合一款真实的CPU架构进行对应分析,图6和图7分别是x86和ARM体系结构的内核架构图(都是具有OoOE特性的CPU架构),可以看到他们基本的组成都是一样的(虽然x86是CISC而ARM是RISC,但是现代x86内部也是先把CISC翻译成RISC的),因此我在这里就只分析x86结构。

1.取指令阶段(IF)处理器在执行指令之前,必须先装载指令。指令会先保存在 L1 缓存的 I-cache (Instruction-cache)指令缓存当中,Nehalem 的指令拾取单元使用 128bit 带宽的通道从 I-cache 中读取指令。这个 I-cache 的大小为 32KB,采用了 4 路组相连,在后面的存取单元介绍中我们可以得知这种比 Core 更少的集合关联数量是为了降低延迟。
为了适应超线程技术,RIP(Relative Instruction Point,相对指令指针)的数量也从一个增加到了两个,每个线程单独使用一个。

指令拾取单元包含了分支预测器(Branch Predictor),分支预测是在 Pentium Pro 处理器开始加入的功能,预测如 if then 这样的语句的将来走向,提前读取相关的指令并执行的技术,可以明显地提升性能。指令拾取单元也包含了 Hardware Prefetcher,根据历史操作预先加载以后会用到的指令来提高性能,这会在后面得到详细的介绍。
当分支预测器决定了走向一个分支之后,它使用 BTB(Branch Target Buffer,分支目标缓冲区)来保存预测指令的地址。Nehalem 从以前的一级 BTB 升级到了两个级别,这是为了适应很大体积的程序(数据库以及 ERP 等应用,跳转分支将会跨过很大的区域并具有很多的分支)。Intel 并没有提及 BTB 详细的结构。与BTB 相对的 RSB(Return Stack Buffer,返回堆栈缓冲区)也得到了提升,RSB 用来保存一个函数或功能调用结束之后的返回地址,通过重命名的 RSB 来避免多次推测路径导致的入口/出口破坏。RSB 每个线程都有一个,一个核心就拥有两个,以适应超线程技术的存在。
指令拾取单元通过 128bit 的总线将指令从 L1 ICache 拾取到一个 16Bytes(刚好就是 128bit)的预解码拾取缓冲区。128 位的带宽让人有些迷惑不解,Opteron 一早就已经使用 了 256bit 的指令拾取带宽。最重要的是,L1D 和 L1I 都是通过 256bit 的带宽连接到 L2 Cache 的。
由于一般的CISC x86指令都小于4Bytes(32位x86指令;x86指令的特点就是不等长), 因此一次可以拾取 4 条以上的指令,而预解码拾取缓冲区的输出带宽是 6 指令每时钟周期, 因此可以看出指令拾取带宽确实有些不协调,特别是考虑到 64 位应用下指令会长一些的情 况下(解码器的输入输出能力是 4 指令每时钟周期,因此 32 位下问题不大)。
在将指令充填到可容纳 18 条目的指令队列之后,就可以进行解码工作了。解码是类 RISC (精简指令集或简单指令集)处理器导致的一项设计,从 Pentium Pro 开始在 IA 架构出现。 处理器接受的是 x86 指令(CISC 指令,复杂指令集),而在执行引擎内部执行的却不是x86 指令,而是一条一条的类 RISC 指令,Intel 称之为 Micro Operation——micro-op,或者写 为 μ-op,一般用比较方便的写法来替代掉希腊字母:u-op 或者 uop。相对地,一条一条的 x86 指令就称之为 Macro Operation或 macro-op。
RISC 架构的特点就是指令长度相等,执行时间恒定(通常为一个时钟周期),因此处理器设计起来就很简单,可以通过深长的流水线达到很高的频率,IBM 的 Power6 就可以轻松地达到 4.7GHz 的起步频率。和 RISC 相反,CISC 指令的长度不固定,执行时间也不固定,因此 Intel 的 RISC/CISC 混合处理器架构就要通过解码器 将 x86 指令翻译为 uop,从而获得 RISC 架构的长处,提升内部执行效率。
和 Core 一样,Nehalem 的解码器也是 4 个(3 个简单解码器加 1 个复杂解码器)。简单解码器可以将一条 x86 指令(包括大部分 SSE 指令在内)翻译为一条 uop,而复杂解码器则将一些特别的(单条)x86 指令翻译为 1~4 条 uops——在极少数的情况下,某些指令需要通过 额外的可编程 microcode 解码器解码为更多的 uops(有些时候甚至可以达到几百个,因为 一些 IA 指令很复杂,并且可以带有很多的前缀/修改量,当然这种情况很少见),下图 Complex Decoder 左方的 ucode 方块就是这个解码器,这个解码器可以通过一些途径进行升级或者扩展,实际上就是通过主板 Firmware 里面的 Microcode ROM 部分。
之所以具有两种解码器,是因为仍然是关于 RISC/CISC 的一个事实: 大部分情况下(90%) 的时间内处理器都在运行少数的指令,其余的时间则运行各式各样的复杂指令(不幸的是, 复杂就意味着较长的运行时间),RISC 就是将这些复杂的指令剔除掉,只留下最经常运行的指令(所谓的精简指令集),然而被剔除掉的那些指令虽然实现起来比较麻烦,却在某些领域确实有其价值,RISC 的做法就是将这些麻烦都交给软件,CISC 的做法则是像现在这样: 由硬件设计完成。因此 RISC 指令集对编译器要求很高,而 CISC 则很简单。对编程人员的要求也类似。


LSD 循环流监测器也算包含在解码部分,它的作用是: 假如程序使用的循环段(如 for..do/do..while 等)少于 28 个 uops,那么 Nehalem 就可以将这个循环保存起来,不再需要重新通过取指单元、分支预测操作,以及解码器,Core 2 的 LSD 放在解码器前方,因此无法省下解码的工作。
Nehalem LSD 的工作比较像 NetBurst 架构的 Trace Cache,其也是保存 uops,作用也是部分地去掉一些严重的循环,不过由于 Trace Cache 还同时担当着类似于 Core/Nehalem 架构的 Reorder Buffer 乱序缓冲区的作用,容量比较大(可以保存 12k uops,准确的大小 是 20KB),因此在 cache miss 的时候后果严重(特别是在 SMT 同步多线程之后,miss 率加 倍的情况下),LSD 的小数目设计显然会好得多。不过笔者认为 28 个 uop 条目有些少,特 别是考虑到 SMT 技术带来的两条线程都同时使用这个 LSD 的时候。
在 LSD 之后,Nehalem 将会进行 Micro-ops Fusion,这也是前端(The Front-End)的最后一个功能,在这些工作都做完之后,uops 就可以准备进入执行引擎了。
OoOE— Out-of-Order Execution 乱序执行也是在 Pentium Pro 开始引入的,它有些类似于多线程的概念。乱序执行是为了直接提升 ILP(Instruction Level Parallelism)指令级并行化的设计,在多个执行单元的超标量设计当中,一系列的执行单元可以同时运行一些没有数据关联性的若干指令,只有需要等待其他指令运算结果的数据会按照顺序执行,从而总体提升了运行效率。乱序执行引擎是一个很重要的部分,需要进行复杂的调度管理。
首先,在乱序执行架构中,不同的指令可能都会需要用到相同的通用寄存器(GPR,General Purpose Registers),特别是在指令需要改写该通用寄存器的情况下——为了让这些指令们能并行工作,处理器需要准备解决方法。一般的 RISC 架构准备了大量的GPR, 而x86 架构天生就缺乏 GPR(x86具有8个GPR,x86-64 具有 16 个,一般 RISC 具有 32 个,IA64 则具有 128 个),为此 Intel 开始引入重命名寄存器(Rename Register),不同的指令可以通过具有名字相同但实际不同的寄存器来解决。
此外,为了 SMT 同步多线程,这些寄存器还要准备双份,每个线程具有独立的一份。

乱序执行从Allocator定位器开始,Allocator 管理着RAT(Register Alias Table,寄存器别名表)、ROB(Re-Order Buffer,重排序缓冲区)和 RRF(Retirement Register File,退回寄存器文件)。在 Allocator 之前,流水线都是顺序执行的,在 Allocator 之后,就可以进入乱序执行阶段了。在每一个线程方面,Nehalem 和 Core 2 架构相似,RAT 将重命名的、虚拟的寄存器(称为 Architectural Register 或 Logical Register)指向ROB 或者RRF。RAT 是一式两份,每个线程独立,每个 RAT 包含了 128 个重命名寄存器。RAT 指向在 ROB 里面的最近的执行寄存器状态,或者指向RRF保存的最终的提交状态。
ROB(Re-Order Buffer,重排序缓冲区)是一个非常重要的部件,它是将乱序执行完毕的指令们按照程序编程的原始顺序重新排序的一个队列,以保证所有的指令都能够逻辑上实现正确的因果关系。打乱了次序的指令们(分支预测、硬件预取)依次插入这个队列,当一条指令通过 RAT 发往下一个阶段确实执行的时候这条指令(包括寄存器状态在内)将被加入 ROB 队列的一端,执行完毕的指令(包括寄存器状态)将从 ROB 队列的另一端移除(期间这些指令的数据可以被一些中间计算结果刷新),因为调度器是 In-Order 顺序的,这个队列(ROB)也就是顺序的。从 ROB 中移出一条指令就意味着指令执行完毕了,这个阶段叫做 Retire 回退,相应地 ROB 往往也叫做 Retirement Unit(回退单元),并将其画为流水线的最后一部分。
在一些超标量设计中,Retire 阶段会将 ROB 的数据写入 L1D 缓存(这是将MOB集成到ROB的情况),而在另一些设计里, 写入 L1D 缓存由另外的队列完成。例如,Core/Nehalem 的这个操作就由 MOB(Memory Order Buffer,内存重排序缓冲区)来完成。
ROB 是乱序执行引擎架构中都存在的一个缓冲区,重新排序指令的目的是将指令们的寄存器状态依次提交到RRF退回寄存器文件当中,以确保具有因果关系的指令们在乱序执行中可以得到正确的数据。从执行单元返回的数据会将先前由调度器加入ROB 的指令刷新数据部分并标志为结束(Finished),再经过其他检查通过后才能标志为完毕(Complete),一旦标志为完毕,它就可以提交数据并删除重命名项目并退出ROB 了。提交状态的工作由 Retirement Unit(回退单元)完成,它将确实完毕的指令包含的数据写入RRF(“确实” 的意思是,非猜测执性、具备正确因果关系,程序可以见到的最终的寄存器状态)。和 RAT 一样,RRF 也同时具有两个,每个线程独立。Core/Nehalem 的 Retirement Unit 回退单元每时钟周期可以执行 4 个 uops 的寄存器文件写入,和 RAT 每时钟 4 个 uops 的重命名一致。
由于 ROB 里面保存的指令数目是如此之大(128 条目),因此一些人认为它的作用是用来从中挑选出不相关的指令来进入执行单元,这多少是受到一些文档中的 Out-of-Order Window 乱序窗口这个词的影响(后面会看到ROB 会和 MOB 一起被计入乱序窗口资源中)。
ROB 确实具有 RS 的一部分相似的作用,不过,ROB 里面的指令是调度器(dispacher)通过 RAT发往 RS 的同时发往ROB的(里面包含着正常顺序的指令和猜测执行的指令,但是乱序执行并不是从ROB中乱序挑选的),也就是说,在“乱序”之前,ROB 的指令就已经确定了。指令并不是在 ROB 当中乱序挑选的(这是在RS当中进行),ROB 担当的是流水线的最终阶段: 一个指令的 Retire回退单元;以及担当中间计算结果的缓冲区。
RS(Reservation Station,中继站): 等待源数据到来以进行OoOE乱序执行(没有数据的指令将在 RS 等待), ROB(ReOrder Buffer,重排序缓冲区): 等待结果到达以进行 Retire 指令回退 (没有结果的指令将在 ROB等待)。
Nehalem 的 128 条目的 ROB 担当中间计算结果的缓冲区,它保存着猜测执行的指令及其数据,猜测执行允许预先执行方向未定的分支指令。在大部分情况下,猜测执行工作良好——分支猜对了,因此其在 ROB 里产生的结果被标志为已结束,可以立即地被后继指令使用而不需要进行 L1 Data Cache 的 Load 操作(这也是 ROB 的另一个重要用处,典型的 x86 应用中 Load 操作是如此频繁,达到了几乎占 1/3 的地步,因此 ROB 可以避免大量的Cache Load 操作,作用巨大)。在剩下的不幸的情况下,分支未能按照如期的情况进行,这时猜测的分支指令段将被清除,相应指令们的流水线阶段清空,对应的寄存器状态也就全都无效了,这种无效的寄存器状态不会也不能出现在 RRF 里面。
重命名技术并不是没有代价的,在获得前面所说的众多的优点之后,它令指令在发射的时候需要扫描额外的地方来寻找到正确的寄存器状态,不过总体来说这种代价是非常值得的。RAT可以在每一个时钟周期重命名 4 个 uops 的寄存器,经过重命名的指令在读取到正确的操作数并发射到统一的RS(Reservation Station,中继站,Intel 文档翻译为保留站点) 上。RS 中继站保存了所有等待执行的指令。
和 Core 2 相比,Nehalem 的 ROB 大小和 RS 大小都得到了提升,ROB 重排序缓冲区从 96 条目提升到 128 条目(鼻祖 Pentium Pro 具有 40 条),RS 中继站从 32 提升到 36(Pentium Pro 为 20),它们都在两个线程(超线程中的线程)内共享,不过采用了不同的策略:ROB 是采用了静态的分区方法,而 RS 则采用了动态共享,因为有时候会有一条线程内的指令因 等待数据而停滞,这时另一个线程就可以获得更多的 RS 资源。停滞的指令不会发往 RS,但是仍然会占用 ROB 条目。由于 ROB 是静态分区,因此在开启 HTT 的情况下,每一个线程只能 分到 64 条,不算多,在一些极少数的应用上,我们应该可以观察到一些应用开启 HTT 后会 速度降低,尽管可能非常微小。

36 条目的中继站指令在分发器的管理下,挑选出尽量多的可以同时执行的指令(也就是乱序执行的意思)——最多 6 条——发送到执行端口。 这些执行端口并不都是用于计算,实际上,有三个执行端口是专门用来执行内存相关的操作的,只有剩下的三个是计算端口,因此,在这一点上 Nehalem 实际上是跟 Core 架构一 样的,这也可以解释为什么有些情况下,Nehalem 和 Core 相比没有什么性能提升。
计算操作分为两种: 使用 ALU(Arithmetic Logic Unit,算术逻辑单元)的整数(Integer) 运算和使用 FPU(Floating Point Unit,浮点运算单元)的浮点(Floating Point)运算。SSE 指令(包括 SSE1 到 SSE4)是一种特例,它虽然有整数也有浮点,然而它们使用的都是 128bit 浮点寄存器,使用的也大部分是 FPU 电路。在 Nehalem 中,三个计算端口都可以做整数运算(包括 MMX)或者SSE 运算(浮点运算不太一样,只有两个端口可以进行浮点 ADD 和 MUL/DIV 运算,因此每时钟周期最多进行 2 个浮点计算,这也是目前 Intel 处理器浮点性能不如整数性能突出的原因),不过每一个执行端口都不是完全一致:只有端口 0 有浮点乘和除功能,只有端口 5 有分支能力(这个执行单元将会与分支预测单元连接),其他 FP/SSE 能力也不尽相同,这些不对称之处都由统一的分发器来理解,并进行指令的调度管理。没有采用完全对称的设计可能是基于统计学上的考虑。和 Core 一样,Nehalem 的也没有采用 Pentium 4 那样的 2 倍频的 ALU 设计(在 Pentium 4,ALU 的运算频率是 CPU 主频的两倍, 因此整数性能明显要比浮点性能突出)。
不幸的是,虽然可以同时执行的指令很多,然而在流水线架构当中运行速度并不是由最 “宽”的单元来决定的,而是由最“窄”的单元来决定的。这就是木桶原理,Opteron的解码器后端只能每时钟周期输出 3 条 uops,而 Nehalem/Core2 则能输出 4 条,因此它们的实际最大每时钟运行指令数是 3/4,而不是 6。同样地,多少路超标量在这些乱序架构处理器中也不再按照运算单元来划分,Core Duo 及之前(到 Pentium Pro 为止)均为三路超标量处理器,Core 2/Nehalem 则为四路超标量处理器。可见在微架构上,Nehalem/Core 显然是 要比其他处理器快一些。顺便说一下,这也是 Intel 在超线程示意图中,使用 4 个宽度的方 块来表示而不是 6 个方块的原因。
运算需要用到数据,也会生成数据,这些数据存取操作就是存取单元所做的事情,实际 上,Nehalem 和 Core 的存取单元没什么变化,仍然是 3 个。
这三个存取单元中,一个用于所有的 Load 操作(地址和数据),一个用于 Store 地址,一个用于 Store 数据,前两个数据相关的单元带有 AGU(Address Generation Unit,地址生成单元)功能(NetBurst架构使用快速 ALU 来进行地址生成)。



内存数据相依性预测功能(Memory Disambiguation)可以预测哪些指令是具有依赖性的或者使用相关的地址(地址混淆,Alias),从而决定哪些 Load/Store 指令是可以提前的, 哪些是不可以提前的。可以提前的指令在其后继指令需要数据之前就开始执行、读取数据到ROB当中,这样后继指令就可以直接从中使用数据,从而避免访问了无法提前 Load/Store 时访问 L1 缓存带来的延迟(3~4 个时钟周期)。
不过,为了要判断一个 Load 指令所操作的地址没有问题,缓存系统需要检查处于 in-flight 状态(处理器流水线中所有未执行的指令)的 Store 操作,这是一个颇耗费资源的过程。在 NetBurst 微架构中,通过把一条 Store 指令分解为两个 uops——一个用于计算地址、一个用于真正的存储数据,这种方式可以提前预知 Store 指令所操作的地址,初步的解决了数据相依性问题。在 NetBurst 微架构中,Load/Store 乱序操作的算法遵循以下几条 原则:
这种原则下的问题也很明显,比如第一条原则会在一条处于等待状态的 Store 指令所操作的地址未确定之前,就延迟所有的 Load 操作,显然过于保守了。实际上,地址冲突问题是极少发生的。根据某些机构的研究,在一个Alpha EV6 处理器中最多可以允许 512 条指令处于 in-flight 状态,但是其中的 97%以上的 Load 和 Store 指令都不会存在地址冲突问题。
基于这种理念,Core 微架构采用了大胆的做法,它令 Load 指令总是提前进行,除非新加入的动态混淆预测器(Dynamic Alias Predictor)预测到了该 Load 指令不能被移动到 Store 指令附近。这个预测是根据历史行为来进行的,据说准确率超过 90%。
在执行了预 Load 之后,一个冲突监测器会扫描 MOB 的 Store 队列,检查该是否有Store操作与该 Load 冲突。在很不幸的情况下(1%~2%),发现了冲突,那么该 Load 操作作废、 流水线清除并重新进行 Load 操作。这样大约会损失 20 个时钟周期的时间,然而从整体上看, Core 微架构的激进 Load/Store 乱序策略确实很有效地提升了性能,因为Load 操作占据了通常程序的 1/3 左右,并且 Load 操作可能会导致巨大的延迟(在命中的情况下,Core 的 L1D Cache 延迟为 3 个时钟周期,Nehalem 则为 4 个。L1 未命中时则会访问 L2 缓存,一般为 10~12 个时钟周期。访问 L3 通常需要 30~40 个时钟周期,访问主内存则可以达到最多约 100 个时钟周期)。Store 操作并不重要,什么时候写入到 L1 乃至主内存并不会影响到执行性能。

如上图所示,我们需要载入地址 X 的数据,加 1 之后保存结果;载入地址 Y 的数据,加1 之后保存结果;载入地址 Z 的数据,加 1 之后保存结果。如果根据 Netburst 的基本准则, 在第三条指令未决定要存储在什么地址之前,处理器是不能移动第四条指令和第七条指令的。实际上,它们之间并没有依赖性。因此,Core 微架构中则“大胆”的将第四条指令和第七条指令分别移动到第二和第三指令的并行位置,这种行为是基于一定的猜测的基础上的“投机”行为,如果猜测的对的话(几率在 90%以上),完成所有的运算只要5个周期,相比之前的9个周期几乎快了一倍。
和为了顺序提交到寄存器而需要 ROB 重排序缓冲区的存在一样,在乱序架构中,多个打乱了顺序的 Load 操作和Store操作也需要按顺序提交到内存,MOB(Memory Reorder Buffer, 内存重排序缓冲区)就是起到这样一个作用的重排序缓冲区(介于 Load/Store 单元 与 L1D Cache 之间的部件,有时候也称之为LSQ),MOB 通过一个 128bit 位宽的 Load 通道与一个 128bit 位宽的 Store 通道与双口 L1D Cache 通信。和 ROB 一样,MOB的内容按照 Load/Store 指令实际的顺序加入队列的一端,按照提交到 L1 DCache 的顺序从队列的另一端移除。ROB 和 MOB 一起实际上形成了一个分布式的 Order Buffer 结构,有些处理器上只存在 ROB,兼备了 MOB 的功能(把MOB看做ROB的一部分可能更好理解)。
和ROB 一样,Load/Store 单元的乱序存取操作会在 MOB 中按照原始程序顺序排列,以提供正确的数据,内存数据依赖性检测功能也在里面实现(内存数据依赖性的检测比指令寄存器间的依赖性检测要复杂的多)。MOB 的 Load/Store 操作结果也会直接反映到 ROB当中(中间结果)。
MOB还附带了数据预取(Data Prefetch)功能,它会猜测未来指令会使用到的数据,并预先从L1D Cache 缓存 Load入MOB 中(Data Prefetcher 也会对 L2 至系统内存的数据进行这样的操作), 这样 MOB 当中的数据有些在 ROB 中是不存在的(这有些像 ROB 当中的 Speculative Execution 猜测执行,MOB 当中也存在着“Speculative Load Execution 猜测载入”,只不过失败的猜测执行会导致管线停顿,而失败的猜测载入仅仅会影响到性能,然而前端时间发生的Meltdown漏洞却造成了严重的安全问题)。MOB包括了Load Buffers和Store Buffers。
乱序执行中我们可以看到很多缓冲区性质的东西: RAT 寄存器别名表、ROB 重排序缓冲 区、RS 中继站、MOB 内存重排序缓冲区(包括 load buffer 载入缓冲和 store buffer 存储缓冲)。在超线程的作 用下,RAT是一式两份,包含了 128 个重命名寄存器; 128 条目的 ROB、48 条目的 LB 和 32 条目的 SB 都 每个线程 64 个 ROB、24 个 LB 和 16 个 SB; RS 则是在两个线程中动态共享。可见,虽然整体数量增加了,然而就单个线程而言,获得的资源并没有 提升。这会影响到 HTT 下单线程下的性能。