Skip to the content.

第 1 章 超标量处理器概览

全书目录 · 本章学习辅助

书内第 1–16 页;PDF 第 13–28 页。原书疑点见校注。

1.1 为什么需要超标量

如何才能让一个程序执行得更快?在计算机领域有这样一个经典的公式来计算一个程序的执行时间,如图 1.1 所示。

图 1.1 衡量一个程序执行时间的公式

图 1.1 衡量一个程序执行时间的公式

图中公式转写:

\[\mathrm{Total\ Instructions}\times\frac{\mathrm{Cycles}}{\mathrm{Instructions}}\times\frac{\mathrm{Seconds}}{\mathrm{Cycle}}\]

从上面的式子可以看出,有三个因素影响了程序的执行时间,即 Total Instructions、 Cycles/Instructions 和 Seconds/Cycle, 其中,Total Instructions 表示程序总共需要执行的指令个数,Cycles/Instructions 表示执行每条指令所需要的周期数,简称为 CPI(Cycles Per Instruction),Seconds/Cycle 表示每周期需要的时间。 通过这三个因素可以看出,要加快处理器执行程序的速度,可以采取的策略有如下几种。

(1) 减少程序中指令的数量,这个取决于程序本身所要完成的工作量、实现某个功能所选择的算法、编译器强大与否,甚至是指令集是否会对某些特殊的功能有扩展,例如某些需要多条普通指令才能完成的功能,如果使用扩展的特殊指令集,可以大大减少指令的数量, 例如 x86 指令集中的多媒体指令等,甚至它依赖于程序员的功力,一个优秀的程序员需要知道如何编写出更快的程序。 但是,对于那些已经编写好的固定程序,这个变量就变成定值了。

(2) 减少每条指令在处理器中执行所需要的周期数,也就是减少 CPI, 这也意味着增加 IPC(Instructions Per Cycle),其中,CPI 和 IPC 是互为倒数的,从名字就可以看出来,CPI 表示处理器执行一条指令需要的周期数,而 IPC 则表示处理器在一个周期内可以执行的指令个数。 对于非流水线的处理器,需要多个周期才能执行一条指令,而对于普通的流水线处理器,每周期最多执行一条指令,因此 IPC 最大也就是 1 了,要想每周期执行多于一条的指令,就需要使用超标量 (Superscalar) 处理器。 什么是超标量处理器呢?简单来说,超标量处理器每周期可以执行多于一条的指令,这样可以大大提高 IPC 的值,当然,并不是每周期执行的指令个数多于一条的处理器都是超标量处理器,在 VLIW 结构的处理器中,每周期也可以执行多于一条的指令,但是它和超标量处理器在本质上是有差别的,处理器采用何种架构进行实现也称为微架构 (Microarchitecture), 它直接影响了处理器的性能,本书重点关注的内容是超标量处理器。

(3) 减少处理器的周期时间 (cycle time),一个频率为 2GHz 的处理器,它的周期时间一定比一个频率为 1GHz 的处理器要小,在进行处理器的微架构设计时,可以通过精巧的电路设计、更深的流水线来减少处理器的周期时间;在现代处理器的设计中,优秀的 EDA 工具也可以帮助减少处理器的周期时间;除此之外,还有一个更关键的影响因素,那就是硅工艺,一个在 90nm 的硅工艺下很难达到 1GHz 频率的处理器,到了 45nm 的硅工艺时,就可以很容易地达到这个速度了,硅工艺的进步能够制造出更快更省电的处理器,但是这个内容并不是本书所讨论的范围。

从上面给出的影响程序执行时间的三个要素可以看出,在程序一定的情况下,要想更快地执行它,需要处理器有比较大的 IPC, 以及更快的运行频率。 但是在实际当中,这两个因素是互相制约,此消彼长的关系,更大的 IPC 要求每周期执行更多条指令,这样设计复杂度就会急剧上升,导致处理器的周期时间很难降低下来;相反,很小的周期时间很难容下复杂的逻辑设计,虽然可以通过更深的流水线来获得小的周期时间,但是却导致处理器在各种预测失败时,例如分支预测失败 (mis-prediction),有更大的惩罚 (penalty),严重地降低了处理器的 IPC, 并增大了功耗,造成 “高频低能”的后果,一个著名的例子就是 Intel 的 Pentium 4 系列处理器和 Pentium 3 系列处理器,Pentium 4 系列处理器虽然有着更快的主频,但是由于其流水线太深,导致同样主频下,Pentium 4 处理器的执行效率小于 Pentium Ⅲ 系列处理器。

硅工艺尺寸的不断缩小是处理器设计者最希望看到的事情,使提高 IPC 的同时尽量降低对周期时间的影响成为了可能,超标量处理器就是为了提高 IPC 而产生的,在超标量处理器中,每周期可以从 I-Cache 中取出 n 条指令送到处理器的流水线中,处理器在每周期内也最少可以同时执行 n 条指令,这称为 n-way 的超标量处理器,现代的高性能处理器大多使用了超标量的结构,不管是在桌面领域还是在移动领域,无一例外。 在处理器的发展史中,每周期可以执行多于一条指令的处理器并非超标量处理器这一种,超长指令字 (Very Long Instruction Word,VLIW) 也是一种每周期可以执行多条指令的处理器架构,但是这两种架构有着本质的区别,超标量处理器是靠硬件自身来决定哪些指令可以并行地执行,而 VLIW 处理器则是依靠编译器和程序员自身来决定哪些指令可以并行执行,对于通用处理器来说,超标量结构是必需的,程序员可以抛开底层硬件的实现细节,专注于软件本身的功能,而且这个程序可以运行在任何支持该指令集的处理器上,这是通用处理器所必须具有的特性,而 VLIW 处理器则无法实现这个功能,但是由于需要编译器和程序员自身来调度指令的执行顺序,这种处理器在硬件实现上是很简单的,在功能比较专一的专用处理器领域可以大有一番作为,例如 DSP 处理器。

正是由于超标量处理器可以获得比较高的 IPC, 因此面向通用领域的高性能处理器都采用了这种结构,但是由于超标量处理器天生的复杂性,并没有一套放之四海而皆准的设计准则,很多时候需要处理器设计师根据实际的需求做出各种的折中 (tradeoff),例如实现一个精确的分支预测算法需要复杂的硬件资源,这就会导致其无法在一个周期内完成,而分支预测如果不能够在一个周期给出预测的结果,那么处理器就无法利用预测信息进行连续的取指令,这样处理器的性能就会下降。 再例如 load/store 指令如果按照完全乱序的方式执行,虽然会获得最大的 IPC, 却会导致 load/store 指令之间的相关性检查变得非常复杂,而且一旦发现相关性违例,还需要复杂的恢复机制,这样增加了硬件复杂度和功耗。 在超标量处理器中,这样矛盾的方面还有很多,例如 Checkpoint 的个数和硬件的面积,发射队列 (issue queue) 的个数和仲裁 (select) 电路的复杂度,每周期可以同时执行的指令个数 (issue width) 和寄存器堆 (register file) 的端口个数等,要想获得更好的性能,就需要更复杂的设计和更多的硬件资源,由此带来了更高的成本和功耗,在现实世界的处理器中,需要根据实际的需求,做出相应的折中方案。

本书将按照超标量处理器的流水线对各个部件进行讲解,并给出如何在设计中做出折中的一些思路,由于超标量处理器并没有一套放之四海而皆准的设计原则,更多的时候是需要根据处理器的应用领域和场合来决定设计的思路,例如面向服务器和桌面 PC 领域的处理器在设计的时候可以将性能放在第一位,而面向移动领域的处理器,由于需要使用电池来供电,则需要在保证一定的性能情况下,尽量降低设计的复杂度,这样才能获得比较低的功耗。

1.2 普通处理器的流水线1

1.2.1 流水线概述

流水线是现代处理器获得高性能的重要法宝,通过流水线可以降低处理器的周期时间 (cycle time), 从而获得更快的执行频率,如图 1.2 所示。

图 1.2 使用流水线后,周期时间的变化

图 1.2 使用流水线后,周期时间的变化

当处理器没有使用流水线时,它的周期时间是 $D$,也就是频率为 $1/D$;在使用了 $n$ 级流水线之后,周期时间变为了“$D/n+S$”,其中 $S$ 表示流水线寄存器的延迟,加入流水线之后频率变为“$1/(D/n+S)$”,并且,此处暂时假设,处理器的性能和处理器的频率直接相关(注意,在现实世界的处理器中,频率并不是决定处理器性能的唯一因素),此时就可以使用处理器的频率来表达性能。

\[\mathrm{Performance}=1/(D/n+S)\]

同时,在加入流水线之后,处理器所消耗的硬件也有了变化,如图 1.3 所示。

图 1.3 使用流水线后,面积的变化

图 1.3 使用流水线后,面积的变化

当处理器没有使用流水线时,需要消耗的硬件面积是 $G$;当使用了 $n$ 级流水线之后,消耗的硬件可以表示为“$G+n\times L$”,其中 $L$ 表示每个流水线寄存器及其附带的控制逻辑所消耗的硬件面积,这样,在加入 $n$ 级流水线之后,处理器总共消耗的硬件面积可以表示为:

\[\mathrm{Cost}=G+n\times L\]

对于处理器来说,当然希望 Cost/Performance 尽可能得小,也就是获得同样的性能,消耗的硬件面积越小越好,这可以用公式表示为:

\[\begin{aligned} \mathrm{Cost}/\mathrm{Performance} &=(G+n\times L)/(1/(D/n+S))\\ &=(G+n\times L)\times(D/n+S)\\ &=GD/n+SL\times n+LD+GS \end{aligned}\]

上式中,$G$ 表示不加入流水线时的硬件面积,$L$ 表示流水线寄存器的硬件面积,$D$ 表示不加入流水线时的电路延迟,$S$ 表示流水线寄存器的电路延迟,在 $G$、$D$、$L$ 和 $S$ 都一定的情况下,要找出一个合适的流水线级数 $n$,使 Cost/Performance 获得最小值,通过观察上式可以看出,Cost/Performance 的表达式是典型的“$x+a/x$”类型的函数,它的曲线如图 1.4 所示。

图 1.4 x+a/x 函数的曲线

图 1.4 $x+a/x$ 函数的曲线

求这个函数最低点的方法有很多种,例如可以对其求导数,其过程如下。

\[\frac{\mathrm{d}}{\mathrm{d}n}\left\{\frac{GD}{n}+SL\times n+LD+GS\right\} =SL-\frac{GD}{n^2}+0+0 \xrightarrow{\mathrm{yields}}n_{\mathrm{opt}}=\sqrt{\frac{GD}{LS}}\]

通过上面的式子可以看出,当 $G$、$D$、$L$ 和 $S$ 都已经知道的情况下,最终可以得到最优化的流水线级数 $n$,当然,这只是一个理论上的值,在现实世界当中,很难直接估计出 $G$ 和 $D$ 的值,而且,处理器的频率也不是表示性能的唯一因素,还有很多其他的因素,例如分支预测的准确度、load/store 指令的处理方式等内容都会影响处理器的性能。而且,不同的处理器有不同的定位,有些处理器面向高性能计算,此时处理器的硬件面积就是次要考虑的,主要应该考虑性能;有些处理器面向低功耗的嵌入式应用,此时处理器的面积就是首要考虑的,因此现实当中的处理器不一定追求最优的 Cost/Performance 值,而是根据实际需求来决定流水线的级数。

1.2.2 流水线的划分

在理想情况下,流水线的划分需要满足下面几个条件。

(1)流水线中每个阶段所需要的时间都是近似相等的,当然在实际的设计当中很难精确地达到每个流水段的时间都一样,只能是近似地接近这个结果,最长的流水段所需要的时间决定了整个处理器的周期时间。

(2)流水线中每个阶段的操作都会被重复地执行,因为处理器支持的指令类型有很多种,不同的指令类型需要的操作也是不同的,因此这一条在现实当中也很难满足,不过这并不影响流水线的执行,例如算术运算类型的指令不需要访问存储器,则它只需要在流水线的访问存储器阶段什么都不做就可以了。

(3)流水线中每个阶段的操作都和其他流水段相互独立、互不相干,在现实世界的处理器中,因为指令之间存在各种的相关性,例如先写后读(RAW)相关性,导致流水线的各个阶段之间存在着复杂的关系,因此这一条是最难满足的,也是影响流水线执行效率的关键因素。

不同的指令集,流水线实现的难易也是不同的,对于复杂的 CISC 指令集,如 x86,指令长度不等,并且执行的时间也不等,所以直接实现流水线是比较复杂的;而对于一般的精简指令集(RISC)来说,如 MIPS 和 ARM,由于指令的长度相等,并且每条指令所完成的任务比较规整,所以容易使用流水线来实现,可以说,RISC 指令集天生就是为流水线而存在的,一个典型 RISC 处理器的流水线如图 1.5 所示2。

图 1.5 经典 RISC 处理器的流水线

图 1.5 经典 RISC 处理器的流水线

图 1.5 中,流水线的每个阶段完成的任务如表 1.1 所示。

表 1.1 在五级流水线中,每个阶段完成的任务

流水段 完成的任务
Fetch 取指令,使用 PC 寄存器的值作为地址,从 I-Cache 中取出指令,并将指令存储在指令寄存器中
Decode & Regfile read 将指令进行解码,并根据解码出的值来读取寄存器堆(register file),得到指令的源操作数
Execute 根据指令的类型,完成计算任务,例如对算术类型的指令完成算术运算,对访问存储器类型的指令完成地址的计算等
Memory 访问 D-Cache,只对访问存储器类型的指令(主要是 load/store 指令)起作用,其他类型的指令在这个流水段不会做任何事情
Write back 如果指令存在目的寄存器,则将指令最终的结果写到目的寄存器中

表 1.1 原始扫描

图 1.5 所示的流水线实际上是在经典的 MIPS 处理器中使用的,关于这种流水线的详细说明,可以阅读参考文献 2 中的内容,此处不再对这个流水线做详细的说明。

但是,上面的这种流水线未必是最优化的,因为每个阶段所需要的时间相差很多,如图 1.6 所示表示了一种可能的情况。

图 1.6 流水线中各个阶段的时间分布——一种可能的情况

图 1.6 流水线中各个阶段的时间分布——一种可能的情况

当这个处理器不使用流水线时,处理器的周期时间可以近似地表示为(暂且不考虑流水线寄存器所带来的影响):

\[T=T_{\mathrm{IF}}+T_{\mathrm{ID}}+T_{\mathrm{EX}}+T_{\mathrm{MEM}}+T_{\mathrm{WB}}=36\mathrm{ns}\]

在使用流水线之后,周期时间可以近似地表示为:

\[T=\max\{T_{\mathrm{IF}},T_{\mathrm{ID}},T_{\mathrm{EX}},T_{\mathrm{MEM}},T_{\mathrm{WB}}\}=11\mathrm{ns}\]

由此可见,使用图 1.6 的流水线可以使处理器的速度提高 36/11 倍,但是上面的流水线中,各个阶段所占用的时间是很不平衡的,要想获得更优化的设计,需要对流水线中的各个阶段进行平衡,有两种方法可以进行这个工作。

方法一:合,将两个或者多个流水段合并成一个流水段,使用这种方法得到的流水线如图 1.7 所示。

图 1.7 将流水线进行合并

图 1.7 将流水线进行合并

这种方法将原始流水线中的解码(Decode)部分(假设占用 3ns)和取指令(Fetch)阶段合并成一个流水段,占用 7+3=10ns;将读取寄存器堆(Regfile Read)和执行(Execute)两部分任务合并在一起形成一个新的流水段,占用 8+5=13ns;将访问存储器(Memory)和写回寄存器(Write Back)这两部分合并在一起形成一个流水段,占用 10+3=13ns。通过这种“合”的方法,使流水线从五级降到了三级,每个流水段所占据的时间也比较均衡,此时处理器的周期时间是 13ns,这种方法适用于对性能要求不高的低功耗嵌入式处理器,如 ARM 公司的 ARM7、ARM9,以及 Cortex-M0、M3 等处理器。而现代的高性能处理器都追求比较高的运行频率,“合”的方法显然不适合这种潮流,因此现代的高性能处理器都使用下面的方法。

方法二:拆,将流水线的一个阶段拆成多个更小的阶段,使用这种方法得到的流水线如图 1.8 所示。

这种方法将占用时间比较长的流水段进行了拆分,在图 1.8 所示中,将流水线的取指令(Fetch)阶段拆分成了两个流水段,每个流水段占据 3.5ns(暂不考虑流水线寄存器的影响);将读取寄存器堆(Regfile Read)的阶段拆分成了三个流水段,每个流水段占据 2.7ns;将流水线的执行(Execute)阶段拆分成了两个流水段,每个流水段占据 2.5ns;将访问存储器(Memory)阶段拆分成了三个流水段,每个流水段占据 3.3ns。使用这种“拆”的方法得到的处理器周期时间是 3.5ns。这种方法适合高性能处理器,可以获得比较高的主频,但是这种比较深的流水线会导致硬件消耗的增大,例如需要更多的流水线寄存器和控制逻辑;寄存器堆的端口数也需要增加,以支持多个流水段同时读写;存储器(例如 D-Cache)的端口数量也需要随之增加,用来支持更多的流水段同时访问存储器。随着芯片面积的增大和主频的提高,处理器的功耗也会随之增大,制约了这种方法的使用,而且,深流水线对于分支预测的影响也是非常大的,会导致预测失败时的惩罚(mis-prediction penalty)增大,从而影响处理器的执行效率,因此这个方法并非可以无限制地使用下去,Intel 的 Pentium 4 处理器就是一个例子3,Intel 本来希望通过增加流水线的深度,并随着工艺的进步而获得更快的处理器,但是居高不下的功耗,以及过低的执行效率(分支预测失败时的惩罚过大)导致这种方法没有办法继续下去,最后 Pentium 4 处理器给人们留下了“高频低能”的印象,惨淡收场,退出了历史的舞台。

图 1.8 将流水线进行拆分

图 1.8 将流水线进行拆分

但是,在一定的范围内,“拆”的方法还是可以很明显地提高处理器性能的,如图 1.9 所示给出了 MIPS R3000 和 AMD 某款处理器的流水线对比。

图 1.9 MIPS R3000 和 AMD AHL470V/7 处理器的流水线对比

图 1.9 MIPS R3000 和 AMD AHL470V/7 处理器的流水线对比

MIPS R3000 将取指令(IF)和解码(ID)两部分内容都放在了流水线的 IF 阶段,然后在流水线的 RD 阶段读取寄存器的内容,在 EX 阶段执行算术运算,在 MEM 阶段访问存储器,在 WB 阶段将结果写回寄存器,因此这是一个五级的流水线;而在 AMD AHL470V/7 处理器的流水线中,很明显使用了“拆”的方法,将流水线中取指令的 IF 阶段拆成了三个流水段,将读取操作数的 OF 阶段拆成了四个流水段,将执行(EX)和保存结果(OS)两个阶段都拆成了两个流水段,这样处理器总共被拆成了十二级的流水线,很显然,这个处理器要比 MIPS R3000 有着更小的周期时间,也就是更高的频率,在性能方面 AMD 的这款处理器也是占据优势的。虽然流水线太深时,处理器的功耗和执行效率都会不尽如人意,但是在一定范围内增加处理器的流水线深度,仍旧是可以提高性能的,上面的两个处理器就是例证。

1.2.3 指令间的相关性

在继续讲述超标量处理器之前,首先需要了解指令之间存在的相关性,它是阻碍程序并行执行的关键因素,概括来讲,指令之间存在三种类型的相关性。

(1)先写后读(Read After Write, RAW),也称为 true dependence,一条指令的操作数如果来自之前指令的结果,那么这条指令必须等到之前的指令得到了结果,才可以继续执行,这种类型的相关性是无法回避的,举例如下。

指令 A: R1 = R2 + R3
指令 B: R5 = R1 + R4

原图中的相关性箭头

在上面的指令中,指令 B 的一个操作数 R1 来自于指令 A 的结果,所以就必须等待指令 A 将结果计算出来,指令 B 才可以继续执行。

(2)先读后写(Write After Read, WAR),也称为 anti-dependence,一条指令要将结果写到某个寄存器中,但是这个寄存器还在被其他指令读取,所以不能够马上写入,举例如下。

指令 A: R1 = R2 + R3
指令 B: R2 = R5 + R4

原图中的相关性箭头

在上面的指令中,指令 A 读取寄存器 R2 之前,指令 B 不能够将结果写到寄存器 R2 中,这种类型的相关性是可以避免的,只要将指令 B 的结果写到其他寄存器就可以了。

(3)先写后写(Write After Write, WAW),也称为 output dependence,如果两条指令都要将结果写到同一个寄存器中,那么后面的指令必须等到前面的指令写完之后,自己才能执行写操作,举例如下。

指令 A: R1 = R2 + R3
指令 B: R1 = R5 + R4

原图中的相关性箭头

在上面的指令中,指令 A 和指令 B 都将结果写到目的寄存器 R1 中,指令 B 应该在指令 A 之后进行写入,这种类型的相关性也是可以避免的,只要后续的指令 B 将结果写到其他寄存器就可以了。

还有一种类型的相关性称为控制相关性(control dependence),它是由于分支指令引起的,只有当分支指令的结果被计算出来的时候,才可以知道从哪里取得后续的指令来执行,由于分支指令需要一段时间才可以得到结果(跳转或者不跳转、跳转的目标地址),在这段时间内只能按照预测的方式取指令,这些内容在后文中会进行详细介绍。

如图 1.10 所示为一段 MIPS 的汇编程序,其中存在着上述的四种相关性,其实在一般的程序中,它们都是经常出现的。

图 1.10 一段典型的 MIPS 汇编程序

图 1.10 一段典型的 MIPS 汇编程序

图中汇编转写(标签拼写与操作数顺序按原图保留,见校注):

    mul   r8,     r9,  r4
    addu  r3,     r6,  r8
    lw    r4,     0(r3)
    addu  r8,     r6,  r13
    bge   LABEL1  r8,  r10
    addu  r10,    r10, 1
    ...
LABLE1:
    addu  r11,    r11, -1
    ...

WAR、RAW 和 WAW 这三种相关性不仅对寄存器之间的关系适用,对于存储器地址之间的关系也是适用的,只不过寄存器之间的相关性可以通过指令直接表现出来,而存储器地址之间的相关性则很难直接看出来,例如下面的两条访问存储器的指令。

sw r1,0(r5)    //将寄存器 r1 的值保存到 MEM[r5]中
lw r2,0(r6)    //将 MEM[r6]的值读取到寄存器 r2 中

通过直接观察,很难分辨这两条指令之间是否存在相关性,事实上,当寄存器 r5 中的值和寄存器 r6 中的值相等时,这两条指令之间存在 RAW 的相关性,即 load 指令需要的操作数来自于 store 指令的结果,这种类型的相关性比较隐蔽,需要将 load/store 指令所携带的地址计算出来才可以判别。

指令之间存在的各种相关性,使它们在处理器中无法完全地乱序执行,在普通的处理器中,例如前面讲过的 MIPS R3000 处理器,由于每周期只能执行一条指令,并且写寄存器和写存储器都发生在统一的时刻,它们都处于流水线的后面,所以在这种流水线中,WAW 和 WAR 这两种相关性并不会引起问题,而 RAW 相关性则可以通过旁路(bypass)的方式来解决;而对于超标量处理器来说,WAW、WAR 和 RAW 这三种相关性都会阻碍指令的乱序执行,都需要在流水线中进行特殊的处理,这些内容将在后文中逐一详细介绍。

1.3 超标量处理器的流水线

简单来说,如果一个处理器每周期可以取出多于一条的指令送到流水线中执行,并且使用硬件来对指令进行调度,那么这个处理器就可以称为超标量处理器了,从本质上来讲,超标量处理器有两种方式可以执行指令,顺序执行(in-order)和乱序执行(out-of-order)。如表 1.2 所示给出了这两种方式的特点。

表 1.2 顺序执行和乱序执行两种处理器的特点

  Frontend Issue Write back Commit
In-Order Superscalar in-order in-order in-order in-order
Out-of-Order Superscalar in-order out-of-order out-of-order in-order

表 1.2 原始扫描

在这个表格中,Frontend 表示流水线中的取指令(Fetch)和解码(Decode)阶段,这两个阶段很难(事实上也没有意义)实现乱序执行;Issue 表示将指令送到对应的功能单元(Function Unit, FU)中执行,这里可以实现乱序执行,因为只要指令的源操作数准备好了,就可以将其先于其他指令而执行;Write back 表示将指令的结果写到目的寄存器中,可以在处理器内部使用寄存器重命名,将指令集中定义的逻辑寄存器(Architecture Register File, ARF)动态地转化为芯片内部实际使用的物理寄存器(Physical Register File, PRF),从而实现乱序方式的写回寄存器;Commit 表示一条指令被允许更改处理器的状态(Architecture state,例如 D-Cache 等),为了保证程序按照原来的意图得到执行,并且实现精确的异常,这个阶段需要顺序执行,这样才能够保证从处理器外部看起来,程序是串行执行的。表 1.2 中的四个阶段是超标量处理器的流水线中比较关键的部分,在后文中都会进行详细的说明。

1.3.1 顺序执行

在顺序执行(in-order)的超标量处理器中,指令的执行必须遵循程序中指定的顺序,这种类型处理器的流水线可以概括地用图 1.11 来表示4。

图 1.11 顺序执行的超标量处理器的流水线

图 1.11 顺序执行的超标量处理器的流水线

假设如图 1.11 所示的流水线每周期可以从 I-Cache 中取出两条指令来执行,则称为 2-way 的超标量处理器,在指令经过解码之后,需要根据自身的类型,将两条指令送到对应的 FU 中执行,这个过程称为发射(Issue)。如果将发射的过程放到指令的解码阶段,会严重影响处理器的周期时间,因此将发射的过程单独使用一个流水段,在这个阶段,指令会读取寄存器而得到操作数,同时根据指令的类型,将指令送到对应的 FU 中进行执行。在执行阶段使用了三个 FU:第一个 FU 用来执行 ALU 类型的指令,第二个 FU 用来执行访问存储器类型的指令,第三个 FU 用来执行乘法操作,因为要保证流水线的写回(Write back)阶段是顺序执行的,因此所有 FU 都需要经历同样周期数的流水线,在图 1.11 中,乘法运算需要的时间最长,因此第三个 FU 使用了三级流水线,其他的 FU 也需要跟随着使用三级流水线,即使它们在有些流水段什么事情都没有做。ScoreBoard 用来记录流水线中每条指令的执行情况,例如一条指令在哪个 FU 中执行,在什么时候这条指令可以将结果计算出来等,一个典型的 ScoreBoard 如图 1.12 所示。

图 1.12 一个典型的 ScoreBoard

图 1.12 一个典型的 ScoreBoard

在图 1.12 所示的 ScoreBoard 中,记录了指令集中定义的每个逻辑寄存器(R0~R31)的执行情况,在典型情况下,需要记录的信息如下。

在一些更复杂的处理器中,ScoreBoard 中还会有其他的内容,但是在图 1.11 所示的处理器中,使用这个简单的 ScoreBoard 就可以追踪指令的执行情况,并协助流水线的旁路工作,如图 1.13 所示为一段程序在图 1.11 所示的流水线中的执行情况。

图 1.13 程序在顺序执行的超标量流水线中的执行情况

图 1.13 程序在顺序执行的超标量流水线中的执行情况

图中指令转写(时序与相关性箭头保留在原图中):

指令 A: add r3,  r2, r1
指令 B: mul r5,  r4, r2
指令 C: mul r6,  r3, r1
指令 D: add r10, r5, r4
指令 E: add r7,  r5, r6
指令 F: add r8,  r9, 1

图 1.13 对情况进行了简化,假设在流水线的写回阶段才可以对计算结果进行旁路,由于这是一个顺序执行(in-order)的处理器,很多指令在流水线都会由于前面指令的阻塞而不能够继续执行,例如图 1.13 中的指令 F,它和前面的指令都是不相关的,但是这条指令只有等到前面所有的指令都已经发射(issue)了,它才可以送到 FU 中执行,这样就降低了处理器的性能。每条指令都可以从旁路网络(bypassing network)获得操作数,不需要等待源寄存器的值被写回到通用寄存器中,由于指令需要按照顺序的方式执行,所以指令在很多时候都处于等待的状态,图 1.13 中的程序在一个 2-way 顺序执行的超标量处理器中需要 12 个周期才可以执行完毕。

在图 1.13 的右边表示了程序中存在的先写后读(RAW)相关性,在所有的处理器中,RAW 相关性都是不可以绕开的,如果一个程序中存在过多的 RAW 相关性,那么这个程序就不能够在处理器中被有效地执行,但是对于 WAW 和 WAR 这两种相关性来说,由于图 1.13 所示的顺序执行的处理器只有一个统一的写回阶段,而且这个阶段位于流水线的最后一级,因此这两种相关性都不会对流水线产生影响。

1.3.2 乱序执行

在乱序执行(out-of-order)的超标量处理器中,指令在流水线中不再遵循程序中指定的顺序来执行,一旦某条指令的操作数准备好了,就可以将其送到 FU 中执行,这种类型处理器的流水线可以用图 1.14 来表示4。

图 1.14 乱序执行的超标量处理器的流水线

图 1.14 乱序执行的超标量处理器的流水线

在图 1.14 所示中,仍然表示了一个 2-way 的超标量处理器,每周期从 I-Cache 中取出两条指令并进行解码,为了在乱序执行时解决 WAW 和 WAR 这两种相关性,需要对寄存器进行重命名(register renaming),这个过程可以在流水线的解码(Decode)阶段完成,也可以单独使用一个流水段来完成,处理器中需要增加物理寄存器堆(Physical Register File, PRF)来配合对指令集中定义的寄存器(Architecture Register File, ARF)进行重命名,PRF 中寄存器的个数要多于 ARF。指令在流水线的取指令、解码和寄存器重命名阶段都是按照程序中规定的顺序(in-order)来执行的,直到指令到达流水线的发射(Issue)阶段,在这个阶段,指令将被存储在一个缓存中,这个缓存称为发射队列(Issue Queue, IQ),一旦指令的操作数准备好了,就可以从发射队列中离开,送到对应的 FU 中执行,因此发射阶段是流水线从顺序执行到乱序执行的分界点。每个 FU 都有自己的流水线级数,如执行 ALU 类型指令的 FU 需要一个周期就可以计算出结果,不再需要像上一节顺序执行的处理器那样被拉长到和乘法 FU 一样的周期数,在这种流水线中,由于每个 FU 的执行周期数都不相同,所以指令在流水线的写回(Write Back)阶段是乱序的,在这个阶段,一条指令只要计算完毕,就会将结果写到 PRF 中,由于分支预测失败(mis-prediction)或者异常(exception)的存在,PRF 中的结果未必都会写到 ARF 中,因此也将 PRF 称为 Future File。为了保证程序的串行结果,指令需要按照程序中规定的顺序更新处理器的状态,这需要使用一个称为重排序缓存(ROB)的部件来配合,流水线中的所有指令都按照程序中规定的顺序存储在重排序缓存中,使用重排序缓存来实现程序对处理器状态的顺序更新,这个阶段称为提交(Commit)阶段,一条指令在这个阶段,会将它的结果从 PRF 搬移到 ARF 中,同时重排序缓存也会配合完成对异常(exception)的处理,如果不存在异常,那么这条指令就可以顺利地离开流水线,并对处理器的状态进行更改,此时称这条指令退休(retire)了,一条指令一旦退休,它就再也不可能回到之前的状态了。

一条指令在退休之前,都可以从流水线中被清除,但是一旦它顺利地退休而离开流水线,它的生命周期也就结束了,不能够再返回到以前的状态,这对于 store 指令会带来额外的麻烦,因为 store 指令需要写存储器,如果在流水线的写回阶段就将 store 指令的结果写到存储器中,那么一旦由于分支预测失败或者异常等原因,需要将这条 store 指令从流水线中抹掉时,就没有办法将存储器的状态进行恢复了,因为存储器中原来的值已经被覆盖,于是,在图 1.14 中使用了一个缓存,称为 Store Buffer(SB),来存储 store 指令没有退休之前的结果,store 指令在流水线的写回阶段,会将它的结果写到 Store Buffer 中,只有一条 store 指令真的从流水线中退休的时候,才可以将它的值从 Store Buffer 写到存储器中。使用了这个部件之后,Load 指令此时除了从 D-Cache 中寻找数据,还需要从 Store Buffer 中进行查找,这样在一定程度上增加了设计的复杂度。

如图 1.15 所示为同样的一段程序,在 2-way 乱序执行的超标量处理器中的执行情况。

图 1.15 程序在乱序执行的超标量流水线中的执行情况

图 1.15 程序在乱序执行的超标量流水线中的执行情况

在图 1.15 中,i 表示流水线的发射(issue)阶段,将解码和寄存器重命名这两个过程放到了同一个流水段,r 表示指令已经计算完成,在重排序缓存(ROB)中等待退休(retire),一条指令只有等到它之前的所有指令都离开重排序缓存了,才允许它离开重排序缓存而从流水线中退休,C 表示一条指令经过了流水线的提交(Commit)阶段,离开流水线而退休了,这个过程是按照程序中规定的顺序(in-order)执行的。图 1.15 中的程序只要 9 个周期就可以完成,快于之前顺序执行程序的处理器,这是由于乱序执行提高了流水线的执行效率,当需要执行的指令个数更多时,乱序执行的优势就会更加明显。

本书重点关注的就是乱序执行的超标量处理器,因此本节给出的流水线在本书中会逐一详细介绍,下面对这个流水线中的各个阶段做一个粗略的介绍。

(1)Fetch(取指令):这部分负责从 I-Cache 中取指令,主要由两大部件构成,I-Cache 负责存储最近常用的指令;分支预测器用来决定下一条指令的 PC 值。不同于普通的处理器,这两大部件在超标量处理器中会遇到新的问题,都需要进行特殊的处理。

(2)Decode(解码):这部分用来识别出指令的类型、指令需要的操作数以及指令的一些控制信号等,这部分的设计和指令集是息息相关的,对于 RISC 指令集来说,例如 MIPS,由于比较简洁,所以解码部分也就相对比较简单,而对于 CISC 指令集来说,例如 x86,由于比较复杂,所以解码部分需要更多的逻辑电路来对这些指令进行识别,但是在超标量处理器中,即使对于 RISC 指令集,仍旧需要对一些特殊的指令进行处理,这些内容增加了解码部分的设计复杂度。

(3)Register Renaming(寄存器重命名):在流水线的解码阶段,可以得到指令的源寄存器和目的寄存器,这些寄存器都是逻辑寄存器,在指令集中定义好的,为了解决 WAW 和 WAR 这两种“伪相关性”,需要使用寄存器重命名的方法,将指令集中定义的逻辑寄存器重命名为处理器内部使用的物理寄存器,物理寄存器的个数要多于逻辑寄存器,通过寄存器重命名,处理器可以调度更多可以并行执行的指令。在进行重命名时,通常使用一个表格来存储当前逻辑寄存器到物理寄存器之间的对应关系,同时在其中还存储着哪些物理寄存器还没有被使用等信息,使用一些电路来分析当前周期被重命名的指令之间的 RAW 相关性,将那些存在 RAW 相关性的指令加以标记,这些指令会通过后续的旁路网络(bypassing network)来解决它们之间存在的“真相关性”。由于寄存器重命名阶段需要的时间比较长,现实当中的处理器都会将其单独使用一级流水线,而不是和解码阶段放在一起。

(4)Dispatch(分发):在这个阶段,被重命名之后的指令会按照程序中规定的顺序,写到发射队列(Issue Queue)、重排序缓存(ROB)和 Store Buffer 等部件中,如果在这些部件中没有空闲的空间可以容纳当前的指令,那么这些指令就需要在流水线的重命名阶段进行等待,这就相当于暂停了寄存器重命名以及之前的所有流水线,直到这些部件中有空闲的空间为止。分发阶段可以和寄存器重命名阶段放在一起,在一些对周期时间要求比较紧的处理器中,也可以将这个部分单独使用一个流水段。

(5)Issue(发射):经过流水线的分发(Dispatch)阶段之后,指令被写到了发射队列(Issue Queue)中,仲裁(select)电路会从这个部件中挑选出合适的指令送到 FU 中执行,这个仲裁电路可繁可简,对于顺序进行发射(in-order issue)的情况,只需要判断发射队列中最旧的那条指令是否准备好就可以了,而对于乱序进行发射(out-of-order issue)的情况,则仲裁电路会变得比较复杂,它需要对发射队列中所有的指令进行判断,并从所有准备好的指令中找出最合适的那条指令,送到 FU 中执行,对于乱序执行的处理器,这个阶段是顺序执行(in-order)到乱序执行(out-of-order)的分界点,指令在这个阶段之后,都是按照乱序的方式来执行,直到流水线的提交(Commit)阶段,才会重新变为顺序执行的状态。在发射队列中还存在唤醒(wake-up)电路,它可以将发射队列中对应的源操作数置为有效的状态,仲裁电路和唤醒电路互相配合进行工作,是超标量处理器中的关键路径。

(6)Register File Read(读取寄存器):被仲裁电路选中的指令需要从物理寄存器堆(Physical Register File, PRF)中读取操作数,一般情况下,被仲裁电路选中的指令可以从 PRF 中得到源操作数,当然还有“不一般”的情况,那就是指令不能从 PRF 中得到操作数,但是却可以在送到 FU 中执行之前,从旁路网络(bypassing network)中得到操作数,事实上很大一部分指令都是通过旁路网络获得操作数的35,这也为减少 PRF 的读端口提供了可能。由于超标量处理器每周期需要执行好几条指令,PRF 所需要的端口个数也是比较多的,多端口寄存器堆的访问速度一般都不会很快,因此在现实世界的处理器中,这个阶段都会单独使用一个流水段。

(7)Execute(执行):指令得到了它所需要的操作数之后,马上就可以送到对应的 FU 中执行了,在超标量处理器中,这个阶段通常有很多个不同类型的 FU,例如负责普通运算的 FU、负责乘累加运算的 FU、负责分支指令运算的 FU、负责 load/store 指令执行的 FU 等,现代的处理器还会加入一些多媒体运算的 FU,例如进行单指令多数据(SIMD)运算的 FU。

(8)Write back(写回):这个阶段会将 FU 计算的结果写到物理寄存器堆(PRF)中,同时这个阶段还有一个重要的功能,就是通过旁路网络(bypassing network)将这个计算结果送到需要的地方,一般都是送到所有 FU 的输入端,由 FU 输入端的控制电路来决定最终需要的数据,在现代的处理器中,旁路网络是影响速度的关键因素,因为这部分电路需要大量的布线,而随着硅工艺尺寸的减少,连线的延迟甚至超过了门电路的延迟,因此旁路网络会严重影响处理器的周期时间,为了解决这个问题,很多处理器都使用了 Cluster 的结构,将 FU 分成不同的组,在一个组内的 FU,布局布线时会被紧挨在一起,这样在这个组内的旁路网络,由于经过的路径比较短,一般都可以在一个周期内完成;而当旁路网络跨越不同的组时,就需要两个甚至多个周期了,这种 Cluster 的结构是一种典型的折中方案。

(9)Commit(提交):这个阶段起主要作用的部件是重排序缓存(ROB),它会将乱序执行的指令拉回到程序中规定的顺序,之所以能够完成这样的任务,是因为指令在流水线的分发(Dispatch)阶段,按照程序中规定的顺序(in-order)写到了重排序缓存中;程序在处理器中表现出来的结果总是串行的,如果在程序中先向寄存器 R1 写数据,然后向寄存器 R2 写数据,那么处理器表现出来的执行结果一定是先写 R1 再写 R2,也就是说,处理器执行的结果要和程序中原始的顺序是一样的,但是在超标量处理器中,指令是按照乱序的方式在内部执行的,最后需要这样一个阶段,将这些乱序执行的指令变回到程序规定的原始顺序;在重排序缓存中,如果一条指令之前的指令还没有执行完,那么即使这条指令已经执行完了,它也不能离开重排序缓存,必须等待它之前的所有指令都执行完成;在这个阶段也会对指令产生的异常(exception)进行处理,指令在流水线的很多阶段都可能发生异常,但是所有的异常都必须等到指令到达流水线的提交(Commit)阶段时才能进行处理,这样可以保证异常的处理按照程序中规定的顺序进行,并且能够实现精确的异常;一条指令一旦从重排序缓存中离开而退休(retire),那么就对处理器的状态进行了修改,再也无法返回到之前的状态了。

在超标量处理器中,还有一个非说不可的话题就是处理器的状态恢复,现代的处理器在很多地方使用了预测技术,因为超标量处理器的流水线一般比较深,不使用预测技术是没有办法获得高性能的,一般情况下,预测能够有效工作的前提就是有规律可循,一个很明显的例子就是分支预测,分支指令在执行过程中表现出的规律性,使分支预测成为了可能。但是,只要是预测,就会存在失败的可能,这时候就需要一种方法,将处理器恢复到正确的状态,这就是恢复电路的工作,它不但要将错误的指令从流水线中抹掉,还需要将这些错误指令在流水线中造成的“痕迹”进行消除,例如这些错误的指令可能已经修改了重命名映射表,或者已经将结果写到了物理寄存器中等,这都需要被修正过来。恢复电路和预测技术是天生的一对,只要有预测,就必然有状态恢复,激进的预测技术会提高处理器的性能,但是代价就是更复杂的恢复电路。在超标量处理器中,对异常的处理由于需要抹掉流水线中的指令,因此也需要使用恢复电路来使处理器恢复到正确的状态,这些内容将在本书详细地展开介绍。

本站所有文章转发 CSDN 将按侵权追究法律责任,其它情况随意。