1. 引言
1.1 什么是流水线?
流水线爱是一种将多条指令重叠执行的实现技术,它利用了一条指令所需的多个操作之间的并行性。(指令操作的非原子性和指令类型的多样性)
在计算流水线中,每个步骤完成指令的一部分,每一步都被称为流水段或流水级。指令流水线的吞吐量由指令退出流水线的频率决定。由于流水级是连在一起的,所以所有流水级都必须做好同时工作的准备。将一条指令在流水线中下移一级所需的时间为处理器周期。由于所有流水级同时进行,所以处理器的长度由最慢流水级所需的时间决定。
流水线设计者的目标是平衡每级流水线的长度。如果各级达到完美平衡,那么每条指令在流水线处理器中的的加速比等于流水级的数目。
流水线可以缩短每条指令的平均执行时间。流水线技术利用了串行指令流中各指令之间的并行度。
1.2 RISC-V指令集基础知识
所有RISC-V架构都有以下关键特性:
-
所有数据操作都是对寄存器中数据的操作。
-
只有载入和存储会影响存储器。
-
指令格式相对固定,所有指令位宽通常相同。
这些简单属性极大地简化了流水线的实现。
1.3 RISC指令集的简单实现
假设五级流水线:
-
取指周期:将程序计数器发送到存储器,从存储器提取当前指令。程序计数器更新到下一条指令。
-
指令译码/读寄存器周期:对指令进行译码,并从寄存器堆中读取与寄存器源说明符相对应的寄存器。在读取寄存器时对其进行相等测试,以确定可能得分支。在后续需要时,对指令的偏移量字段进行符号扩展。将符号扩展后的偏移量添加到所增加的程序计数器上,计算出可能得分支目标地址。
指令译码与寄存器的读取是并行执行的,这之所以可能,是因为RISC体系结构中,寄存器说明符位于固定位置。这一技术称为固定字段译码。对于载入和ALU指令的立即数操作,立即数字段总是在相同的位置,因此我们可以轻松地对其进行符号扩展。
- 执行/有效地址周期:ALU对上一周期准备的操作数进行操作,根据指令类型进行4种操作之一:
a. 存储器访问:ALU将基址寄存器和偏移量加到一起,形成有效地址;
b. 寄存器-寄存器ALU指令:ALU对读自寄存器堆的值执行由ALU操作码指定的操作。
c. 寄存器-立即数ALU指令:ALU对读自寄存器的第一个值和符号扩展立即数执行由ALU操作码指定的操作。
d. 条件分支:判断条件是否为真。
在载入-存储体系结构中,有效地址与执行周期可以合并到一个时钟周期中,这是因为没有指令需要同时计算数据地址并对数据进行操作。
-
存储器访问:如果指令类型是载入,则使用上一周期中计算的有效地址从存储器中读取数据。如果是存储,则使用有效地址将从寄存器堆的第二个寄存器读取的数据写入存储器。
-
写回周期:
a. 寄存器-寄存器ALU或载入指令:将结果写入寄存器堆,无论结果是来自存储系统还是ALU。
在5级流水线中,分支指令需要3个周期,存储指令需要4周期,所有其他指令需要5周期。
1.4 RISC处理器的经典五级流水线
首先,我们必须确定在处理器的每个时钟周期都会发生什么,并确保不会在同一时钟周期内对相同数据路径执行两个不同操作。例如,不能要求ALU同时计算有效地址和执行减法操作。幸运的是,RISC指令集比较简单,这使得资源评估相对容易。主要功能单元是在不同周期使用的,因此多条指令的执行重叠不会引入多少冲突。
-
使用分离的指令存储器和数据存储器,通常用分离的指令缓存和数据缓存来实现它们。因为在使用单个存储器时,在取指和数据存储器访问之间可能发生冲突。如果我们的流水线处理器的时钟周期等于非流水线版本的时钟周期,则存储器系统提供5倍的带宽。这种需求时提高性能的代价之一。
-
在两个阶段都使用了寄存器堆:一个用于在ID中进行读取,一个用在WB中进行写入。这些用法是不同的。因此,每个时钟需要执行2读1写。为了处理对同一寄存器的多次读取和一次写入,在时钟周期的前半部分的写寄存器,在后半部分读寄存器。
-
为了在每个时钟周期都启动一条新指令,必须在每个时钟周期使程序计数器递增,这必须在IF阶段完成,以便为下一条指令做好准备。此外,还必须有一个加法器,在ID期间计算潜在的分支目标。另一个问题是我们需要ALU阶段的ALU来评估分支条件。实际上,我们并不是真的需要一个完整的ALU来评估两个寄存器之间的比较,但需要它们的功能有足够多的一部分在这一流水级完成。
尽管确保流水线中的指令不会试图在同一时间使用硬件资源是至关重要的,但我们还必须确保不同流水级中的指令不会相互干扰。这种分离是通过在连续的流水级之间引入流水线寄存器来完成的,这样会砸时钟周期的末尾,将给定流水级得出的所有结果都存储在寄存器中,在下一时钟周期用作下一级的输入。
在流水化处理器中,在将中间结果从一级传送到另一级,而源位置与目标位置可能并非直接相邻的,流水线寄存器也发挥重要作用。例如,存储指令的地址在ID期间读取,但是要等待MEM才会起作用。ALU指令的结果是在EX期间计算的,但要到WB才会实际存储。
1.5 流水化的基本性能问题
流水化提高处理器指令的吞吐量,但不会缩短单条指令的执行时间。事实上,由于流水线控制会产生开销,它通常还意味着延长每条指令的执行时间。
单条指令的执行时间并没有缩短这一事实限制了流水线的实际深度。除了因为流水线延迟产生的局限之外,流水级之间的失衡和流水化的开销也会造成限制。流水级之间的不平衡会降低性能,因为时钟的运行速度不可能快于最缓慢的流水级。流水线开销包含流水线寄存器延迟和时钟偏差。流水线寄存器增加了建立时间,也就是发出触发写操作的时钟信号之前,寄存器输入必须保持稳定的时间,加上时钟周期的传播延迟。时钟偏差是时钟到达任意两个寄存器时刻之间的最大延迟,时钟周期的下限也受此因素的影响。一旦时钟周期与时钟偏差和延迟开销的总和一样小,进一步流水化就没有用了,因为时钟周期中没有剩余时间来做有用的工作了。
2. 流水线的主要阻碍:流水线冒险
有一些被称为冒险的情景,会阻止指令流中的下一条指令在其自己的指定时钟周期内执行。冒险降低了流水化所获得的理想加速比的性能。共有3种冒险:
a. 结构冒险:在重叠执行模式下,如果硬件无法同时支持指令的所有可能组合方式,就会出现结构冒险。在现代处理器中,结构冒险主要发生在不太常用的特殊用途功能单元(比如浮点除法,或其他复杂的,长时间运行的指令)。这种情况通常并非性能瓶颈,是因为往往可以假定程序员和编译器知道这些指令的吞吐量较低。无需关注这种不常出现的情况。
b. 数据冒险:由于流水线中的指令重叠执行,如果一条指令依赖先前指令的结果,就可能导致数据冒险。
c. 控制冒险:分支指令及其他改变程序计数器的指令实现流水化时可能导致控制冒险。
流水线中的冒险会导致停顿。为了避免冒险,通常要求在流水线中的一些指令延迟时,其他一些指令能够继续执行。当一条指令停顿时,在其之后发射的所有指令也会停顿。而在停顿指令之前发生的指令必须执行,否则永远不会清除冒险情况。因此,在停顿期间不会提取新的指令。
2.1 带有停顿的流水线性能
加速比= 流水线深度 / (1 + 每条指令的流水线停顿周期)
2.2 数据冒险
流水化的主要效果是通过重叠指令的执行过程来改变它们的相对执行时间。这种重叠引入了数据冒险和控制冒险。当流水线改变对操作数的读写访问顺序,使该顺序不同于在非流水化处理器上一次执行指令的顺序时,可能会发生数据冒险。假设指令i在程序的顺序先于指令j,并且两个指令都使用寄存器x,i和j之间可能发生的3种冒险:写后读、读后写和写后溪。
1.利用前递技术将数据冒险停顿冒险减至最少
前递技术的关键认知后一条指令要等到前一条指令实际生成结果之后才会真正用到它。工作方式:
a. 来自EX/MEM和MEM/WB流水线寄存器的ALU结果总是被反馈回ALU的输入端;
b. 如果前递硬件检测到前一个ALU操作已经对当前ALU操作的源寄存器进行了写操作,则控制逻辑选择前递结果作为ALU输入,而不是选择从寄存器堆中读取的值。
可以将前递技术加以推广,将结果直接传送给需要它的功能单元,而不是仅限于同一单元的输入与输出之间。
- 需要停顿的数据冒险
遗憾的是,并非所有潜在数据冒险都可以通过旁路方式处理。
ld指令在MEM周期结束前不会得到数据,而sub需要在该时钟周期的开头就得到数据(除非前递路径在时间上进行回退,但这是不可能的)。因此,因为使用载入指令的结果而产生的数据冒险无法使用简单的硬件消除。但是对于and指令来说,能够立即将该结果从流水线寄存器前递给ALU,供and指令使用。
载入指令的延迟无法简单地通过前递技术来解决。需要增加一种称为流水线互锁的硬件来确保正确的执行模式下。一般情况下,流水线互锁会检测冒险,并使流水线停顿,直到该冲突被清除。此时,互锁使流水线停顿,让希望使用这一数据的指令等待,直到源指令生成该数据为止。引入的停顿被称为气泡,使得CPI变大,增加的值等于停顿周期。
2.3 分支冒险
控制冒险造成的性能损失可能比数据冒险还要大。在执行一个分支时,更新后的程序计数器的值可能等于当前值加4。回想一下,如果分支将程序计数器改为其目标地址,则它就是选中分支,否则就是未选中分支。通常在ID末尾,完成地址计算和对比之后才会改变程序计数器。
处理分支的最简单办法是:一旦在ID期间检测到分支,就对该分支之后的指令重新取指。
1. 降低流水线分支代价
讨论4种简单的编译时机制,此时认为分支的操作是静态的。然后再介绍动态预测分支行为。
a. 处理分支的最简单机制是冻结或冲刷流水线,即保留或删除分支之后的所有指令,直到直到知道分支目标为止。主要优点是对于软硬件来说都是很简单的。此时,分支代价是固定的,不能通过软件来缩减。
b. 将每个分支都看做未选中分支,让硬件继续执行,就好像该分支未被执行一样。这时在确切知道分支结果之前,不要改变处理器状态。这种机制的复杂性在于必须要知道处理器状态可能何时被指令改变,以及如何"撤销"这种改变。
c. 将所有分支都看作选中分支。只要对分支指令进行了译码并计算了目标地址,就假定该分支将被选中,并开始在目标位置提取和执行。当分支被实际选中国了,这位我们带来了一个周期的提升,因为在ID结束时就知道了目标地址。
d. 延迟分支:分支延迟为1的执行周期是,分支指令-->依序后续指令-->选中时的分支目标。
在硬件预测成本高的情况下,延迟分支对于简短的流水线很有用,但是当存在动态分支预测时,这种技术会变得复杂,因此,RISC适当地省却了延迟分支。
2. 分支机制的性能
分支代价的实际流水线加速:
流水线加速比 = 流水线深度 / (1 + 分支导致的流水线停顿周期)
分支导致的流水线停顿周期 = 分支频率 * 分支代价
2.4 通过预测降低分支成本
当流水线变得越来越深,而且分支的潜在代价增加时,仅使用延迟分支及类似机制就不够了。需要一种更积极的方式来预测分支:依赖编译时可用信息的低成本静态机制,根据程序行为对分支进行动态预测的策略。
2.5 静态分支预测
改进编译时分支预测的一种重要方式是利用先前运行过程收集的特征数据。是因为人们观察到分支的行为特性往往两极化分布。
任意分支预测机制的有效性都同时取决于机制的准确率和条件分支的频率。
2.6 动态分支预测和分支预测缓冲区
最简单的动态分支预测机制是分支预测缓冲区或分支历史表。分支预测缓冲区是一个小型存储器,根据分支指令地址的低位部分进行索引。这个存储器中包含一个位,表明该分支最近是否曾被选中。缓冲区的性能取决于两点:对所关注分支的预测频繁程度,以及该预测在匹配时的准确率。短板在于即使扣个分支几乎总是被选中,在其未被选中时,我们也可能会得到两次错误预测,而不是一次,因为错误预测会导致该预测位反转。为了弥补这一弱点,经常使用2位预测机制。在2位预测机制中,预测必须错过2次之后才会修改。对n位预测器的研究表明,2位预测器的效果几乎与n位预测器相同,所以大多数系统采用2位预测器,而不是更一般的n位预测器。
分支预测缓存可以实现为一个特殊的"缓存",在IF流水级中使用指令地址进行访问,或者实现为一对比特,附加到指令缓存中的每个块,并随指令一起提取。如果指令的译码结果为一个分支,并且该分支被预测为选中,则在知道PC之后立即从目标位置开始提取。否则,继续进行顺序提取和执。如果预测结果错误,将改变预测位。
由于尝试利用更多的ILP,所以分支预测的准确率变得非常关键。整数程序的预测器准确率低于循环密集的科学应用程序。 来解决这一问题:增大缓冲区的大小,提升每种预测机制的准确率。
3. 如何实现流水化
3.2 RISC-V基本流水线
因为每个流水级在每个时钟周期都处于活动状态,所以流水级中的所有操作都必须在1个时钟周期内完成,任何操作组合都必须能够立即发生。此外,要实现数据路径的流水化,必须将流水级之间传递的数值放在寄存器中。流水线中包含了每个流水级之间适当的寄存器,称为流水线寄存器或流水线锁存器。
流水线寄存器用于在一条指令的时钟周期之内保存临时值的所有寄存器包含在这些流水线寄存器中。IR是IF/ID寄存器的一部分,当指令寄存器的字段用于提供寄存器名称时,它们会被标记。这些流水线寄存器用于从一个流水级向下一个流水级传送数据和控制。在后续流水级上需要的所有值都必须放在这样一个寄存器中,并从一个流水线寄存器复制到下一个寄存器,直到不再需要它们为止。
IF行为取决于EX/MEM中的指令是否为选中分支。如果是,则会在IF结束时将EX/MEM中分支指令的分支目标写入PC中;如果不是,则写回递增后的PC。寄存器源操作数的固定位置编码对于在ID期间提取寄存器至关重要。
指令类型由ID/EX寄存器的IR字段指定,上面的ALU输入多路选择器根据该指令是否为分支来进行设定,下面的多路选择器根据该指令时寄存器-寄存器ALU操作还是任意其他类型的操作来设定。IF级中的多路选择器选择时使用递增PC的值,还是EX/MEM.ALUOutput(分支目标)的值来写入PC。这个多路选择器由EX/MEM.cond字段控制。第4个多路选择器由WB级的指令是载入指令还是ALU指令来设定。
3.3 实现RISC-V流水线的控制
让一条指令从ID移入EX的过程通常称为指令发射,已经执行这一步骤的指令称为已发射指令。对于RISC-V整数流水线,所有数据冒险都可以在该流水线的ID阶段进行检查。如果存在数据冒险,则该指令将在被发射之前停顿。与此类似,我们可以确定在ID期间需要哪种前递,并设适当的控制。在流水线早期互锁降低了硬件的复杂性,因为除非整个处理器停顿,否则硬件从来不需要挂起一条已经改变处理器状态的指令。
一旦检测到冒险,控制单元必须插入流水线停顿,并防止IF和ID级中的指令继续前进。所有控制信息都承载于流水线寄存器中。(仅承载指令就足够了,因为所有控制都是由其派生而来的。)因此,在检测冒险时,只需要将ID/EX流水线寄存器的控制部分改为权0,它正好是一个空操作、在更具复杂冒险的流水线中,通过对比一组流水线寄存器来检测冒险,并转换为空操作,以防止错误的执行。
要实现前递逻辑,关键是要注意到流水线寄存器中既包含了要前递的数据,也包含了源寄存器字段和目标寄存器字段。可以对比EX/MEM级和MEM/WB级中所包含的IR的目标寄存器与ID/EX和EX/MEM寄存器中所包含的IR的源寄存器,以此来实现前递。
除了需要启用前递路径时必须确定的比较器和组合逻辑之外,还必须扩大ALU输入端的多路选择器,并添加一些连接,这些连接源于前递结果所用的流水线寄存器。
3.4 处理流水线中的分支
在RISC-V中,条件分支依赖于比较2个寄存器值,假定这发生在EX周期中并且使用ALU完成这一操作。需要计算分支目标地址。因为测试分支条件和确定下一步PC将决定分支代价是什么,所以希望在EX周期结束前计算可能的PC和选择正确的PC。为此,通过添加一个单独的加法器来计算ID期间的分支目标地址。因为这条指令还没有被译码,所以将计算一个可能的目标。就好像每条指令都是一个分支,这可能比在EX中同时计算目标和评估条件要快,但是消耗的能量稍微多一点。
早期RISC对分支的条件测试被限制为允许在ID中进行测试,从而将分支延迟减少到1个时钟周期。当然,这意味着对寄存器的ALU操作之后跟着一个基于该寄存器的条件分支会导致数据冒险;如果在EX中计算分支条件,则不会发生这种冒险。
随着流水线深度的增加,分支延迟也随之增加,这使得动态分支预测成为必要。例如,拥有独立译码和寄存器提取级的处理器可能存在至少长出1个时钟周期的分支延迟。如果不进行处理,分支延长会转变为分支代价。许多实现更复杂指令集的较老处理器的分支延迟为4个时钟周期,大型深度流水化处理器的分支代价经常为6或7个时钟周期,激进的超标量处理器,如Intel i7可能会有10到15个时钟周期的分支代价。一般来说,流水线越深,以时钟周期为度量的分支代价就越糟,准确预测分支就越重要。
4. 流水线难以实现的原因
在理解如何检测和解决冒险之后,考虑若指令的执行顺序发生了意外变化带来的挑战。
4.1 处理异常
异常情景在流水化处理器中更难处理器,因为指令的重叠使得判断一条指令能否安全地改变处理器状态变得更加困难。在详细讨论这些问题及其解决方案之前,需要了解可能出现哪些类型的情景,以及目前为它们提供的支持的体系结构需求有哪些?
1. 异常的类型与需求:
a. IO设备请求;
b. 从用户程序调用系统服务;
c. 跟踪指令执行;
d. 断点(程序员请求的中断);
e. 整数算术溢出;
f. 浮点算术异常;
g. 缺页错误(不在主存储器中);
h. (在需要对其时)存储器访问未对齐;
i. 违反存储器保护原则;
j. 使用未定义或未实现的指令;
k. 硬件或电源故障;
关于异常的需求,可以从5个半独立的方面进行描述:
-
同步于异步:如果每次以相同数据和存储器分配执行程序时,事件都在同一位置发生,那事件就是同步的。除了硬件故障之外,异步事件是由处理器核存储器之外的设备引起的。通常在当前指令完成后处理异步事件,这样更容易一点。
-
用户请求与强制:如果用户任务直接请求某一事件,那它就是用户请求事件。在某种意义上,用户请求的异常不是真正的异常,因为它是可预测的,但是,由于这些用户请求事件依赖相同的状态保存、状态复原机制,所以也将它们看做异常。因为对于出发这一异常的指令来说,其唯一功能就是引发该异常,所以用户请求异常总是可以在该指令完成之后在处理。强制异常是由某一不受用户程序控制的硬件事件导致的。
-
用户可屏蔽与用户不可屏蔽:如果一个事件可以借由用户任务来屏蔽或禁用,那它就是用户可屏蔽的。这一屏蔽只是控制硬件是否对异常做出回应。
-
指令内部和指令之间:妨碍指令完成的事件是发生在执行过程中间,还是被看做发生在指令之间。发生在指令内部的异常通常是同步的,因为这条指令触发了异常。在指令内部发生的异常实现起来要难于指令之间的异常,因为该指令必须被停止和重新启动。发生在指令内部的异步异常通常是灾难性情景(例如,硬件故障)造成的,并且总是导致程序终止。
-
恢复与终止:如果程序的执行总是在中断之后停止,那它就是终止事件。如果程序在执行在中断之后继续,那它就是恢复事件。终止执行的异常实现起来更容易一点,因为处理器不需要再处理异常之后重新开始执行。
5类异常划分的难点在于实现指令内部发生的中断,此时必须恢复指令的执行。要实现此类异常,必须调用另一个程序来保存所执行程序的状态、解决导致异常的问题,然后恢复程序的状态,之后才能再次尝试导致该异常的指令。
2. 停止执行与重启
和在非流水化实现中一样,最困难的异常有2个特性:
-
发生在指令内部(即在指令执行过程期间发生,与EX或MEM流水级相对应);
-
必须可以重新启动。
例如MEM的缺页错误是可重新启动的,并且需要另一进程(例如操作系统)的干预。因此,必须能够安全关闭流水线并保存其状态,以使指令能够以正确状态重新启动。重启通常是通过保存待重启指令的PC来实现的。如果重启的指令是分支指令,则重新计算分支条件,并根据计算结果提取目标指令或直通指令。在发生异常时,流水线控制可以采取以下步骤安全地保存流水线状态:
-
在下一个IF向流水线中插入一个陷阱指令;
-
在选中该陷阱指令之前,禁止错误指令的所有写操作,禁止流水线后续所有指令的写操作。实现方式为:从生成该异常的指令开始(不包括之前的指令),将流水线中所有指令的流水线锁存置零。
-
在操作系统的异常处理例程接收控制权之后,它会立即保存错误指令的PC。稍后将使用此值从异常中返回。
在处理异常之后,特殊指令通过重新加载PC并重启指令流(在RISC-V中使用异常返回)。使处理器从异常中返回。如果流水线可以停止,以使紧邻错误指令之前的指令能够完成,其后的指令可以从头重新启动,就说该流水线拥有精确异常。
3. RISC-V中的异常
异常可能是乱序发生。可能在一条指令产生异常之后,排在前面的指令才产生异常。流水线不能在发生异常时直接处理它,因为这会导致这些异常的发生顺序不同于非流水化顺序。硬件会将一条给定指令产生的所有异常都记录在一个与该指令相关联的状态向量中。这个异常状态向量将一致随该指令向流水线下方移动。一旦在异常状态向量中设定了异常指示,则会关闭任何可能导致数据值写入的控制信号。
当一条指令进入WB时(或将要离开MEM时),将检查异步状态向量。如果发现存在任何异常,则按照他们在非流水化处理器中发生顺序进行处理---首先处理与最早指令相对应的一次。这样保证指令i引发的所有异常将优先于指令i+1引发的所有异常得到处理。
4.2 指令集的复杂性
在RISC-V整数流水线中,如果所有指令到达MEM级的末尾,而且没有指令在该级之前更新状态,则说这些指令已提交。因此,精确异常非常简单。但对于一些指令会在指令执行过程中更改状态,这时指令及其之前的指令可能还未完成,所以在未添加硬件支持的情况下,异常将是不精确的。再这样一个非精确异常之后的重启指令流是有难度的。我们也可以避免在指令提交之前更新状态,但是难度很大,成本很高,因为可能会用到经过更新的状态。
一些在执行期间更新存储器状态的指令也会增加难度,如X86的字符串复制操作。为中断和重启这些指令,规定这些指令使用通用寄存器作为工作寄存器。因此,部分完成指令的状态总是位于寄存器中,这些寄存器在发生异常时被保存,并在异常之后恢复,这使得指令样继续执行。
奇数个状态位可能会导致另外一组不同的数据:可能另外增加流水线冒险,也可能需要额外的硬件来进行状态保存和恢复。例如条件码,许多处理器隐式设定条件码,将其作为指令的一部分。优点在于将条件判断和实际分支分离开来。缺点在调度条件码设定与分支之间的流水线延迟时,由于大多数指令会设定条件码,而且不能在条件判定与分支之间的延迟槽中使用,所以隐式设定条件可能会增加调度难度。
在具有条件码的处理器中,处理器必须判断何时确定分支条件。这就需要找出分支之前最后一次设定条件码是在什么时候。在大多数隐式设定条件码的处理器中,实现方式是推迟分支条件判断,直到先前所有指令都有机会设定条件码为止。
显式设定条件码的体系结构允许在条件测试与待调度分支之间插入延迟。但是,流水线必须跟踪最后一条设定条件码的指令,以便知道何时确定分支条件。实际上,必须将条件码当做操作数,需要进行RAW冒险检测。
流水线最后一个棘手问题是多周期操作。因为指令所需的数据存储器访问次数不同,数据冒险非常复杂,在指令之间和指令内部均会发生。一个简单解决方案是让所有指令的执行周期数相同,但这不可接受,因为它会引入数目庞大的冒险和旁通条件。对于x86而言,它引入了微指令流水化,所以流水线控制容易得多。对于载入-存储处理器的操作比较简单,工作量也不多,而且更容易流水化。
许多年来,指令集架构与实现之间的互动非常少,在设计指令集时,实现问题不是主要关注点。20世纪80年代,人们认识到指令集的复杂性会增加流水化的难度、降低流水化效率。20世纪90年代,所有公司都转向更简单的指令集,目的在于降低积极实现的复杂性。
5. 扩展RISC-V流水线,以处理多周期
要求RISC-V在1或者2个周期完成浮点运算很难,这意味着要么接受缓慢的时钟,要么在浮点单元中使用大量逻辑,后者二者皆有。而实际情况是,浮点流水线将会允许更长的操作延迟。如果假想浮点指令拥有和整数指令相同的流水线,就容易理解了,当然流水线会有2处重要改变:
-
为了完整操作,EX周期可能重复多次---不同操作的重复次数可能不同;
-
可能存在多个浮点功能单元;如果待发射指令会导致它所用功能单元的结构冒险,或者导致数据冒险,将会出现停顿。
针对本节,假定RISC-V实现中有以下4个独立的功能单元:
-
主整数单元:处理载入和存储、整型ALU操作、以及分支;
-
浮点与整数乘法器;
-
浮点加法器:处理浮点加、减和转换;
-
浮点和整型除法器;
假定EX未被流水化,所以在前一指令离开EX之前,不会发射任何其他使用这一功能单元的指令。
推广上述流水线,以允许实现某些级的流水化,并允许多个操作同时进行。为了描述这样一个流水线,必须定义功能单元的延迟及启动间隔。延迟即生成结果的指令与使用结果的指令之间的周期数。启动间隔指在发出两个给定类型的操作之间必须间隔的周期数。
由于大多数操作是在EX的开头使用操作数,所以其延迟通长是EX之后的级数。例如ALU运算之后0个流水级,而载入指令1级。主要另外是存储指令,它会在1个周期之后使用被存储的值。因此,存储指令的延迟是针对被存储的值而言,而不是基址寄存器,所以少1个周期。流水线延迟基本上等于执行流水线深度减去1个时钟周期,而流水线深度等于从EX级到生成结果的级数。于是,浮点加级数为4,乘法为7。为了获得更高时钟频率,设计师需要减少每个流水级中的逻辑级数,而这会增加更复杂操作所需要的流水级数。高时钟频率的代价是延长了操作的延迟。
注意一个流水级一次只能有一个操作,所以控制信息可以与该流水级头部的寄存器关联在一起。
5.1 长延迟流水中的冒险与前递
冒险检测与前递有许多不同方面:
-
因为除法单元未完全流水化,所以可能发生结构冒险。需要对这些冒险进行检测,还需要停顿指令发射;
-
因为指令的运行时间不同,所以一个周期内需要的寄存器写入次数可能会大于1;
-
由于指令不会顺序到达WB,所以有可能存在WAW冒险。注意,由于寄存器读总是在ID中发生,所以不可能存在读后写冒险。
-
指令的完成顺序可能不同于其发射顺序,从而导致异常问题;
-
由于操作的延迟长,所以因RAW冒险引发的停顿将会更加频繁。
如果我们假定浮点寄存器堆有1个写端口,那么浮点操作序列(浮点载入和浮点运算)可能会导致寄存器写端口的冒险。在时钟11中,所有3条指令将达到WB,并想写入寄存器堆。由于仅有1个写端口,所以处理器必须依次完成各条指令。写端口的结构冒险可以通过增加端口数目来解决,但由于增加的写端口很少用到,所以这种解决没啥吸引力。之所以很少用到写端口,是因为写端口的最大稳定状态数为1。选择将对写端口的访问作为一种结构性危险进行检测和强制执行。
(RAW冒险)实现这种互锁方法有二:
-
跟踪ID级对写端口的使用,并在指令发射之前使其停顿,就像对于任何其他结构冒险一样。可以用一个移位寄存器来跟踪写端口的使用,这个移位寄存器可以标记已发射指令将会在何时使用这个寄存器堆。如果ID中的指令需要与已发射指令同时使用寄存器堆,则ID中的指令将会停顿一个周期。在每个时钟周期,保留寄存器会移动1位。优点是所有互锁检测与停顿插入都在ID流水级内进行。成本是需要增加移位寄存器和写冒险逻辑。
-
当一个冒险指令尝试进入MEM级或WB级,使其停顿。如果等待冒险指令希望进入MEM或WB级时才使其停顿,则可以选择停顿任意一个指令。一种启发式方法是位那些延迟最长的单元赋予优先级,因为它是最有可能导致另一指令因RAW冒险而停顿的指令。优点是在进入容易检测冒险的MEM或WB级之前不需要检测冒险。缺点是由于停顿现在可能会出现在两个地方,所以流水线控制会变得复杂。
对于WAW冒险而言,方法有二:
-
对于载入指令而言,延迟载入指令的发射。
-
对于ALU指令而言,废除结果,即检测冒险并改变控制。替换方法是如果ID中的一条指令希望和一条已发射的指令同时写入同一个寄存器,就不要向EX发射指令。
在本节中RISC-V架构中,考虑浮点指令和整数指令之间的冒险时,只需要考虑浮点载入和浮点寄存移动。流水线控制的这种家简化是整数和浮点数据采用分离寄存器堆的另一项好处(主要好处是在各寄存器堆大小保持不变的情况下使寄存器数目加倍,还能在不增加各寄存器端口的情况下增加带宽)。主要缺点是偶尔需要再两组寄存器之间进行移动会产生微小的成本。
假定流水线在ID中进行所有冒险检测,则必须在执行3种检查之后才能发射指令:
-
检查结构冒险:直到所需功能单元不再忙碌为止,并确保在需要寄存器写端口时该端口可用。
-
检查RAW数据冒险:直到源寄存器未被列为流水线寄存器中的目的地位置。这里需要大量检查,取决于源指令和目标指令,其者决定结果何时可用,后者决定何时需要该值。
-
检查WAW数据冒险:判断是否有任何已发射指令的目标寄存器与待发射相同。如果有,则暂停发射ID中的指令。
5.2 保持精确异常
fdiv.d f0,f2,f4
fadd.d f10,f10,f8
fsub.d f12,f12,f14
在拥有长时操作的流水线中很容易出现乱序完成,例如add和div指令。如果sub出现异常,但是add已经完成了,但div还未完成,最终会出现不精确异常。这是因为指令的完成顺序与发射顺序不同。解决方法有4:
-
忽略问题,容忍非精确异常。20世纪60~70年代计算机即是如此。不过在现代处理器很难使用这一方法,因为虚拟存储器和IEEE浮点标准的特点都潜在地要求通过软硬件结合的精确异常的支持。
-
缓冲一个操作的结果,直到先前发射的所有操作都完成为止。不过当操作的运行时间差别很大时,要缓冲的结果数量会变得非常庞大,所以成本很高昂。此外,必须绕过来自队列的结果,以便在等待较长指令的同时发射指令。这就需要大量的比较器和一个很大的多路选择器。
-
允许异常变得不十分精确,但保存足够的信息,以便陷阱处理例程可以生成精确的异常序列。这意味着要知道流水线中有那些操作及其PC。在处理异常之后,由软件完成安歇在最后完成的指令之前的所有指令,然后该序列就能重新启动了。
-
仅在确定所发射指令之前的所有指令都会完成,而且不会导致异常时,才会继续允许指令发射。
对于深流水而言,浮点延长远超整数时,分支延长变长很多。较长的分支延迟会显著增加在分支上花费的周期数,特别是对于分支频率较高的整数程序。这就是几乎中等或深度流水线的后续处理器都采用动态分支预测器的原因了。以及与结构冒险相比,浮点功能单元的延迟会导致更多的的停顿。这主要来源于初始间隔限制和不同浮点指令对功能单元的冲突。因此,降低浮点运算的延迟应当是第一目标,而不是实现功能单元的深度流水线或重复。当然,降低延迟可能会增加结构性停顿,这是因为许多潜在的结构性停顿隐藏在数据冒险之后。
7. 交叉问题
7.1 RISC指令集及流水线效率
简单指令集除在流水线的优势明显之外,还更容易调度代码,以提高流水线的执行效率。无论是静态调度或动态调度,其实现都简单。
7.2 动态调度流水线
简单流水线提取一条指令并发射它,除非流水线中的已有指令和被提取的指令之间存在数据相关性,并且不能通过前递来隐藏。前递逻辑降低了世纪流水线延迟,是特定的相关性不会导致冒险。如果存在不可避免的冒险,则冒险检测硬件会使流水线停顿(从使用该结构开始)。在清除这种相关性之前,不会提取或发射新指令。为了弥补这些性能损失,编译器可以尝试调度指令来避免冒险。
除了编译器调度之外,目前处理器借助硬件重新安排指令的执行过程以减少停顿。
让一条指令可以在其操作数可用时立即开始执行,不受先前停顿指令的影响,必须将发射过程分为两部分:检查结构冒险和等待数据冒险的消失。为了实现乱序执行,必须将ID流水级分为2级:发射(指令译码,检查结构冒险)和读取操作数(等到没有数据冒险,随后读取操作数)。
执行可能占用多个周期,所以需要区分一条指令何时开始,何时执行结束。在这两个时刻之间,指令处于执行过程中。允许多条指令同时处于执行过程中。除了对流水线结构的修改之外,还需呀改变功能单元设计:改变单元数、操作延迟和功能单元流水化,以更好地探索这些更高级的流水线技术。
采用记分牌的动态调度:
在动态调度流水线中,所有指令读顺序通过发射级(顺序发射);但是,它们可能在在第二级(读取操作数)停顿,绕过其他指令,然后进行乱序执行状态。记分牌技术有足够资源且没有数据依赖性,允许指令乱序执行。
记分牌技术通过停顿冒险中涉及的后续指令,避免了WAW和WAR冒险。记分牌目标是:通过尽早执行指令,保持每时钟周期1条指令的执行速率(在没有结构冒险时)。因此,当下一条要执行的指令停顿时,如果其他指令不依赖于任何活动指令或停顿指令,则发射和执行这些指令。记分牌全面负责指令发射和执行,包括所有冒险检测任务。要充分利用乱序执行,需要在其EX级中同时有多条指令。这一点可以通过多个功能单元、流水化功能单元来实现。
每条指令都进入记分牌,在这里构建一条数据相关性记录;这一步与指令发射相对应,并替换流水线的ID步骤。记分牌随后判断指令什么时候能够读取它的操作数并开始执行。如果记分牌判断该指令不能立即执行,则它监控硬件中所有变化,以判断该指令何时能够执行。记分牌还控制一条指令什么时候能将其结果写到目标寄存器中。因此,所有冒险检测与解决都集中在记分牌中。然后依次进行ID、EX和WB步骤
-
发射:如果指令的一个功能单元空闲,并且没有其他活动指令以同一寄存器为目标寄存器,则记分牌向该功能单元发射指令,并更新其内部数据结构。代替了流水线中的ID步骤。只要确保没有其他活动单元将自己的结果写入目标寄存器,就能保证不会出现WAW冒险。如果存在结构冒险或者WAW冒险,则指令发射停顿,并且在清除这些冒险之前,不会再发射其他指令,当发射级停顿时,会导致取指令与发射之间的缓冲区被填满。
-
读取操作数:记分牌监视源操作数的可用性。如果先前发射的活动指令不再写入源操作数,而该源操作数可用。记分牌告诉功能单元继续从寄存器读取操作数,并开始执行。记分牌在这一步解决RAW冒险,可以发射指令以进入乱序执行。这一步和发射步骤一起,完成了简单的RISC-V流水线中ID步骤的功能。
-
执行:功能单元接到操作数后开始执行。结果准备就绪后,它通知记分牌已经完成执行。
-
写结果:记分牌一旦知道功能单元已经完成执行,就检查WAR冒险,并在必要时停顿正在完成的指令。
在以下情况下,不能允许一条正在执行的指令写入其结果:
-
在正在执行的指令前面(按发射顺序)有一条指令还没有读取其操作数;
-
在这些操作数之一与正在执行的指令的结果是同一寄存器。
如果不存在或已经清除WAR冒险,则记分牌会告诉功能将其结果存储到目标寄存器中。
乍看起来,记分牌似乎难以区分RAW冒险和RAW冒险。因为只有在当寄存器堆中拥有一条指令的两个操作数时,才会读取这些操作数,所以记分牌未能利用前递。与简单5级流水线不同,记分牌中的指令会在完成执行之后立即将结果写入寄存器堆(假设没有WAR冒险),而不是等待可能间隔几个周期的静态分配的写入槽。这降低了流水线延迟,也削弱了前递带来的好处。由于结果的写入和读操作数不能重叠,所以仍然会增加一个周期的延迟。我们需要增加缓存,以消除这一开销。
在记分牌中,指向寄存器堆的源操作数总线和结果总线数目是有限的,所以可能会存在结构冒险。
8. 谬论与易犯错误
易犯错误:预料之外的指令执行序列可能导致预料之外的冒险。
初看起来,WAW冒险永远不可能出现在一个指令序列中,因为没有那个编译器会生成对同一寄存器的两次写操作,而中间却没有读操作。
易犯错误:全面流水化可能会影响设计的其他方面,从而降低整体性能。
易犯错误:根据未经优化的代码来评估或静态调度。
为了公平地评估编译时调度器或运行时调度,必须使用优化后的代码,因为在实际系统中,除了调度之外,还会通过其他优化方法来提高性能。