流水线(Pipelining)

流水线是一种让 CPU 运行更快的基础技术,其中多条指令在执行过程中相互重叠。CPU 中的流水线技术受到了汽车装配线的启发。指令的处理被划分为若干阶段,各阶段并行运行,分别处理不同指令的不同部分。DLX 是由 John L. Hennessy 和 David A. Patterson 于 1994 年设计的一种相对简单的架构。如 [Hennessy] 所定义,它具有由以下阶段组成的 5 级流水线:

  1. 取指(IF,Instruction Fetch)
  2. 译码(ID,Instruction Decode)
  3. 执行(EXE,Execute)
  4. 访存(MEM,Memory Access)
  5. 写回(WB,Write Back)

简单 5 级流水线示意图。

简单 5 级流水线示意图。

图 Pipelining 展示了 5 级流水线 CPU 的理想流水线视图。在第 1 个时钟周期,指令 x 进入流水线的 IF 阶段。在下一个周期,随着指令 x 进入 ID 阶段,程序中的下一条指令进入 IF 阶段,依此类推。一旦流水线被填满(如上面第 5 个周期所示),CPU 的所有流水线阶段都忙于处理不同的指令。没有流水线的话,指令 x+1 直到指令 x 完成其工作之后才能开始执行。

现代高性能 CPU 具有多个流水线阶段,根据架构和设计目标的不同,通常在 10 到 20 级之间(有时更多)。这比前面介绍的简单 5 级流水线要复杂得多。例如,译码阶段可以拆分为几个新的阶段,也可以在执行阶段之前增加新的阶段来缓冲已译码的指令,等等。

流水线 CPU 的吞吐量(throughput)定义为单位时间内完成并退出流水线的指令数量。任意给定指令的延迟(latency)是指经过流水线所有阶段的总时间。由于流水线的所有阶段都相互关联,每个阶段必须同步地(lockstep)移向下一条指令。将指令从一个阶段移到下一个阶段所需的时间定义了 CPU 的基本机器周期(cycle)或时钟。给定流水线所选的时钟值由流水线中最慢的阶段决定。CPU 硬件设计师努力平衡每个阶段所能完成的工作量,因为这直接影响 CPU 的运行频率。

在实际实现中,流水线引入了几个约束,这些约束限制了图 Pipelining 所示的理想流水线执行。流水线冒险(Pipeline hazards)会阻止理想的流水线行为,导致流水线停顿(stalls)。冒险分为三类:结构冒险(structural hazards)、数据冒险(data hazards)和控制冒险(control hazards)。对程序员来说幸运的是,在现代 CPU 中,所有类型的冒险都由硬件来处理。

  • 结构冒险:由资源冲突引起,即当两条指令竞争同一资源时发生。此类冒险的一个例子是:两条 32 位加法指令在同一周期都准备好执行,但该周期只有一个可用的执行单元。在这种情况下,我们需要选择执行哪一条指令,另一条则在下一个周期执行。在很大程度上,结构冒险可以通过复制硬件资源来消除,例如使用多个执行单元、指令译码器、多端口寄存器文件等。然而,这可能在芯片面积和功耗方面代价高昂。

  • 数据冒险:由程序中的数据依赖性引起,分为三种类型:

    先写后读(RAW,Read-After-Write)冒险要求依赖的读操作在写操作之后执行。当指令 x+1 在前一条指令 x 写入源操作数之前读取该源操作数时,会读到错误的值,从而发生此类冒险。CPU 通过将数据从流水线的后续阶段转发到早期阶段(称为"旁路"(bypassing))来降低 RAW 冒险带来的惩罚。其思想是将指令 x 的结果在指令 x 完全完成之前转发给指令 x+1。请看以下示例:

    R1 = R0 ADD 1
    R2 = R1 ADD 2
    

    这里存在对寄存器 R1 的 RAW 依赖。如果我们在加法 R0 ADD 1 完成后(从 EXE 流水线阶段)直接取该值,就不必等到 WB 阶段完成(届时该值才会被写入寄存器文件)。旁路技术有助于节省数个周期。流水线越长,旁路技术就越有效。

    先读后写(WAR,Write-After-Read)冒险要求依赖的写操作在读操作之后执行。当某条指令在更早的指令读取源操作数之前就写入了该寄存器,导致读到错误的新值时,就会发生 WAR 冒险。WAR 冒险不是真正的依赖,可以通过一种称为寄存器重命名(register renaming)的技术来消除。寄存器重命名是一种将逻辑寄存器与物理寄存器抽象分离的技术。CPU 通过维护大量物理寄存器来支持寄存器重命名。逻辑(架构)寄存器——即 ISA 所定义的寄存器——只是较大寄存器文件上的别名。有了这种架构状态的解耦,解决 WAR 冒险就变得简单了:我们只需为写操作使用不同的物理寄存器即可。例如:

    ; 机器码,WAR 冒险              ; 寄存器重命名后
    ; (架构寄存器)                 ; (物理寄存器)
    R1 = R0 ADD 1                  =>       R101 = R100 ADD 1
    R0 = R2 ADD 2                           R103 = R102 ADD 2
    

    在原始汇编代码中,寄存器 R0 存在 WAR 依赖。对于左边的代码,我们不能对两条指令的执行顺序进行重排,因为这可能导致 R1 中保存错误的值。然而,我们可以利用大量物理寄存器来克服这一限制。为此,我们需要从写操作(R0 = R2 ADD 2)及其之后的所有 R0 寄存器出现位置重命名为一个空闲寄存器。重命名后,我们给这些寄存器赋予与物理寄存器对应的新名称,例如 R103。通过寄存器重命名,我们消除了初始代码中的 WAR 冒险,可以安全地以任何顺序执行这两个操作。

    先写后写(WAW,Write-After-Write)冒险要求依赖的写操作在写操作之后执行。当某条指令在更早的指令写入同一寄存器之前就写入了该寄存器,导致存储了错误的值时,就会发生 WAW 冒险。WAW 冒险也通过寄存器重命名来消除,允许两次写操作以任意顺序执行,同时保留正确的最终结果。下面是一个消除 WAW 冒险的示例:

    ; 机器码,WAW 冒险              ; 寄存器重命名后
    (架构寄存器)                   (物理寄存器)
    R1 = R0 ADD 1                  =>       R101 = R100 ADD 1
    R2 = R1 SUB R3  ; RAW                   R102 = R101 SUB R103 ; RAW
    R1 = R0 MUL 3   ; WAW and WAR           R104 = R100 MUL 3
    

    在许多生产程序中可以看到类似的代码。在我们的示例中,R1 保存 ADD 操作的临时结果。一旦 SUB 指令完成,R1 立即被重新用于存储 MUL 操作的结果。左边的原始代码具有三种类型的数据冒险。ADDSUB 之间在 R1 上存在 RAW 依赖,它必须在寄存器重命名后得以保留。此外,MUL 操作在同一寄存器 R1 上存在 WAW 和 WAR 冒险。同样,我们需要重命名寄存器来消除这两个冒险。注意,重命名后 MUL 操作有了新的目标寄存器(R104)。现在我们可以安全地将 MUL 与其他两个操作进行重排。

  • 控制冒险:由程序流的改变引起。它们来源于对分支指令和其他改变程序流的指令的流水线化。决定分支方向(跳转或不跳转)的分支条件在执行流水线阶段才得到解析。因此,如果控制冒险未被消除,下一条指令的取指就无法流水线化。下一节将介绍动态分支预测和推测执行等技术,它们用于缓解控制冒险。

results matching ""

    No results matching ""