# 5.7.1 整体操作

图 5-11 是现代微处理器的一个非常简单化的示意图。我们假想的处理器设计是不太严格地基于近期的 Intel 处理器的结构。这些处理器在工业界称为超标量(superscalar),意思是它可以在每个时钟周期执行多个操作,而且是乱序的(out-of-order),意思就是指令执行的顺序不一定要与它们在机器级程序中的顺序一致。整个设计有两个主要部分:指令控制单元(Instruction Control Unit,ICU)和执行单元(Execution Unit,EU)。前者负责从内存中读出指令序列,并根据这些指令序列生成一组针对程序数据的基本操作;而后者执行这些操作。和第 4 章中研究过的按序(in-order)流水线相比,乱序处理器需要更大、更复杂的硬件,但是它们能更好地达到更高的指令级并行度。

乱序处理器的框图

图 5-11 一个乱序处理器的框图。指令控制单元负责从内存中读出指令,并产生一系列基本操作。然后执行单元完成这些操作,以及指出分支预测是否正确。

ICU 从指令高速缓存(instruction cache)中读取指令,指令高速缓存是一个特殊的高速存储器,它包含最近访问的指令。通常,ICU 会在当前正在执行的指令很早之前取指,这样它才有足够的时间对指令译码,并把操作发送到 EU。不过,一个问题是当程序遇到分支时,程序有两个可能的前进方向。一种可能会选择分支,控制被传递到分支目标。另一种可能是,不选择分支,控制被传递到指令序列的下一条指令。现代处理器采用了一种称为分支预测(branch prediction)的技术,处理器会猜测是否会选择分支,同时还预测分支的目标地址。使用投机执行(speculative execution)的技术,处理器会开始取出位于它预测的分支会跳到的地方的指令,并对指令译码,甚至在它确定分支预测是否正确之前就开始执行这些操作。如果过后确定分支预测错误,会将状态重新设置到分支点的状态,并开始取出和执行另一个方向上的指令。标记为取指控制的块包括分支预测,以完成确定取哪些指令的任务。

术语“分支”专指条件转移指令。对处理器来说,其他可能将控制传送到多个目的地址的指令,例如过程返回和间接跳转,带来的也是类似的挑战。

指令译码逻辑接收实际的程序指令,并将它们转换成一组基本操作(有时称为微操作)。每个这样的操作都完成某个简单的计算任务,例如两个数相加,从内存中读数据,或是向内存写数据。对于具有复杂指令的机器,比如 x86 处理器,一条指令可以被译码成多个操作。关于指令如何被译码成操作序列的细节,不同的机器都会不同,这个信息可谓是高度机密。幸运的是,不需要知道某台机器实现的底层细节,我们也能优化自己的程序。

在一个典型的 x86 实现中,一条只对寄存器操作的指令,例如

addq %rax,%rdx

会被转化成一个操作。另一方面,一条包括一个或者多个内存引用的指令,例如

addq %rax,8(%rdx)

会产生多个操作,把内存引用和算术运算分开。这条指令会被译码成为三个操作:一个操作从内存中加载一个值到处理器中,一个操作将加载进来的值加上寄存器 %rax 中的值,而一个操作将结果存回到内存。这种译码逻辑对指令进行分解,允许任务在一组专门的硬件单元之间进行分割。这些单元可以并行地执行多条指令的不同部分。

EU 接收来自取指单元的操作。通常,每个时钟周期会接收多个操作。这些操作会被分派到一组功能单元中,它们会执行实际的操作。这些功能单元专门用来处理不同类型的操作。

读写内存是由加载和存储单元实现的。加载单元处理从内存读数据到处理器的操作。这个单元有一个加法器来完成地址计算。类似,存储单元处理从处理器写数据到内存的操作。它也有一个加法器来完成地址计算。如图中所示,加载和存储单元通过数据高速缓存(data cache)来访问内存。数据高速缓存是一个高速存储器,存放着最近访问的数据值。

使用投机执行技术对操作求值,但是最终结果不会存放在程序寄存器或数据内存中,直到处理器能确定应该实际执行这些指令。分支操作被送到 EU,不是确定分支该往哪里去,而是确定分支预测是否正确。如果预测错误,EU 会丢弃分支点之后计算出来的结果。它还会发信号给分支单元,说预测是错误的,并指出正确的分支目的。在这种情况中,分支单元开始在新的位置取指。如在 3.6.6 节中看到的,这样的预测错误会导致很大的性能开销。在可以取出新指令、译码和发送到执行单元之前,要花费一点时间。

图 5-11 说明不同的功能单元被设计来执行不同的操作。那些标记为执行“算术运算”的单元通常是专门用来执行整数和浮点数操作的不同组合。随着时间的推移,在单个微处理器芯片上能够集成的晶体管数量越来越多,后续的微处理器型号都增加了功能单元的数量以及每个单元能执行的操作组合,还提升了每个单元的性能。由于不同程序间所要求的操作变化很大,因此,算术运算单元被特意设计成能够执行各种不同的操作。比如,有些程序也许会涉及整数操作,而其他则要求许多浮点操作。如果一个功能单元专门执行整数操作,而另一个只能执行浮点操作,那么,这些程序就没有一个能够完全得到多个功能单元带来的好处了。

举个例子,我们的 Intel Core i7 Haswell 参考机有 8 个功能单元,编号为 0~7。下面部分列出了每个单元的功能:

  • 0:整数运算、浮点乘、整数和浮点数除法、分支
  • 1:整数运算、浮点加、整数乘、浮点乘
  • 2:加载、地址计算
  • 3:加载、地址计算
  • 4:存储
  • 5:整数运算
  • 6:整数运算、分支
  • 7:存储、地址计算

在上面的列表中,“整数运算”是指基本的操作,比如加法、位级操作和移位。乘法和除法需要更多的专用资源。我们看到存储操作要两个功能单元——一个计算存储地址,一个实际保存数据。5.12 节将讨论存储(和加载)操作的机制。

我们可以看出功能单元的这种组合具有同时执行多个同类型操作的潜力。它有 4 个功能单元可以执行整数操作,2 个单元能执行加载操作,2 个单元能执行浮点乘法。稍后我们将看到这些资源对程序获得最大性能所带来的影响。

在 ICU 中,退役单元(retirement unit)记录正在进行的处理,并确保它遵守机器级程序的顺序语义。我们的图中展示了一个寄存器文件,它包含整数、浮点数和最近的 SSE 和 AVX 寄存器,是退役单元的一部分,因为退役单元控制这些寄存器的更新。指令译码时,关于指令的信息被放置在一个先进先出的队列中。这个信息会一直保持在队列中,直到发生以下两个结果中的一个。首先,一旦一条指令的操作完成了,而且所有引起这条指令的分支点也都被确认为预测正确,那么这条指令就可以退役(retired)了,所有对程序寄存器的更新都可以被实际执行了。另一方面,如果引起该指令的某个分支点预测错误,这条指令会被清空(flushed),丢弃所有计算出来的结果。通过这种方法,预测错误就不会改变程序的状态了。

正如我们已经描述的那样,任何对程序寄存器的更新都只会在指令退役时才会发生,只有在处理器能够确信导致这条指令的所有分支都预测正确了,才会这样做。为了加速一条指令到另一条指令的结果的传送,许多此类信息是在执行单元之间交换的,即图中的“操作结果”。如图中的箭头所示,执行单元可以直接将结果发送给彼此。这是 4.5.5 节中简单处理器设计中采用的数据转发技术的更复杂精细版本。

控制操作数在执行单元间传送的最常见的机制称为寄存器重命名(register renaming)。当一条更新寄存器 r 的指令译码时,产生标记 t,得到一个指向该操作结果的唯一的标识符。条目(r, t)被加入到一张表中,该表维护着每个程序寄存器 r 与会更新该寄存器的操作的标记 t 之间的关联。当随后以寄存器 r 作为操作数的指令译码时,发送到执行单元的操作会包含 t 作为操作数源的值。当某个执行单元完成第一个操作时,会生成一个结果(v, t),指明标记为 t 的操作产生值 v。所有等待 t 作为源的操作都能使用 v 作为源值,这就是一种形式的数据转发。通过这种机制,值可以从一个操作直接转发到另一个操作,而不是写到寄存器文件再读出来,使得第二个操作能够在第一个操作完成后尽快开始。重命名表只包含关于有未进行写操作的寄存器条目。当一条被译码的指令需要寄存器 r,而又没有标记与这个寄存器相关联,那么可以直接从寄存器文件中获取这个操作数。有了寄存器重命名,即使只有在处理器确定了分支结果之后才能更新寄存器,也可以预测着执行操作的整个序列。

旁注 乱序处理的历史

乱序处理最早是在 1964 年 Control Data Corporation 的 6600 处理器中实现的。指令由十个不同的功能单元处理,每个单元都能独立地运行。在那个时候,这种时钟频率为 10 MHz 的机器被认为是科学计算最好的机器。

在 1966 年,IBM 首先是在 IBM 360/91 上实现了乱序处理,但只是用来执行浮点指令。在大约 25 年的时间里,乱序处理都被认为是一项异乎寻常的技术,只在追求尽可能高性能的机器中使用,直到 1990 年 IBM 在 RS/6000 系列工作站中重新引入了这项技术。这种设计成为了 IBM/Motorola PowerPC 系列的基础,1993 年引入的型号 601,它成为第一个使用乱序处理的单芯片微处理器。Intel 在 1995 年的 Pentium Pro 型号中引入了乱序处理,Pentium Pro 的底层微体系结构类似于我们的参考机。