BLOG

Record, summarize, and improve.

CPU ISA & Pipeline 基础

指令集原理

具体来说,我们重点关注四个主题。首先,我们提出了指令集备选方案的分类法,并对各种方法的优缺点进行了一些定性评估。其次,我们提出并分析了一些在很大程度上独立于特定指令集的指令集度量。第三,我们讨论了语言和编译器及其对指令集架构的影响。最后,“将所有内容放在一起”部分展示了这些思想如何在 RISC-V 指令集中得到体现,RISC-V 指令集是 RISC 架构的典型代表。

在开始介绍一般原则之前,让我们回顾一下第一章中的三个应用领域。桌面计算强调使用整数和浮点数据类型的程序的性能,而很少考虑程序大小。例如,在五代 SPEC 基准测试中从未报告过代码大小。当今的服务器主要用于数据库、文件服务器和 Web 应用程序,以及一些面向许多用户的时分复用应用程序。因此,浮点性能对于性能而言远不如整数和字符串重要,但几乎每台服务器处理器仍然包含浮点指令。个人移动设备和嵌入式应用程序重视成本和能源,因此代码大小很重要,因为更少的内存既更便宜又更省电,并且某些类别的指令(例如浮点)可能是可选的,以降低芯片成本,并且可以压缩指令集版本以节省内存空间。

因此,所有三个应用程序的指令集非常相似。事实上,我们在此重点关注的类似于 RISC-V 的架构已成功用于台式机、服务器和嵌入式应用程序。

一个与 RISC 非常不同的成功架构是 80x86(参见附录 K)。令人惊讶的是,它的成功并不一定否认 RISC 指令集的优势。二进制兼容性与 PC 软件相结合的商业重要性以及摩尔定律提供的晶体管的丰富性,促使英特尔在内部使用 RISC 指令集,同时在外部支持 80x86 指令集。最近的 80x86 微处理器,包括过去十年中构建的所有英特尔酷睿微处理器,都使用硬件将 80x86 指令转换为类似 RISC 的指令,然后在芯片内部执行转换后的操作。它们为程序员维持了 80x86 架构的假象,同时允许计算机设计人员实现 RISC 风格的处理器以提高性能。然而,对于像 80x86 这样的复杂指令集仍然存在严重的缺点,我们将在结论中进一步讨论这些缺点。

现在背景已经设定,我们开始探讨如何对指令集架构进行分类。

指令集架构分类

处理器内部存储器的类型是最基本的差异化,因此在本节中,我们将重点关注架构中此部分的替代方案。主要选择是堆栈、累加器或一组寄存器。操作数可以显式或隐式地命名:堆栈架构中的操作数隐式位于堆栈的顶部,而在累加器架构中,一个操作数隐式地是累加器。通用寄存器架构只有显式操作数——寄存器或内存位置。

图 A.1 显示了此类架构的框图,图 A.2 显示了代码序列 C = A + B 通常如何出现在这三类指令集中。显式操作数可以直接从内存中访问,或者可能需要先将其加载到临时存储器中,具体取决于架构的类型和特定指令的选择。

Image in a image block

四种指令集架构类的操作数位置。箭头指示操作数是输入还是算术逻辑单元 (ALU) 操作的结果,或者既是输入又是结果。浅色阴影表示输入,深色阴影表示结果。在 (A) 中,栈顶 (TOS) 寄存器指向顶部输入操作数,该操作数与下面的操作数组合。第一个操作数从栈中删除,结果代替第二个操作数,TOS 更新为指向结果。所有操作数都是隐式的。在 (B) 中,累加器既是隐式输入操作数又是结果。在 (C) 中,一个输入操作数是寄存器,一个在内存中,结果进入寄存器。在 (D) 中,所有操作数都是寄存器,并且与栈架构一样,只能通过单独的指令传输到内存:对于 (A) 来说是 push 或 pop,对于 (D) 来说是 load 或 store。

Image in a image block

四类指令集的 C = A + B 的代码序列。注意,Add 指令对堆栈和累加器架构具有隐式操作数,对寄存器架构具有显式操作数。假设 A、B 和 C 都属于内存,并且 A 和 B 的值不会被破坏。图 A.1 显示了每类架构的 Add 操作。

如图所示,寄存器计算机实际上有两类。一类可以将内存作为任何指令的一部分进行访问,称为寄存器-内存架构,另一类只能使用加载和存储指令来访问内存,称为加载-存储架构。第三类在当今计算机中找不到,它将所有操作数保存在内存中,称为内存-内存架构。一些指令集架构具有比单个累加器更多的寄存器,但对这些特殊寄存器的使用施加了限制。这种架构有时称为扩展累加器或专用寄存器计算机。

尽管大多数早期计算机使用堆栈或累加器式架构,但几乎所有 1980 年后设计的全新架构都使用加载-存储寄存器架构。通用寄存器 (GPR) 计算机出现的主要原因有两个。首先,寄存器(与处理器内部的其他存储形式一样)比内存更快。其次,寄存器对于编译器比其他形式的内部存储更易于使用。例如,在寄存器计算机上,表达式 (A * B) + (B * C) – (A * D) 可以通过按任何顺序进行乘法来求值,这可能由于操作数的位置或流水线问题而更有效(参见第 3 章)。然而,在堆栈计算机上,硬件必须仅按一个顺序求值表达式,因为操作数隐藏在堆栈中,并且它可能必须多次加载一个操作数。

更重要的是,寄存器可用于保存变量。当变量分配给寄存器时,内存流量减少,程序加速(因为寄存器比内存快),并且代码密度提高(因为寄存器可以用比内存位置更少的位来命名)。

如 A.8 节所述,编译器编写者更希望所有寄存器都是等效且未保留的。较早的计算机通过将寄存器专门用于特殊用途来妥协这种愿望,从而有效地减少了通用寄存器的数量。如果真正通用寄存器的数量太少,则尝试将变量分配给寄存器将无利可图。相反,编译器将保留所有未提交的寄存器以用于表达式求值。

多少个寄存器才足够?答案当然取决于编译器的有效性。大多数编译器保留一些寄存器用于表达式求值,一些用于参数传递,并允许将其余寄存器分配给变量。现代编译器技术及其有效使用更多寄存器的能力导致了最近架构中寄存器数量的增加。

GPR 架构由两大指令集特征区分。这两个特征都涉及典型算术或逻辑指令(ALU 指令)操作数的性质。第一个特征涉及 ALU 指令是具有两个还是三个操作数。在三操作数格式中,指令包含一个结果操作数和两个源操作数。在双操作数格式中,其中一个操作数既是操作的源,又是结果。GPR 架构之间的第二个区别涉及 ALU 指令中可以有多少操作数是内存地址。典型 ALU 指令支持的内存操作数数量可能从无到三个不等。图 A.3 显示了这些两个属性的组合以及计算机示例。尽管有七个可能的组合,其中三种用于对几乎所有现有的计算机进行分类。正如我们之前提到的,这三种是加载存储(也称为寄存器寄存器)、寄存器内存和内存内存。

Image in a image block

典型 ALU 指令的内存操作数和总操作数的典型组合,并附有计算机示例。每个 ALU 指令没有内存引用的计算机称为加载存储或寄存器寄存器计算机。每个典型 ALU 指令具有多个内存操作数的指令称为寄存器内存或内存内存,具体取决于它们是否具有一个或多个内存操作数。

图 A.4 显示了每种替代方案的优点和缺点。当然,这些优点和缺点并不是绝对的:它们是定性的,其实际影响取决于编译器和实现策略。具有存储器-存储器操作的 GPR 计算机很容易被编译器忽略,并用作加载存储计算机。最普遍的架构影响之一是对指令编码和执行任务所需的指令数量的影响。我们在附录 C 和第 3 章中看到了这些架构替代方案对实现方法的影响。

Image in a image block

三种最常见的通用寄存器计算机的优点和缺点。符号 (m, n) 表示 m 个内存操作数和 n 个总操作数。通常,具有较少备选方案的计算机可以简化编译器的任务,因为编译器需要做出的决策较少(参见 A.8 节)。具有各种灵活指令格式的计算机可以减少对编码程序所需的比特数。寄存器的数量也会影响指令大小,因为您需要 log(寄存器数)来表示指令中的每个寄存器说明符。因此,对于寄存器-寄存器架构,寄存器数量翻倍需要三个额外的比特,或者大约是 32 位指令的 10%。

可能的组合,其中三种用于对几乎所有现有的计算机进行分类。正如我们之前提到的,这三种是加载存储(也称为寄存器寄存器)、寄存器内存和内存内存。

图 A.4 显示了每种替代方案的优点和缺点。当然,这些优点和缺点并不是绝对的:它们是定性的,其实际影响取决于编译器和实现策略。具有存储器-存储器操作的 GPR 计算机很容易被编译器忽略,并用作加载存储计算机。最普遍的架构影响之一是对指令编码和执行任务所需的指令数量的影响。我们在附录 C 和第 3 章中看到了这些架构替代方案对实现方法的影响。

内存寻址

无论体系结构是加载存储还是允许任何操作数作为内存引用,它都必须定义如何解释内存地址以及如何指定它们。此处提供的测量值在很大程度上(但并非完全)独立于计算机。在某些情况下,测量值会受到编译器技术的影响。这些测量值是使用优化编译器进行的,因为编译器技术起着至关重要的作用。

解释内存地址

如何解释内存地址?也就是说,根据地址和长度访问哪个对象?本书中讨论的所有指令集都是字节寻址的,并提供对字节(8 位)、半字(16 位)和字(32 位)的访问。大多数计算机还提供对双字(64 位)的访问。

在同一台计算机中操作时,字节序通常不会引起注意——只有同时访问相同位置的程序(例如,字和字节)才能注意到差异。但是,在不同排序的计算机之间交换数据时,字节序是一个问题。小端排序也无法在比较字符串时匹配字的正常排序。字符串在寄存器中显示为“SDRAWKCAB”(反向)。

为什么有人会设计具有对齐限制的计算机?未对齐会导致硬件复杂性,因为内存通常对齐在单词或双字边界的倍数上。因此,未对齐的内存访问可能需要多个对齐的内存引用。因此,即使在允许未对齐访问的计算机中,具有对齐访问的程序运行得也更快。

即使数据对齐,支持字节、半字和字访问也需要一个对齐网络来对齐 64 位寄存器中的字节、半字和字。例如,在图 A.5 中,假设我们从地址中读取一个字节,其 3 个低阶位的值为 4。我们需要右移 3 个字节才能将字节对齐到 64 位寄存器中的正确位置。根据指令,计算机可能还需要对该数量进行符号扩展。存储很简单:只有内存中寻址的字节可能会被更改。在某些计算机上,字节、半字和字操作不会影响寄存器的上半部分。尽管本书中讨论的所有计算机都允许对内存进行字节、半字和字访问,但只有 IBM 360/370、Intel 80x86 和 VAX 支持对窄于全宽的寄存器操作数进行 ALU 操作。

现在我们已经讨论了内存地址的替代解释,我们可以讨论指令指定的地址方式,称为寻址模式。

寻址模式

给定一个地址,我们现在知道在内存中访问哪些字节。在本小节中,我们将研究寻址模式——架构如何指定它们将访问的对象的地址。寻址模式除了指定内存中的位置外,还指定常量和寄存器。当使用内存位置时,寻址模式指定的实际内存地址称为有效地址。

图 A.6 显示了最近计算机中使用过的所有数据寻址模式。立即数或字面量通常被认为是内存寻址模式(即使它们访问的值在指令流中),尽管寄存器通常是分开的,因为它们通常没有内存地址。我们保留了依赖于程序计数器的寻址模式,称为 PC 相对寻址,单独。PC 相对寻址主要用于指定第 A.6 节讨论的控制转移指令中的代码地址。

Image in a image block

图 A.6 寻址模式选择,包括示例、含义和用法。在自动增量/-减量和缩放寻址模式中,变量 d 指定正在访问的数据项的大小(即指令是否访问 1、2、4 或 8 个字节)。这些寻址模式仅在正在访问的元素在内存中相邻时才有用。RISC 计算机使用位移寻址来模拟地址为 0 的寄存器间接寻址,并使用基寄存器中的 0 来模拟直接寻址。在我们的测量中,我们对每种模式使用显示的第一个名称。用作硬件描述的 C 语言扩展在第 A.38 页中定义。

图 A.6 显示了寻址模式最常见的名称,尽管名称因架构而异。在本图和本书中,我们将使用 C 编程语言的扩展作为硬件描述符号。在本图中,只使用了一个非 C 特性:左箭头 ( ) 用于赋值。我们还使用数组 Mem 作为主存储器的名称,使用数组 Regs 作为寄存器的名称。因此,Mem[Regs[R1]] 指的是地址由寄存器 1 (R1) 的内容给出的存储器位置的内容。稍后,我们将介绍用于访问和传输小于一个字的数据的扩展。

寻址模式有能力显著减少指令计数;它们还增加了构建计算机的复杂性,并且可能会增加实现这些模式的计算机的平均时钟周期每条指令 (CPI)。因此,各种寻址模式的使用对于帮助架构师选择要包含的内容非常重要。

图 A.7 显示了在 VAX 架构上的三个程序中测量寻址模式使用模式的结果。我们在本附录中对旧 VAX 架构进行了一些测量,因为它拥有最丰富的寻址模式和对内存寻址的限制最少。例如,A.9 页上的图 A.6 显示了 VAX 支持的所有模式。然而,本附录中的大多数测量将使用更新的寄存器-寄存器架构来展示程序如何使用当前计算机的指令集。

如图 A.7 所示,位移和立即寻址主导寻址模式使用。让我们看看这两种大量使用的模式的一些属性。

Image in a image block

图 A.7 内存寻址模式(包括立即数)使用情况总结。这些主要寻址模式占所有内存访问的 97% 至 99%。未计入的寄存器模式占操作数引用的二分之一,而内存寻址模式(包括立即数)占另一半。当然,编译器会影响所使用的寻址模式;请参见第 A.8 节。VAX 上的内存间接模式可以使用位移、自动增量或自动减量来形成初始内存地址;在这些程序中,几乎所有内存间接引用都使用位移模式作为基准。位移模式包括所有位移长度(8、16 和 32 位)。几乎专门用于分支的 PC 相对寻址模式不包括在内。仅显示平均频率超过 1% 的寻址模式。

位移寻址模式

对于位移式寻址模式,出现的主要问题是所用位移的范围。基于使用各种位移大小,可以决定支持哪些大小。选择位移字段大小很重要,因为它们直接影响指令长度。图 A.8展示了使用我们的基准程序在加载存储架构上对数据访问进行的测量。我们在第 A.6 节中查看分支偏移——数据访问模式和分支是不同的;将它们组合起来几乎没有好处,尽管在实践中,为了简单起见,立即大小被设为相同。

Image in a image block

图 A.8 位移值分布很广。既有大量小值,也有相当数量的大值。位移值分布很广的原因是变量有多个存储区域,并且访问它们需要不同的位移(参见第 A.8 节),以及编译器使用的整体寻址方案。x 轴是位移的对数,即表示位移大小所需的字段大小。x 轴上的零显示值为 0 的位移的百分比。该图不包括符号位,该符号位受存储布局的影响很大。大多数位移为正,但大多数最大位移(14 + 位)为负。由于这些数据是在具有 16 位位移的计算机上收集的,因此它们无法告诉我们更长的位移。这些数据是在 Alpha 架构上使用完全优化(参见第 A.8 节)针对 SPEC CPU2000 采集的,显示了整数程序(CINT2000)的平均值和浮点程序(CFP2000)的平均值。

直接或文字寻址模式

立即数可用于算术运算、比较(主要用于分支)以及需要在寄存器中使用常量的移动中。后一种情况发生在代码中编写的常量(通常较小)和地址常量(通常较大)中。对于立即数的使用,重要的是要知道它们是否需要支持所有操作还是仅支持子集。图 A.9 显示了指令集中整数和浮点运算的一般类别的立即数频率。

另一个重要的指令集度量是立即数的值范围。与位移值一样,立即值的大小会影响指令长度。如图 A.10 所示,最常用的是较小的立即值。然而,有时也会使用较大的立即值,最有可能用于寻址计算。

摘要:内存寻址

首先,由于它们的普及性,我们希望一种新架构至少支持以下寻址模式:位移、立即数和寄存器间接。图 A.7 显示它们表示 75%–99% 的寻址模式

Image in a image block

图 A.9 大约四分之一的数据传输和 ALU 操作具有立即操作数。底部的条形图显示,整数程序在大约五分之一的指令中使用立即数,而浮点程序在大约六分之一的指令中使用立即数。对于加载,加载立即数指令将 16 位加载到 32 位寄存器的任一半中。从严格意义上来说,加载立即数不是加载,因为它们不访问内存。有时会使用一对加载立即数来加载 32 位常量,但这很少见。(对于 ALU 操作,以常量量移位被包括为具有立即操作数的操作。)用于收集这些统计信息的程序和计算机与图 A.8 中的相同。

Image in a image block

图 A.10 立即值的分布。x 轴显示表示立即值大小所需的位数——0 表示立即字段值为 0。大多数立即值是正数。对于 CINT2000,大约 20% 为负数,对于 CFP2000,大约 30% 为负数。这些测量是在 Alpha 上进行的,其中最大立即值为 16 位,与图 A.8 中的程序相同。在支持 32 位立即数的 VAX 上进行的类似测量表明,大约 20%–25% 的立即数长度超过 16 位。

因此,16 位将捕获大约 80%,8 位将捕获大约 50%。

在我们的测量中。其次,我们期望位移模式的地址大小至少为 12-16 位,因为图 A.8 中的标题表明这些大小将捕获 75%-99% 的位移。第三,我们期望立即字段的大小至少为 8-16 位。此声明未得到其所引用的图形标题的证实。

涵盖了指令集类并决定了寄存器-寄存器架构,加上之前关于数据寻址模式的建议,接下来我们涵盖数据的大小和含义。

操作数的类型和大小

如何指定操作数的类型?通常,操作码中的编码指定操作数的类型——这是最常用的方法。或者,数据可以用硬件解释的标记进行注释。这些标记指定操作数的类型,并相应地选择操作。但是,只有在计算机博物馆中才能找到带有标记数据的计算机。

让我们从台式机和服务器架构开始。通常,操作数的类型——整数、单精度浮点数、字符等——有效地给出了它的尺寸。常见的操作数类型包括字符(8 位)、半字(16 位)、字(32 位)、单精度浮点数(也为 1 字)和双精度浮点数(2 字)。整数几乎普遍表示为二进制补码。字符通常为 ASCII,但 16 位 Unicode(用于 Java)随着计算机的国际化而越来越受欢迎。在 20 世纪 80 年代初之前,大多数计算机制造商都选择了自己的浮点表示。自那时起,几乎所有计算机都遵循相同的浮点标准,即 IEEE 标准 754,尽管这种级别的精度最近已在特定于应用程序的处理器中被放弃。IEEE 浮点标准在附录 J 中进行了详细讨论。

某些体系结构提供字符字符串操作,尽管此类操作通常非常有限,并且将字符串中的每个字节视为单个字符。

字符字符串上支持的典型操作是比较和移动。

对于业务应用程序,某些体系结构支持十进制格式,通常称为压缩十进制或二进制编码十进制——4 位用于编码值 0-9,并且 2 个十进制数字打包到每个字节中。数字字符字符串有时称为未压缩十进制,并且通常提供称为压缩和解压缩的操作来在它们之间进行转换。

使用十进制操作数的一个原因是为了获得与十进制数完全匹配的结果,因为某些十进制分数在二进制中没有精确的表示形式。例如,0.10 是十进制中的一个简单分数,但在二进制中,它需要一组无限重复的数字:0:0001100110011。因此,在十进制中精确的计算在二进制中可能接近但不精确,这对于金融交易来说可能是一个问题。(请参阅附录 J 以了解有关精确算术的更多信息。)SPEC 基准使用字节或字符、半字(短整数)、字(整数和单精度浮点数)、双字(长整数)和浮点数据类型。图 A.11 显示了从内存中引用的这些程序对象的尺寸的动态分布。访问不同的数据类型有助于决定哪些类型最重要的是有效支持。计算机应该有 64 位访问路径,还是访问双字需要两个周期才令人满意?正如我们之前看到的,字节访问需要一个对齐网络:将字节作为基元支持有多重要?图 A.11 使用内存引用来检查正在访问的数据类型。

在某些架构中,寄存器中的对象可以作为字节或半字访问。然而,这种访问非常不频繁——在 VAX 上,它仅占寄存器引用的 12%,或这些程序中所有操作数访问的 6%。

Image in a image block

图 A.11 基准程序按大小分布的数据访问。双字数据类型用于浮点程序中的双精度浮点数和地址,因为计算机使用 64 位地址。在 32 位地址计算机上,64 位地址将被 32 位地址替换,因此整数程序中的几乎所有双字访问都将变为单字访问。

指令集中的操作

大多数指令集架构支持的操作可以归类为图 A.12 中所示。所有架构的一个经验法则就是执行最广泛的指令是指令集的简单操作。例如,图 A.13 显示了 10 条简单指令,这些指令占在流行的 Intel 80x86 上运行的整数程序集合中执行的指令的 96%。因此,这些指令的实现者应该确保使这些指令快速,因为它们是常见情况。

Image in a image block

图 A.12 指令操作符的类别及其示例。所有计算机通常为前三个类别提供一整套操作。指令集中对系统功能的支持在不同架构中差异很大,但所有计算机都必须对基本系统功能提供一些指令支持。指令集中对最后四个类别的支持量可能从无到一组广泛的特殊指令不等。任何打算用于大量使用浮点的应用程序的计算机都将提供浮点指令。这些指令有时是可选指令集的一部分。十进制和字符串指令有时是基元,如 VAX 或 IBM 360 中的指令,或者可以由编译器从更简单的指令中合成。图形指令通常并行操作许多较小的数据项,例如对两个 64 位操作数执行八个 8 位加法。

Image in a image block

图 A.13 80x86 的前 10 条指令。简单指令占据此列表的主导地位,并且负责执行 96% 的指令。这些百分比是五个 SPECint92 程序的平均值。

如前所述,图 A.13 中的指令可在每台计算机的每个应用程序(桌面、服务器、嵌入式)中找到,图 A.12 中的操作变化在很大程度上取决于指令集包含哪些数据类型。

控制流说明

由于分支和跳转行为的测量与其他测量和应用程序相当独立,因此我们现在检查控制流指令的使用,这与前几节的操作几乎没有共同之处。

对于改变控制流的指令没有一致的术语。在 20 世纪 50 年代,它们通常被称为传输。从 1960 年开始,开始使用分支名称。后来,计算机引入了其他名称。在本书中,当控制更改无条件时,我们将使用跳转,当更改有条件时,我们将使用分支。

我们可以区分四种不同类型的控制流更改:

  • 条件分支
  • 跳转
  • 过程调用
  • 过程返回

我们希望了解这些事件的相对频率,因为每个事件都是不同的,可能使用不同的指令,并且可能具有不同的行为。图 A.14 显示了运行我们基准的加载存储计算机的这些控制流指令的频率。

Image in a image block

图 A.14 将控制流指令细分为三类:调用或返回、跳转和条件分支。条件分支明显占主导地位。每种类型都在三个条形图之一中进行计数。用于收集这些统计信息的程序和计算机与图 A.8 中的相同。

控制流指令寻址模式

控制流指令的目标地址必须始终指定。在绝大多数情况下,此目标在指令中明确指定——过程返回是主要例外,因为对于返回,目标在编译时未知。指定目标最常见的方法是提供一个位移,该位移将添加到程序计数器 (PC) 中。这种类型的控制流指令称为 PC 相对。PC 相对分支或跳转是有利的,因为目标通常靠近当前指令,并且相对于当前 PC 指定位置需要更少的位。使用 PC 相对寻址还允许代码独立于加载位置运行。此属性称为位置独立性,可以在程序链接时消除一些工作,并且在执行期间动态链接的程序中也很有用。

要在编译时不知道目标时实现返回和间接跳转,需要一种不同于 PC 相对寻址的方法。在此,必须有一种方法来动态指定目标,以便它可以在运行时更改。此动态地址可能像命名包含目标地址的寄存器一样简单;或者,跳转可以允许使用任何寻址模式来提供目标地址。

这些寄存器间接跳转还可用于另外四个重要功能:

  • 大多数编程语言中发现的 case 或 switch 语句(在几个备选方案中进行选择)。
  • 面向对象语言(如 C++ 或 Java)中的虚函数或方法(允许根据参数的类型调用不同的例程)。
  • 在 C 或 C++ 等语言中,高阶函数或函数指针(允许将函数作为参数传递,提供了一些面向对象编程的风格)。
  • 动态共享库(允许在程序实际调用库时才在运行时加载和链接库,而不是在程序运行之前静态加载和链接库)。

在所有四种情况下,目标地址在编译时未知,因此通常在寄存器间接跳转之前从内存加载到寄存器中。

由于分支通常使用 PC 相对寻址来指定其目标,因此一个重要的问题涉及分支目标与分支的距离。了解这些位移的分布将有助于选择要支持的分支偏移量,从而影响指令长度和编码。图 A.15 显示了指令中 PC 相对分支的位移分布。

大约 75% 的分支是向前方向的。

条件分支选项

由于控制流中的大多数更改都是分支,因此决定如何指定分支条件非常重要。图 A.16 显示了当今使用中的三种主要技术及其优缺点。

Image in a image block

图 A.15 目标与分支指令之间指令数量的分支距离。整数程序中最频繁的分支是到可以用 4-8 位编码的目标。此结果告诉我们,较短的位移字段通常足以用于分支,并且设计人员可以通过使用具有较小分支位移的较短指令来获得一些编码密度。这些测量是在负载存储计算机(Alpha 架构)上进行的,所有指令都对齐在字边界上。对于相同程序需要较少指令的架构(例如 VAX)将具有较短的分支距离。但是,如果计算机具有可变长度指令以对齐在任何字节边界上,则位移所需的位数可能会增加。用于收集这些统计信息的程序和计算机与图 A.8 中的相同。

Image in a image block

图 A.16 评估分支条件的主要方法及其优点和缺点。虽然条件码可以由 ALU 操作设置,而这些操作对于其他目的是必需的,但对程序的测量表明这种情况很少发生。当条件码由大量或随意选择的指令子集设置时,条件码的主要实现问题就会出现,而不是由指令中的位控制。具有比较和分支的计算机通常会限制比较的集合,并使用单独的操作和寄存器进行更复杂的比较。通常,对于基于浮点比较的分支和基于整数比较的分支,使用不同的技术。这种二分法是合理的,因为依赖于浮点比较的分支数量远小于依赖于整数比较的分支数量。

分支最明显的特性之一是,大量的比较都是简单的测试,并且大量的比较都是与零的比较。因此,一些架构选择将这些比较作为特殊情况来处理,尤其是在使用比较和分支指令时。图 A.17 显示了用于条件分支的不同比较的频率。

过程调用选项

过程调用和返回包括控制转移,可能还有一些状态保存;至少必须在某个地方保存返回地址,有时在特殊链接寄存器中或只是 GPR 中。一些较旧的架构提供了一种保存许多寄存器的机制,而较新的架构则要求编译器为每个保存和恢复的寄存器生成存储和加载。

有两种基本约定用于保存寄存器:在调用站点或在被调用的过程中。调用者保存意味着调用过程必须保存它希望在调用后保留以供访问的寄存器,因此被调用过程不必担心寄存器。被调用者保存正好相反:被调用过程必须保存它要使用的寄存器,让调用者不受限制。有时必须使用调用者保存,因为对两个不同过程中全局可见变量的访问模式。例如,假设我们有一个过程 P1 调用过程 P2,并且两个过程都操作全局变量 x。如果 P1 已将 x 分配给寄存器,则在调用 P2 之前,它必须确保将 x 保存到 P2 已知的位置。编译器发现被调用过程可能访问寄存器分配数量的能力因单独编译的可能性而变得复杂。假设 P2 可能不会接触 x,但可以调用另一个过程 P3,该过程可能会访问 x,但 P2 和 P3 是单独编译的。由于这些复杂性,大多数编译器将保守地调用保存任何可能在调用期间访问的变量。

在可以使用任一约定的情况下,一些程序使用被调用者保存会更优,而另一些程序使用调用者保存会更优。因此,当今大多数实际系统都将这两种机制结合使用。此约定在应用程序二进制接口 (ABI) 中指定,该接口规定了哪些寄存器应由调用者保存,哪些寄存器应由被调用者保存的基本规则。在本附录的后面部分,我们将检查用于自动保存寄存器的复杂指令与编译器需求之间的不匹配。

Image in a image block

图 A.17 条件分支中不同类型比较的频率。小于(或等于)分支在这个编译器和架构组合中占主导地位。这些测量包括分支中的整数和浮点比较。用于收集这些统计数据的程序和计算机与图 A.8 中的相同。

摘要:控制流指令

控制流指令是最常执行的指令之一。尽管有许多条件分支选项,但我们期望在一种新架构中分支寻址,以便能够跳转到分支上方或下方的数百条指令。此要求表明 PC 相对分支位移至少为 8 位。我们还希望看到寄存器间接和 PC 相对寻址用于跳转指令,以支持返回以及当前系统中的许多其他功能。

我们现在已经完成了汇编语言程序员或编译器编写人员看到的级别的指令架构之旅。我们倾向于采用具有位移、立即数和寄存器间接寻址模式的加载存储架构。这些数据是 8 位、16 位、32 位和 64 位整数以及 32 位和 64 位浮点数据。指令包括简单操作、PC 相对条件分支、用于过程调用的跳转和链接指令以及用于过程返回的寄存器间接跳转(以及其他一些用途)。

现在我们需要选择如何以一种便于硬件执行的形式来表示此架构。

编码指令集

显然,本文中提到的选择将影响指令如何编码成二进制表示以供处理器执行。此表示不仅影响已编译程序的大小,还影响处理器的实现,处理器必须解码此表示以快速找到操作及其操作数。操作通常在一个称为操作码的字段中指定。正如我们将看到的,重要的决定是如何使用操作对寻址模式进行编码。

此决定取决于寻址模式的范围以及操作码和模式之间的独立程度。一些较旧的计算机每个操作数有一个到五个操作数,每个操作数有 10 个寻址模式(参见图 A.6)。对于如此大量的组合,通常需要为每个操作数一个单独的地址说明符:地址说明符告诉使用什么寻址模式来访问操作数。另一个极端是只有单个内存操作数且仅有一个或两个寻址模式的加载存储计算机;显然,在这种情况下,寻址模式可以编码为操作码的一部分。

在对指令进行编码时,寄存器数量和寻址模式数量都会对指令的大小产生重大影响,因为寄存器字段和寻址模式字段可能在单个指令中出现多次。事实上,对于大多数指令,在对寻址模式和寄存器字段进行编码时消耗的比特数比在指定操作码时消耗的比特数要多得多。在对指令集进行编码时,架构师必须平衡几个相互竞争的力量:

  1. 希望尽可能多地拥有寄存器和寻址模式。
  2. 寄存器和寻址模式字段的大小对平均指令大小以及因此对平均程序大小的影响。
  3. 希望将指令编码成在流水线实现中易于处理的长度。(在附录 C 和第 3 章中讨论了易于解码指令的价值。)作为最低要求,架构师希望指令是字节的倍数,而不是任意位长。许多台式机和服务器架构师选择使用固定长度指令以获得实现优势,同时牺牲平均代码大小。

图 A.18 展示了用于对指令集进行编码的三种流行选择。第一个我们称之为可变,因为它允许几乎所有寻址模式与所有操作一起使用。当寻址模式和操作很多时,这种风格最好。第二个选择我们称之为固定,因为它将操作和寻址模式组合到操作码中。通常,固定编码只有一个

Image in a image block

图 A.18 指令编码中的三种基本变化:可变长度、固定长度和混合。可变格式可以支持任意数量的操作数,每个地址说明符确定寻址模式和该操作数的说明符长度。它通常启用最小的代码表示,因为不需要包含未使用的字段。固定格式始终具有相同数量的操作数,寻址模式(如果存在选项)指定为操作码的一部分。它通常会导致最大的代码大小。尽管字段往往不会在位置上变化,但它们将被不同的指令用于不同的目的。混合方法具有由操作码指定的多重格式,添加一个或两个字段来指定寻址模式,并添加一个或两个字段来指定操作数地址。

所有指令的大小;当寻址模式和操作较少时,效果最佳。可变编码和固定编码之间的权衡是程序大小与处理器中解码的难易程度。可变编码尝试使用尽可能少的位来表示程序,但各个指令的大小和执行的工作量可能会有很大差异。

让我们看一条 80x86 指令,以了解变量编码的一个示例:

add EAX,1000(EBX)

add 指令名称表示一个带两个操作数的 32 位整数加法指令,此操作码占 1 个字节。80x86 地址说明符为 1 或 2 个字节,指定源/目标寄存器 (EAX) 和寻址模式(本例中为偏移量)以及第二个操作数的基址寄存器 (EBX)。此组合占 1 个字节来指定操作数。在 32 位模式下(请参阅附录 K),地址字段的大小为 1 个字节或 4 个字节。由于 1000 大于 2,因此指令的总长度为

1+1+4=6 bytes

80x86 指令的长度在 1 到 17 个字节之间变化。80x86 程序通常比使用固定格式的 RISC 架构更小(参见附录 K)。

鉴于可变和固定这两种指令集设计的两极分化,第三种选择立即浮现在脑海中:减少可变架构中大小和工作量的可变性,但提供多种指令长度以减小代码大小。这种混合方法是第三种编码选择,我们很快就会看到示例。

RISC 中的代码大小减小

随着 RISC 计算机开始用于嵌入式应用程序,32 位固定格式成为一种负担,因为成本和更小的代码很重要。作为回应,一些制造商提供了 RISC 指令集的新混合版本,既有 16 位指令,也有 32 位指令。窄指令支持更少的操作、更小的地址和立即字段、更少的寄存器以及双地址格式,而不是 RISC 计算机的经典三地址格式。RISC-V 提供了这样的扩展,称为 RV32IC,C 代表压缩。常见的指令出现,例如具有小值的中值和源寄存器与目标寄存器相同的常见 ALU 操作,都以 16 位格式编码。附录 K 给出了另外两个示例,ARM Thumb 和 microMIPS,它们都声称代码大小减少了 40%。

与这些指令集扩展相反,IBM 只是压缩其标准指令集,然后添加硬件以在指令高速缓存未命中时从内存中获取指令时对其进行解压缩。因此,指令高速缓存包含完整的 32 位指令,但压缩代码保留在主内存、ROM 和磁盘中。压缩格式的优点,例如 RV32IC,microMIPS 和 Thumb2 是指令缓存的作用,好像它们大约大了 25%,而 IBM 的 CodePack 意味着编译器无需更改即可处理不同的指令集,并且指令解码可以保持简单。

CodePack 从对任何 PowerPC 程序进行游程编码压缩开始,然后将生成的压缩表加载到芯片上的 2 KB 表中。因此,每个程序都有自己独特的编码。为了处理不再对齐到字边界的分支,PowerPC 在内存中创建一个哈希表,用于在压缩地址和未压缩地址之间进行映射。与 TLB(参见第 2 章)类似,它缓存最近使用的地址映射,以减少内存访问次数。IBM 声称整体性能成本为 10%,导致代码大小减少 35%–40%。

摘要:编码指令集

在前面章节中讨论的指令集设计组件中做出的决策决定了架构师是否可以在可变和固定指令编码之间进行选择。在给定选择的情况下,更注重代码大小而不是性能的架构师将选择可变编码,而更注重性能而不是代码大小的架构师将选择固定编码。RISC-V、MIPS 和 ARM 都具有使用 16 位指令的指令集扩展,以及 32 位;具有严重代码大小限制的应用程序可以选择使用 16 位变体来减小代码大小。附录 E 给出了 13 个架构师选择结果的示例。在附录 C 和第 3 章中,将进一步讨论可变性对处理器性能的影响。

我们几乎已经完成了为将在 A.9 节中介绍的 RISC-V 指令集架构奠定基础的工作。然而,在这样做之前,快速了解一下编译器技术及其对程序属性的影响将很有帮助。

交叉问题:编译器的作用

如今,几乎所有编程都是针对桌面和服务器应用程序以高级语言完成的。这一发展意味着,由于执行的大多数指令都是编译器的输出,因此指令集架构本质上是编译器目标。在这些应用程序的早期,通常会做出架构决策以简化汇编语言编程或针对特定内核。由于编译器将极大地影响计算机的性能,因此当今理解编译器技术对于设计和高效实现指令集至关重要。

曾经流行的做法是尝试将编译器技术及其对硬件性能的影响与架构及其性能隔离开来,就像尝试将架构与其实现分开一样。这种分离对于当今的桌面编译器和计算机来说基本上是不可能的。架构选择会影响可为计算机生成的代码的质量以及为其构建良好编译器的复杂性,无论好坏。

在本节中,我们主要从编译器的角度讨论指令集中的关键目标。它从回顾当前编译器的解剖开始。接下来,我们讨论编译器技术如何影响架构师的决策,以及架构师如何让编译器更轻松或更难生成良好的代码。我们以对编译器和多媒体操作的回顾结束,不幸的是,这是编译器编写者和架构师之间合作的一个糟糕示例。

最近编译器的结构

首先,让我们看看当今的优化编译器是什么样的。图 A.19 显示了最近编译器的结构。

编译器编写者的首要目标是正确性——所有有效程序都必须正确编译。第二个目标通常是编译代码的速度。通常,还有一整套其他目标紧随其后,包括快速编译、调试支持以及语言之间的互操作性。通常,这些过程在编译器中,将更高级、更抽象的表示形式转换为更低级的表示形式。最终它到达指令集。此结构有助于管理转换的复杂性,并使编写无错误的编译器变得更容易。

编写一个正确的编译器的复杂性是优化量的一个主要限制。尽管多遍结构有助于降低编译器复杂性,但这也意味着编译器必须在其他转换之前对某些转换进行排序并执行。在图 A.19 中优化编译器的图表中,我们可以看到在知道结果代码将是什么样子之前,会执行某些高级优化。一旦进行这样的转换,编译器就无法回过头来重新审视所有步骤,可能会撤消转换。这种迭代在编译时间和复杂性上都是不可接受的。因此,编译器对后续步骤处理某些问题的能力做出假设。例如,编译器通常必须在知道被调用过程的确切大小之前选择要内联展开哪些过程调用。编译器编写人员将此问题称为阶段排序问题。

这种转换顺序如何与指令集架构交互?一个很好的例子是称为全局公共子表达式消除的优化。此优化找到计算相同值的表达式的两个实例,并将第一次计算的值保存在一个临时变量中。然后它使用临时值,消除公共表达式的第二次计算。

为了使此优化具有意义,必须将临时变量分配给寄存器。否则,将临时变量存储在内存中并稍后重新加载它的成本可能会抵消不重新计算表达式而节省的成本。事实上,当临时变量未分配给寄存器时,此优化实际上会使代码运行变慢。阶段排序使此问题复杂化,因为寄存器分配通常在全局优化传递的末尾完成,就在生成代码之前。因此,执行此优化的优化器必须假设寄存器分配器会将临时变量分配给寄存器。

现代编译器执行的优化可以按转换样式分类,如下所示:

  • 高级优化通常在源代码上完成,输出会馈送到后期的优化步骤中。
  • 局部优化仅优化直线代码片段(编译器人员称之为基本块)中的代码。
  • 全局优化将局部优化扩展到分支中,并引入了一组旨在优化循环的转换。
  • 寄存器分配将寄存器与操作数关联起来。
  • 与处理器相关的优化尝试利用特定的架构知识。

Image in a image block

图 A.19 编译器通常由两到四次遍历组成,优化程度更高的编译器具有更多遍历。此结构最大程度地提高了在不同优化级别编译的程序在给定相同输入时产生相同输出的可能性。优化遍历被设计为可选的,并且当目标是更快的编译且可接受较低质量的代码时,可以跳过这些遍历。遍历只是编译器读取和转换整个程序的一个阶段。(术语阶段通常与遍历互换使用。)由于优化遍历是分开的,因此多种语言可以使用相同的优化和代码生成遍历。新语言只需要一个新的前端。

寄存器分配

由于寄存器分配在加速代码和使其他优化变得有用方面发挥着核心作用,因此它是最重要的优化之一,如果不是最重要的优化。如今的寄存器分配算法基于一种称为图着色的技术。图着色的基本思想是构建一个图,表示分配给寄存器的可能候选,然后使用该图来分配寄存器。粗略地说,问题是如何使用有限的一组颜色,以便依赖关系图中没有任何两个相邻节点具有相同的颜色。该方法的重点是实现活动变量的 100% 寄存器分配。给图着色的问题通常可以作为图的大小(NP 完全)的函数呈指数时间。然而,有一些启发式算法在实践中效果很好,可以产生在近线性时间内运行的接近分配。

当至少有 16 个(最好更多)可用于整数变量全局分配的通用寄存器以及用于浮点的其他寄存器时,图着色效果最佳。不幸的是,当寄存器数量较少时,图着色效果不佳,因为用于对图着色的启发式算法很可能失败。

优化对性能的影响

有时很难将一些更简单的优化(局部和处理器相关的优化)与代码生成器中完成的转换区分开来。图 A.20 中给出了典型优化的示例。图 A.20 的最后一列指示了列出的优化转换应用于源程序的频率。

图 A.21 显示了各种优化对两个程序执行的指令的影响。在这种情况下,经过优化的程序执行的指令比未优化的程序少约 25%–90%。该图说明了在提出新的指令集特性之前查看经过优化的代码的重要性,因为编译器可能会完全删除架构师试图改进的指令。

编译器技术对架构决策的影响

编译器和高级语言的交互显著影响程序如何使用指令集架构。有两个重要问题:变量如何分配和寻址?需要多少寄存器才能适当地分配变量?为了解决这些问题,我们必须研究当前高级语言分配其数据的三个独立区域:

  • 堆栈用于分配局部变量。在过程调用或返回时,堆栈分别增长或缩小。堆栈上的对象相对于堆栈指针寻址,并且主要是标量(单个变量)而不是数组。堆栈用于激活记录,而不是用作计算表达式的堆栈。因此,几乎从不将值压入或弹出堆栈。
  • 全局数据区用于分配静态声明的对象,例如全局变量和常量。这些对象中很大一部分是数组或其他聚合数据结构。
  • 堆用于分配不遵守堆栈规则的动态对象。堆中的对象通过指针访问,通常不是标量。
Image in a image block

图 A.20 每类中的主要优化类型和示例。这些数据告诉我们各种优化出现的相对频率。第三列列出了在 12 个小型 Fortran 和 Pascal 程序集中应用一些常见优化的静态频率。编译器执行了九项局部和全局优化,这些优化包含在测量中。图中涵盖了其中六项优化,其余三项占静态总发生次数的 18%。缩写 N.M. 表示未测量该优化的发生次数。处理器相关的优化通常在代码生成器中完成,并且在此实验中未测量任何此类优化。百分比是指定类型静态优化的部分。数据来自 Chow, F.C., 1983。一种可移植的机器无关全局优化器——设计和测量(博士论文)。斯坦福大学,帕洛阿尔托,加利福尼亚州(使用斯坦福 UCODE 编译器收集)。

对于堆栈分配的对象,寄存器分配比全局变量更有效,而对于堆分配的对象,寄存器分配基本上是不可能的,因为它们是通过指针访问的。全局变量和一些堆栈变量无法分配,因为它们是别名——有多种方法可以引用变量的地址,这使得将其放入寄存器中是非法的。(对于当今的编译器技术,大多数堆变量实际上都是别名。)例如,考虑以下代码序列,其中 & 返回变量的地址,* 解引用指针:

Image in a image block

变量 a 无法通过对 *p 的赋值进行寄存器分配,而不生成不正确的代码。 别名会导致严重的问题,因为通常很难或不可能确定指针可以引用哪些对象。 编译器必须是保守的; 当存在可能引用局部变量之一的指针时,某些编译器不会在寄存器中分配过程的任何局部变量。

架构师如何帮助编译器编写器

如今,编译器的复杂性并不源于翻译诸如 A = B + C 之类的简单语句。大多数程序在局部都是简单的,简单的翻译效果很好。相反,复杂性之所以产生,是因为程序在交互中庞大且在全局上复杂,并且因为编译器的结构意味着每次都会针对哪个代码序列最佳做出决策。

编译器编写器通常在其自己的基本原理推论下工作

架构中的原则:使常见情况快速,罕见情况正确。也就是说,如果我们知道哪些情况常见,哪些情况罕见,并且为两者生成代码都很简单,那么罕见情况的代码质量可能并不重要——但它必须是正确的!

一些指令集属性有助于编译器编写器。这些属性不应被视为严格的规则,而应被视为编写编译器以生成高效且正确的代码的准则。

  • 提供规则性——只要有意义,指令集的三个主要组成部分(操作、数据类型和寻址模式)应该是正交的。如果架构的两个方面是独立的,则称它们是正交的。例如,如果对于可以应用一种寻址模式的每个操作,所有寻址模式都适用,则操作和寻址模式是正交的。这种规则性有助于简化代码生成,并且在将生成什么代码的决策分成编译器的两遍时尤其重要。此属性的一个很好的反例是限制哪些寄存器可用于特定类别的指令。针对专用寄存器架构的编译器通常会陷入这种困境。此限制可能导致编译器发现自己拥有大量可用寄存器,但没有一种是合适的!
  • 提供基本功能,而不是解决方案——“匹配”语言结构或内核函数的特殊功能通常不可用。支持高级语言的尝试可能只适用于一种语言,或者比正确有效地实现该语言所需的更多或更少。有关此类尝试如何失败的示例,请参见 A.10 节。
  • 简化替代方案之间的权衡——编译器编写人员最困难的工作之一是找出对出现的每个代码段来说最佳的指令序列。在早期,指令计数或总代码大小可能是很好的度量,但——正如我们在第 1 章中看到的——这不再是真的。有了缓存和流水线,权衡变得非常复杂。设计人员可以采取任何措施来帮助编译器编写人员了解备用代码序列的成本,这将有助于改进代码。在寄存器-内存架构中,最困难的复杂权衡之一是决定在将变量加载到寄存器中之前应该引用它多少次。此阈值很难计算,事实上,它可能因同一架构的不同模型而异。
  • 提供将编译时已知数量绑定为常量的指令——编译器编写者讨厌处理器在运行时解释编译时已知的值。此原则的良好反例包括解释在编译时已固定的值的指令。例如,VAX 过程调用指令 (calls) 动态解释一个掩码,说明在调用时要保存哪些寄存器,但该掩码在编译时是固定的(参见 A.10 节)。
编译器对多媒体指令的支持(或缺乏支持)

唉,SIMD 指令的设计者(参见第 4 章的 4.3 节)基本上忽略了前一小节。这些指令往往是解决方案,而不是基元;它们缺少寄存器;并且数据类型与现有的编程语言不匹配。架构师希望找到一种廉价的解决方案来帮助一些用户,但通常只有少数低级图形库例程使用它们。

SIMD 指令实际上是优雅架构风格的缩写版本,它有自己的编译器技术。如第 4.2 节所述,矢量架构对数据矢量进行操作。最初为科学代码发明,多媒体内核通常也可以矢量化,尽管通常使用较短的矢量。如第 4.3 节所述,我们可以将英特尔的 MMX 和 SSE 或 PowerPC 的 AltiVec,或 RISC-V P 扩展视为短矢量计算机:MMX 具有八个 8 位元素、四个 16 位元素或两个 32 位元素的矢量,而 AltiVec 具有两倍长度的矢量。它们被实现为宽寄存器中的简单相邻窄元素。
这些微处理器架构将矢量寄存器大小构建到架构中:对于 MMX,元素大小之和限制为 64 位,对于 AltiVec 限制为 128 位。当英特尔决定扩展到 128 位矢量时,它添加了一整套新的指令,称为流式 SIMD 扩展 (SSE)。

这些微处理器体系结构将向量寄存器的大小内置到体系结构中:元素大小的总和对于MMX来说被限制为64位,对于AltiVec来说被限制为128位。当Intel决定扩展到128位向量时,它添加了一个全新的指令集,称为流式SIMD扩展(SSE)。

矢量计算机的一大优势是通过一次加载多个元素,然后用数据传输覆盖执行,从而隐藏内存访问的延迟。矢量寻址模式的目标是收集分散在内存中的数据,将它们放在紧凑的形式中以便可以有效地对它们进行操作,然后将结果放回它们所属的位置。

矢量计算机包括跨步寻址和收集/分散寻址(参见第 4.2 节)以增加可矢量化的程序数量。跨步寻址在每次访问之间跳过固定数量的字,因此顺序寻址通常称为单位跨步寻址。收集和分散找到它们的另一个向量寄存器中的地址:将其视为向量计算机的寄存器间接寻址。相比之下,从向量角度来看,这些短向量 SIMD 计算机仅支持单位步长访问:内存访问一次从单个宽内存位置加载或存储所有元素。由于多媒体应用程序的数据通常是从内存开始和结束的流,因此步进和收集/分散寻址模式对于成功的向量化至关重要(参见第 4.7 节)。

总结:编译器的作用

本节提出了几项建议。首先,我们希望新的指令集架构至少有 16 个通用寄存器——不包括

这一部分提出了几点建议。首先,我们期望新的指令集架构至少具有16个通用寄存器 —— 不包括浮点数的单独寄存器,以简化使用图着色分配寄存器的过程。正交性建议表明,所有支持的寻址模式适用于传输数据的所有指令。最后,最后三条建议 —— 提供原语而不是解决方案,简化替代之间的权衡,不在运行时绑定常量 —— 都表明在指令集设计中,容易出错的一方更好。换句话说,请理解在指令集设计中,少即是多。唉,SIMD扩展更多地是好的市场营销例子,而不是硬件-软件协同设计的杰出成就。

将所有内容放在一起:RISC-V 架构

本节中,我们将介绍名为 RISC-V 的加载存储架构。RISC-V 是一种自由许可的开放标准,类似于许多 RISC 架构,并且基于与上一节中涵盖的类似观察结果。(在 M.3 节中,我们将讨论这些架构如何以及为何变得流行。)RISC-V 构建在 30 年的 RISC 架构经验之上,并“清理”了大多数短期纳入和遗漏,从而形成了一种更容易、更有效率的实现架构。RISC-V 提供 32 位和 64 位指令集,以及各种扩展,用于浮点等功能;这些扩展可以添加到 32 位或 64 位基本指令集中。我们讨论了 RISC-V 的 64 位版本 RV64,它是 32 位版本 RV32 的超集。

一个极简指令集。本节没有涵盖浮点程序,但它们通常使用单独的浮点寄存器。这样做的理由是增加寄存器的总数,而不会在指令格式或通用寄存器文件的速度方面产生问题。然而,这种折衷并不是正交的。

我们通过展示 RISC-V 如何遵循这些建议来介绍 RISC-V。与 RISC 前辈一样,RISC-V 强调

  • 一个简单的加载存储指令集。
  • 设计用于流水线效率(在附录 C 中讨论),包括一个固定的指令集编码。
  • 作为编译器目标的效率。

RISC-V 提供了一个很好的架构模型用于学习,不仅是因为这种类型的处理器很流行,还因为它是一种易于理解的架构。我们将在附录 C 和第 3 章中再次使用此架构,它构成了许多练习和编程项目的基础。

RISC-V 指令集组织

RISC-V 指令集被组织为三个基本指令集,支持 32 位或 64 位整数,以及对其中一个基本指令集的各种可选扩展。这允许 RISC-V 为广泛的潜在应用实施,从逻辑和内存预算最小的嵌入式处理器(可能成本低于 1 美元)到完全支持浮点、向量和多处理器配置的高端处理器配置。图 A.22 总结了三个基本指令集和具有其基本功能的指令集扩展。出于本文的目的,我们在示例中使用 RV64IMAFD(也称为 RV64G,简称)。RV32G 是 64 位架构 RV64G 的 32 位子集。

RISC-V 寄存器

RV64G 具有 32 个 64 位通用寄存器 (GPR),分别命名为 x0、x1、…、x31。GPR 有时也称为整数寄存器。此外,对于作为 RV64G 一部分的浮点 F 和 D 扩展,还有一组 32 个浮点寄存器 (FPR),分别命名为 f0、f1、…、f31,它们可以保存 32 个单精度(32 位)值或 32 个双精度(64 位)值。(当保存一个单精度数字时,FPR 的另一半未使用。)提供了单精度和双精度浮点运算(32 位和 64 位)。

x0 的值始终为 0。我们稍后将看到如何使用此寄存器从简单的指令集中合成各种有用的运算。

可以将几个特殊寄存器传输到通用寄存器和从通用寄存器传输。一个示例是浮点状态寄存器,用于保存用于保存浮点运算结果的信息。还有一些用于在 FPR 和 GPR 之间移动的指令。

RISC-V 的数据类型

对于整数数据,数据类型为 8 位字节、16 位半字、32 位字和 64 位双字,对于浮点数,数据类型为 32 位单精度和 64 位双精度。添加半字是因为它们存在于 C 等语言中并且在一些程序中很流行,例如操作系统,关心数据结构的大小。如果 Unicode 得到广泛使用,它们也将变得更加流行。

RV64G 操作在 64 位整数和 32 或 64 位浮点上进行。字节、半字和字以零或复制的符号位加载到通用寄存器中,以填充 GPR 的 64 位。加载后,使用 64 位整数运算对其进行操作。

RISC-V 数据传输寻址模式

唯一的数据寻址模式是立即寻址和位移寻址,两者都带有 12 位字段。寄存器间接寻址只需在 12 位位移字段中放置 0 即可实现,而使用寄存器 0 作为基寄存器,则可以通过 12 位字段实现有限的绝对寻址。采用零为我们提供了四种有效模式,尽管架构中只支持两种模式。

RV64G 内存可通过 64 位地址进行字节寻址,并使用小端字节编号。由于它是一种加载存储架构,因此内存与 GPR 或 FPR 之间的所有引用都是通过加载或存储进行的。为了支持本文中提到的数据类型,涉及 GPR 的内存访问可以是字节、半字、字或双字。FPR 可以使用单精度或双精度数字进行加载和存储。内存访问不必对齐;但是,未对齐的访问可能会运行得非常慢。实际上,程序员和编译器使用未对齐的访问是愚蠢的。

RISC-V 指令格式

由于 RISC-V 只有两种寻址模式,因此可以将它们编码到操作码中。按照使处理器易于流水线化和解码的建议,所有指令都是 32 位,带有 7 位主操作码。图 A.23 显示了四种主要指令类型的指令布局。这些格式很简单,同时为位移寻址、立即数常量或 PC 相对分支地址提供了 12 位字段。

Image in a image block

图 A.23 RISC-V 指令布局。这些格式有两种变体,称为 SB 和 UJ 格式;它们对立即数字段的处理略有不同。

Image in a image block

图 A.24 每个指令类型的指令字段使用情况。主要用途显示使用该格式的主要指令。空白表示此指令类型中不存在相应的字段。I 格式用于加载和 ALU 立即数,其中 12 位立即数保存立即数的值或加载的位移。类似地,S 格式对存储指令(其中第一个源寄存器是基寄存器,第二个包含要存储的值的寄存器源)和比较和分支指令(其中寄存器字段包含要比较的源,立即数字段指定分支目标的偏移量)进行编码。实际上还有另外两种格式:SB 和 UJ,它们遵循与 S 和 J 相同的基本组织,但略微修改了立即数字段的解释。

指令格式和指令字段的使用在图 A.24 中进行了描述。操作码指定了通用指令类型(ALU 指令、ALU 立即数、加载、存储、分支或跳转),而 funct 字段用于特定操作。例如,ALU 指令使用单个操作码进行编码,funct 字段决定了确切的操作:加、减、与等。请注意,几种格式对多种类型的指令进行编码,包括 I 格式用于 ALU 立即数和加载,以及 S 格式用于存储和条件分支。

RISC-V 操作

RISC-V(或更准确地说是 RV64G)支持本文推荐的简单操作列表以及其他一些操作。指令有四类:加载和存储、ALU 操作、分支和跳转以及浮点操作。

可以加载或存储任何通用或浮点寄存器,但加载 x0 无效。图 A.25 给出了加载和存储指令的示例。单精度浮点数占据浮点寄存器的一半。必须显式地进行单精度和双精度之间的转换。浮点格式为 IEEE 754(参见附录 J)。图 A.28(第 A.42 页)列出了所有 RV64G 指令。

RISC-V 控制流指令

控制通过一组跳转和一组分支来处理,图 A.27 给出了一些典型的分支和跳转指令。两个跳转指令(跳转和链接以及跳转和链接寄存器)是无条件传输,并且总是将“链接”存储在寄存器中,该“链接”是跳转指令后连续的指令的地址,由 rd 字段指定。如果不需要链接地址,则 rd 字段可以简单地设置为 x0,这将导致典型的无条件跳转。两个跳转指令的区别在于地址是通过向 PC 添加立即字段还是通过添加立即字段到寄存器的内容。偏移量被解释为半字偏移量,以兼容包含 16 位指令的压缩指令集 R64C。

所有分支都是条件分支。分支条件由指令指定,并且允许任何算术比较(等于、大于、小于及其反向)。分支目标地址使用 12 位带符号偏移量指定,该偏移量左移一位(以获得 16 位对齐),然后添加到当前程序计数器。基于浮点寄存器内容的分支通过执行浮点比较(例如,feq.d 或 fle.d)来实现,该比较根据比较将整数寄存器设置为 0 或 1,然后使用 x0 作为操作数执行 beq 或 bne。

细心的读者会注意到 RV64G 中只有极少数仅限 64 位的指令。主要是 64 位加载和存储以及 32 位、16 位和 8 位加载的版本,这些版本不会进行符号扩展(默认情况下进行符号扩展)。为了在没有额外指令的情况下支持 32 位模运算,有一些指令版本会忽略 64 位寄存器的上 32 位,例如加法和减法字(addw、subw)。令人惊讶的是,其他一切都正常工作。

RISC-V 浮点运算

浮点指令操作浮点寄存器,并指示要执行的操作是单精度还是双精度。浮点运算包括加、减、乘、除、平方根,以及融合乘加和乘减。所有浮点指令都以字母 f 开头,并使用后缀 d 表示双精度,s 表示单精度(例如,fadd.d、fadd.s、fmul.d、fmul.s、fmadd.d fmadd.s)。浮点比较根据比较设置整数寄存器,类似于整数指令集小于和大于。

除了浮点加载和存储(flw、fsw、fld、fsd)之外,还提供了用于在不同 FP 精度之间转换、在整数和 FP 寄存器之间移动(fmv)以及在浮点和整数之间转换(fcvt,它根据需要使用整数寄存器作为源或目标)的指令。

图 A.28 包含几乎所有 RV64G 指令的列表及其含义的摘要。

RISC-V 指令集使用

为了给你一个了解哪些指令是流行的的概念,图A.29显示了SPECint2006程序的指令和指令类的频率,使用的是RV32G。

结束语

最早的架构受到当时硬件技术的指令集限制。一旦硬件技术允许,计算机架构师就开始寻找支持高级语言的方法。这种搜索导致了关于如何有效支持程序的三种不同的思考时期。在 20 世纪 60 年代,堆栈架构变得流行。它们被视为与高级语言的良好匹配——鉴于当时的编译器技术,它们很可能是这样。在 20 世纪 70 年代,架构师的主要关注点是如何降低软件成本。这种关注主要通过用硬件替换软件或提供可以简化软件设计人员任务的高级架构来满足。结果既有高级语言计算机架构运动,也有像 VAX 这样的强大架构,它具有大量的寻址模式、多种数据类型和高度正交的架构。在 20 世纪 80 年代,更复杂的编译器技术和对处理器性能的重新强调见证了对更简单的架构的回归,主要基于计算机的加载存储样式。

1990 年代发生了以下指令集架构变化:

  • 地址大小加倍——大多数台式机和服务器处理器的 32 位地址指令集扩展到 64 位地址,将寄存器的宽度(除其他内容外)扩展到 64 位。附录 K 给出了从 32 位到 64 位的三种架构示例。
  • 通过条件执行优化条件分支——在第 3 章中,我们看到条件分支可以限制激进计算机设计的性能。因此,人们有兴趣用条件完成操作(例如条件移动(参见附录 H))来替换条件分支,条件移动已添加到大多数指令集中。
  • 通过预取优化缓存性能——第 2 章解释了内存层次结构在计算机性能中日益重要的作用,某些计算机上的缓存未命中所花费的指令时间与早期计算机上的页面错误所花费的时间一样多。因此,添加了预取指令以尝试通过预取(参见第 2 章)来隐藏缓存未命中的成本。
  • 支持多媒体——大多数台式机和嵌入式指令集都扩展了对多媒体应用程序的支持。
  • 更快的浮点运算——附录 J 描述了为增强浮点性能而添加的操作,例如执行乘法和加法以及成对单次执行的操作,这些操作是 RISC-V 的一部分。

在 1970 年至 1985 年间,许多人认为计算机架构师的主要工作是指令集设计。因此,那个时代的教科书强调指令集设计,就像 20 世纪 50 年代和 60 年代的计算机架构教科书强调计算机算术一样。受过教育的架构师应该对流行计算机的优点,尤其是缺点有强烈的看法。许多研究人员和教科书作者低估了二进制兼容性在抑制指令集设计创新中的重要性,给人一种印象,即许多架构师将有机会设计指令集。

如今,计算机架构的定义已扩展到包括整个计算机系统的设计和评估,而不仅仅是指令集的定义和处理器,因此架构师有大量主题可以研究。事实上,本附录中的材料是 1990 年第一版书中的中心要点,但现在主要作为参考材料包含在附录中!

附录 K 可满足对指令集架构感兴趣的读者;它描述了多种指令集,这些指令集在当今市场上或历史上都非常重要,并比较了九种流行的加载-存储计算机与 RISC-V。

流水线

  1. 指令提取周期 (IF):

    将程序计数器 (PC) 发送到内存并从内存中获取当前指令。通过向 PC 添加 4(因为每条指令为 4 个字节)来将 PC 更新为下一个顺序指令。

  2. 指令译码/寄存器获取周期 (ID):

    译码指令并从寄存器文件中读取与寄存器源说明符相对应的寄存器。在读取寄存器时对寄存器执行相等性测试,以进行可能的跳转。如果需要,对指令的偏移字段进行符号扩展。通过将符号扩展的偏移量添加到增量 PC 来计算可能的跳转目标地址。

    解码与寄存器读取同时进行,这是可能的,因为在 RISC 架构中,寄存器说明符位于固定位置。此技术称为固定字段解码。请注意,我们可能会读取我们不使用的寄存器,这无济于事,但也不会损害性能。(读取不需要的寄存器确实会浪费能量,而对功耗敏感的设计可能会避免这种情况。)对于加载和 ALU 立即操作,立即字段始终位于同一位置,因此我们可以轻松地对其进行符号扩展。(对于 RISC V 的更完整实现,我们需要计算两个不同的符号扩展值,因为存储的立即字段位于不同的位置。)

  3. 执行/有效地址周期 (EX):

    ALU 对前一个周期准备的操作数进行操作,根据指令类型执行三个函数之一。

    • 内存引用——ALU 将基寄存器和偏移量相加以形成有效地址。
    • 寄存器-寄存器 ALU 指令——ALU 对从寄存器文件中读取的值执行 ALU 操作码指定的运算。
    • 寄存器立即数 ALU 指令——ALU 对从寄存器文件中读取的第一个值和符号扩展的立即数执行 ALU 操作码指定的运算。
    • 条件分支——确定条件是否为真。

    在加载存储架构中,有效地址和执行周期可以合并到一个时钟周期中,因为没有指令需要同时计算数据地址和对数据执行操作。

  4. 内存访问 (MEM):

    如果指令是加载,则内存使用在前一个周期计算的有效地址进行读取。如果它是存储,则内存使用从寄存器文件中读取的第二个寄存器中的数据,并使用有效地址进行写入。

  5. 回写周期 (WB):

    寄存器-寄存器 ALU 指令或加载指令:

    将结果写入寄存器文件,无论它来自内存系统(用于加载)还是来自 ALU(用于 ALU 指令)。

流水线的主要障碍——流水线冒险

存在一些情况(称为危害),它们会阻止指令流中的下一条指令在其指定的时钟周期内执行。危害会降低流水线获得的理想加速性能。危害分为三类:

  • 当硬件无法同时支持所有可能的指令组合时,结构性危害会因资源冲突而产生。在现代处理器中,结构性危害主要发生在不常使用的专用功能单元中(例如浮点除法或其他复杂的长时间运行指令)。假设程序员和编译器编写者意识到这些指令的较低吞吐量,那么它们并不是一个主要的性能因素。与其花费更多时间在这个不常见的情况下,我们专注于另外两种更常见的危害。
  • 当一条指令依赖于前一条指令的结果,而这种依赖关系在流水线中指令重叠时暴露出来时,就会出现数据风险。
  • 控制风险源于分支和其他改变 PC 的指令的流水线。

流水线中的风险可能使流水线停滞成为必要。避免风险通常要求允许流水线中某些指令继续执行,而其他指令则被延迟。对于我们在本附录中讨论的流水线,当一条指令停滞时,所有晚于停滞指令发出的指令(因此在流水线中没有那么远)也会停滞。早于停滞指令发出的指令(因此在流水线中更远)必须继续,否则风险永远不会消除。因此,在停滞期间不会获取任何新指令。

Image in a image block
数据冒险

流水线的一个主要作用是通过重叠指令执行来改变指令的相对时间。这种重叠会引入数据和控制风险。当流水线改变对操作数的读/写访问顺序,使得该顺序与在非流水线处理器上顺序执行指令所看到的顺序不同时,就会发生数据风险。假设指令 i 在程序顺序中出现在指令 j 之前,并且这两个指令都使用寄存器 x,那么 i 和 j 之间可能发生三种不同类型的风险:

  • 写后读 (RAW) 风险:最常见,当指令 j 读寄存器 x 发生在指令 i 写寄存器 x 之前时,就会发生这种情况。如果这种风险没有被阻止,指令 j 将使用 x 的错误值。
  • 读后写 (WAR) 风险:当指令 i 读寄存器 x 发生在指令 j 写寄存器 x 之后时,就会发生这种风险。在这种情况下,指令 i 将使用 x 的错误值。WAR 风险在简单的五级整数流水线中是不可能的,但当指令被重新排序时就会发生,正如我们在从第 C.65 页开始讨论动态调度流水线时所看到的那样。
  • 写后写(WAW)风险:当指令 i 写入寄存器 x 后,指令 j 写入寄存器 x 时,就会发生此风险。发生这种情况时,寄存器 x 将具有错误的值。在简单的五级整数流水线中,WAR 风险也不可能发生,但当指令重新排序或运行时间发生变化时,它们就会发生,我们稍后会看到。

第 3 章更详细地探讨了数据相关性和风险问题。现在,我们只关注 RAW 风险。

add x1,x2,x3
sub x4,x1,x5
and x6,x1,x7
or x8,x1,x9
xor x10,x1,x11

add 指令之后的指令都使用 add 指令的结果。如图 C.4 所示,add 指令在 WB 管道阶段写入 x1 的值,但 sub 指令在其 ID 阶段读取该值,这会导致 RAW 冲突。

Image in a image block

如果在 add 和 sub 指令之间发生中断,则 add 的 WB 阶段将完成,并且此时 x1 的值将是 add 的结果。这种不可预测的行为显然是不可接受的。

and 指令也会产生可能的 RAW 冲突。正如我们从图 C.4 中可以看到的,x1 的写入直到时钟周期 5 结束才完成。因此,在时钟周期 4 期间读取寄存器的 and 指令将收到错误的结果。

xor 指令正常运行,因为其寄存器读取发生在时钟周期 6,即寄存器写入之后。or 指令也可以在不产生冲突的情况下运行,因为我们在周期的后半部分执行寄存器文件读取,在前半分部分执行写入。请注意,xor 指令仍然依赖于 add,但它不再产生冲突;

一种消除涉及 sub 和 and 指令的冲突的停顿的技术

通过转发最小化数据危险停顿

图 C.4 中提出的问题可以通过一种称为转发(也称为旁路,有时称为短路)的简单硬件技术来解决。转发的关键见解是,在加法实际产生结果之前,子级实际上并不需要结果。如果结果可以从加法存储它的流水线寄存器移动到子级需要它的位置,那么就可以避免停顿的需要。利用这一观察,转发的工作方式如下:

  1. EX/MEM 和 MEM/WB 流水线寄存器中的 ALU 结果始终反馈到 ALU 输入。
  2. 如果转发硬件检测到先前的 ALU 操作已写入与当前 ALU 操作的源对应的寄存器,则控制逻辑会选择转发结果作为 ALU 输入,而不是从寄存器文件中读取的值。

请注意,如果使用转发,如果子级停顿,则加法将完成,并且旁路不会被激活。对于这两个指令之间的中断,这种关系也是成立的。

如图 C.4 所示,我们需要转发结果,不仅来自紧接在上一个指令,而且可能来自两个周期前开始的指令。图 C.5 显示了我们的示例,其中包含旁路路径,并突出显示了寄存器读取和写入的时间。此代码序列可以在没有停顿的情况下执行。

转发可以概括为包括将结果直接传递到需要它的功能单元:结果从对应于一个单元的输出的流水线寄存器转发到另一个单元的输入,而不是仅仅从一个单元的结果转发到同一个单元的输入。以以下序列为例:

add x1,x2,x3
ld x4,0(x1)
sd x4,12(x1)

为了防止此序列中的停顿,我们需要将算术逻辑单元输出和存储器单元输出的值从流水线寄存器转发到算术逻辑单元和数据存储器输入。图 C.6 显示了此示例的所有转发路径。

Image in a image block

需要停顿的数据风险

不幸的是,并非所有潜在的数据危害都可以通过旁路处理。考虑以下指令序列:

ld x1,0(x2)
sub x4,x1,x5
and x6,x1,x7
or x8,x1,x9

此示例的带有旁路路径的流水线数据路径如图 C.7 所示。此情况与连续的 ALU 操作的情况不同。ld 指令在时钟周期 4 结束时(其 MEM 周期)之前没有数据,而 sub 指令需要在该时钟周期开始时具有数据。因此,使用加载指令结果引起的数据危害无法通过简单的硬件完全消除。如图 C.7 所示,这样的转发路径必须在时间上向后运行——这是计算机设计人员尚未具备的能力!我们可以立即将结果从流水线寄存器转发到 ALU,以便在加载后 2 个时钟周期开始的 and 操作中使用。同样,or 指令没有问题,因为它通过寄存器文件接收值。对于sub指令,转发的结果到达得太晚了——在时钟周期结束时,而在开始时需要它。

加载指令存在转发无法消除的延迟或等待时间。相反,我们需要添加称为流水线互锁的硬件,以保留正确的执行模式。通常,流水线互锁会检测到危险并使流水线停滞,直到危险消除。在这种情况下,互锁使流水线停滞,从想要使用数据的指令开始,直到源指令生成数据。此流水线互锁会引入停滞或气泡,就像结构危险一样。停滞指令的 CPI 会增加停滞长度(在本例中为 1 个时钟周期)。

图 C.8 显示了使用流水线阶段名称在停滞前后流水线。由于停滞导致从 sub 开始的指令在时间上向后移动一个周期,因此对 and 指令的转发现在通过寄存器文件进行,而 or 指令根本不需要转发。插入气泡会导致完成此序列的周期数增加一个。在时钟周期 4 期间没有启动任何指令(并且在周期 6 期间没有完成任何指令)。

Image in a image block
分支冲突

对于我们的 RISC V 流水线来说,控制危害比数据危害造成的性能损失更大。当执行分支时,它可能会或可能不会将 PC 更改为除其当前值加 4 之外的其他值。回想一下,如果分支将 PC 更改为其目标地址,则它是一个已执行分支;如果它失败,则它未执行或未执行。如果指令 i 是一个已执行分支,则 PC 通常在 ID 结束时才更改,即在地址计算和比较完成后。

图 C.9 显示了处理分支的最简单方法是在 ID(解码指令时)检测到分支后,重新获取分支后的指令。第一个 IF 周期本质上是一个停顿,因为它永远不会执行有用的工作。您可能已经注意到,如果分支未执行,则重复 IF 阶段是不必要的,因为确实获取了正确的指令。我们很快将制定几种方案来利用这一事实。

每个分支一个停滞周期将导致 10% 到 30% 的性能损失,具体取决于分支频率,因此我们将研究一些处理此损失的技术。

Image in a image block

分支导致五级流水线中出现一个时钟周期的停顿。分支后的指令被取指,但该指令被忽略,并且一旦分支目标已知,取指就会重新开始。很明显,如果分支未被采用,则分支后继的第二个 IF 是冗余的。这将很快得到解决。

减少流水线分支处罚

处理分支延迟导致的流水线停顿的方法有很多;我们将在本小节讨论四种简单的编译时方案。在这四种方案中,分支的操作是静态的——它们在整个执行过程中对于每个分支都是固定的。软件可以尝试使用硬件方案和分支行为的知识来最小化分支惩罚。然后,我们将研究动态预测分支行为的基于硬件的方案,第 3 章将研究用于动态分支预测的更强大的硬件技术。

  1. 处理分支的最简单方案是冻结或刷新流水线,在分支目标未知之前,保持或删除分支后的任何指令。此解决方案的吸引力主要在于其对硬件和软件的简单性。这是图 C.9 中所示流水线中较早使用的解决方案。在这种情况下,分支惩罚是固定的,不能通过软件减少。
  2. 一种性能更高且仅稍微复杂一些的方案是将每个分支视为未执行,只需允许硬件继续执行,就好像分支未执行一样。在此,必须注意在分支结果明确之前不要更改处理器状态。此方案的复杂性在于必须知道何时指令可能会更改状态以及如何“退出”此类更改。

在简单的五级流水线中,此预测未执行或预测未执行方案通过继续获取指令来实现,就好像分支是正常指令一样。流水线看起来好像没有发生任何异常情况。但是,如果执行分支,我们需要将获取的指令转换为无操作指令,并在目标地址处重新开始获取。图 C.10 显示了这两种情况。

Image in a image block

当分支未执行(顶部)和执行(底部)时的预测未执行方案和流水线序列。当分支未执行时,在 ID 期间确定,我们获取后继并继续。如果分支在 ID 期间执行,我们重新在分支目标处获取。这会导致分支后的所有指令停滞 1 个时钟周期。

  1. 另一种方案是将每个分支视为已执行。一旦解码分支并计算出目标地址,我们就假设分支已执行,并开始在目标处获取和执行。当分支实际发生时,这为我们带来了一个周期改进,因为我们在 ID 结束时知道了目标地址,比我们知道 ALU 阶段是否满足分支条件早一个周期。在预测为发生或预测为不发生方案中,编译器可以通过组织代码来提高性能,以便最频繁的路径与硬件的选择相匹配。
  2. 第四种方案在早期的 RISC 处理器中大量使用,称为延迟分支。在延迟分支中,具有一个延迟周期的执行周期为
branch instruction
sequential successor 1
branch target if taken

顺序后继位于分支延迟槽中。无论是否执行分支,都将执行此指令。具有分支延迟的五级流水线的流水线行为如图 C.11 所示。虽然有可能具有一个延迟周期超过一个的延迟分支,但实际上几乎所有具有延迟分支的处理器都具有一个指令延迟;如果流水线具有更长的潜在分支惩罚,则使用其他技术。编译器的任务是使后继指令有效且有用。

虽然延迟分支在硬件预测过于昂贵时对短而简单的流水线很有用,但当有动态分支预测时,该技术会使实现复杂化。出于这个原因,RISC V 适当地省略了延迟分支。

Image in a image block

无论是否取分支,延迟分支的行为都是相同的。延迟槽中的指令(对于大多数包含它们的 RISC 架构,只有一个延迟槽)被执行。如果未取分支,则执行将继续执行分支延迟指令之后的指令;如果取分支,则执行将继续在分支目标处执行。当分支延迟槽中的指令也是分支时,含义不明确:如果未取分支,分支延迟槽中的分支应该如何处理?由于这种混淆,具有延迟分支的架构通常不允许在延迟槽中放置分支。

分支方案的性能

这些方案的有效性能如何?假设理想的 CPI 为 1,则有效流水线加速(包括分支惩罚)为

Image in a image block

由于以下原因:

Image in a image block

所以:

Image in a image block

分支频率和分支惩罚可能同时包含无条件和条件分支的组成部分。然而,后者占主导地位,因为它们更频繁。

通过预测降低分支成本

随着流水线越来越深,分支的潜在惩罚越来越大,使用延迟分支和类似方案变得不够。相反,我们需要转向更激进的方法来预测分支。此类方案分为两类:依赖于编译时可用信息的低成本静态方案和基于程序行为动态预测分支的策略。

我们在此讨论这两种方法。

静态分支预测

提高编译时分支预测的一个关键方法是使用从早期运行中收集的概要信息。使此方法有价值的关键观察是分支的行为通常是双峰分布的;也就是说,单个分支通常高度偏向于已采取或未采取。图 C.14 显示了使用此策略进行分支预测的成功。相同的输入数据用于运行和收集概要;其他研究表明,更改输入以使概要用于不同的运行只会导致基于概要的预测的准确性发生很小的变化。

任何分支预测方案的有效性都取决于方案的准确性和条件分支的频率,后者在 SPEC 中从 3% 到 24% 不等。整数程序的错误预测率较高,并且此类程序通常具有较高的分支频率,这是静态分支预测的主要限制。在下一节中,我们将考虑大多数最新处理器采用的动态分支预测器。

动态分支预测和分支预测缓冲区

最简单的动态分支预测方案是分支预测缓冲区或分支历史表。分支预测缓冲区是一个小内存,由分支指令地址的下部进行索引。该内存包含一个位,表示分支最近是否被执行。此方案是最简单的缓冲区类型;它没有标记,并且仅在分支延迟长于计算可能的目标 PC 的时间时才可用于减少分支延迟。

有了这样的缓冲区,我们实际上并不知道预测是否正确——它可能由具有相同低阶地址位的另一个分支放置在那里。但这并不重要。预测是一个被认为是正确的提示,并且在预测的方向开始获取。如果提示被证明是错误的,则预测位将被反转并存储回。

这个缓冲区实际上是一个缓存,其中每次访问都是命中,并且,正如我们将看到的,缓冲区的性能取决于预测感兴趣的分支的频率以及预测匹配时的准确性。在分析性能之前,对分支预测方案的准确性进行一些小的但重要的改进是有用的。

这种简单的 1 位预测方案有一个性能缺点:即使一个分支几乎总是被采用,当它不被采用时,我们很可能会错误地预测两次,而不是一次,因为错误预测会导致预测位翻转。

为了弥补这一弱点,通常使用 2 位预测方案。在 2 位方案中,预测必须错误两次才会更改。图 C.15 显示了 2 位预测方案的有限状态处理器。

分支预测缓冲区可以实现为一个小型特殊“高速缓存”,在 IF 管道阶段使用指令地址访问,或作为附加到指令高速缓存中每个块的一对位,并随指令一起获取。如果指令被解码为分支,并且分支被预测为已执行,则在 PC 已知后立即从目标开始获取。否则,顺序获取和执行将继续。如图 C.15 所示,如果预测结果错误,则预测位将更改。

对于在实际应用程序中每个条目使用 2 位的分支预测缓冲区,可以预期什么样的准确性?

结果表明,对于SPEC89基准测试,具有4096个条目的分支预测缓冲区的预测准确度范围从99%到82%以上,或者错误预测率为1%-18%。在2017年,用于这些结果的4K条目缓冲区被认为是很小的,更大的缓冲区可能会产生更好的结果。

Image in a image block

当我们尝试利用更多 ILP 时,分支预测的准确性变得至关重要。正如我们在图 C.16 中看到的,整数程序的预测器准确性通常也具有较高的分支频率,低于循环密集型科学程序。我们可以通过两种方式来解决这个问题:增加缓冲区的大小和提高我们用于每个预测的方案的准确性。然而,如图 C.17 所示,具有 4K 条目的缓冲区与无限缓冲区的性能相当,至少对于 SPEC 中的基准测试是这样。图 C.17 中的数据清楚地表明,缓冲区的命中率不是主要的限制因素。正如我们提到的,在不改变预测器结构的情况下简单地增加每个预测器的位数也几乎没有影响。相反,我们需要研究如何提高每个预测器的准确性,正如我们在第 3 章中所做的那样。

流水线是如何实现的?

每个 RISC V 指令最多可以在 5 个时钟周期内实现。5 个时钟周期如下:

  1. 指令提取周期 (IF):
    IR Mem[PC];
    NPC PC + 4;

    操作——将 PC 发出并从内存中提取指令到指令寄存器 (IR) 中;将 PC 增加 4 以寻址下一个顺序指令。IR 用于保存后续时钟周期中所需的指令;同样,寄存器 NPC 用于保存下一个顺序 PC。

  2. 指令译码/寄存器提取周期 (ID):

    操作——解码指令并访问寄存器文件以读取寄存器(rs1 和 rs2 是寄存器说明符)。通用寄存器的输出被读入两个临时寄存器 (A 和 B) 中,以便在后面的时钟周期中使用。IR 的低 16 位也符号扩展并存储到临时寄存器 Imm 中,以便在下一个周期中使用。

    解码与读取寄存器同时进行,这是可能的,因为这些字段在 RISC V 指令格式中位于固定位置。由于加载和 ALU 立即数的立即数部分位于每个 RISC V 指令中的相同位置,因此在下一个周期需要时,也会在此周期内计算符号扩展立即数。对于存储,需要单独的符号扩展,因为立即数字段被分成两部分。

  3. 执行/有效地址周期 (EX):

    ALU 对前一个周期准备的操作数进行操作,根据 RISC V 指令类型执行四个函数之一:

    • 内存引用:ALUOutput A + Imm;

      操作——ALU 将操作数相加以形成有效地址,并将结果放入寄存器 ALUOutput 中。

    • 寄存器-寄存器 ALU 指令:ALUOutput A func B;

      操作——ALU 对寄存器 A 中的值和寄存器 B 中的值执行由功能码(func3 和 func7 字段的组合)指定的操作。结果放置在临时寄存器 ALUOutput 中。

    • 立即数寄存器 ALU 指令:ALUOutput A op Imm;

      操作——ALU 对寄存器 A 中的值和寄存器 Imm 中的值执行由操作码指定的操作。结果放置在临时寄存器 ALUOutput 中。

    • 分支:ALUOutput NPC + (Imm << 2); Cond (A == B)

      操作——ALU 将 NPC 加到 Imm 中的符号扩展立即值,该值左移 2 位以创建字偏移,以计算分支目标的地址。寄存器 A(已在先前的周期中读取)通过与寄存器 B 比较来检查是否执行分支,因为我们仅考虑相等分支。

      RISC V 的加载存储架构意味着有效地址和执行周期可以组合到单个时钟周期中,因为没有指令需要同时计算数据地址、计算指令目标地址和对数据执行操作。此处未包含的其他整数指令是各种形式的跳转,类似于分支。

  4. 内存访问/分支完成周期 (MEM):

    PC 对所有指令更新:PC <- NPC;

    • 内存引用:LMD Mem[ALUOutput] or Mem[ALUOutput] B;

      操作——如果需要,访问内存。如果指令是加载,数据从内存返回并放入 LMD(加载内存数据)寄存器;如果它是存储,则 B 寄存器中的数据将写入内存。在任何情况下,使用的地址都是在上一个周期中计算并存储在寄存器 ALUOutput 中的地址。

    • 分支:if (cond) PC ← ALUOutput

      操作——如果指令分支,则 PC 将替换为寄存器 ALUOutput 中的分支目标地址。

  5. 回写周期 (WB):
    • 寄存器-寄存器或寄存器-立即数 ALU 指令:Regs[rd] ← ALUOutput;
    • 加载指令:Regs[rd] ← LMD;

操作——将结果写入寄存器文件,无论它来自内存系统(在 LMD 中)还是来自 ALU(在 ALUOutput 中),其中 rd 指定寄存器。

图 C.18 显示了指令如何通过数据通路。在每个时钟周期的末尾,在该时钟周期内计算的每个值以及在后续时钟周期内需要的(无论是针对此指令还是下一个指令)的值都会写入存储设备,该设备可以是内存、通用寄存器、PC 或临时寄存器(即 LMD、Imm、A、B、IR、NPC、ALUOutput 或 Cond)。临时寄存器在时钟周期之间为一个指令保存值,而其他存储元素是状态的可见部分,并在连续指令之间保存值。

尽管当今所有处理器都是流水线化的,但这种多周期实现方式合理地近似了大多数处理器在早期是如何实现的。一个简单的有限状态机可以用来实现遵循本文所示的五周期结构的控制。对于更复杂的处理器,可以使用微代码控制。在任何情况下,本节中描述的指令序列都将决定控制的结构。

在这个多周期实现中,可以消除一些硬件冗余。例如,有两个算术逻辑单元:一个用于递增程序计数器,另一个用于有效地址和算术逻辑单元计算。因为它们不需要在同一个时钟周期内,我们可以通过添加额外的多路复用器并将相同的算术逻辑单元共享来合并它们。同样,指令和数据可以存储在同一个存储器中,因为数据和指令访问发生在不同的时钟周期。

与其优化这个简单的实现,我们将在图 C.18 中保留设计,因为这为我们提供了流水线化实现的更好基础。

Image in a image block

与之前一样,我们几乎无需更改即可通过在每个时钟周期启动一条新指令来流水线处理图 C.18 的数据路径。由于每个流水线阶段在每个时钟周期都处于活动状态,因此流水线阶段中的所有操作都必须在 1 个时钟周期内完成,并且任何操作组合都必须能够同时发生。此外,流水线处理数据路径需要将从一个流水线阶段传递到下一个流水线阶段的值放入寄存器中。图 C.19 显示了 RISC V 流水线,其中在每个流水线阶段之间都有适当的寄存器,称为流水线寄存器或流水线锁存器。寄存器使用它们连接的阶段的名称进行标记。

Image in a image block

可以看出管道寄存器从一个阶段到另一个阶段的连接。

在时钟周期内暂时保存值以供一条指令使用的所有寄存器都包含在这些流水线寄存器中。指令寄存器 (IR) 的字段(它是 IF/ID 寄存器的一部分)在用于提供寄存器名称时会标记。流水线寄存器将数据和控制从一个流水线阶段传递到下一个流水线阶段。在后面的流水线阶段需要的任何值都必须放在这样的寄存器中,并从一个流水线寄存器复制到下一个流水线寄存器,直到不再需要它。如果我们尝试只使用我们在早期非流水线数据路径中拥有的临时寄存器,则在完成所有使用之前可能会覆盖值。例如,用于在加载或 ALU 操作中写入的寄存器操作数的字段是从 MEM/WB 流水线寄存器而不是从 IF/ID 寄存器提供的。这是因为我们希望加载或 ALU 操作写入由该操作指定的寄存器,而不是指令当前正从 IF 过渡到 ID!此目标寄存器字段只是从一个流水线寄存器复制到下一个流水线寄存器,直到在 WB 级需要它为止。

任何指令在流水线的一个阶段中都是活跃的;因此,代表指令执行的任何操作都发生在对流水线寄存器的一对之间。因此,我们还可以通过检查根据指令类型在任何流水线阶段必须发生的情况来查看流水线的活动。图 C.20 显示了此视图。流水线寄存器的字段被命名为显示流将数据从一个阶段传递到下一个阶段。请注意,前两个阶段中的操作与当前指令类型无关;它们必须独立,因为指令直到 ID 阶段结束时才解码。IF 活动取决于 EX/MEM 中的指令是否为已执行分支。如果是,则在 IF 结束时将 EX/MEM 中分支指令的分支目标地址写入 PC;否则,将回写增量 PC。(如前所述,分支的这种影响会导致管道中的复杂情况,我们将在接下来的几节中对此进行处理。)寄存器源操作数的固定位置编码对于允许在 ID 期间获取寄存器至关重要。

要控制此简单流水线,我们只需要确定如何设置图 C.19 数据路径中四个多路复用器的控制。ALU 阶段中的两个多路复用器根据指令类型设置,该类型由 ID/EX 寄存器的 IR 字段决定。ALU 顶部输入多路复用器根据指令是否是分支指令设置,而底部多路复用器根据指令是否是寄存器-寄存器 ALU 操作或任何其他类型的操作设置。IF 阶段中的多路复用器选择使用增量 PC 的值还是 EX/MEM.ALUOutput(分支目标)的值写入 PC。此多路复用器由字段 EX/MEM.cond 控制。第四个多路复用器由 WB 阶段中的指令是加载还是 ALU 操作控制。除了这四个多路复用器之外,还需要一个额外的多路复用器,该多路复用器未绘制在图 C.19 中,但从查看 ALU 操作的 WB 阶段可以清楚地看出它的存在。目标寄存器字段根据指令类型(寄存器-寄存器 ALU 与 ALU 立即数或加载)位于两个不同的地方之一。 因此,我们需要一个多路复用器来选择 MEM/WB 寄存器中 IR 的正确部分,以指定寄存器目标字段,假设指令写入寄存器。

实现 RISC V 流水线的控制

将一条指令从指令译码阶段 (ID) 移入此流水线的执行阶段 (EX) 的过程通常称为指令发出;已完成此步骤的指令称为已发出。对于 RISC V 整数流水线,所有数据冲突都可以在流水线的 ID 阶段进行检查。如果存在数据冲突,则在指令发出之前会将其暂停。同样,我们可以在 ID 期间确定需要哪些转发,然后设置适当的控制。在流水线早期检测互锁可降低硬件复杂性,因为硬件不必暂停已更新处理器状态的指令,除非整个处理器已暂停。或者,我们可以在使用操作数的时钟周期开始时检测冲突或转发(对于此流水线,为 EX 和 MEM)。为了展示这两种方法的差异,我们将展示如何通过在 ID 中进行检查来实现读取后写入 (RAW) 冲突的互锁,其中源来自加载指令(称为加载互锁),而转发路径的实现为ALU 输入可以在 EX 期间完成。图 C.21 列出了我们必须处理的各种情况。

Image in a image block

通过比较相邻指令的目标和源,流水线危险检测硬件可以查看的情况。此表表明,唯一需要的比较是目标与写入目标的指令之后的两个指令的源之间的比较。在停顿的情况下,一旦执行继续(通过转发克服依赖),流水线依赖关系将看起来像第三种情况。当然,可以忽略涉及 x0 的危险,因为该寄存器始终包含 0,并且可以扩展前面的测试来执行此操作。

让我们从实现加载互锁开始。如果源指令为加载时存在 RAW 危害,则当需要加载数据的指令处于 ID 阶段时,加载指令将处于 EX 阶段。因此,我们可以用一个小表描述所有可能的危害情况,该表可以直接转换为实现。图 C.22 显示了一个表,该表检测当使用加载结果的指令处于 ID 阶段时所有加载互锁。

一旦检测到一个危险,控制单元必须插入流水线停顿,并阻止 IF 和 ID 阶段的指令前进。正如我们之前所说的,所有控制信息都保存在流水线寄存器中。(携带指令就足够了,因为所有控制都是从指令中派生的。)因此,当我们检测到一个危险时,我们只需要将 ID/EX 流水线寄存器的控制部分全部更改为 0,这恰好是一个空操作(一个不执行任何操作的指令,例如 add x0,x0,x0)。此外,我们只需重新循环 IF/ID 寄存器的内容来保存停滞的指令。在具有更复杂危险的流水线中,同样的思想也适用:我们可以通过比较一组流水线寄存器并移入空操作来检测危险,以防止错误执行。

Image in a image block

在指令的 ID 阶段检测负载互锁需求的逻辑需要两个比较,每个可能的源一个。记住,IF/ID 寄存器保存了 ID 中指令的状态,该指令可能使用负载结果,而 ID/EX 保存了 EX 中指令的状态,即负载指令。

实现转发逻辑类似,尽管需要考虑更多的情况。实现转发逻辑所需的关键观察是,流水线寄存器既包含要转发的 data,也包含源寄存器和目标寄存器字段。所有转发在逻辑上都从 ALU 或 data 内存输出发生到 ALU 输入、data 内存输入或零检测单元。因此,我们可以通过比较 EX/MEM 和 MEM/WB 阶段中包含在 IR 中的目标寄存器与 ID/EX 和 EX/MEM 寄存器中包含在 IR 中的源寄存器来实现转发。图 C.23 显示了比较和可能的转发操作,其中转发结果的目标是当前处于 EX 中的指令的 ALU 输入。

除了比较器和组合逻辑之外,我们还必须确定何时需要启用转发路径,我们还必须扩大算术逻辑单元输入端的复用器,并添加用于转发结果的流水线寄存器的连接。图 C.24 显示了流水线数据路径的相关部分,其中包含附加的多路复用器和连接。

对于 RISC V,危害检测和转发硬件相当简单;当我们将此流水线扩展到处理浮点数时,我们会看到事情变得更加复杂。在这样做之前,我们需要处理分支。

处理流水线中的分支

在 RISC V 中,条件分支取决于比较两个寄存器值,我们假设这发生在 EX 周期中,并使用 ALU 来执行此功能。我们还需要计算分支目标地址。由于测试分支条件和确定下一个 PC 将决定分支惩罚是什么,因此我们希望在 EX 周期结束之前计算可能的 PC 并选择正确的 PC。我们可以通过添加一个单独的加法器来实现,该加法器在 ID 期间计算分支目标地址。由于指令尚未解码,因此我们将计算一个可能的 target,就好像每条指令都是一个分支。这可能比在 EX 中计算目标和评估条件更快,但确实会使用稍多能量。

图 C.25 显示了一个流水线数据路径,假设 ID 中的加法器和 EX 中的分支条件评估,这是流水线结构的一个小改动。此流水线将在分支上产生两个周期的延迟。在一些早期的 RISC 处理器(如 MIPS)中,分支上的条件测试受到限制,以允许测试在 ID 中进行,从而将分支延迟减少到一个周期。当然,这意味着对寄存器的 ALU 操作后跟基于该寄存器的条件分支会产生数据危害,如果在 EX 中评估分支条件,则不会发生这种情况。

Image in a image block

将数据转发到两个 ALU 输入(用于 EX 中的指令)可以从 ALU 结果(在 EX/MEM 或 MEM/WB 中)或从 MEM/WB 中的加载结果中进行。需要进行 10 次单独比较才能判断是否应该执行转发操作。ALU 顶部和底部输入分别指对应于第一个和第二个 ALU 源操作数的输入,并在 C.30 页的图 C.18 和 C.36 页的图 C.24 中明确显示。请记住,EX 中目标指令的流水线锁存器是 ID/EX,而源值来自 EX/MEM 或 MEM/WB 的 ALUOutput 部分或 MEM/WB 的 LMD 部分。此逻辑未解决的一个复杂问题:处理写入同一寄存器的多条指令。例如,在代码序列 add x1, x2, x3; addi x1, x1, 2; sub x4, x3, x1 中,逻辑必须确保 sub 指令使用 addi 指令的结果,而不是 add 指令的结果。此处显示的逻辑可以通过简单地测试仅当未针对同一输入启用来自 EX/MEM 的转发时,才启用来自 MEM/WB 的转发,从而扩展到处理此情况。 由于加法结果将在 EX/MEM 中,因此将转发,而不是 MEM/WB 中的加法结果。

Image in a image block

将结果转发到 ALU 需要在每个 ALU 多路复用器上添加三个额外的输入,并向新输入添加三条路径。这些路径对应于旁路:(1) EX 结束时的 ALU 输出,(2) MEM 阶段结束时的 ALU 输出,以及 (3) MEM 阶段结束时的内存输出。

随着流水线深度增加,分支延迟增加,这使得动态分支预测变得必要。例如,一个具有独立解码和寄存器取指阶段很可能会有至少比时钟周期长 1 个时钟周期的分支延迟。除非处理分支延迟,否则它会变成分支惩罚。许多实现更复杂指令集的较旧处理器具有 4 个或更多时钟周期的分支延迟,而大型深度流水线处理器通常具有 6 或 7 个分支惩罚。第 3 章讨论的英特尔 i7 等激进的高端超标量处理器可能具有 10-15 个周期的分支惩罚!一般来说,流水线越深,时钟周期中的分支惩罚就越严重,准确预测分支就越关键。

流水线难以实现的原因是什么?

既然我们已经了解了如何检测和解决冲突,我们就可以处理一些我们到目前为止避免的复杂情况。本节的第一部分考虑了指令执行顺序以意外方式更改的特殊情况的挑战。在本节的第二部分,我们将讨论不同指令集带来的部分挑战。

处理异常

在流水线处理器中,异常情况更难处理,因为指令的重叠使得更难知道指令是否可以安全地更改处理器的状态。在流水线处理器中,指令逐个执行,并且在几个时钟周期内无法完成。不幸的是,流水线中的其他指令可能会引发异常,这可能会迫使处理器在完成之前中止流水线中的指令。在我们详细讨论这些问题及其解决方案之前,我们需要了解可能出现哪些类型的情况以及支持它们需要哪些架构要求。

异常类型和要求

用于描述改变指令正常执行顺序的异常情况的术语因处理器而异。术语中断、故障和异常被使用,尽管不是以一致的方式。我们使用术语异常来涵盖所有这些机制,包括以下内容:

  • I/O 设备请求
  • 从用户程序调用操作系统服务
  • 跟踪指令执行
  • 断点(程序员请求的中断)
  • 整数算术溢出
  • FP 算术异常
  • 页面错误(不在主内存中)
  • 未对齐的内存访问(如果需要对齐)
  • 内存保护违规
  • 使用未定义或未实现的指令
  • 硬件故障
  • 电源故障

当我们希望引用此类异常的某个特定类别时,我们将使用较长的名称,例如 I/O 中断、浮点异常或页面错误。

尽管我们使用术语异常来涵盖所有这些事件,但各个事件都有重要的特征,这些特征决定了硬件中需要采取什么操作。对异常的要求可以用五个半独立的轴来表征:

  1. 同步与异步——如果事件每次在程序使用相同数据和内存分配执行时发生在同一位置,则该事件是同步的。除了硬件故障之外,异步事件是由处理器和内存外部的设备引起的。异步事件通常可以在当前指令完成后处理,这使得它们更容易处理。
  2. 用户请求与强制——如果用户任务直接要求它,则它是一个用户请求的事件。从某种意义上说,用户请求的异常并不是真正的异常,因为它们是可预测的。然而,它们被视为异常,因为用于保存和恢复状态的机制与这些用户请求的事件相同。由于触发此异常的指令的唯一功能是导致异常,因此用户请求的异常总是在指令完成后处理。强制异常是由用户程序无法控制的某些硬件事件引起的。强制异常更难实现,因为它们不可预测。
  3. 用户可屏蔽与用户不可屏蔽——如果事件可以被用户任务屏蔽或禁用,则它是用户可屏蔽的。此屏蔽仅控制硬件是否响应异常。
  4. 指令内部与指令之间——此分类取决于事件是否在执行过程中(无论多短)发生以阻止指令完成,或者是否在指令之间识别。在指令内部发生的异常通常是同步的,因为指令触发了异常。很难实现异常,即发生在指令内部的异常比发生在指令之间的异常更严重,因为必须停止并重新启动指令。发生在指令内部的异步异常是由灾难性情况(例如,硬件故障)引起的,并且总是导致程序终止。
  5. 恢复与终止——如果程序执行在中断后始终停止,则为终止事件。如果程序执行在中断后继续,则为恢复事件。实现终止执行的异常更容易,因为处理器不必能够在处理异常后重新启动同一程序的执行。

图 C.26 根据这五个类别对前面的示例进行了分类。困难的任务是实现指令内部发生的、必须恢复指令的中断。实现此类异常需要调用另一个程序来保存正在执行的程序的状态,纠正异常原因,然后在导致异常的指令可以再次尝试。此过程必须对执行程序有效地不可见。如果管道为处理器提供了处理异常、保存状态和在不影响程序执行的情况下重新启动的能力,则称该管道或处理器是可重新启动的。虽然早期的超级计算机和微处理器通常不具备此特性,但如今几乎所有处理器都支持它,至少对于整数管道是这样,因为它需要实现虚拟内存(参见第 2 章)。

Image in a image block

使用五个类别来定义针对不同异常类型需要采取哪些操作。必须允许恢复的异常标记为恢复,尽管软件通常可以选择终止程序。在可恢复的指令中发生的同步、强制异常是最难实现的。我们可能期望内存保护访问冲突总是会导致终止;然而,现代操作系统使用内存保护来检测事件,例如首次尝试使用页面或首次写入页面。因此,处理器应该能够在发生此类异常后恢复。

停止和重新开始执行

与非流水线实现一样,最困难的异常具有两个属性:(1) 它们发生在指令中(即,在对应于 EX 或 MEM 管道级的指令执行过程中),并且 (2) 它们必须可重新启动。例如,在我们的 RISC V 流水线中,由数据获取导致的虚拟内存页面错误直到指令的 MEM 阶段才会发生。当看到该错误时,其他几个指令将正在执行。页面错误必须可重新启动,并且需要另一个进程(例如操作系统)的干预。因此,必须安全地关闭管道并保存状态,以便可以在正确状态下重新启动指令。重新启动通常通过保存要重新启动的指令的 PC 来实现。如果重新启动的指令不是分支,那么我们将继续获取顺序后继并以正常方式开始执行它们。如果重新启动的指令是分支,那么我们将重新评估分支条件并开始从目标或后备中获取。当发生异常时,管道控制可以采取以下步骤安全地保存管道状态:

  1. 在下一个 IF 中强制将陷阱指令送入管道。
  2. 在陷阱被捕获之前,关闭故障指令和管道中所有后续指令的所有写入;这可以通过将管道中所有指令的管道锁存器置零来完成,从生成异常的指令开始,但不包括该指令之前的指令。这可以防止在异常处理之前不会完成的指令的任何状态更改。
  3. 在操作系统中的异常处理例程接收控制权后,它会立即保存故障指令的 PC。此值将用于稍后从异常中返回。

异常处理后,特殊指令通过重新加载 PC 并重新启动指令流(使用 RISC V 中的异常返回)使处理器从异常中返回。如果可以停止流水线,以便完成故障指令之前的指令,并且可以从头重新启动故障指令之后的指令,则称流水线具有精确异常。理想情况下,故障指令不会改变状态,正确处理某些异常需要故障指令没有影响。对于其他异常,例如浮点异常,某些处理器上的故障指令会在异常被处理之前写入其结果。在这种情况下,硬件必须准备好检索源操作数,即使目标与其中一个源操作数相同。由于浮点运算可能运行多个周期,因此很可能其他一些指令已经写入了源操作数(正如我们将在下一节中看到的那样,浮点运算通常按乱序完成)。为了克服这个问题,许多最近的高性能处理器引入了两种操作模式。一种模式具有精确的异常,而另一种模式(快速或性能模式)没有。当然,精确异常模式较慢,因为它允许浮点指令之间的重叠更少。

在许多系统中,支持精确异常是必需的,而在其他系统中,它“仅仅”是有价值的,因为它简化了操作系统接口。至少,任何具有按需分页或 IEEE 算术陷阱处理程序的处理器都必须使其异常精确,无论是在硬件中还是在某些软件支持下。对于整数流水线,创建精确异常的任务更容易,并且适应虚拟内存极大地促进了对内存引用精确异常的支持。在实践中,这些原因促使设计人员和架构师始终为整数流水线提供精确异常。在本节中,我们将介绍如何为 RISC V 整数流水线实现精确异常。我们将在 C.5 节中介绍处理浮点流水线中出现的更复杂的挑战的技术。

RISC V 中的异常

图 C.27 显示了 RISC V 流水线阶段以及每个阶段可能发生的哪些问题异常。使用流水线时,由于有多条指令正在执行,因此在同一时钟周期内可能会发生多个异常。例如,考虑以下指令序列:

Image in a image block

这一对指令可能同时导致数据页面错误和算术异常,因为 ld 处于 MEM 阶段,而 add 处于 EX 阶段。可以通过仅处理数据页面错误,然后重新启动执行来处理此情况。第二个异常将重新发生(但如果软件正确,则第一个异常不会发生),并且当第二个异常发生时,可以独立处理。

实际上,情况并不像这个简单的示例那么简单。异常可能会乱序发生;也就是说,一条指令可能会在较早的指令导致异常之前导致异常。再次考虑前面的指令序列,ld 后跟 add。ld 可能会出现数据页面错误,当指令在 MEM 中时会看到,而 add 可能会出现指令页面错误,当 add 指令在 IF 中时会看到。指令页面错误实际上会首先发生,即使它是由后面的指令引起的!

由于我们正在实现精确异常,因此流水线需要首先处理由 ld 指令引起的异常。为了解释其工作原理,让我们将 ld 指令所在位置的指令称为 i,将 add 指令所在位置的指令称为 i + 1。流水线不能在异常发生时简单地处理异常,因为这会导致异常按非流水线顺序发生。相反,硬件将由给定指令引起的所有异常发布到与该指令关联的状态向量中。异常状态向量将随着指令在流水线中向下移动而被携带。一旦在异常状态向量中设置了异常指示,则任何可能导致写入数据值控制信号都会被关闭(这包括寄存器写入和内存写入)。由于存储可以在 MEM 期间导致异常,因此硬件必须做好准备,以防止存储在引发异常时完成。

当一条指令进入 WB(或即将离开 MEM)时,将检查异常状态向量。如果发布了任何异常,则将按照它们在非流水线处理器上按时间发生的顺序进行处理——首先处理与最早指令(通常是该指令最早的流水线阶段)相对应的异常。这保证了在 i + 1 上看到任何异常之前,将在指令 i 上看到所有异常。当然,代表指令 i 在早期流水线阶段执行的任何操作都可能是无效的,但由于禁用了对寄存器文件和内存的写入,因此无法更改任何状态。正如我们将在 C.5 节中看到的那样,为 FP 操作维护此精确模型要困难得多。

在下一小节中,我们将描述在具有更强大、运行时间更长的指令的处理器的流水线中实现异常时出现的问题。

指令集复杂性

没有 RISC V 指令具有多个结果,我们的 RISC V 流水线仅在指令执行结束时写入该结果。当指令保证完成时,称为已提交。在 RISC V 整数流水线中,所有指令在到达 MEM 阶段末尾(或 WB 开始)时提交,并且在该阶段之前没有指令更新状态。因此,精确异常很简单。有些处理器具有在指令执行过程中更改状态的指令,在指令及其前置指令保证完成之前。例如,IA-32 架构中的自动增量寻址模式会导致在指令执行过程中更新寄存器。在这种情况下,如果由于异常而中止指令,它将使处理器状态发生改变。尽管我们知道哪条指令导致了异常,但在没有额外的硬件支持的情况下,异常将是不精确的,因为指令将完成一半。在这样的不精确异常之后重新启动指令流很困难。或者,我们可以在指令提交之前避免更新状态,但这可能很困难或代价高昂,因为可能存在对更新状态的依赖关系:考虑一条自动增量同一寄存器的 VAX 指令多次。因此,为了维护精确的异常模型,具有此类指令的大多数处理器都能够在指令提交之前撤消所做的任何状态更改。 如果发生异常,处理器使用此功能将处理器的状态重置为中断指令开始前的值。在下一节中,我们将看到更强大的 RISC V 浮点流水线可能会引入类似的问题,而第 C.7 节介绍了大幅增加异常处理复杂性的技术。

一种相关的困难来源是执行期间更新内存状态的指令,例如 Intel 架构或 IBM 360 上的字符串复制操作(请参阅附录 K)。为了使中断和重新启动这些指令成为可能,指令被定义为使用通用寄存器作为工作寄存器。因此,部分完成的指令的状态始终在寄存器中,这些寄存器在异常时保存,并在异常后恢复,从而允许指令继续执行。

奇数状态位可能会产生额外的流水线危险,或者可能需要额外的硬件来保存和恢复,从而产生另一组困难。条件码就是一个很好的例子。许多处理器将条件码隐式地作为指令的一部分进行设置。这种方法有其优势,因为条件码将条件的评估与实际分支分离。但是,隐式设置的条件码可能会导致在设置条件码和分支之间调度任何流水线延迟时遇到困难,因为大多数指令都会设置条件码,并且不能在条件评估和分支之间的延迟槽中使用。

此外,在具有条件码的处理器中,处理器必须决定何时修复分支条件。这涉及找出在分支之前条件码最后一次设置的时间。在大多数具有隐式设置条件码的处理器中,这是通过延迟分支条件评估来完成的,直到所有先前的指令都有机会设置条件码为止。

当然,具有明确设置条件码的架构允许在条件测试和分支之间安排延迟;但是,流水线控制仍然必须跟踪设置条件码的最后一条指令,以了解何时决定分支条件。实际上,条件码必须被视为一个操作数,它需要对分支的 RAW 危害进行危害检测,就像 RISC V 必须在寄存器上执行的那样。

流水线中一个棘手的最终领域是多周期操作。想象一下尝试流水线处理如下 x86 指令序列:

Image in a image block

尽管这些指令都不是特别长(x86指令最多可达15个字节),但它们在所需的时钟周期数上确实有很大差异,从低至一个时钟周期到数百个时钟周期。这些指令还需要不同的数据内存访问次数,从零到可能数百次。数据冲突非常复杂,并且发生在指令之间和指令内部(没有任何东西可以防止movsb具有重叠的源和目标!)。使所有指令执行相同数量的时钟周期的简单解决方案是不可接受的,因为它引入了大量的冲突和旁路条件,并使流水线变得非常长。在指令级别流水化x86很困难,但找到了一个巧妙的解决方案,类似于VAX使用的解决方案。他们对微指令执行进行流水线处理;微指令是在序列中用于实现更复杂指令集的简单指令。由于微指令很简单(它们看起来很像RISC V),因此流水线控制要容易得多。 自 1995 年以来,所有 Intel IA-32 微处理器都采用了这种将 IA-32 指令转换为微操作,然后对微操作进行流水线的策略。事实上,这种方法甚至用于 ARM 架构中一些更复杂的指令。

相比之下,加载存储处理器具有工作量相似的简单操作,并且更容易进行流水线处理。如果架构师意识到指令集设计与流水线处理之间的关系,他们就可以设计出用于更有效率的流水线处理的架构。在下一节中,我们将看到 RISC V 流水线如何处理长时间运行的指令,特别是浮点运算。

多年来,人们一直认为指令集和实现之间的交互很小,并且实现问题并不是设计指令集的主要重点。在 20 世纪 80 年代,很明显,流水线处理的困难和低效率都可能因指令集复杂性而增加。在 20 世纪 90 年代,所有公司都转向更简单的指令集,目标是降低激进实现的复杂性。

扩展 RISC V 整数流水线以处理多周期操作

我们现在想探索如何扩展我们的 RISC V 流水线以处理浮点运算。本节重点介绍基本方法和设计备选方案,最后以 RISC V 浮点流水线的性能测量结束。

要求所有 RISC V FP 操作在 1 个时钟周期内完成,甚至在 2 个时钟周期内完成是不切实际的。这样做意味着接受慢时钟或在 FP 单元中使用大量的逻辑,或两者兼而有之。相反,FP 管道将允许操作具有更长的延迟。如果我们想象 FP 指令具有与整数指令相同的管道,那么这更容易理解,但有两个重要的变化。首先,EX 周期可以根据需要重复多次以完成操作——不同操作的重复次数可能不同。其次,可能有多个 FP 功能单元。如果要发出的指令会导致其使用的功能单元出现结构性冲突或数据冲突,则会发生停顿。

对于本节,让我们假设在我们的 RISC V 实现中有四个单独的功能单元:

  1. 处理加载和存储、整数 ALU 操作和分支的主整数单元
  2. 浮点和整数乘法器
  3. 处理 FP 加法、减法和转换的 FP 加法器
  4. 浮点和整数除法器

如果我们还假设这些功能单元的执行阶段不是流水线化的,那么图 C.28 显示了由此产生的流水线结构。由于 EX 不是流水线化的,因此在上一条指令离开 EX 之前,使用该功能单元的任何其他指令都不得发出。此外,如果一条指令无法进入 EX 阶段,该指令后面的整个流水线将被暂停。

实际上,中间结果可能不会像图 C.28 所示的那样在 EX 单元周围循环;相反,EX 流水线阶段有一些时钟延迟大于 1。我们可以将图 C.28 中所示的 FP 流水线的结构推广到允许对某些阶段进行流水线处理和多个正在进行的操作。为了描述这样的流水线,我们必须定义功能单元的延迟以及启动间隔或重复间隔。我们以之前定义的方式定义延迟:产生结果的指令和使用结果的指令之间的介入周期数。启动或重复间隔是指发出两种给定类型的操作之间必须经过的周期数。例如,我们将使用图 C.29 中所示的延迟和启动间隔。

因此,对于正在存储的值(而不是基地址寄存器)的存储延迟将减少一个周期。流水线延迟本质上等于执行流水线深度减一,即从 EX 阶段到产生结果的阶段的阶段数。因此,对于前面的示例流水线,FP 加法中的阶段数为 4,而 FP 乘法中的阶段数为 7。为了获得更高的时钟速率,设计人员需要放置更少的逻辑级别在每个流水线阶段,这使得更复杂操作所需的流水线阶段数量更大。因此,时钟速率越快,操作的延迟就越长。

图 C.29 中的示例流水线结构允许最多四个未完成的 FP 加法、七个未完成的 FP/整数乘法和一个 FP 除法。图 C.30 显示了如何通过扩展图 C.28 来绘制此流水线。重复间隔在图 C.30 中通过添加额外的流水线级来实现,这些级将由额外的流水线寄存器分隔。由于这些单元是独立的,因此我们对这些级进行不同的命名。需要多个时钟周期的流水线级(例如除法单元)被进一步细分以显示这些级的延迟。由于它们不是完整的级,因此只能有一个操作处于活动状态。流水线结构还可以使用附录前面熟悉的图表来显示,如图 C.31 所示,适用于一组独立的 FP 操作和 FP 加载和存储。自然地,FP 操作的延迟时间越长,RAW 冲突和由此产生的停顿的频率就越高,正如我们将在本节后面看到的那样。

Image in a image block

支持多个未完成 FP 操作的流水线。FP 乘法器和加法器是完全流水线化的,深度分别为七级和四级。FP 除法器不是流水线化的,但需要 24 个时钟周期才能完成。在发出 FP 操作和使用该操作的结果而不产生 RAW 停滞之间的指令延迟由执行阶段花费的周期数决定。例如,FP 加法后的第四条指令可以使用 FP 加法的结果。对于整数 ALU 操作,执行流水线的深度始终为一,下一条指令可以使用结果。

图 C.30 中流水线的结构需要引入额外的流水线寄存器(例如,A1/A2、A2/A3、A3/A4),并修改与这些寄存器的连接。ID/EX 寄存器必须扩展到将 ID 连接到 EX、DIV、M1 和 A1;我们可以使用 ID/EX、ID/DIV、ID/M1 或 ID/A1 符号来引用与下一阶段之一关联的寄存器的部分。ID 与所有其他阶段之间的流水线寄存器可以被认为是逻辑上独立的寄存器,事实上,可以作为独立的寄存器来实现。由于一次只能在一个管道阶段中进行一个操作,因此控制信息可以与该阶段开头的寄存器相关联。

较长延迟流水线中的冒险和转发

对于图 C.30 中所示的流水线,存在许多不同的冒险检测和转发方面。

  1. 由于除法单元不是完全流水线化的,因此可能会发生结构冒险。这些需要被检测到,并且需要暂停发出指令。
  2. 由于指令具有不同的运行时间,因此一个周期中所需的寄存器写入数可以大于 1。
  3. 写后写 (WAW) 冒险是可能的,因为指令不再按顺序到达 WB。请注意,写后读 (WAR) 冒险是不可能的,因为寄存器读取始终在 ID 中发生。
  4. 指令可以按与发出指令不同的顺序完成,从而导致异常问题;我们将在下一小节中处理这个问题。
  5. 由于操作的延迟较长,因此 RAW 冒险的停顿将更加频繁。

由于较长的操作延迟而导致的停顿的增加从根本上与整数流水线相同。在描述此 FP 流水线中出现的新问题并寻找解决方案之前,让我们检查 RAW 危害的潜在影响。图 C.32 显示了一个典型的 FP 代码序列和由此产生的停顿。在本节的末尾,我们将检查此 FP 流水线在我们 SPEC 子集中性能。

Image in a image block

一个典型的 FP 代码序列,显示了由 RAW 隐患引起的停顿。较长的流水线大幅提高了停顿频率,而不是较浅的整数流水线。此序列中的每条指令都依赖于前一条指令,并在数据可用时立即执行,这假设流水线具有完全旁路和转发。fsd 必须额外停顿一个周期,以便其 MEM 不与 fadd.d 冲突。额外的硬件可以轻松处理这种情况。

现在查看由写入引起的前面列表中描述为 (2) 和 (3) 的问题。如果我们假设 FP 寄存器文件有一个写入端口,则 FP 操作序列以及 FP 加载和 FP 操作一起可能会导致寄存器写入端口发生冲突。考虑图 C.33 中所示的流水线序列。在时钟周期 11 中,所有三个指令都将到达 WB 并希望写入寄存器文件。由于只有一个寄存器文件写端口,因此处理器必须序列化指令完成。这个单寄存器端口表示结构性危险。我们可以增加写端口的数量来解决这个问题,但该解决方案可能没有吸引力,因为额外的写端口很少使用。这是因为所需的写入端口的最大稳态数为 1。相反,我们选择检测和强制访问写端口作为结构性危险。

实现此互锁有两种不同的方法。第一种方法是在 ID 阶段跟踪写端口的使用情况,并在指令发出之前将其暂停,就像我们对任何其他结构性危害所做的那样。跟踪写端口的使用情况可以使用移位寄存器来完成,该寄存器指示已发出的指令何时将使用寄存器文件。如果 ID 中的指令需要同时使用寄存器文件

Image in a image block

三条指令想要同时对 FP 寄存器文件执行写回,如时钟周期 11 所示。这不是最坏的情况,因为 FP 单元中较早的除法也可能在同一时钟完成。请注意,尽管 fmul.d、fadd.d 和 fld 在时钟周期 10 处于 MEM 阶段,但实际上只有 fld 使用内存,因此 MEM 不存在结构性冲突。

作为已发出的指令,ID 中的指令会停滞一个周期。在每个时钟,保留寄存器会移动 1 位。此实现有一个优点:它保持了所有互锁检测和停滞插入都发生在 ID 阶段的特性。成本是增加了移位寄存器和写入冲突逻辑。

在本节中,我们将采用此方案。

另一种方案是在冲突指令尝试进入 MEM 或 WB 阶段时将其搁置。如果我们等到冲突指令想要进入 MEM 或 WB 阶段时再将其搁置,我们可以选择搁置任一指令。一种简单但有时次优的启发式方法是优先考虑具有最长延迟的单元,因为该单元最有可能导致另一条指令因 RAW 冲突而被搁置。此方案的优点是它不要求我们在 MEM 或 WB 阶段的入口处检测冲突,在那里很容易看到冲突。缺点是它使流水线控制复杂化,因为现在可能从两个地方出现搁置。请注意,在进入 MEM 之前搁置会导致 EX、A4 或 M7 阶段被占用,可能迫使搁置在流水线中回流。同样,在 WB 之前搁置会导致 MEM 备份。

我们的另一个问题是 WAW 危害的可能性。要了解这些危害的存在,请考虑图 C.33 中的示例。如果 fadd.d 指令提前一个周期发出,并且目标是 f2,那么它将创建一个 WAW 危害,因为它将比 fadd.d 提前一个周期写入 f2。请注意,此危害仅在 fadd.d 的结果被覆盖且没有任何指令使用它时才会发生!如果在 fadd.d 和 fadd.d 之间使用了 f2,则流水线需要因 RAW 危害而停滞,并且 fadd.d 将不会发出,直到 fadd.d 完成。我们可以争辩说,对于我们的流水线,WAW 危害仅在执行无用指令时才会发生,但我们仍然必须检测它们并确保 fadd.d 的结果在我们完成后出现在 f2 中。(正如我们在第 C.8 节中所看到的,此类序列有时确实出现在合理的代码中。)

处理此 WAW 危害有两种可能的方法。第一种方法是延迟发出加载指令,直到 fadd.d 进入 MEM。第二种方法是通过检测危害并更改控制来消除 fadd.d 的结果,以便 fadd.d 不写入其结果。然后 fadd.d 可以立即发出。由于此危害很少见,因此任一方案都可以正常工作——您可以选择实现起来更简单的方法。在任一情况下,当 fadd.d 发出时,都可以在 ID 期间检测到危害,并且很容易使 fadd.d 停滞或使 fadd.d 成为无操作数。困难的情况是检测到 fadd.d 可能在 fadd.d 之前完成,因为这需要知道流水线的长度和 fadd.d 的当前位置。幸运的是,此代码序列(两次写入,没有中间读取)将非常罕见,因此我们可以使用一个简单的解决方案:如果 ID 中的指令想要写入与已发出的指令相同的寄存器,则不要向 EX 发出指令。在 C.7 节中,我们将看到如何使用额外的硬件消除此类危害的停滞。 首先,让我们将实现 FP 管道中的危险和问题逻辑的部分组合在一起。

在检测可能存在的危害时,我们必须考虑浮点指令之间的危害,以及浮点指令和整数指令之间的危害。除了浮点加载存储和浮点整数寄存器移动之外,浮点和整数寄存器是不同的。所有整数指令都在整数寄存器上操作,而浮点操作仅在其自己的寄存器上操作。因此,在检测浮点和整数指令之间的危害时,我们只需要考虑浮点加载存储和浮点寄存器移动。这种流水线控制的简化是为整数和浮点数据设置单独寄存器文件的一个额外优势。(主要优势是寄存器数量增加了一倍,而无需使任一组更大,并且增加了带宽,而无需向任一组添加更多端口。除了需要额外的寄存器文件之外,主要缺点是两个寄存器组之间偶尔需要移动的小成本。)假设流水线在 ID 中执行所有危害检测,则在指令可以发出之前必须执行三个检查:

  1. 检查结构性危害——等到所需功能单元不忙(这仅适用于此管道中的划分),并确保寄存器写端口在需要时可用。
  2. 检查 RAW 数据风险——等到源寄存器不在流水线寄存器中列为挂起目标,而该流水线寄存器在该指令需要结果时不可用。此处必须进行多项检查,具体取决于源指令(决定结果何时可用)和目标指令(决定何时需要该值)。例如,如果 ID 中的指令是源寄存器为 f2 的 FP 操作,则 f2 不能在 ID/A1、A1/A2 或 A2/A3 中列为目标,这些目标对应于 FP 加法指令,该指令在 ID 中的指令需要结果时不会完成。(ID/A1 是发送到 A1 的 ID 输出寄存器的部分。)如果我们希望允许重叠除法的最后几个周期,则除法有点棘手,因为我们需要处理除法接近完成的情况,这种情况很特殊。在实践中,设计人员可能会忽略此优化,而采用更简单的发布测试。
  3. 检查 WAW 数据风险——确定 A1、…、A4、D、M1、…、M7 中的任何指令是否与该指令具有相同的寄存器目标。如果是,则暂停发布 ID 中的指令。

尽管多周期浮点运算的危险检测更加复杂,但其概念与 RISC V 整数流水线相同。转发逻辑也是如此。可以通过检查 EX/MEM、A4/MEM、M7/MEM、D/MEM 或 MEM/WB 寄存器中的目标寄存器是否是浮点指令的源寄存器之一来实现转发。如果是,则必须启用适当的输入多路复用器以选择转发的数据。在练习中,您将有机会指定 RAW 和 WAW 危险检测以及转发的逻辑。

多周期浮点运算也会给我们的异常机制带来问题,我们接下来将处理这些问题。

保持精确的异常

由这些长时间运行的指令导致的另一个问题可以用以下代码序列来说明:

fdiv.d f0,f2,f4
fadd.d f10,f10,f8
fsub.d f12,f12,f14

这段代码序列看起来很简单;没有依赖关系。然而,问题出现了,因为早发出的指令可能在晚发出的指令之后完成。在这个例子中,我们可以预期 fadd.d 和 fsub.d 在 fdiv.d 完成之前完成。这称为乱序完成,在具有长期运行操作的流水线中很常见(参见 C.7 节)。由于危害检测将防止指令之间的任何依赖关系被违反,为什么乱序完成是一个问题?假设 fsub.d 在 fadd.d 已完成但 fdiv.d 尚未完成时导致浮点算术异常。结果将是一个不精确的异常,这是我们试图避免的事情。似乎可以通过让浮点流水线排空来处理这个问题,就像我们对整数流水线所做的那样。但异常可能处于无法做到这一点的位置。例如,如果 fdiv.d 决定在加法完成后引发浮点算术异常,我们无法在硬件级别获得精确的异常。事实上,因为 fadd.d 销毁了一个操作数,我们无法将状态恢复到 fdiv.d 之前的状态,即使有软件帮助。

这个问题出现是因为指令的完成顺序与它们发出的顺序不同。有四种可能的方法来处理乱序完成。第一个是忽略问题并满足于不精确的异常。这种方法在 20 世纪 60 年代和 70 年代早期使用。在过去 15 年中,它仍在一些超级计算机中使用,其中某些类别的异常不被允许或由硬件处理,而不会停止流水线。由于虚拟内存和 IEEE 浮点标准等特性,在当今构建的大多数处理器中很难使用这种方法,这些特性本质上需要通过硬件和软件的组合来实现精确异常。如前所述,一些最近的处理器通过引入两种执行模式解决了这个问题:一种快速但可能不精确的模式和一种较慢但精确的模式。较慢的精确模式通过模式切换或插入测试 FP 异常的显式指令来实现。 无论哪种情况,FP 管道中允许的重叠和重新排序的数量都受到很大限制,因此实际上一次只能激活一条 FP 指令。此解决方案用于 DEC Alpha 21064 和 21164、IBM Power1 和 Power2 以及 MIPS R8000 中。

第二种方法是缓冲操作的结果,直到之前发出的所有操作都完成。有些处理器实际上使用这种解决方案,但是当操作之间的运行时间差异很大时,它会变得非常昂贵,因为需要缓冲的结果数量可能会变得很大。此外,队列中的结果必须被绕过,以便在等待较长的指令时继续发出指令。这需要大量的比较器和一个非常大的多路复用器。

这种基本方法有两种可行的变体。第一个是历史文件,用于 CYBER 180/990。历史文件跟踪寄存器的原始值。当发生异常并且必须将状态回滚到早于某些乱序完成的指令时,可以从历史文件中恢复寄存器的原始值。类似的技术用于 VAX 等处理器上的自动增量和自动减量寻址。另一种方法是未来文件,由 Smith 和 Pleszkun (1988) 提出,它保留寄存器的较新值;当所有较早的指令都已完成时,主寄存器文件将从未来文件更新。在异常情况下,主寄存器文件具有中断状态的确切值。在第 3 章中,我们将看到另一种方法,该方法对于支持推测(一种在知道先前分支结果之前执行指令的方法)是必需的。

正在使用的第三种技术是允许异常变得有点不精确,但保留足够的信息,以便陷阱处理例程可以为异常创建精确的序列。这意味着知道管道中的操作及其 PC。然后,在处理异常后,软件完成在最新完成的指令之前的所有指令,并且序列可以重新启动。考虑以下最坏情况的代码序列:

Image in a image block

给定管道中所有指令的 PC 和异常返回 PC,软件可以找到指令 1 和指令的状态。因为指令已完成,所以我们希望在指令处重新启动执行。在处理异常后,软件必须模拟指令、…、指令的执行。然后,我们可以从异常返回并在指令处重新启动。由处理程序正确执行这些指令的复杂性是此方案的主要困难。

对于简单的 RISC V 型流水线,有一个重要的简化:如果指令、…、指令都是整数指令,我们知道如果指令已完成,那么指令、…、指令也已完成。因此,只需要处理 FP 操作。为了使此方案可行,可以限制执行中可以重叠的浮点指令数。例如,如果我们只重叠两个指令,那么只需要通过软件完成中断指令。如果 FP 流水线很深或有大量 FP 功能单元,则此限制可能会降低潜在吞吐量。这种方法在一些 SPARC 实现中使用,以允许浮点和整数操作重叠。

最后一种技术是一种混合方案,它允许指令发出继续,但前提是确定在发出指令之前的所有指令都将完成而不会引发异常。这保证了当异常发生时,中断指令之后的所有指令都不会完成,而中断指令之前的所有指令都可以完成。这有时意味着使处理器停顿以保持精确的异常。为了使此方案起作用,浮点功能单元必须在 EX 阶段的早期(在 RISC V 管道中的前 3 个时钟周期内)确定是否可能发生异常,以防止进一步的指令完成。此方案用于 MIPS R2000/3000、R4000 和 Intel Pentium 中。它在附录 J 中进一步讨论。

简单 RISC V FP 管道的性能

C.48 页上的图 C.30 中的 RISC V FP 管道可以为除法单元生成结构停顿和 RAW 冲突停顿(它也可以有 WAW 冲突,但这在实践中很少发生)。图 C.34 显示了每种浮点操作的停顿周期数,以每个实例为基础(即,每个 FP 基准的第一个条形显示每个 FP 加法、减法或转换的 FP 结果停顿数)。正如我们所料,每个操作的停顿周期跟踪 FP 操作的延迟,从功能单元延迟的 46% 到 59% 不等。

图 C.35 给出了五个 SPECfp 基准的整数和 FP 停顿的完整细分。显示了四类停顿:FP 结果停顿、FP 比较停顿、加载和分支延迟以及 FP 结构延迟。分支延迟停顿(使用一个周期延迟甚至一个适度的分支预测器时会很小)不包括在内。每个指令的停顿总数从 0.65 到 1.21 不等。

将所有内容放在一起:MIPS R4000 管道

在本节中,我们研究了 MIPS R4000 处理器系列(包括 4400)的流水线结构和性能。MIPS 架构和 RISC V 非常相似,仅在少数指令中有所不同,包括 MIPS ISA 中的延迟分支。R4000 实现了 MIPS64,但对整数和 FP 程序都使用了比我们五级设计更深的流水线。这种更深的流水线允许它通过将五级整数流水线分解为八级来实现更高的时钟速率。由于高速缓存访问特别耗时,因此额外的流水线级来自分解内存访问。这种类型的更深流水线有时称为超流水线。

图 C.36 使用数据路径的抽象版本显示了八级流水线结构。图 C.37 显示了流水线中连续指令的重叠。请注意,尽管指令和数据内存占用多个周期,但它们是完全流水线化的,因此每隔一个时钟就可以启动一条新指令。

Image in a image block

SPEC89 FP 基准测试中每种主要类型 FP 操作的每个 FP 操作的停顿。除了除法结构危害之外,这些数据不依赖于操作的频率,仅依赖于其延迟和结果被使用之前的周期数。RAW 危害的停顿数大致跟踪 FP 单元的延迟。例如,每个 FP 加法、减法或转换的平均停顿数为 1.7 个周期,或延迟的 56%(三个周期)。同样,乘法和除法的平均停顿数分别为 2.8 和 14.2,或相应延迟的 46% 和 59%。除法的结构危害很少见,因为除法频率很低。

事实上,管道在高速缓存命中检测完成之前就使用了数据;第 3 章更详细地讨论了如何执行此操作。

每个阶段的功能如下:

IF——指令取出的前半部分;PC 选择实际上发生在这里,同时启动指令高速缓存访问。

IS——指令取出的后半部分,完成指令高速缓存访问。

RF——指令译码和寄存器提取,冒险检查和指令高速缓存命中检测。

EX——执行,包括有效地址计算、ALU 运算、分支目标计算和条件评估。

DF——数据获取,数据缓存访问的前半部分。

DS——数据获取的后半部分,数据缓存访问的完成。

TC——标签检查,以确定数据缓存访问是否命中。

WB——加载和寄存器-寄存器操作的写回。

Image in a image block

R4000 的八级流水线结构使用流水线指令和数据高速缓存。流水线阶段已标记,其详细功能在文本中进行了描述。垂直虚线表示阶段边界以及流水线锁存器的位置。指令实际上在 IS 结束时可用,但标记检查在 RF 中完成,同时获取寄存器。因此,我们显示指令存储器通过 RF 操作。TC 阶段对于数据存储器访问是必需的,因为在不知道高速缓存访问是否命中之前,我们无法将数据写入寄存器

Image in a image block

R4000 整数流水线的结构导致 x1 加载延迟。x1 延迟是可能的,因为数据值在 DS 结束时可用,并且可以被旁路。如果 TC 中的标记检查指示未命中,则流水线将备份一个周期,此时可获得正确的数据。

除了大幅增加所需的转发量之外,这种延迟较长的流水线还会增加加载和分支延迟。图 C.37 显示加载延迟为两个周期,因为数据值在 DS 结束时可用。图 C.38 显示了紧跟加载之后立即使用的简写流水线计划。它表明,需要将加载指令的结果转发到三个或四个周期后的目标。

图 C.39 显示基本分支延迟为三个周期,因为分支条件在 EX 期间计算。MIPS 架构具有单周期延迟分支。R4000 对分支延迟的剩余两个周期使用预测不取策略。如图 C.40 所示,未取分支只是单周期延迟分支,而取分支有一个单周期延迟槽,后跟两个空闲周期。指令集提供了一个分支可能指令,我们之前已对此进行描述,它有助于填充分支延迟槽。

Image in a image block

加载指令后紧跟立即使用会导致 x1 停顿。两个周期后可以使用正常的转发路径,因此加法和减法在停顿后通过转发获取值。or 指令从寄存器文件获取值。由于加载后的两个指令可能是独立的,因此不会停顿,旁路可以是加载后三个或四个周期的指令

流水线互锁强制执行对已执行分支的 x1 分支停顿惩罚,以及因使用加载结果而产生的任何数据危险停顿。在 R4000 之后,MIPS 处理器的所有实现都使用了动态分支预测。

除了增加负载和分支的停顿外,更深的流水线增加了 ALU 操作的前向传输级别。在我们的 RISC V 五级流水线中,两个寄存器-寄存器 ALU 指令之间的前向传输可以从 ALU/MEM 或 MEM/WB 寄存器发生。在 R4000 流水线中,ALU 旁路有四个可能来源:EX/DF、DF/DS、DS/TC 和 TC/WB。

Image in a image block

上半部分显示了一个已执行的分支,它有一个单周期延迟槽,后跟一个 x1 停顿,而图的下半部分显示了一个未执行的分支,它只有一个单周期延迟槽。分支指令可以是普通延迟分支或分支-可能,如果分支未执行,则取消延迟槽中指令的效果。

浮点流水线

R4000 浮点单元由三个功能单元组成:浮点除法器、浮点乘法器和浮点加法器。加法器逻辑用于乘法或除法的最后一步。双精度 FP 运算可能需要 2 个周期(对于取反)到 112 个周期(对于平方根)。此外,各个单元具有不同的启动速率。可以将 FP 功能单元视为具有八个不同阶段,如表 C.41 所示;这些阶段以不同的顺序组合以执行各种 FP 运算。

每个阶段只有一个副本,并且各种指令可能使用一个阶段零次或多次,并且以不同的顺序使用。表 C.42 显示了最常见的双精度 FP 运算使用的延迟、启动速率和流水线阶段。

Image in a image block

FP 操作的延迟和启动间隔 FP 操作的启动间隔都取决于给定操作必须使用的 FP 单元阶段。延迟值假设目标指令是 FP 操作;当目标是存储时,延迟少一个周期。管道阶段按用于任何操作的顺序显示。符号 S + A 表示同时使用 S 和 A 阶段的时钟周期。符号 D 表示 D 阶段连续使用 28 次。

从图 C.42 中的信息,我们可以确定一系列不同的、独立的 FP 操作是否可以在不发生停滞的情况下发出。如果序列的时序使得共享流水线阶段发生冲突,则需要停滞。图 C.43–C.46 显示了四种常见的可能的两条指令序列:乘法后跟加法,加法后跟乘法,除法后跟加法,以及加法后跟除法。这些图显示了第二条指令的所有有趣的起始位置,以及第二条指令在每个位置是否会发出或停滞。当然,可能有三个指令处于活动状态,在这种情况下,停滞的可能性会高得多,并且这些图也会更复杂。

R4000 流水线的性能

在本节中,我们检查了在 R4000 流水线结构上运行时 SPEC92 基准测试中发生的停顿。流水线停顿或损失有四个主要原因:

  1. 加载停顿——在加载后一或两个周期使用加载结果而产生的延迟
  2. 分支停顿——在每个已执行分支上停顿两个周期,加上未填充或已取消的分支延迟槽。R4000 中实现的 MIPS 指令集版本支持在编译时预测分支的指令,并在分支行为与预测不同时导致取消分支延迟槽中的指令。这使得填充分支延迟槽变得更容易。
  3. FP 结果停顿——由于 FP 操作数的 RAW 冲突而导致的停顿
  4. FP 结构停顿——由于 FP 流水线中功能单元冲突而产生的问题限制而导致的延迟
Image in a image block

在时钟 0 发出的 FP 乘法后,在时钟 1 和 7 之间发出一个 FP 加法。第二列指示当在 n 个周期后发出指定类型的指令时,该指令是否会停滞,其中 n 是第二个指令的 U 阶段发生的时钟周期数。导致停滞的阶段以粗体显示。请注意,此表仅处理乘法和时钟 1 和 7 之间发出的一个加法之间的交互。在这种情况下,如果加法在乘法后四个或五个周期发出,它将停滞;否则,它会发出而不会停滞。请注意,如果加法在周期 4 中发出,它将停滞两个周期,因为在下一个时钟周期中它仍会与乘法冲突;然而,如果加法在周期 5 中发出,它将仅停滞 1 个时钟周期,因为这将消除冲突。

Image in a image block

在加法后进行多重发出总是可以继续进行,而不会停滞,因为较短的指令在较长的指令到达之前会清除共享的流水线阶段。

从图 C.47 和 C.48 中的数据可以看出,更深流水线的代价。R4000 的流水线比经典的五级流水线有更长的分支延迟。更长的分支延迟大幅增加了分支上花费的周期,特别是对于具有较高分支频率的整数程序。这就是为什么几乎所有具有中等至深流水线的后续处理器(如今通常为 8-16 级)都采用动态分支预测器的原因。

Image in a image block

FP 除法会导致在除法接近尾声时开始的加法停滞。除法在周期 0 开始,在周期 35 完成;显示了除法的最后 10 个周期。由于除法大量使用了加法所需的舍入硬件,因此它会使在周期 28-33 中开始的加法停滞。请注意,在周期 28 开始的加法将停滞到周期 36。如果加法在除法之后立即开始,则不会冲突,因为加法可以在除法需要共享阶段之前完成,正如我们在图 C.44 中看到的乘法和加法一样。与前面的图形一样,此示例假定在时钟周期 26 和 35 之间只有一个加法到达 U 阶段

Image in a image block

双精度加法后跟双精度除法。如果除法在加法后一个周期开始,则除法会停滞,但之后不会有冲突。

在 FP 程序中观察到的一个有趣效果是,FP 功能单元的延迟导致比结构性危险更多的结果停滞,结构性危险既源于启动间隔限制,也源于不同 FP 指令的功能单元冲突。因此,减少 FP 操作的延迟应该是首要目标,而不是增加流水线或复制功能单元。当然,减少延迟可能会增加结构性停滞,因为许多潜在的结构性停滞都隐藏在数据危险之后。

交叉问题

RISC 指令集和流水线的效率

我们已经讨论了在构建流水线时指令集简单的优势。简单的指令集提供了另一个优势:它们使代码调度更容易,从而在流水线中实现执行效率。为了了解这一点,请考虑一个简单的示例:假设我们需要在内存中添加两个值并将结果存储回内存。在一些复杂的指令集中,这只需要一条指令;在其他指令集中,这需要两条或三条指令。典型的 RISC 架构需要四条指令(两个加载、一个加法和一个存储)。在大多数流水线中,这些指令无法按顺序调度,而不会出现中间停顿。

使用 RISC 指令集,各个操作都是独立的指令,可以由编译器(使用我们前面讨论的技术和第 3 章中讨论的更强大的技术)或使用动态硬件调度技术(我们将在下一部分和第 3 章中进一步详细讨论)单独调度。这些效率优势,加上更易于实现,似乎非常重要,以至于几乎所有最近的复杂指令集流水线实现实际上都会将其复杂指令转换为简单的类 RISC 操作,然后调度和流水线化这些操作。所有最近的英特尔处理器都使用这种方法,它也用于 ARM 处理器中一些更复杂的指令。

动态调度流水线

简单的流水线获取指令并发出指令,除非流水线中已有的指令与获取的指令之间存在数据相关性,并且无法通过旁路或转发来隐藏。转发逻辑减少了有效的流水线延迟,以便某些相关性不会导致危险。如果存在不可避免的危险,则危险检测硬件会使流水线停顿(从使用结果的指令开始)。在相关性清除之前,不会获取或发出任何新指令。为了克服这些性能损失,编译器可以尝试调度指令以避免危险;这种方法称为编译器或静态调度。

一些早期的处理器使用了另一种方法,称为动态调度,其中硬件重新安排指令执行以减少停顿。本节通过解释 CDC 6600 的记分牌技术,对动态调度进行了更简单的介绍。一些读者在深入研究更复杂的 Tomasulo 方案以及扩展它的推测方法(这两者都在第 3 章中介绍)之前,会发现阅读此材料更容易。

到目前为止,本附录中讨论的所有技术都使用按序指令发出,这意味着如果流水线中某个指令停滞,则后面的指令无法继续执行。对于按序发出,如果两条指令之间存在冲突,则管道将停滞,即使有后续的指令是独立的并且不会停滞。

在之前开发的 RISC V 流水线中,在指令译码 (ID) 期间检查结构和数据冲突:当指令可以正确执行时,它会从 ID 发出。为了允许指令在其操作数可用后立即开始执行,即使前驱被暂停,我们必须将发出过程分成两部分:检查结构冲突和等待数据冲突消失。我们按顺序译码和发出指令;但是,我们希望指令在其数据操作数可用后立即开始执行。因此,流水线将执行乱序执行,这意味着乱序完成。为了实现乱序执行,我们必须将 ID 管道阶段拆分为两个阶段:

  1. 发出——译码指令,检查结构冲突。
  2. 读取操作数——等待直到没有数据冲突,然后读取操作数。

IF 阶段在发出阶段之前,EX 阶段在读取操作数阶段之后,就像在 RISC V 流水线中一样。就像在 RISC V 浮点流水线中一样,执行可能需要多个周期,具体取决于操作。因此,我们可能需要区分一条指令何时开始执行以及何时完成执行;在这两个时间之间,该指令正在执行。这允许多条指令同时执行。除了对流水线结构进行这些更改之外,我们还将通过改变单元数量、操作延迟和功能单元流水线来更改功能单元设计,以便更好地探索这些更高级的流水线技术。

使用记分板的动态调度

在动态调度流水线中,所有指令按顺序通过颁发阶段(按顺序颁发);然而,它们可以在第二阶段(读取操作数)中停滞或绕过彼此,从而乱序执行。记分板是一种允许指令在有足够资源且没有数据依赖关系时乱序执行的技术;它以开发此功能的 CDC 6600 记分板命名。

在我们了解如何在 RISC V 流水线中使用记分板之前,重要的是要注意,在指令乱序执行时可能会出现 WAR 冲突,这在 RISC V 浮点或整数流水线中不存在。例如,考虑以下代码序列:

fdiv.d f0,f2,f4
fadd.d f10,f0,f8
fsub.d f8,f8,f14

fadd.d 和 fsub.d 之间存在潜在的 WAR 危害:如果流水线在 fadd.d 之前执行 fsub.d,它将违反产生不正确的执行。同样,流水线必须避免 WAW 危害(例如,就像 fsub.d 的目标是 f10 时会发生的那样)。正如我们将看到的,记分板通过暂停涉及该冲突的后续指令来避免这两种冲突。

记分板的目标是通过尽可能早地执行指令,在没有结构性冲突时保持每时钟周期一个指令的执行速率。因此,当要执行的下一条指令被暂停时,如果其他指令不依赖于任何活动或暂停的指令,则可以发出并执行这些指令。记分板对指令的发出和执行承担全部责任,包括所有冲突检测。利用乱序执行需要在 EX 阶段同时执行多条指令。这可以通过多个功能单元、流水线功能单元或两者来实现。由于这两个功能(流水线功能单元和多个功能单元)在流水线控制的目的上本质上是等效的,因此我们将假设处理器具有多个功能单元。

CDC 6600 有 16 个独立的功能单元,包括 4 个浮点单元、5 个用于内存引用的单元和 7 个用于整数运算的单元。在 RISC V 架构的处理器上,记分板主要在浮点单元上有意义,因为其他功能单元的延迟非常小。让我们假设有两个乘法器、一个加法器、一个除法单元和一个用于所有内存引用、分支和整数运算的单个整数单元。虽然此示例比 CDC 6600 简单,但它足够强大,可以在没有大量细节或不需要很长的示例的情况下演示原理。由于 RISC V 和 CDC 6600 都是加载存储架构,因此这两种处理器的技术几乎相同。图 C.49 显示了处理器的外观。

每条指令都经过记分板,其中构建了数据依赖关系的记录;此步骤对应于指令发出,并替换了 RISC V 流水线中 ID 步骤的一部分。然后,记分板确定指令何时可以读取其操作数并开始执行。如果记分板决定指令无法立即执行,它将监视硬件中的每次更改并决定指令何时可以执行。记分板还控制指令何时可以将其结果写入目标寄存器。因此,所有危害检测和解决都集中在记分板上。我们稍后将看到记分板的图片(C.68 页的图 C.49),但首先我们需要了解流水线中发出和执行段的步骤。

Image in a image block

带记分板的 RISC V 处理器的基本结构。记分板的功能是控制指令执行(垂直控制线)。所有数据在寄存器文件和功能单元之间通过总线(水平线,在 CDC 6600 中称为中继线)流动。有两个 FP 乘法器、一个 FP 除法器、一个 FP 加法器和一个整数单元。一组总线(两个输入和一个输出)为一组功能单元提供服务。我们将在第 3 章中更详细地探讨记分板及其扩展。

每条指令在执行时经历四个步骤。(由于我们专注于 FP 操作,因此我们不会考虑内存访问步骤。)让我们首先非正式地检查这些步骤,然后详细了解记分板如何保留必要的信息,以确定何时从一个步骤进入到下一个步骤。这四个步骤取代了标准 RISC V 流水线中的 ID、EX 和 WB 步骤,如下所示:

  1. 发出——如果指令的功能单元是空闲的,并且没有其他活动指令具有相同目标寄存器,则记分板会将指令发出到功能单元并更新其内部数据结构。此步骤取代了 RISC V 流水线中 ID 步骤的一部分。通过确保没有其他活动功能单元想要将结果写入目标寄存器时,我们保证不会出现 WAW 冲突。如果存在结构性或 WAW 冲突,则指令发出将暂停,并且在这些冲突清除之前不会发出进一步的指令。当发出阶段暂停时,它会导致指令提取和发出之间的缓冲区填满;如果缓冲区是单一入口,则指令提取会立即暂停。如果缓冲区是具有多条指令的队列,则在队列填满时会暂停。
  2. 读取操作数——记分板监视源操作数的可用性。如果之前发出的活动指令不会写入源操作数,则该源操作数可用。当源操作数可用时,记分板会告诉执行单元开始从寄存器读取操作数并开始执行。记分板在此步骤中动态解决 RAW 冲突,并且指令可以乱序发送到执行中。此步骤与发出一起,完成了简单 RISC V 流水线中 ID 步骤的功能。
  3. 执行——功能单元在收到操作数后开始执行。当结果准备就绪时,它会通知记分板它已完成执行。此步骤替换了 RISC V 流水线中的 EX 步骤,并在 RISC V FP 流水线中占用多个周期。
  4. 写入结果——一旦记分板意识到功能单元已完成执行,记分板就会检查 WAR 冲突并在必要时暂停完成指令。

如果存在代码序列(如我们早先使用 fadd.d 和 fsub.d 的示例),并且两者都使用 f8,则存在 WAR 冲突。在该示例中,我们有代码

fdiv.d f0,f2,f4
fadd.d f10,f0,f8
fsub.d f8,f8,f14

fadd.d 的源操作数为 f8,与 fsub.d 的目标寄存器相同。但 fadd.d 实际上依赖于更早的指令。记分板仍会阻止 fsub.d 处于其写入结果阶段,直到 fadd.d 读取其操作数。因此,通常情况下,当出现以下情况时,不能允许完成指令写入其结果:

  • 存在一条指令尚未读取其操作数,并且该指令在完成指令之前(即按发出顺序)
  • 其中一个操作数与完成指令的结果是同一个寄存器。

如果不存在此 WAR 冲突,或者当它清除时,记分板会告诉功能单元将其结果存储到目标寄存器。此步骤替换了简单 RISC V 流水线中的 WB 步骤。

乍一看,记分板似乎很难区分 RAW 和 WAR 冲突。

由于指令的操作数仅在寄存器文件中两个操作数都可用时才读取,因此该记分板不会利用转发。相反,仅在两个寄存器都可用时才读取寄存器。这不像您最初想象的那么大的损失。与我们早期的简单流水线不同,指令会在执行完成后立即将其结果写入寄存器文件(假设没有 WAR 冲突),而不是等待可能相隔几个周期的静态分配写入槽。该效果会降低流水线延迟并减少转发的优势。仍然有一个额外的延迟周期,因为写入结果和读取操作数阶段不能重叠。我们需要额外的缓冲来消除此开销。

记分板基于自己的数据结构,通过与功能单元通信来控制指令从一步到下一步的执行。然而,有一个小问题。寄存器文件只有数量有限的源操作数总线和结果总线,这表示存在结构性风险。记分板必须保证允许进入步骤 2 和步骤 4 的功能单元数量不超过可用总线数量。除了提到 CDC 6600 通过将 16 个功能单元分组为四组并为每组提供一组总线(称为数据中继线)来解决此问题之外,我们不会对此进行更详细的讨论。一组中只有一个单元可以在一个时钟周期内读取其操作数或写入其结果。

陷阱

乍一看,WAW 危害看起来永远不会出现在代码序列中,因为没有编译器会在没有中间读取的情况下生成对同一寄存器的两次写入,但当序列意外时,它们可能会发生。例如,考虑一个导致陷阱的长时间浮点除法。如果陷阱例程在早期写入与除法相同的寄存器,则可能会导致 WAW 危害,如果它在除法完成之前写入寄存器。硬件或软件必须避免这种情况。

这种现象的最佳示例来自 VAX 的两个实现,即 8600 和 8700。当 8600 最初交付时,它的周期时间为 80 ns。随后,引入了称为 8650 的重新设计版本,时钟为 55 ns。8700 具有一个在微指令级别运行的更简单的流水线,从而产生一个时钟周期为 45 ns 的更小的处理器。总体结果是 8650 具有大约 20% 的 CPI 优势,但 8700 的时钟速率大约快 20%。因此,8700 以更少的硬件实现了相同的性能。

未优化代码(包含冗余的加载、存储和其他可能被优化器消除的操作)比“紧凑”的优化代码更容易调度。这适用于调度控制延迟(带有延迟分支)和由 RAW 危害引起的延迟。在 R3000 上运行的 gcc 中,其管道几乎与 C.1 节中的管道相同,空闲时钟周期的频率从未优化和已调度的代码增加到优化和已调度的代码的 18%。当然,优化后的程序要快得多,因为它有更少的指令。要公平地评估编译时调度程序或运行时动态调度,你必须使用经过优化的代码,因为在实际系统中,除了调度之外,你还可以从其他优化中获得良好的性能。

第 3 章中讨论的复杂动态调度、多指令问题方法。