除非是流水线中的已有指令与要提取的指令之间存在数据相关,而且无法通过旁路(Bypass)或转发(Forward)来隐藏这一数据相关,否则,简单的静态调度流水线就会提取一条指令并发射出去。(转发逻辑可以减少实际流水线延迟,所以某些特定的相关不会导致冒险)如果存在不能隐藏的数据相关,那些冒险检查软件会从使用该结果的指令开始,将流水线置于停顿状态。在清除这一相关之前,不会提取和发射新的指令。

本文将介绍动态调度,在这种调度方式中,硬件会重新安排指令的执行顺序来减少停顿,并同时保持数据流和异常行为。动态调度有几个优点。第一,它允许针对一种流水线编译的代码在不同流水线上高效执行,不需要在使用不同微体系结构时重新进行编译,并拥有多个二进制文件。在当今的计算机环境中,大多数软件都来自第三方,而且是以二进制文件形式分发的,这一优势尤其明显。第二,在某些情况下,在编译代码时还不能知道相关性,利用动态调度可以处理某些此类情况;比如,这些相关可能涉及存储器引用或者与数据有关的分支,或者,它们可能源自使用动态链接或动态分发的现代编程环境。第三,也可能是最重要的一个优点,它允许处理器容忍一些预料之外的延迟,比如缓存缺失,它可以在等待解决缺失问题时执行其他代码。

尽管动态调度的处理器不能改变数据流,但它会在存在相关性时尽力避免停顿。相反,由编译器调度的静态流水线也会经历将停顿时间降至最低,具体方法是隔离相关指令,使它们不会导致冒险。当然,对于那些本来准备在采用动态调度流水线的处理器上运行的代码,也可以使用编译器流水线调度。

1. 动态调度:思想

简单流水线技术的一个主要限制是它们使用循环指令发射与执行:指令按程序顺序发射,如果一条指令停顿在流水线中,后续指令都不能继续执行。因此,如果流水线中两条相距很近的指令存在相关性,就会导致冒险和停顿。如果存在多个功能单元,这些单元也可能处于空闲状态。如果指令j依赖于长时间运行的指令i(当前正在流水线中执行),那么j之后的所有指令都必须停顿,直到i完成、j可以执行为止。例如,考虑以下代码:

动态调度python 动态调度的优缺点_寄存器

由于fadd.d对fdiv.d的相关性会导致流水线停顿,所以fsub.d指令不能执行;但是,fsub.d与流水线中的任何指令都没有数据相关性。这一冒险会对性能造成限制,如果不需要以程序顺序来执行指令,就可以消除这一限制。

在经典的五级流水线中,会在指令译码(ID)期间检查结构冒险和数据冒险:当一个指令可以无冒险执行时,知道所有数据冒险都已解决,从ID将其发射出去。

为了能够开始执行上面例子中的fsub.d,必须将发射过程分为两个部分:检查所有结构冒险和等待数据冒险的消失。因此,我们仍然使用循序指令发射(即,按程序顺序发射指令),但我们希望一条指令能够在其数据操作数可用时立即开始执行。这样一种流水线实际是乱序执行,也就意味着乱序完成。

乱序执行就可能导致WAR和WAW冒险,在这个五级整数流水线及其循序浮点流水线的逻辑扩展中不存在这些冒险。考虑以下RISC-V浮点代码序列:

动态调度python 动态调度的优缺点_寄存器_02

在fadd.d和fsub.d之间存在反相关,如果流水线在fadd.d之前执行fsub.d(fadd.d在等待fdiv.d),将会违反相关,产生WAR冒险。与此类似,为了避免违反输出相关,比如在fdiv.d完成之前通过fadd.d写f0,就必须处理WAW冒险。我们将会看到,利用寄存器重命名可以避免这些冒险。

乱序完成还会使异常处理变得复杂。采用乱序完成的动态调度必须保持异常行为,使那些在严格按照程序顺序执行程序时会发生的异常仍然会实际发生,也不会发生其他异常。动态调度处理器会推迟产生相关的异常,一直等到处理器知道该指令就是接下来要完成的指令为止,通过这一方式来保持异常行为。

尽管异常行为必须保持,但动态调度处理器可能生成一些非精确异常。如果在发生异常时,处理器的状态与严格按照程序顺序执行指令时的状态不完全一致,那就说这一异常是非精确的。非精确异常可以因为以下两种可能性而发生。

1)流水线在执行导致异常的指令时,可能已经完成了按照程序顺序排在这一指令之后的指令。

2)流水线在执行导致异常的指令时,可能还没有完成按照程序顺序排在这一指令之前的指令。

非精确异常增大了在异常之后重新开始执行的难度。本文不介绍如何解决这些问题,而是讨论一种解决方案,能够在具有推测功能的处理器环境中提供精确异常。

为了能够进行乱序执行,我们将五级简单流水线的ID流水线级大体分为以下两个阶段。

1)发射——译码指令,检查结构性冒险。

2)读操作数——一直等到没有数据冒险,然后读取操作数。

指令提取阶段位于发射阶段之前,既可以把指令放到指令寄存器中,也可能放到一个待完成指令队列中;然后从寄存器或队列发射这些指令。执行阶段跟在读操作数据阶段之后,这一点和五级流水线中一样。执行过程可能需要多个周期,具体数目取决于所执行的操作。

我们区分一个指令开始执行和完成执行的时刻,在这两个时刻之间,指令处于执行过程中。我们的流水线允许同时执行多条指令,没有这一功能,就会失去动态调度的主要优势。要同时执行多条指令,需要有多个功能单元,流水化功能单元,或者同时需要这两者。由于这两种功能(流水化功能单元和多个功能单元)在流水线控制方面大体相当,所有我们假设处理器拥有多个功能单元。

在动态调度流水线中,所有指令都循序经历发射阶段;但是它们可能在第二阶段(读操作数阶段)停顿或者相互旁路,从而进入乱序执行状态。记分板技术运行在有足够资源和没有数据相关时乱序执行指令,它的名字源于CDC6600记分板,CDC6600记分板开发了这一功能。这里重点介绍一种名为Tomasulo算法的更高级技术。它们之间的主要区别在于Tomasulo算法通过对寄存器进行有效动态重命名来处理反相关和输出相关。此外,还可以对Tomasulo算法进行扩展,用来处理推测,这种技术通过预测一个分支的输出、执行预测目标地址的指令、当预测错误时采取纠正措施,从而降低控制相关的影响。使用记分板可能足以支持简单的处理器,而高性能处理器则受益于预测的使用。

2. 使用Tomasulo算法进行动态调度

IBM 360/91浮点单元使用一种支持乱序执行的高级方案。这一方案由Robert Tomasulo发明,它会跟踪指令的操作数何时可用,将RAW冒险降至最低,并在硬件中引入寄存器重命名功能,将WAW和WAR冒险降至最低。在现代处理器中存在这一方案的许多变体,但它们仍然依赖于两个关键的原则:动态决定当指令准备好后立即执行和重命名寄存器以避免不必要的冒险。

IBM的目标是从指令集出发、从为整个360计算机系列设计的编译器出发来实现高浮点性能,而不是通过采用专门为高端处理器设计的编译器来实现。360体系结构只有4个双精度浮点寄存,它限制了编译器调度的有效性;这一事实是开发Tomasulo方法的另一个动机。此外,IBM 360/91的内存访问时间和浮点延迟都很长,Tomasulo算法就是设计用来克服这些问题的。

我们将在RISC-V指令集上下文中解释这一算法,重点放在浮点单元和load-store单元。RISC-V与360之间的主要区别是后者的体系结构中存储寄存器-存储器指令。由于Tomasulo算法使用一个load功能单元,所有添加寄存器-存储器寻址模式并不需要进行大量修改。IBM 360/91还有一点不同,其拥有的是流水化功能单元,而不是多个功能单元,但我们在描述该算法时仍然假定它有多个功能单元。它只是对功能单元进行流水化的概念扩展。

如果仅在操作数可用时才执行指令,就可以避免RAW冒险,而这正是一些简单记分板方法提供的功能。WAR和WAW冒险(源于命名相关)可以通过寄存器重命名来消除。对所有目标寄存器(包括较早指令正在进行读取或写入的寄存器)进行重命名,使乱序写入不会影响到任何依赖某一操作数较早值的指令。如果ISA中有足够的寄存器,则编译器通常可以实现这种重命名。原始的360/91仅具有四个浮点寄存器,并且Tomasulo算法就是为克服这种不足而创建的。现代处理器具有32~64个浮点和整数寄存器,而在最近的实现中可用的重命名寄存器的数量为数百个。

为了更好地理解寄存器重命名如何消除WAR和WAW冒险,考虑以下可能出现WAR和WAW冒险的代码序列示例:

动态调度python 动态调度的优缺点_动态调度python_03

以上代码共有两处反相关:fadd.d和fsub.d之间,fsd和fmul.d之间。在fadd.d和fmul.d之间还有一处输出相关,从而一共可能存在3处冒险:fadd.d使用f8和fsub.d使用f6时的WAR冒险,以及因为fadd.d可能在fmul.d之后完成所造成的WAW冒险。还有3个真正的数据相关:fdiv.d和fadd.d之间、fsub.d和fmul.d之间、fadd.d和fsd之间。

这3个名称相关都可以通过寄存器重命名来消除。为简单起见,假定存在两个临时寄存器:S和T。利用S和T,可以对这一序列进行改写,使其没有任何相关,如下所示:

动态调度python 动态调度的优缺点_动态调度python_04

此外,对f8的任何后续使用都必须用寄存器T来代替。在这个代码段中,可以由编译器静态完成这一重命名过程。要在后续代码中找出所有使用F8的地方,需要采用高级编译器分析或硬件支持,这是因为上述代码段与后面使用F8的位置之间可能存在插入分支。我们将会看到,Tomasulo算法可以处理跨域分支的重命名问题。

在Tomasulo方案中,寄存器重命名功能由保留站提供,保留站会为等待发射的指令缓冲操作数。其基本思想是:保留站在一个操作数可用时马上提取并缓冲它,这样就不再需要从寄存器中获取该操作数。此外,等待执行的指令会指定保留站,为自己提供输入。最后,在对寄存器连续进行写入操作并且重叠执行时,只会实际使用最后一个操作更新寄存器。在发射指令时,会将待用操作数的寄存说明符更名,改为保留站的名字,这就实现了寄存器重命名。

由于保留站的数目可能多于实际寄存器,所以这一技术甚至可以消除因为名称相关而导致的冒险,这类冒险是编译器所无法消除的。在研究Tomasulo方案的各个部分时,将会再次讨论寄存器重命名这一问题,了解重命名究竟如何实现以及如何消除WAR和WAW冒险。

使用保留站,而不使用集中式寄存器堆,可以导致另外两个重要特性。第一,冒险检测和执行控制是分布式的:每个功能单元保留站中保存的信息决定了一条指令什么时候可以开始在该单元中执行。第二,结果将直接从缓冲它们的保留站中传递给功能单元,而不需要经过寄存器。这一旁路是使用公共总线完成的,它允许同时载入所有等待一个操作数的单元(在360/91中,这种总线被称为公共数据总线,或CDB)。在具有多个执行单元并且每个时钟周期发射多条指令的流水线中,将需要不止一条总线。

下图给出了基于Tomasulo算法的处理器的基本结构,其中包括浮点单元和载入/存储单元;所有执行控制表均未显示。每个保留站保存一条已经被发射、正在功能单元等待执行的指令,如果已经计算出这一指令的操作数值,则保留这些操作数值,如果还没有计算出,则保留站提供这些操作数值的保留站名称。

动态调度python 动态调度的优缺点_操作数_05

载入缓冲区和存储缓冲区保存来自和进入存储器的数据或地址,其行为方式基本与保留站相同,所以我们仅在必要时才区分它们。浮点寄存器通过一对总线连接到功能单元,由一根总线连接到存储缓冲区。来自功能单元和来自存储器的所有结果都通过公共数据总线发送,它会通向载入缓冲区之外的所有地方。所有保留站都有标记字段,供流水线控制使用。

在详细描述保留站和此算法之前,让我们看看一条指令所经历的步骤。尽管每条指令现在可能需要任意数目的时钟周期,但一共只有以下3个步骤。

1)发射——从指令队列的头部获取下一条指令,指令队列按FIFO顺序维护,以确保能够保持数据流的正确性。如果有一个匹配保留站为空,则将这条指令发送到这个站中,如果操作数值当前已经存在于寄存器,也一并发送到站中。如果没有空保留站,则存在结构性冒险,该指令会停顿,直到有保留站或缓冲区被释放为止。如果操作数不在寄存器中,则一直跟踪将生成这些操作数的功能单元。这一步骤将对寄存器进行重命名,消除WAR和WAW冒险。

2)执行——如果还有一个或多个操作数不可用,则在等待计数的同时监视公共数据总线。当一个操作数变为可用时,就将它放到任何一个正在等待它的保留站中。当所有操作数都可用时,则可以在相应功能单元中执行运行。通过延迟指令执行,直到操作数可用为止,可用避免RAW冒险。

注意,在同一时钟周期,同一功能单元可能有几条指令同时变为就绪状态。尽管独立功能单元可用在同一时钟周期执行不同指令,如果单个功能单元有多条指令准备就绪,那这个单元就必须从这些指令中进行选择。对于浮点保留站,可以任意作出这一选择;但是载入和存储指令可能要更复杂一些。

载入和存储指令的执行过程需要两个步骤。第一步是在基址寄存器可用时计算有效地址,然后将有效地址放在载入缓冲区或存储缓冲区中。载入缓冲区中的载入指令在存储单元可用时立即执行。存储缓冲区中的存储指令等待要存储的值,然后将其发送给存储器单元。通过有效地址的计算,载入和存储指令保持程序顺序,稍后将会看到,这样有助于通过存储器来避免冒险。

为了保持异常行为,对于任何一条指令,必须要等到根据程序顺序排在这条指令之前的所有分支全部完成之后,才能执行该指令。这一限制保证了在执行期间导致异常的指令实际上已经执行。在使用分支预测的处理器中(就和所有动态调度处理器一样),这意味着处理器在允许分支之后的指令开始执行之前,必须知道分支预测是正确的。如果处理器记录了异常的发生,但没有时间触发,则可以开始执行一条指令,在进入写结果阶段之前没有停顿。

3)写结果——在计算出结果之后,将其写到CDB上,再从CDB传送给寄存器和任意等待这一结果的保留站(包括存储缓冲区)。存储指令一直缓存在存储缓冲区,直到待存储值和存储地址可用为止,然后在有空闲存储器单元时,立即写入结果。

保留站、寄存器堆和载入/存储缓冲区都采用了可用检测和消除冒险的数据结构,根据对象的不同,这些数据结构中的信息也稍有不同。这些标签实际上就是用于重命名的虚拟寄存器扩展集的名字。在这里的例子中,标签字段包含4个数位,用来表示5个保留站之一或5个载入缓冲区之一。在拥有更多真正寄存器的处理器中,我们可能希望重命名能够提供更多的虚拟存储器。标签字段指出哪个保留站中包含的指令将会生成作为源操作数的结果。

在指令被发射出去并开始等待源操作数之后,将使用一个保留站编号来引用该操作数,这个保留站中保存着将对寄存器进行写操作的指令。如果使用一个未用作保留站编号的值来引用该操作数(比如0),则表明该操作数已经在寄存器中准备就绪。由于保留站的数目多于实际寄存器数目,所以使用保留站编号对结果进行重命名,就可以避免WAW和WAR冒险。在Tomasulo方案中,保留站被用作扩展虚拟寄存器,而其他方法可能使用拥有更多寄存器的寄存集,也可能使用诸如重排序缓冲区这样的结构。

在Tomasulo方案已经后面将会介绍的支持推测的方法中,结果都是在受保留站监视的总线(CDB)上广播。采用公共结果总线,再由保留站从总线中提取结果,共同实现了静态调度流水线中使用的转发和旁路机制。但在这一做法中,动态调度方案会在源与结果之间引入一个时钟周期的延迟,这是因为要等到“写结果”阶段才能让结果与其应用匹配起来。因此,在动态调度流水线中,在生成结果的指令与使用结果的指令之间至少要比生成该结果的功能单元的延迟长一个时钟周期。

一定别忘了,Tomasulo方案中的标签引用的是将会生成结果的缓冲区或单元;当一条指令发射到保留站之后,寄存器名称将会丢弃。(这就是Tomasulo方案与记分板之间的一个关键区别:在记分板中,操作数保存在寄存器中,只有生成结果的指令已经完成、使用结果的指令做好执行准备之后才会读取操作数。)

每个保留站有以下7个字段。

  • Qp——对源操作数S1和S2执行的运算。
  • Qj、Qk——将生成相应源操作数的保留站;当取值为0时,表明已经可以在Vj或Vk中获得源操作数,或者不需要源操作数。
  • Vj、Vk——源操作数的值。注意,对于每个操作数,V字段和Q字段只有一个是有效的。对于载入指令,Vk字段用于保存偏移量字段。
  • Busy——指明这个保留站及其相关功能单元正被占用。

寄存器堆有一个字段Qi。

  • Qi——一个运算的结果应当存储在这个寄存器中,则Qi是包含此运算的保留站的编号。如果Qi的值为空(或0),则当前没有活动指令正在计算以此寄存器为目的地的结果,也就是说这个值就是寄存器的内容。

载入缓冲区和存储缓冲区各有一个字段A,一旦完成了第一个执行步骤,这个字段中就包含了有效地址的结果。