2.2.2 超流水线及其挑战

流水线长度可以增加,也可以减少,比如将两级合并为一级。这种策略在某些应用中可能是有利的,比如在对性能要求不高且更注重功耗的嵌入式处理器设计中,这种方法可以减少流水线寄存器的数量,从而降低功耗。

对于追求高性能的现代处理器,这种合并的策略就不太适用了。更细分的流水线意味着在一个时钟周期内,更多的指令可以被同时处理,处理器的吞吐量(处理速度)可以增加。此外,更细分的流水线也可以允许更高的工作频率,因为每个阶段需要的时间会减少。

超流水线技术的基本思想是将一个较长的任务划分为几个较短的子任务,每个子任务在一个单独的流水线阶段执行。在最理想的情况下,每个流水线阶段都能在一个时钟周期内完成。因此,使用超流水线的一个重要目标是减少每个流水线阶段的执行时间,以便提高处理器的工作频率。

如图2-3所示,这里有3个组合逻辑,将流水线寄存器(Pipeline Register)划分成独立的3个阶段,得到了一个简易的流水线化计算硬件。对于每个阶段,我们需要100ps的组合逻辑计算时间以及20ps加载到寄存器的时间,所以我们这里能将时钟周期设定为120ps。可以发现,每过一个时钟周期就有一个指令完成,所以吞吐量变为了8.33GIPS,但是每个指令需要经过3个时钟周期,所以延迟为360ps。

我们将每个组合逻辑进一步划分成更小的部分,构建更深的流水线,时钟周期变为70ps,吞吐量为14.29GIPS。从这里可以发现,虽然我们将组合逻辑分成了更小的单元,使得组合逻辑的时延减小了,但是吞吐量的性能并没有等量提升。这是由于更深的流水线,会提高对寄存器时延的影响,在70ps的时钟周期中,寄存器的时延就占了28.6%,意味着更深的流水线的吞吐量会依赖于寄存器时延的性能。

图2-3 不断细分流水线带来的吞吐量增加

这里提到的流水线寄存器是用来在每个流水线阶段之间存储指令的中间状态的。在一个流水线阶段的计算结束时,其结果被写入流水线寄存器,然后在下一个时钟周期,下一个流水线阶段开始时,从流水线寄存器中读取数据。

流水线寄存器的读取和写入是在时钟周期的边缘(也就是时钟信号的上升沿或下降沿)进行的。在时钟信号的上升沿,流水线阶段的计算结果被写入流水线寄存器,然后在下一个时钟信号的上升沿,这个数据被送到下一个流水线阶段。这样,流水线寄存器的读取和写入操作并不需要额外的时钟周期。流水线寄存器的存在确实对时钟速率有影响,因为读取和写入寄存器需要一定的时间(寄存器的设置时间和保持时间)。如果流水线寄存器的读取和写入时间过长,则可能需要降低时钟速率,以确保数据能正确地从一个流水线阶段传输到下一个阶段。

通过将一个长的逻辑路径分解成两个较短的逻辑路径,可以减少逻辑操作的最大延迟时间。在没有使用流水线的处理器中,整个指令需要在一个时钟周期内完成,这就意味着处理器的工作频率受限于最慢的操作。如果一个指令需要 Tmax的时间来完成,那么处理器的最高工作频率就是1Tmax。

然而,当我们引入流水线后,情况就改变了。原本需要在一个时钟周期内完成的操作现在被分解为两个子操作,每个子操作需要Tmax/2的时间。这意味着现在每个时钟周期可以完成一个子操作,因此处理器的工作频率可以提高到1/(Tmax/2),也就是2/Tmax。这是流水线技术提高CPU运行频率的基本原理。

上面的案例是完全不考虑指令之间数据依赖的环境下的假设,实际上提升流水线长度的困难远不止于此。指令无法并行执行将使流水线毫无意义,指令无法并行执行可能由多种因素导致,这些因素通常被称为“冒险”。

● 数据冒险(Data Hazard):即一个指令依赖于另一个指令的结果。例如,如果有两个指令,第一个指令是将两个数字相加并将结果存入寄存器R1,第二个指令是从R1中读取数据并与另一个数相乘。第二个指令需要等待第一个指令完成,这就导致了数据冒险。假设我们有以下两行指令代码:

这是一个经典的数据冒险例子,因为指令2依赖于指令1的结果。在一个理想的流水线处理器中,我们希望能够在每个时钟周期内执行一个指令。然而,在这个例子中,我们无法在指令1完成之前开始执行指令2,因为需要等待指令1的结果。如果试图在指令1完成之前执行指令2,那么就会遇到流水线停顿,因此必须等待,直到指令1完成并将结果写入R1。

● 控制冒险(Control Hazard):当一个指令改变了程序的控制流,就会产生控制冒险。因为这个时候,直到这个指令执行完,才能确定接下来要执行哪个指令,所以它会影响流水线的并行性。假设我们有以下指令代码:

在这个例子中,我们的处理器需要确定应该执行指令2还是直接跳转到指令3。这取决于R1的值,但是在流水线架构中,当我们需要决定下一步执行哪个指令时,R1的值可能还未知。如果我们预测错误(比如预测会执行指令2,但实际上R1等于0,应该跳转到指令3),就需要丢弃已经放入流水线的指令2,并把正确的指令3放入流水线,这样会导致流水线停顿。

控制冒险发生在因分支指令(如条件判断、循环等)造成的程序控制流的改变中。这样的指令会改变程序计数器的值,从而改变下一个要执行的指令。由于流水线架构的特性,在一个指令执行的同时,下一个或者多个指令可能已经在流水线中开始执行了。如果这些指令是因为错误的分支预测而被加载到流水线中的,那么这些指令需要被清空或者流水线需要停顿,直到正确的指令被加载。

● 结构冒险(Structural Hazard):当多个指令同时需要使用同一硬件资源时,就会产生结构冒险。例如,如果两个指令同时需要访问主存,但是 CPU 只有一个可以连接到主存的总线,那么这就是一个结构冒险。这种冒险可以通过增加硬件资源(如添加更多的总线或缓存)来减少。假设我们有以下指令代码:

在这个例子中,如果指令1和指令2都在同一个时钟周期内尝试使用内存(一个是读,另一个是写),而内存系统只能在一个时钟周期内执行一次读取或写入操作,那么就会发生结构冒险。此时需要某种策略(如流水线停顿或者指令重新排序)来解决这种冲突。

数据冒险和结构冒险可以通过以下方式解决,以帮助提高流水线效率。

(1)解决数据冒险的方式。

● 操作数前推(Operand Forwarding):当一个指令的结果是另一个指令的操作数,且这两个指令在流水线中相邻时,可以直接将结果从一个阶段传送到另一个阶段,而无须等待第一个指令完成并将结果写入寄存器。

● 指令重排序(Instruction Reordering):编译器可以尝试重新排列指令,以避免数据冒险。例如,它可能会尝试将与当前指令无关的指令放在两个有数据冒险的指令之间,从而使得第一个指令有更多的时间来完成,使第二个指令不必等待。

● 延迟槽(Delay Slot):在一些体系结构中,编译器可以将无关的指令插入到可能导致数据冒险的指令之后,这个无关的指令称为“延迟槽”。这使得第一个指令有额外的时间来完成,从而避免数据冒险。

(2)解决结构冒险的方式。

● 硬件资源复制(Hardware Duplication):如果有多个指令需要同时使用同一硬件资源,则可以复制这个硬件资源来解决冒险。例如,如果两个指令都需要访问存储器,则可以使用两个独立的存储器接口。

● 流水线停顿(Pipeline Stall):当一个资源被一个指令占用时,可以让其他需要这个资源的指令暂停,直到资源可用为止。这会导致流水线效率下降,但能避免结构冒险。

● 动态调度(Dynamic Scheduling):在这种方法中,硬件在运行时自动调整指令的执行顺序,从而最大限度地利用硬件资源并避免结构冒险。

控制冒险则可通过分支预测解决,后面我们会讲解分支预测的设计逻辑和动态分支预测技术。