• 缺页异常:尝试访问未正确映射的物理内存。

  • 系统调用:在X86上,通过int指令主动触发中断以实现系统调用。

中断处理机制

当发生中断时,硬件需要执行一系列操作来保存当前状态并跳转到处理程序。

以下是中断发生时硬件需要执行的关键步骤:

  1. 停止当前程序:在某个点暂停当前指令流的执行。

  2. 保存程序计数器(PC):为了将来能返回到被中断的指令,需要将当前的PC值保存起来。保存的位置可能是一个专用的异常PC寄存器(如MIPS中的EPC),也可能被压入内存栈中(如X86)。

  3. 保存处理器状态:中断处理程序开始执行时,原有的寄存器值仍然是有效的,不能被丢弃。不同架构有不同的保存机制:

    • MIPS:预留两个专用寄存器(如$k0, $k1)供中断处理程序使用,用于将其他寄存器保存到内存。

    • X86:硬件机制(如pusha指令)可以将多个寄存器自动压栈,现代操作系统则倾向于只保存必要的寄存器。

  4. 屏蔽其他中断:为了防止在中断处理程序中再次被中断(即中断嵌套),通常需要暂时屏蔽(关闭)其他中断。处理完成后,再通过返回指令(如iret)重新启用中断。更复杂的系统可能允许在将状态安全保存到内存后,在处理程序中重新启用中断以实现嵌套。

流水线中的中断处理

在流水线处理器中,中断或异常可能在流水线的不同阶段被检测到。

以下是流水线各阶段可能产生的异常示例:

  • 取指阶段:PC地址异常(如访问非法的内存区域)。

  • 译码阶段:非法操作码异常。

  • 执行阶段:算术溢出/下溢、浮点异常。

  • 访存阶段:数据地址异常(如缺页、非对齐访问)。

处理原则

当流水线中多个阶段同时产生异常时,需要遵循以下原则:

  1. 优先级:异常处理的优先级应从流水线前端(取指)到后端(写回)依次降低。最老的指令(流水线中最靠前的指令)产生的异常具有最高优先级。

  2. 提交点:处理器需要确定一个提交点,在此点之后,指令对架构状态的更改变得不可撤销。通常,提交点位于流水线末端附近(如访存或写回阶段之后),以确保所有可能的异常都已发生并被检测到。

  3. 处理流程:检测到异常后,相关信息会随指令在流水线中传递。直到提交点,处理器确认该异常需要处理,然后执行以下操作:

    • 将异常PC(可能是触发异常的指令地址,也可能是其下一条指令的地址,取决于架构定义)保存起来。

    • 向流水线前端发送清除信号,冲刷掉该异常指令之后的所有后续指令。

    • 开始从中断向量表指定的地址取指,执行中断处理程序。

精确异常与不精确异常

  • 精确异常:处理器能够精确地停止在导致异常的指令处,并确保该指令之前的所有指令都已完成,之后的指令都未执行。这是现代通用处理器所需支持的特性。

  • 不精确异常:处理器无法精确确定异常与指令的对应关系,可能允许异常指令之后的指令对状态产生部分影响。这通常出现在一些简单的嵌入式处理器中,不利于运行复杂的操作系统。

异常处理与推测执行

现代高性能处理器采用推测执行,包括控制推测(分支预测)和异常推测。

以下是异常推测的处理方式:

  1. 乐观推测:处理器默认指令不会发生异常,继续预取和执行后续指令。

  2. 延迟处理:即使在执行阶段检测到异常(如算术溢出),也不会立即处理,而是将异常标记随指令传递。

  3. 提交时确认:在提交点,如果确认异常发生,则启动恢复机制:

    • 在简单的有序流水线中,只需冲刷流水线,从异常处理程序重新开始。

    • 在乱序执行流水线中,情况更复杂。因为指令可能乱序执行和完成,需要更复杂的机制(如重排序缓冲区和影子寄存器)来确保能够精确地回滚到异常发生时的状态,这将是后续课程讨论的重点。

  4. 旁路与推测:在异常指令结果产生后到提交点之前,其数据可能已经通过旁路网络传递给后续的推测执行指令。这是允许的,但如果最终异常被确认,这些后续的推测指令及其产生的影响都将被清除。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/8c0fff1717b2a3b2474b0f0b5ab93af7_6.png

总结

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/8c0fff1717b2a3b2474b0f0b5ab93af7_8.png

本节课中我们一起学习了中断与异常的核心概念。我们了解了异步中断与同步异常的区别及其常见类型,深入探讨了中断发生时硬件保存状态、跳转处理程序的关键步骤。我们重点分析了在流水线处理器中处理多阶段异常的原则,包括优先级、提交点的概念,以及实现精确异常的必要性。最后,我们触及了异常处理与推测执行(特别是乱序执行)之间的交互,认识到高性能处理器需要复杂的机制来保证异常处理的精确性。理解这些机制是掌握现代处理器设计和操作系统原理的基础。

026:乱序处理器入门

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/a082a143029494fba14a073267706ae0_0.png

在本节课中,我们将要学习乱序处理器的基础知识。我们将从简单的顺序执行处理器出发,逐步引入乱序执行的概念,并介绍一系列新的硬件结构,如记分牌、重排序缓冲区和发射队列等。这些结构对于理解现代高性能处理器的设计至关重要。


概述:从顺序到乱序

到目前为止,我们讨论的都是简单的顺序执行处理器。我们曾暗示过乱序执行的可能性,但现在我们将正式开始研究真正的乱序处理器。我们将引入一系列你可能尚未接触过的新结构,并探讨流水线中哪些部分可以是顺序的,哪些可以是乱序的。

流水线阶段的顺序与乱序

以下是流水线中不同阶段在顺序与乱序设计中的分类。

  • 前端:前端指指令取指和解码阶段。这个阶段很难做到乱序,因为除非能预测未来,否则很难知道接下来要取哪条指令。因此,我们研究的大多数机器的前端都是顺序的。

  • 发射:发射阶段决定指令的操作数是否已就绪,可以开始执行。这个阶段可以是顺序的,但如果尝试乱序发射,可能会获得更高的性能。

  • 写回:写回阶段将结果写入类似寄存器文件的结构,但此时指令可能尚未到达处理器的提交点。这意味着数据可能还在旁路网络中,尚未提交到寄存器文件。这个阶段可以选择顺序或乱序。

  • 提交:提交阶段确认指令确实可以提交。在此点之后,机器状态将无法回滚到之前的状态。我们将研究提交阶段既可以是顺序的,也可以是乱序的机器。

乱序执行简介

本节是对乱序执行的温和介绍。我们将构建几种不同的架构。这里提到的名称只是作为乱序入门而引入的五种架构的命名方式。

  • I4:表示顺序、顺序、顺序、顺序。

  • I2O2:表示顺序、顺序、乱序、乱序。

  • IO2I:表示顺序、乱序、乱序、顺序。

命名中的数字仅表示连续多少个阶段与前面的字母相同。

关键结构与术语

在深入之前,我们先简要介绍一些术语。除非你提前阅读了相关材料,否则可能还不熟悉这些概念。

  • 记分牌:这是一个用于记录哪些指令已准备好执行的结构。

  • 重排序缓冲区:当指令乱序执行时,ROB 是一个可以重新排序指令以便按顺序提交的地方。它解决了所有依赖关系,因此我们实际上不会乱序提交。

  • 存储缓冲区:这是一个在提交点之前暂存存储操作的结构。我们不希望过早写入内存,因为那实际上就等于提交了。关于提交点的一个重要概念是:任何无法回滚的状态改变都算作提交。向主存进行存储操作就是提交,这很难回滚。

  • 发射队列:如果你进行乱序发射,发射队列就是一个决定何时可以安全发射指令的结构。它的重要作用是管理所有读后写、写后写、写后读依赖关系。

今天我们将研究其中的一些结构。

动机:为何需要乱序执行

让我们来看一个简单的代码序列,作为乱序执行的动机。

指令 0: 写寄存器 1
指令 1: 写寄存器 11
指令 2: 读寄存器 1, 写寄存器 5
指令 3: 读寄存器 5
指令 4: 读寄存器 11, 写寄存器 12
指令 5: 读寄存器 12
指令 6: 读寄存器 12

让我们分析一下其中的读后写依赖关系。

  • 指令 0 写入寄存器 1,指令 2 读取寄存器 1。因此存在一条依赖弧。

  • 指令 2 写入寄存器 5,指令 3 读取寄存器 5。这是另一条依赖弧。

  • 指令 1 写入寄存器 11,指令 4 读取寄存器 11。因此指令 4 依赖于指令 1。

  • 指令 4 写入寄存器 12,指令 5 和 6 都读取寄存器 12。因此它们都依赖于指令 4。

有趣的是,指令 1 并不依赖于指令 0,而指令 2 和 3 也不依赖于指令 1。这意味着如果我们有一个乱序处理器,我们可以选择指令的执行顺序。如果我们有一个多发射或超标量乱序处理器,我们甚至可以同时执行这些完全独立的指令,从而从顺序执行流中发现并行性,提升性能。

一个重要的问题是:为什么程序中会出现这种非依赖的指令序列?这是一个哲学问题。顺序指令语义强制你为指令指定某种线性顺序。在顺序计算机的指令集架构中,指令默认是串行化的,这是一个限制。没有办法表达“这两条指令可以同时执行”,除非你像这样画出依赖图。你可以设想一种非顺序的、用图来表示依赖关系的指令集架构,这就是数据流机器。但在顺序处理器中,编译器即使知道两条指令可以同时执行,也无法表达这一点。超长指令字机器允许你一定程度地指定同时执行,但仍有局限。数据流机器则允许更复杂的指令图。因此,顺序机器过度约束了我们的设计。

分析第一个架构:I4(全顺序)

现在,让我们开始评估我们的第一个“乱序-顺序”机器。实际上,I4 是一个顺序机器,但我们将把它作为一个引子来分析,并逐步滑向真正的乱序机器。

我们有一个取指、解码、执行、访存、写回的五级流水线。为了简化,我们使用了一些与实验课中类似的命名(例如执行阶段用 X 表示)。这是一个真实流水线的简化模型。

假设我们想在这个处理器中实现超标量。我们将相同的处理器流水线拆分,得到两条流水线:一条访存流水线和一条执行流水线。这类似于我们的 A 和 B 超标量设计。但今天,我们专注于一个顺序、顺序、顺序、顺序的机器,这两条流水线不能在同一个周期处于流水线的同一阶段。我们将其视为一个单发射处理器。作为引子,我们先理解单发射变体,这样在构建更复杂的乱序结构时,理解起来会轻松一些。

引入更复杂的流水线

事情会变得更复杂一些。让我们采用之前的流水线,并加入一个四级乘法器。

现在我们有一个执行阶段(内含 ALU),一个访存阶段(分为地址计算和实际访存两个阶段),以及一个需要四个周期的乘法阶段。最后是写回阶段。这让我们开始思考这些结构内部是怎样的。

第一个问题是:我们是否需要旁路?需要多少旁路?在这个顺序流水线中,有三个功能单元。我们可能仍然需要旁路。我们能从这里(乘法器中间阶段)旁路吗?乘法直到最后阶段才完成,所以从中间旁路没有意义。我们需要从这里(访存阶段)旁路吗?从这里(执行阶段)呢?可以看到,在这种流水线中,旁路路径的数量会迅速爆炸式增长。如果不进行旁路,就必须等待所有指令都到达流水线末端,那么每指令周期数就会变差,这很不理想。

引入新结构:架构寄存器文件与记分牌

现在,我们将开始引入一些额外的结构,以便在拥有多条流水线和复杂操作时,让设计变得更简单。

首先要介绍的是架构寄存器文件。ARF 是保存已提交的机器规范状态的地方。因此,它被称为架构寄存器文件。我们把它画在流水线的末端,因为写操作发生在这里。我们尝试在流水线的发射阶段或寄存器读取阶段读取它。注意,我们在这里增加了一个额外的阶段,变成了六级流水线,因为我们假设需要进行旁路,并且需要一点额外时间。在更复杂的流水线中,我们会在解码阶段之前放入更多东西。目前,解码阶段进行解码,发射阶段负责将指令引导到相应的功能单元。

接下来是记分牌。记分牌的作用是什么?我们将展示一些记分牌的图示。目前,记分牌是一个方便的辅助结构,用于跟踪不同值在这些流水线中的“在途”状态。在早期的流水线中,这些数据存在于流水线各阶段的指令寄存器中。记分牌是将所有这些信息集中存储在一个地方的便捷方式。当我们开始乱序执行时,我们实际上会在记分牌中存储一些很难从流水线其他位置提取的信息。

记分牌如何读写?我们读取它以确定旁路信息来自哪里。当我们发射指令时,需要写入(更新)它。当指令到达流水线末端时,如果指令提交,我们也需要在记分牌中做一些操作。如果指令未提交或发生异常,我们同样需要在流水线末端清理记分牌。

深入观察记分牌

让我们来看一个基本的记分牌。这是针对我们刚才看到的带长乘法流水线的设计。

我们为每个实际寄存器(MIPS 中 R0 是恒零寄存器,不考虑)跟踪以下信息:

  • P(挂起):一个比特位,表示是否有对该寄存器的写操作挂起。

  • F(功能单元):记录应该从三个功能单元中的哪一个去获取值。这在计算旁路时很重要。

  • 数据可用性移位寄存器:一个为每个寄存器设置的移位寄存器(概念上),我们每周期将比特位向前移动。这告诉我们去哪里获取值。它告诉我们查看三个流水线中的哪一个,而数据可用字段则告诉我们该流水线的哪个阶段可以获取数据。

每个周期,逻辑上这些比特位向右移动。例如,如果这里(某个位置)是 1,意味着寄存器 1 的数据将在 4 个周期后可用。你需要使用功能单元字段和数据可用字段来确定何时以及从哪里进行旁路。如果挂起位是 0,意味着去架构寄存器文件中查找,而不是从旁路网络获取。

示例分析:使用记分牌跟踪依赖

现在,我们来看一个有趣的示例,也就是课程开始时看到的那个动机代码序列。

我们知道,可以尝试同时或乱序执行其中的一些指令。但目前,我们有一个全顺序的机器。让我们看看记分牌对此有何说明。

在图表底部,我们用红色表示值已就绪。

  • 在周期 3,指令 0(乘法)进入解码阶段,然后进入发射阶段,接着被放入记分牌。它被记录在寄存器 1 的记分牌条目中,并且每个周期向右移动。通过查看功能单元(乘法)和它在流水线中的位置,我们可以知道值何时就绪。

  • 下一条指令是指令 1(加法)。它向下流动,并且几乎总是就绪的,因为可以从执行单元旁路出来。这就是为什么它一路都是红色的。

  • 我们可以看到寄存器 1 和寄存器 11 的记分牌条目在何时有效。

现在,看我们的第一个真正的读后写依赖。这里有一个依赖于 R1 的乘法(指令 2)。如图所示,该指令将停留在发射阶段,直到周期 3 的值被旁路到这里,然后该指令才能发射。如果我们查看记分牌,这对应于某个条目变红。然后我们可以发射指令 2,并且基本上旁路了那个值。指令 2 的目的地寄存器 5 变为有效。

这是一个相对简单的流水线设计,也是一个简单的处理器。但好处在于,你可以使用记分牌来跟踪值何时就绪,而无需去查看流水线中间位,也无需知道该查看哪条流水线。当我们转向乱序机器时,这一点将变得更加重要。

关于记分牌的深入讨论

在继续之前,确保每个人都理解如何查看这些图表,因为在本课程后面你需要绘制它们。

一个关键问题是:每个寄存器在记分牌中只有一个条目。如果两个不同的功能单元(具有不同的延迟)向同一个目的寄存器发射指令,会发生什么?从旁路的角度来看,你只需要知道最新的值。因此,记分牌中会填入最新发射指令的功能单元信息。我们不需要知道程序中更早的值。

但我们需要确保没有写后写冒险。这意味着在流水线中,较晚的写操作发生在较早的写操作之后。在当前流水线中不会发生,因为我们总是向前推进。但在下一个我们将要研究的流水线中,确实存在写后写冒险,这将要求我们对记分牌进行更深入的思考。

严格来说,对于今天讨论的内容,你甚至不需要多个比特位来跟踪对同一寄存器的多次写操作,因为你只需要知道最近的一次写操作。有些人设计记分牌时,不是用移位寄存器,而是用一个对数编码或二进制编码来表示在流水线的哪个位置查找最新值,然后每周期递增或递减它。对于更复杂的流水线,我们需要同时跟踪功能单元和它在流水线中的阶段。

另一个细微之处是:如何知道值在 4 个周期后才就绪?如果你知道功能单元的延迟,你只需查表即可。你不需要在表中跟踪这些信息。这可以是解码单元或发射逻辑中组合逻辑的一部分。例如,在这个例子中,我们有一个写 R1 的乘法,和一个需要读 R1 的指令。我们知道必须等待乘法到达某个阶段才能旁路。如果它是 ALU 操作,则可以更早旁路。因此,我们不一定需要编码这些信息。这就是为什么在图示中,有些条目比其他条目更早变红,是功能单元信息编码了这一点。

总结与展望

本节课,我们简要介绍了记分牌。下节课,我们将讨论顺序前端、乱序发射、乱序写回和顺序提交的流水线。它们看起来与今天的很相似,只是去掉了一些流水线阶段。这将促使我们更深入地思考何时读取记分牌。我们将使用解码阶段来处理记分牌的一些操作。

然后,我们将开始讨论真正的乱序机器,其中包含重排序缓冲区和存储缓冲区等更复杂的结构。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/a082a143029494fba14a073267706ae0_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/a082a143029494fba14a073267706ae0_3.png

本节课中,我们一起学习了乱序处理器的基础概念,包括流水线阶段的顺序与乱序分类,以及记分牌这一关键辅助结构的工作原理。我们通过示例分析了如何利用记分牌管理数据依赖和旁路,为理解更复杂的乱序执行机制打下了基础。

027:乱序处理器回顾与深入

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/02d78553e689b6281e5f9d8e1b769395_0.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/02d78553e689b6281e5f9d8e1b769395_0.png

在本节课中,我们将继续学习乱序处理器,并探讨比上节课更复杂的实现方案。我们将按照性能从低到高的顺序,介绍几种不同的处理器设计,并引入一些新的核心概念,如重排序缓冲区和存储缓冲区。课程内容旨在简单明了,便于初学者理解。

课程概述

上节课我们介绍了顺序处理器和记分牌机制,并讨论了前端顺序、发射顺序但写回和提交乱序的处理器。本节课,我们将探讨性能更高的设计,包括前端顺序、后端乱序的处理器,以及前端顺序、所有后端阶段(包括提交)都乱序的处理器。最后,我们将介绍性能最高的设计:顺序取指、乱序发射、乱序执行、乱序写回,但顺序提交的处理器。这种设计可以在保证精确异常的同时实现高性能。

从顺序前端与乱序后端开始

上一节我们介绍了前端和发射都顺序的处理器。本节中,我们来看看一种更常见的折中方案:前端顺序,但后端(写回和提交)乱序的处理器。这种设计需要引入新的硬件结构来管理指令的乱序完成。

为了实现这种设计,我们需要两个关键组件:

  1. 重排序缓冲区:用于在指令乱序执行完毕后,按程序顺序重新排序,以便顺序提交。

  2. 存储缓冲区:用于暂存乱序的存储操作,确保它们按顺序更新内存。

以下是这种处理器需要解决的核心问题:乱序提交可能导致精确异常难以实现。如果一条指令的结果在提交(即永久更新架构状态)之后,才发现它前面的指令发生了异常,那么处理器状态就已经被错误地修改了。

理解乱序提交的挑战

为什么乱序提交看起来有些奇怪呢?关键在于异常处理的精确性。

假设提交点位于流水线末端。在乱序提交的处理器中,完全有可能出现这种情况:一条指令的结果已经写入了架构寄存器文件,但随后才发现这条指令之前(按程序顺序)的某条指令触发了异常。这意味着处理器已经提交了不按程序顺序的、可能无效的结果。

我们来看一个简化的例子:

  1. 指令A(可能引发异常的指令,如除零)尚未执行完毕。

  2. 指令B(与A无关的指令)已执行完毕并将其结果提交到寄存器文件。

  3. 此时,指令A的异常才被检测到。

这时,架构状态(寄存器B的值)已经被一个在异常指令之后才应该生效的结果污染了,破坏了程序的正确状态。本节课后续会展示具体的例子。

高性能设计:顺序取指与提交,乱序执行

最后,我们来探讨性能潜力最高的设计:顺序取指、乱序发射、乱序执行、乱序写回,但顺序提交的处理器。

这种设计可以在流水线末端设置一个提交点,从而支持精确异常。为了实现乱序发射,我们需要引入一个新的组件:发射队列。

发射队列是指令在准备好被发射到执行单元之前“生活”的地方。它允许处理器查看一窗口的指令,并动态地选择那些操作数已就绪的指令优先发射,从而打破程序顺序的限制,充分利用执行单元。

回顾激励示例

在整个课程中,我们将反复使用同一个激励示例来理解不同设计的优劣。考虑以下两条独立的指令序列:

序列1: LD F2, 0(R1)      ; 加载
        MULTD F4, F2, F0  ; 乘法
        ADDD F6, F4, F2   ; 加法

序列2: LD F8, 0(R2)      ; 加载
        MULTD F10, F8, F0 ; 乘法
        ADDD F12, F10, F8 ; 加法

这两组指令序列彼此没有依赖关系。如果你的机器有足够的并行资源,理论上应该可以并行执行这些操作。不同的处理器设计实现这种并行的能力各不相同。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/02d78553e689b6281e5f9d8e1b769395_2.png

上节课已经详细讨论了完全顺序(in-order, in-order)的机器,这里不再赘述。我们直接进入对前端顺序、发射顺序但写回和提交乱序的机器的分析。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/02d78553e689b6281e5f9d8e1b769395_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/02d78553e689b6281e5f9d8e1b769395_4.png

本节课总结

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/02d78553e689b6281e5f9d8e1b769395_4.png

本节课我们一起深入探讨了乱序处理器的多种设计形态。我们从前端顺序、后端乱序的设计入手,引入了重排序缓冲区和存储缓冲区的概念。我们分析了乱序提交对精确异常带来的挑战。最后,我们介绍了目前高性能处理器中常见的设计模式:顺序取指与提交,但中间阶段(发射、执行、写回)全乱序,这需要依赖发射队列来实现灵活的指令调度。理解这些设计之间的权衡,是掌握现代处理器如何挖掘指令级并行的关键。

028:乱序执行与乱序提交处理器

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/4ff536cd424a95557f6fd0895ec0c5fa_0.png

在本节课中,我们将学习一种新的处理器设计:顺序发射、乱序执行、乱序写回、乱序提交的处理器。我们将探讨其基本结构、工作原理,以及它如何处理数据依赖和结构冲突,同时也会分析这种设计带来的挑战,特别是关于精确异常处理的问题。

处理器结构概述

上一节我们介绍了顺序处理器,本节中我们来看看一种更复杂的流水线设计。该处理器包含三条主要流水线:

  • 一条长流水线(例如用于乘法运算)。

  • 一条内存访问流水线(假设需要两个周期)。

  • 一条短ALU流水线(位于顶部)。

与之前类似,处理器中有一个记分牌,用于追踪数据在流水线中的可用位置。架构寄存器文件位于流水线末端,接收写回的数据。

这个结构看起来与顺序发射、顺序提交的处理器相似,但存在一些关键差异。对比两种设计图,你会发现我们移除了许多流水线级。这很巧妙,我们不再需要复杂的旁路网络,可以直接将数据写入架构寄存器文件。只要我们保持写后读和写后写依赖的正确性,处理器就能正常工作。

记分牌与依赖处理

记分牌在这类处理器中的作用与在顺序机器中非常相似。我们可以用它来追踪写回端口的结构冲突,这一点非常重要。

例如,假设一条乘法指令后面紧跟着一条加法指令。如果流水线设计不当,它们可能同时需要使用写回端口,从而引发结构冲突。我们将在后续的流水线图中展示这种情况。

目前,我们仍然不需要一个更复杂的记分牌。有人在上节课提问:记分牌是否需要追踪每个值具体在哪条功能单元流水线中?对于这个相对简单的流水线,我们仍然不需要这样做,因为对于写后写依赖,我们的基本策略是停顿。如果你想打破这个限制,就需要在记分牌中追踪更复杂的信息,甚至可能需要引入寄存器重命名技术(我们将在下节课讨论),这允许处理器动态地打破写后写依赖。

由于现在各流水线的长度不同,记分牌中的条目需要以不同速度推进。例如,一条长指令(如乘法)进入记分牌后,每个周期向下移动一格,追踪其在流水线中的位置。而一条短指令(如加法)不需要等待四个周期才写入寄存器文件,我们可以在记分牌中合适的位置插入标记“1”,然后它只需走完剩余的流水线阶段。

这里的关键点是,因为我们不允许发生针对同一寄存器的写后写冲突,所以你永远不会在记分牌的同一行看到多个乱序的“1”标记。如果出现那种情况,就需要一个更高级的记分牌,我们会在本节课稍后展示其示意图。

流水线执行示例分析

现在,让我们通过一个例子来学习如何使用记分牌,并逐步分析这种“顺序发射、乱序执行与写回、乱序提交”处理器的工作过程。

我们使用与之前例子相同的代码序列:

指令0: MUL R1, R2, R3
指令1: ADD R4, R1, R5  // 读取R1
指令2: ADD R6, R7, R8
指令3: MUL R9, R10, R11
指令4: ADD R12, R11, R13 // 读取R11
指令5: ADD R14, R12, R15 // 读取R12

首先,观察一些写后读依赖及其处理方式。

  • 指令1需要读取R1,而R1由指令0(乘法)产生。因此,指令1必须等待,直到可以从指令0的流水线阶段获得旁路数据。如图所示,指令1在发射阶段被停顿。由于是顺序发射,后续指令也无法被发射。在本节课后面,我们将讨论支持乱序发射的流水线,那样就可以在指令1等待时,发射后续不依赖于它的指令,从而通过重排指令来更充分地利用功能单元,提升性能。

  • 另一个写后读依赖涉及R11(由指令3写入,被指令4读取)。当指令4尝试读取时,R11的数据已经存在于架构寄存器文件中,因此不需要任何特殊的旁路操作。

此外,图中还有几个要点值得指出:

  1. 乱序写回:由于流水线长度不同,你可以看到这里的ADD指令(指令1)在程序顺序中位于它之前的指令(指令0,MUL)之前写回了架构寄存器文件。这带来了重要影响,特别是关于异常处理。假设这条先于ADD完成的MUL指令发生了某种错误或异常,此时架构寄存器文件已经被后面的指令修改,而前面的指令却未完成,这会导致状态不一致。

  2. 结构冲突实例:图中有一个非常有趣的情况。指令5(ADD R14, R12, R15)依赖于R12。R12由指令4产生,并在其流水线阶段末尾准备好用于旁路。指令5直到其流水线的某个阶段才会尝试从旁路读取,此时值已就绪。然而,这条指令却停顿了。原因是它所有的输入都已就绪,但检查发现它将与另一条指令同时使用寄存器文件的写端口,因此发生了结构冲突,必须停顿。

记分牌中的冲突检测

让我们看看上述结构冲突如何在记分牌中体现。

记分牌表格的顶部横轴是周期(0-18)。纵轴是指令和寄存器。

当指令1(ADD)处于发射阶段时,它需要检查是否会在写端口上与之前的MUL指令冲突。在这个具体时刻,冲突并未发生。

但当指令1在记分牌中向下移动时,情况就不同了。对于短流水线的ADD指令,它不会在记分牌第四格开始标记,而是会在距离写回还有两个周期的地方开始标记。它需要检查:对于它要写入的寄存器,是否有其他指令已经计划在两个周期后的同一时刻进行写回?记分牌可以回答这个问题。如果对应位置有“1”,就表示存在冲突。

对于最后一条指令(指令5,ADD),我们可以看到冲突的发生。在某个周期,指令6(应为指令5,原文笔误)本应处于发射阶段并准备前进,但它检查记分牌中“距离写回还有两个周期”的位置,发现那里有一个“1”(来自其他指令),这意味着它不能发射,必须停顿。图中用多个垂直的小方框表示了该指令在后续周期连续检查并发现冲突,从而导致停顿的状态。

记分牌中的其他条目则表示了其他ADD和MUL指令的进行状态。你可以看到R1被写入,并在流水线中有较长的存活时间,而其他由ADD指令写入的寄存器在记分牌中的存活时间则较短。

可变延迟与异常处理挑战

上述讨论假设每条指令或每个功能单元的延迟是固定的。但实际上,功能单元可能有可变延迟。例如:

  • 除法单元:有时设计为持续运算直到完成,延迟可变。

  • 加载指令:如果缓存未命中,在乱序处理器中必须等待数据返回。

在记分牌中处理可变延迟的常见方法是:为每个目的寄存器设置一个特殊的标志位。当一条指令进入可变延迟流水线(如除法或缓存未命中的加载)时,就设置这个位,表示“该寄存器数据未就绪”。任何试图读取该寄存器的指令都会因此停顿。这是一种以性能为代价的简单处理方式。

回到我们的乱序提交处理器。它支持乱序写回和乱序提交。由于我们保持了写后写依赖,架构寄存器文件的状态不会因此出错。但一个严重的问题是:如何处理异常?

假设在我们的指令序列中,某条指令(比如一条MUL)在流水线末尾的提交阶段被检测出发生了异常。这条指令必须作废,所有后续指令也必须作废。但问题是,在它之后发射的指令可能已经写回了架构寄存器文件(例如图中指令1的ADD写回了R4)。当处理器进入异常处理程序时,寄存器R4中的值就是错误的,破坏了精确异常的要求。

这就是人们通常避免构建乱序提交处理器的主要原因之一,因为它使得实现精确异常变得棘手。当然,也有一些方法可以尝试:

  1. 限制指令类型/提前提交点:如果你有一个顺序发射、乱序写回、乱序提交的处理器,你可以将提交点提前到流水线中更早的位置(例如内存访问的第一级或乘法的第一级)。在这个点之前,还没有任何状态被错误地写回,因此仍然可以安全地清除所有后续指令。但缺点是,发生在提交点之后的异常就无法被精确处理了。

  2. 滑动提交点:一些处理器设计有滑动提交点。它们尝试尽早提交指令,但对于某些特定类型的指令,可以将提交点向后移动。然而,这很复杂,因为它意味着某些类型的指令不能在另一些指令之后执行,否则会违反滑动提交点的规则。

在本课程中,我们将主要讨论具有单一固定提交点的设计。我们规定流水线中有一个规范位置,经过该点的所有数据都已被执行并提交,不可回退。

总结

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/4ff536cd424a95557f6fd0895ec0c5fa_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/4ff536cd424a95557f6fd0895ec0c5fa_3.png

本节课我们一起学习了一种顺序发射、乱序执行与写回、乱序提交的处理器设计。我们分析了其利用不同长度流水线提升效率的潜力,以及通过记分牌管理数据依赖和结构冲突的方法。同时,我们也重点探讨了这种设计面临的核心挑战——在乱序写回背景下实现精确异常的困难,并简要了解了一些高级的解决思路,如限制指令类型或使用滑动提交点。理解这些权衡是深入掌握现代高性能处理器设计的关键。

029:乱序执行处理器(I2O1)详解 🚀

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/1a3d37887a39d6f8890054432aae2347_0.png

在本节课中,我们将学习一种更复杂的处理器设计:顺序前端、乱序发射、乱序写回、顺序提交(I2O1)。这种设计能够解决精确异常处理等关键问题。我们将详细探讨其内部结构,特别是重排序缓冲区和未来存储缓冲区。

概述

上一节我们介绍了顺序处理器。本节中,我们来看看一种混合设计:I2O1处理器。它的前端(取指、译码)和提交阶段是顺序的,但发射和写回阶段允许乱序执行。这种设计的核心优势在于能够实现精确异常,因为指令的最终提交(更新架构状态)是严格按照程序顺序进行的。

处理器流水线结构图

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/1a3d37887a39d6f8890054432aae2347_0.png

流水线前端(取指、译码)与之前类似。我们将加载和存储指令分离到两条独立的流水线中。存储流水线更短,可能只需两个阶段,因为它主要执行存储操作。

在图的右侧,我们添加了几个关键的新结构。

新增的关键结构

以下是I2O1处理器中引入的核心组件:

  • 物理寄存器文件:也称为未来文件。它保存处理器推测执行的中间结果。这些值尚未提交,如果发生异常或分支预测错误,可以被丢弃。

  • 架构寄存器文件:代表处理器的已提交状态。只有在指令提交时,其结果才会从物理寄存器文件写入这里。

  • 重排序缓冲区:一个关键的数据结构,用于确保乱序执行的指令能够按顺序提交。指令完成(写回)后进入ROB,但只有成为最旧的已完成指令时才能提交。

  • 未来存储缓冲区:用于暂存已执行但尚未提交到主存的存储操作(地址和数据)。加载指令需要优先检查此缓冲区,以确保读到最新的数据。

数据读写路径分析

现在,我们来看看数据在这些结构中的流动。

  • 架构寄存器文件:在正常执行中不被读取。它仅在发生异常或分支预测错误需要恢复架构状态时被使用。

  • 物理寄存器文件:用于流水线中所有中间值的读写。记分板现在跟踪的是物理寄存器,而非架构寄存器。

  • 重排序缓冲区:

    • 写入:在指令发射阶段分配一个ROB条目;在指令完成阶段更新其状态。

    • 读取/清理:在指令提交阶段,根据ROB中的信息,将物理寄存器文件的值写入架构寄存器文件,并清理该ROB条目。

  • 未来存储缓冲区:在存储指令执行结束时写入。当存储最终提交到内存系统时被清理。加载指令可能需要从此缓冲区读取数据(旁路)。

深入理解重排序缓冲区

重排序缓冲区是保证顺序提交的核心。我们可以将其视为一个循环队列,有头指针和尾指针。

以下是ROB条目中需要存储的最小信息集合:

  • 状态:表示指令状态,例如 P(等待中)、F(已完成)。

  • 推测位:标记该指令是否位于一个尚未解决的分支之后。如果分支预测错误,可以快速找到并刷新所有带此标记的指令。

  • 存储位:标记该指令是否为存储操作,以便在提交时处理未来存储缓冲区。

  • 目标有效位:指示该指令是否写寄存器。

  • 物理寄存器编号:指向物理寄存器文件中存储该指令结果的位置。ROB本身不存数据值,只存指针,以节省空间。

提交规则:只有最旧的指令状态变为“已完成”时,才能将其提交,并从ROB中移除。即使后面的指令先完成,也必须等待。

异常与提交点

乱序写回、顺序提交的设计如何实现精确异常呢?

  1. 当一条指令(如乘法)执行并发生异常时,它将其结果写入物理寄存器文件(推测状态)。

  2. 该指令在ROB中被标记,但不会被提交到架构寄存器文件。

  3. 由于提交是顺序的,所有在该异常指令之后的、仍在流水线中的指令都无法提交。

  4. 当处理器处理异常时,它可以将架构寄存器文件的完整状态复制到物理寄存器文件,从而一次性回滚所有推测状态。有些设计通过重命名指针来实现,效率更高。

分支预测错误处理

对于分支预测错误,ROB中的推测位提供了便利。

  1. 分支指令本身被提交后,其方向确定。

  2. 如果分支预测错误,处理器需要清除在错误路径上取出的所有指令。

  3. 通过检查ROB中所有标记为“推测”的条目,处理器可以快速识别并无效化这些条目,同时清空流水线。

  4. 性能考虑:可以立即无效化(需要多端口ROB支持),也可以让错误路径指令继续流到提交阶段再清理(性能较低,占用资源)。

处理存储缺失停顿

顺序提交可能因存储操作缓存缺失而引发性能问题。如果一个存储指令需要等待数据从下级缓存调入,它会阻塞提交阶段,延迟后面所有指令的提交。

解决方案:将“提交到处理器状态”与“写入内存系统”解耦。

  • 存储指令在通过提交阶段后,即被视为已提交(可以从ROB中移除)。

  • 其数据被放入未来存储缓冲区,并允许其异步地完成向内存的写入。

  • 后续的加载/存储指令需要通过旁路逻辑检查这个缓冲区。

  • 这样,提交阶段就不会被慢速的存储操作阻塞,提高了整体吞吐量。

总结

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/1a3d37887a39d6f8890054432aae2347_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/1a3d37887a39d6f8890054432aae2347_3.png

本节课中,我们一起学习了I2O1(顺序前端、乱序发射、乱序写回、顺序提交)处理器设计。我们深入探讨了其核心组件:物理寄存器文件用于保存推测状态,重排序缓冲区用于保证指令按顺序提交,以及未来存储缓冲区用于处理存储操作。这种设计优雅地解决了精确异常处理的问题,并通过ROB管理分支预测错误的恢复。同时,我们也看到了如何通过解耦提交与内存操作来缓解存储缺失带来的性能瓶颈。理解这些机制是掌握现代高性能乱序执行处理器基础的关键。

030:乱序执行处理器详解

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/ab561dd7c4abfca9651af9c5e6534874_0.png

在本节课中,我们将深入探讨一种重要的处理器设计:顺序取指、乱序发射、乱序写回、乱序提交的处理器。我们将了解其核心组件——发射队列的工作原理,并通过对比分析其性能特点。

概述

上一节我们讨论了顺序发射处理器的一些局限性。本节中,我们来看看如何通过引入乱序发射机制来提升性能。核心思想是:指令仍然按顺序被取指和解码,但允许它们在操作数就绪时,不按顺序地进入执行单元。

发射队列:乱序发射的核心

为了实现乱序发射,我们需要引入一个新的数据结构:发射队列。它本质上是一个缓冲区,指令按顺序进入,但可以被乱序地取出并发射到执行单元。

以下是发射队列内部条目的关键字段(以MIPS指令为例):

  • 操作码:指令本身。

  • 立即数字段:指令中的立即数。

  • 源操作数1/2:

    • 寄存器标识符:需要读取的寄存器编号(如 R5)。

    • V位:指示该指令是否使用此源操作数。例如,某些指令可能只使用一个源。

    • P位:指示该源操作数的值是否尚未就绪。如果为1,表示流水线中后续有一条指令会写入该寄存器。

  • 目的操作数:

    • 寄存器标识符:指令将要写入的寄存器编号。

    • V位:指示该指令是否写入目的寄存器。

发射队列如何工作

发射队列的逻辑是动态的。每个周期,硬件会扫描队列中的所有指令,检查其就绪状态。一条指令就绪的条件是:

  1. 所有它需要使用的源操作数(V位=1)的P位都为0(即值已就绪)。

  2. 目标执行单元没有结构冲突(例如ALU正忙)。

一旦指令就绪且资源可用,它就可以被乱序地从发射队列中取出,进入“发射”阶段,随后进行寄存器读取并开始执行。

当一条指令在流水线末尾提交时,它会从发射队列中被移除。同时,处理器会扫描队列中所有其他条目,如果发现有指令的源操作数寄存器标识符与刚提交指令的目的寄存器标识符匹配,则将该源操作数的P位清零,表示该值现在已就绪。这可能会唤醒一条或多条等待中的指令。

集中式与分布式发射队列

在逻辑上,一个大的集中式发射队列似乎很理想,可以全局查看所有指令。但其实现可能较复杂,且所有指令类型(如整数、浮点)混在一起,通信开销可能较大。

另一种设计是分布式发射队列。例如,可以为整数ALU、浮点单元、乘法器等不同的功能单元分别设置独立的发射队列。解码后的指令被“引导”到相应的队列中。这种设计在早期多芯片处理器中很常见,在现代高度集成的芯片中也有其优势,例如能更好地匹配不同功能单元的特性。Tomasulo算法论文就早期探讨了这种分布式设计。

流水线性能分析

让我们通过一个流水线时序图的例子,来观察这种处理器的行为。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/ab561dd7c4abfca9651af9c5e6534874_0.png

图中,I 表示指令进入发射队列。关键点在于,即使指令很早就进入了发射队列,它的实际发射也可能被延迟,原因包括:

  • 数据依赖:等待前一条指令产生源操作数。

  • 结构冲突:所需功能单元正被占用。

  • 单周期单发射限制:每个周期只能发射一条指令,即使有多条指令就绪,也必须排队。

一个有趣的发现是,即使我们预先将所有指令加载到发射队列中,最终性能可能并不会比指令逐条流入队列的情况更好。因为性能瓶颈转移到了发射带宽(单周期只能发射一条)和执行资源数量(只有一个ALU)上。

这自然引出了下一步的优化方向:超标量处理器。即,通过复制硬件资源(如多个发射端口、多个ALU),实现每个周期发射并执行多条指令,从而进一步提升性能。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/ab561dd7c4abfca9651af9c5e6534874_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/ab561dd7c4abfca9651af9c5e6534874_3.png

关于记分牌的补充说明

之前我们讨论的记分牌主要跟踪写后读危害。在更复杂的乱序流水线中,我们还需要考虑写后写危害。此时,记分牌可能不仅需要记录寄存器状态,还需要记录是哪个功能单元将要写入该寄存器,并在流水线中传递这个信息,以确保写入顺序的正确性。

总结

本节课中我们一起学习了:

  1. 乱序发射处理器的基本模型:顺序取指,乱序发射/执行/写回/提交。

  2. 实现乱序发射的核心组件——发射队列的结构与工作原理,特别是其通过 V位 和 P位 来跟踪数据依赖和就绪状态的机制。

  3. 发射队列的两种组织形式:集中式与分布式。

  4. 通过流水线分析发现,仅实现乱序发射而不增加发射带宽和执行资源,性能提升可能受限,这为引入超标量设计提供了动机。

  5. 提到了在复杂流水线中,记分牌需要扩展以处理写后写危害。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/ab561dd7c4abfca9651af9c5e6534874_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/ab561dd7c4abfca9651af9c5e6534874_3.png

这种结合了顺序前端和乱序后端的处理器设计,是现代高性能CPU的基础。

031:乱序执行处理器详解

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/9b62d64cca6392a0b66de347933b68a9_0.png

在本节课中,我们将深入探讨现代处理器中的乱序执行技术。我们将学习指令流水线中“顺序取指、乱序发射、乱序执行、顺序提交”的核心概念,并分析其对处理器性能的影响。

概述

上一节我们介绍了处理器流水线的基本结构。本节中,我们来看看一种更复杂的处理器设计:乱序执行处理器。这种设计旨在通过允许指令在满足依赖关系的前提下提前执行,来挖掘指令级并行性,从而提高性能。

核心概念与结构

乱序执行处理器的核心工作流程可以概括为:顺序取指 (In-order Fetch),乱序发射 (Out-of-order Issue),乱序执行 (Out-of-order Execute),顺序提交 (In-order Commit)。这意味着流水线的中间阶段是完全乱序的。

为了实现这一流程,处理器需要集成多种关键结构。以下是这些组件的简要介绍:

  • 发射队列 (Issue Queue):用于存放已解码但尚未发射执行的指令。

  • 加载/存储缓冲区 (Load/Store Buffer):管理内存访问操作,处理内存依赖。

  • 重排序缓冲区 (Reorder Buffer, ROB):跟踪所有正在执行中的指令,确保它们能按原始程序顺序提交。

  • 物理寄存器文件 (Physical Register File) 与 记分牌 (Scoreboard):共同管理寄存器重命名和指令间的数据依赖关系。

  • 架构寄存器文件 (Architectural Register File):保存程序可见的最终寄存器状态。

性能分析与挑战

现在,我们来看看这种设计对性能的具体影响。通过分析指令执行时序图,我们可以观察到一些有趣的现象。

首先,乱序发射允许不相关的指令提前执行。例如,一条加法指令可能比它后面的一条乘法指令更早发射,这非常有利于性能。

然而,乱序执行处理器依然面临挑战。一个典型问题是写后写冲突。即使指令可以乱序发射,如果两条指令要写回同一个架构寄存器,它们仍然必须按顺序写回,这可能导致流水线停顿。

有趣的是,尽管乱序执行增加了复杂性,但其性能提升有时并不如预期显著。原因之一在于“顺序提交”的要求。即使指令很早就执行完毕,它也必须等待所有前面的指令都提交后,才能提交自己的结果,这可能会将整个提交阶段推迟。

多发射与资源瓶颈

为了进一步提升性能,我们可以考虑引入多发射能力,即每个周期发射多条指令。

假设我们拥有双发射能力(每个周期解码并送入发射队列两条指令),但执行后端资源(如ALU)仍然是单份。这会使指令更早进入发射队列,可能缓解一些由取指/解码阶段带来的瓶颈。

但正如时序图所示,这并不总能大幅提升性能。因为最终我们可能受限于执行资源。如果多条指令都需要使用同一个功能单元(如乘法器),它们仍然需要串行执行。

因此,更理想的设计是“乱序多发射超标量处理器”,即同时具备多发射能力和多个执行单元。这样,我们可以在每个周期取指、解码、发射多条指令,并且如果有足够的、不相关的执行单元,让它们真正并行执行。这能有效解决资源瓶颈问题。

例如,那些完全不依赖于前面乘法指令的加法指令,就可以在乘法指令执行的同时被发射并执行,这能显著提高效率。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/9b62d64cca6392a0b66de347933b68a9_2.png

总结

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/9b62d64cca6392a0b66de347933b68a9_4.png

本节课中,我们一起学习了现代乱序执行处理器的核心原理。我们明确了“顺序取指、乱序发射、乱序执行、顺序提交”的流程,并认识了实现这一流程所需的各个硬件结构。我们分析了乱序执行带来的性能优势,同时也指出了顺序提交和资源瓶颈可能带来的限制。最后,我们探讨了通过结合多发射与多执行单元来构建更强大的超标量处理器,以更好地挖掘程序中的指令级并行性。下一讲,我们将继续深入,探讨如何增加更多的执行单元以及处理更复杂的依赖关系。

032:分支预测与推测执行

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/f33a769ffdf21a94ce33042f127781e7_0.png

在本节课中,我们将继续探讨乱序处理器和超标量技术。我们将重点讨论如何解决处理器流水线中的写后写和写后读依赖问题。同时,我们也会解答上节课留下的一个疑问:在分支预测错误时,如何处理重排序缓冲区的状态清理。

概述

本节课我们将学习推测执行与分支预测。首先,我们会回顾相关内容,并回答上节课关于重排序缓冲区在推测分支时如何处理的问题。接着,我们将探讨寄存器重命名技术,以打破写后写和写后读依赖。最后,如果有时间,我们会讨论内存消歧,即如何让加载和存储指令相对乱序执行,并确保在存储指令后为特定加载指令获取正确的数据。

推测执行与分支预测

上一节我们回顾了乱序处理器的基本概念,本节中我们来看看分支预测错误时,不同流水线架构是如何处理的。

顺序流水线中的分支处理

首先,我们再次审视简称为I4的顺序流水线,看看分支指令在其上的执行情况。

以下是一段示例代码,其中指令3是一个分支指令,跳转到目标地址T。由于所有指令都是顺序执行的,我们实际上无需担心控制冒险的发生。我们只需简单地“杀死”分支指令之后的所有指令,因为它们都还没有提交任何状态到架构寄存器文件。因此,处理起来相对简单。

  • 推测指令存在:分支后的三条加法指令是推测执行的。

  • 状态未提交:这些推测指令没有机会到达流水线的写回阶段,因此不会触及物理寄存器文件。

  • 清理操作:我们只需要在分支预测错误时重置记分板。推测状态是错误的,但其他一切正常。

顺序取指/顺序发射的乱序处理器

现在,我们来看一个更复杂的流水线:顺序取指、顺序发射、乱序执行/写回、乱序提交。

在这个流水线图中,分支指令的位置有所变化,但核心问题不变。我们仍然能够在推测指令写回之前捕获它们。关键在于,我们进行了顺序发射。由于顺序发射,后续的推测指令虽然处于推测状态,但不会提前执行和写回。这意味着,在分支指令到达流水线中的分支解析阶段之前,像加法这样的指令不会提前写回。

假设我们在X0阶段解析分支。那么,分支指令可以重定向流水线前端,可以“杀死”所有后续指令并重置记分板。因此,处理起来仍然相对容易。

然而,这个流水线引入了重排序缓冲区。因此,我们不仅需要清理记分板,还需要清理重排序缓冲区。我们有两种选择:立即从重排序缓冲区中移除条目,或者等待这些推测指令到达提交阶段再移除。我们将在稍后详细讨论这个问题。

乱序发射处理器中的挑战

接下来,我们进入乱序发射处理器的领域。这里我们有一个顺序取指、乱序发射、乱序写回、乱序提交的处理器。

正如我们上次课所说,这种处理器无法实现精确中断,因为指令可能过早地写回。同样的问题也出现在分支推测上。例如,图中有一条加法指令在它所依赖的乘法指令(分支条件依赖于此)被解析之前,就写入了寄存器文件。我们写入了不可回滚的架构状态,实际上提交了错误的状态。这个推测指令本不应该以错误的程序顺序执行。

这是与不精确异常相同的问题。一种解决方法是完全停止推测执行,即暂停所有后续指令,直到分支被解析。但这会严重限制性能。因此,这种乱序提交流水线的问题在于,它无法回滚任何状态。

带顺序提交的乱序处理器

这把我们带到了上节课讨论的“巅峰”流水线:顺序取指、乱序发射、乱序执行/写回、顺序提交。

让我们仔细看看。这里有两个相互竞争的问题需要考虑。

首先,我们看到指令确实在分支结果已知之前就进行了写操作。但方便的是,它写入的是不同的数据结构——物理寄存器文件,而不是架构状态。就像处理中断时一样,我们可以将架构寄存器文件回滚到物理寄存器文件的状态,对于分支预测错误也可以这样做。

一个有趣的问题是:我们在哪里解析分支?何时尝试“杀死”后续指令?是在分支被解析时立即尝试,还是等到分支提交时?

这回到了我们上节课关于重排序缓冲区清理的问题:清理重排序缓冲区和物理寄存器文件的难易程度如何。

以下是两种策略的分析:

策略一:分支解析时立即清理

  • 操作:一旦知道分支预测错误,立即“杀死”所有后续推测指令,并重定向前端取指。

  • 物理寄存器文件:情况变得复杂。例如,乘法指令写入了物理寄存器文件,这个值是正确的,我们需要保留。而加法指令也写入了物理寄存器文件,但这个值我们不需要。我们必须清理物理寄存器文件中的推测状态,并进行选择性回滚。我们需要区分哪些是推测的,哪些不是。这虽然可行,但需要额外的结构来跟踪哪些物理寄存器需要在推测预测错误时回滚。

策略二:等待分支提交时清理

  • 操作:等待分支指令到达提交阶段。

  • 优势:此时,分支之前的所有指令都已提交到架构寄存器文件。架构寄存器文件相对于该分支是最新的。同时,后续推测指令可能已经写入了物理寄存器文件,但此时物理寄存器文件已经完全过时了。我们可以简单地将整个架构寄存器文件复制到物理寄存器文件,有效地回滚一切。这简化了操作。

重排序缓冲区指针的处理

这引出了我们上节课关于重排序缓冲区指针的问题:在分支预测错误的情况下,如何处理重排序缓冲区?

核心问题是:我们必须等待这些推测指令(图中问号部分)走完整个流水线才能清理重排序缓冲区吗?还是我们可以直接调整指针?

答案是,在简单的等待提交策略中,我们确实可以只调整重排序缓冲区中“下一个可用位置”的指针,并用目标指令填充它,这就能有效地清理掉所有推测状态。

然而,在更激进的立即清理策略中,仅仅调整指针是不够的。因为我们需要进行选择性回滚,必须单独清理重排序缓冲区中的条目,这要复杂得多。

另一个我们尚未讨论的、支持等待提交策略的动机,与其他结构如寄存器重命名有关。

当我们讨论寄存器重命名时,如果物理寄存器数量多于架构寄存器,那么在需要中止推测指令时,我们还有其他结构需要处理,例如物理寄存器的空闲列表。这些寄存器需要被释放。在指令尝试提交时进行批量释放是一个方便的位置。我们稍后会详细讨论这一点。

总而言之,在简单情况下,你可以只调整指针。但尝试更激进的策略会困难得多,因为除了重排序缓冲区(可以只调指针),你还需要推测性地回滚物理寄存器文件,而这不能简单地通过调整指针来完成。如果等到指令到达流水线末端,释放物理寄存器会更容易一些。

总结

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/f33a769ffdf21a94ce33042f127781e7_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/f33a769ffdf21a94ce33042f127781e7_3.png

本节课我们一起学习了推测执行与分支预测在不同流水线架构下的处理方式。我们分析了顺序流水线、顺序发射乱序处理器以及乱序发射处理器在处理分支错误预测时的挑战与解决方案。重点讨论了在带顺序提交的乱序处理器中,清理重排序缓冲区和物理寄存器文件的两种策略(立即清理与等待提交),并比较了它们的复杂度。我们还解答了关于重排序缓冲区指针调整的问题,并指出了寄存器重命名等机制对清理策略选择的影响。理解这些机制对于设计高效且正确的现代处理器至关重要。

033:寄存器重命名简介 🧠

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/eb06c34bb7fdc9d06946cb4643e2f2fd_0.png

在本节课中,我们将要学习一个提升处理器性能的关键技术——寄存器重命名。我们将探讨为什么现代处理器需要它,以及它是如何通过消除“伪依赖”来让指令更高效地并行执行的。


性能瓶颈与依赖关系

上一节我们介绍了乱序执行流水线。本节中我们来看看限制这些流水线性能的因素。

限制我们目前讨论的乱序流水线性能的主要是两种依赖关系:写后写依赖和读后写依赖。

  • 写后写依赖:连续两条指令写入同一个寄存器。在我们目前讨论的流水线中,为了避免冲突,通常需要等待第一条指令的写入提交后才能执行第二条,这会导致流水线停顿。

  • 读后写依赖:一条指令读取某个寄存器后,另一条指令写入同一个寄存器。在乱序执行时,如果处理不当,也可能出现问题。

需要注意的是,写后读依赖是真依赖,因为后续指令确实需要前一条指令的计算结果,这种依赖通常无法被打破。

我们将写后写和读后写依赖称为名字依赖,因为它们是由于寄存器名字(地址)的重复使用引起的,而非真正的数据流动需求。


忽略名字依赖会怎样?

以下是忽略所有名字依赖可能引发的问题。

让我们看一段示例代码,分析如果简单地在乱序处理器中忽略名字依赖会发生什么。

我们有以下代码序列:

MUL R1, R2, R3
MUL R4, R1, R5
ADDI R4, R4, #1
ADDI R1, R4, R6

首先,识别其中的真依赖(写后读):

  1. 第一条MUL写入R1,第二条MUL读取R1。

  2. 第二条MUL写入R4,第一条ADDI读取R4。

这些是真依赖,难以打破。

现在,看看名字依赖:

  1. 写后写依赖:第二条MUL写入R4,紧接着第一条ADDI也写入R4。

  2. 读后写依赖:第一条ADDI读取R4,紧接着第二条ADDI写入R4。

在乱序执行中,如果我们试图打破这些依赖(即不因此停顿),可能会发生以下情况:

  • 由于没有停顿,第二条ADDI(写入R4)可能先于第一条ADDI(也写入R4)执行完毕,导致R4最终被写入错误的值。

  • 同样,第二条ADDI(写入R4)可能先于第一条ADDI(读取R4)执行,导致第一条ADDI读取到错误(过时)的值。

  • 由于提交是按顺序的,错误的寄存器状态会被最终提交到架构寄存器文件中。

因此,我们不能简单地忽略或打破名字依赖,需要更巧妙的解决方案。


解决方案的思路:使用更多寄存器

解决名字依赖的一个直观想法是使用更多的寄存器。

比较两种执行方式:

  • 保守停顿的流水线:在遇到名字依赖时保守地停顿,等待相关指令提交。这保证了正确性,但性能较差。

  • 理想情况:假设没有名字依赖,指令可以充分并行,性能最佳。

如果我们能修改代码,将产生名字依赖的指令改用不同的寄存器,就能自动达到理想性能。例如,将示例中第一条ADDI的目标寄存器从R4改为R8,就消除了所有相关的名字依赖。

然而,我们无法无限增加架构寄存器数量,因为这会增加指令编码的位数(例如,32个寄存器需5位,128个寄存器需7位)。


硬件解决方案:寄存器重命名

本节的核心是介绍如何在硬件层面实现寄存器重命名。

关键思想是:在物理上拥有比架构寄存器更多的寄存器,但在指令集层面(软件视角)仍然使用原有的有限寄存器名。硬件动态地将架构寄存器名映射到不同的物理寄存器上,从而消除名字依赖。

我们定义寄存器重命名为:在硬件层面改变寄存器的命名,以消除写后写和读后写这类名字依赖。


两种主要的实现方案

我们将讨论两种主要的寄存器重命名方案,它们在逻辑上是对偶的,硬件需求略有不同,但能达到相同的性能。

以下是两种方案的简要对比:

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/eb06c34bb7fdc9d06946cb4643e2f2fd_2.png

  1. 指针方案:在指令队列和重排序缓冲区中存储指向物理寄存器的指针,而非直接存储架构寄存器名或数据值。这种方案与我们上节课讨论的乱序执行设计有较好的延续性。

  2. 值方案(如Tomasulo算法):直接在指令队列和重排序缓冲区中存储数据值本身。

本节课我们将从指针方案开始讲解,主要因为它能更自然地衔接我们已有的设计(按序取指、乱序发射、乱序执行写回、按序提交)。


总结

本节课中我们一起学习了:

  1. 名字依赖(写后写、读后写)是限制乱序流水线性能的重要因素,但它们不是真依赖。

  2. 简单地忽略名字依赖会导致执行结果错误。

  3. 寄存器重命名是关键的硬件技术,它通过动态地将架构寄存器映射到更多的物理寄存器上来消除名字依赖。

  4. 寄存器重命名主要有两种实现思路:存储指针或存储数据值,接下来我们将深入探讨第一种方案。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/eb06c34bb7fdc9d06946cb4643e2f2fd_4.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/eb06c34bb7fdc9d06946cb4643e2f2fd_5.png

下一节,我们将详细剖析基于指针的寄存器重命名是如何在处理器流水线中具体实现的。

034:使用指向IQ和ROB指针的寄存器重命名

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/9f477d8badc83267dfb91e32eb24f1ae_0.png

在本节课中,我们将学习如何通过添加寄存器重命名机制来增强流水线性能。我们将探讨需要新增的数据结构,并详细解释它们如何协同工作,以实现指令的无序执行与有序提交。

概述

上一节我们介绍了具有重排序缓冲区和记分牌等结构的流水线。本节中,我们将在此基础上添加两个关键结构:空闲列表和重命名表,并对现有结构进行修改,以实现寄存器重命名。这将允许处理器拥有比架构寄存器更多的物理寄存器,从而减少数据依赖带来的停顿,提升指令级并行度。

新增与修改的数据结构

为了实现寄存器重命名,我们需要对流水线进行以下调整。

以下是需要新增和修改的主要结构:

  1. 空闲列表:一个用于追踪哪些物理寄存器当前可用的数据结构。其核心是一个位图,每个物理寄存器对应一位,表示其是否空闲。

    // 示例:一个简单的空闲列表位图表示
    bool free_list[NUM_PHYSICAL_REGISTERS];
    
  2. 重命名表:一个将架构寄存器编号映射到最新值所在物理寄存器编号的表。它告诉后续指令应该去哪里读取数据。

    // 示例:重命名表条目结构
    struct RenameTableEntry {
        int phys_reg; // 对应的物理寄存器编号
        bool pending; // 该值是否仍在流水线中(未提交)
    };
    RenameTableEntry rename_table[NUM_ARCH_REGISTERS];
    
  3. 重排序缓冲区扩展:我们需要在ROB条目中增加两个字段:

    • arch_reg:架构寄存器目标编号。用于在提交阶段,将物理寄存器中的值写回正确的架构寄存器。

    • prev_phys_reg:前一个物理寄存器编号。记录被当前指令覆盖的旧值所在的物理寄存器,以便在提交后安全地释放它。

  4. 扩大物理寄存器文件:物理寄存器的数量必须大于架构寄存器的数量,否则寄存器重命名将无法带来性能提升。

数据流与关键操作

现在,让我们看看这些结构在流水线中如何互动。

  • 前端(重命名阶段):当指令解码后,会查询重命名表以获得源操作数所在的物理寄存器。同时,它会从空闲列表中分配一个新的物理寄存器作为目标,并更新重命名表,将目标架构寄存器映射到这个新的物理寄存器。此外,旧的映射关系(prev_phys_reg)会被记录到ROB中。

  • 执行阶段:指令从物理寄存器文件或旁路网络读取操作数。记分牌和重命名表中的pending位共同决定指令何时可以发射。

  • 提交阶段:当指令到达ROB头部并提交时,会将其结果(存储在phys_reg中)写回arch_reg指定的架构寄存器。同时,prev_phys_reg所指向的旧物理寄存器可以被释放,并加回空闲列表。

一个关键点是,物理寄存器必须在覆盖其值的指令提交后才能释放。过早释放可能导致仍在流水线中的、按程序顺序在该指令之前的指令读到错误的值。

实例分析

考虑一段简单的代码及其在流水线中的执行过程,这有助于我们理解寄存器重命名如何解决数据冒险。

MUL R1, R2, R3 // (1) R1 = R2 * R3
MUL R4, R1, R5 // (2) R4 = R1 * R5
ADD R1, R6, R7 // (3) R1 = R6 + R7
ADD R8, R1, R4 // (4) R8 = R1 + R4

指令(1)和(3)都写R1,存在写后写依赖。指令(2)读R1,与(1)存在读后写依赖,与(3)存在写后读依赖。没有重命名时,这些依赖会导致严重停顿。

通过寄存器重命名,处理器可以为每次写操作分配不同的物理寄存器(例如P1、P7、P10)。这样,指令(2)可以明确地从P1读取(指令(1)的结果),而指令(4)则从P10读取(指令(3)的结果)。指令(1)和(3)可以并行执行,因为它们写入的是不同的物理寄存器(P1和P10),从而打破了虚假依赖。

优化:合并寄存器文件

一个重要的优化思路是合并物理寄存器文件和架构寄存器文件,以节省面积和功耗。

其核心思想是:当一个物理寄存器中的值不再是“未提交”状态(即已提交或非推测状态)时,它本身就代表了架构寄存器的值。因此,可以不需要两份独立的存储。我们只需维护一个统一的物理寄存器文件,并通过一个额外的架构重命名表来记录已提交的、非推测的映射关系。在提交时,我们只需更新这个表的指针,而无需进行实际的数据拷贝。在发生分支预测错误或异常需要回滚时,也可以快速通过恢复指针来恢复架构状态。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/9f477d8badc83267dfb91e32eb24f1ae_2.png

这种方法的优点是节省了存储空间和提交时的数据移动开销。潜在缺点是,统一的寄存器文件可能更大,访问延迟可能略有增加。

总结

本节课我们一起学习了寄存器重命名的实现机制。我们引入了空闲列表来管理物理寄存器分配,重命名表来维护架构寄存器到物理寄存器的动态映射,并扩展了重排序缓冲区以支持正确的寄存器释放。通过分析实例,我们看到了寄存器重命名如何消除写后写和写后读等虚假数据依赖,从而显著提升指令级并行度。最后,我们还探讨了通过合并寄存器文件来优化设计的方法。掌握这些概念对于理解现代高性能处理器的核心工作原理至关重要。

035:在指令队列和重排序缓冲区中存储值的寄存器重命名方案

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/bf8f72163c322464f4ba6e78ac85b043_0.png

在本节课中,我们将学习第二种寄存器重命名方案。与之前存储指针的方案不同,这种方案将实际的数据值存储在指令队列和重排序缓冲区中。我们将探讨这种设计带来的结构变化、工作原理及其优缺点。

方案概述与结构变化

上一节我们介绍了基于指针的寄存器重命名方案。本节中,我们来看看第二种方案,即在指令队列和重排序缓冲区中直接存储数据值。

这种方案源自Tomasulo算法的原始思想。与第一种方案相比,它带来了几个关键的结构变化:

  1. 移除了物理寄存器文件。其功能被合并到了重排序缓冲区中。

  2. 移除了空闲列表。我们将看到,通过修改重排序缓冲区的管理方式,可以不再需要这个结构。

  3. 修改了多个核心结构,包括重命名表、指令队列和重排序缓冲区,使其能够存储或指向实际的数据值。

此外,流水线的一个显著变化是:现在需要从架构寄存器文件中读取数据。在之前的方案中,架构寄存器文件只在回滚时被读取;而在此方案中,当最新值不在飞行指令中时,可能需要从其中读取规范状态。

重排序缓冲区的修改

首先,我们来看重排序缓冲区的修改。它仍然保留了之前的基本形式,但内容发生了变化。

以下是重排序缓冲区条目的关键字段:

  • 架构寄存器号:用于标识该指令结果最终应写入哪个架构寄存器。

  • 值字段:这是核心变化。指令的结果值将直接存储在这个重排序缓冲区条目中,而不是存储在独立的物理寄存器文件中。

  • 状态位:用于跟踪指令是否完成、提交等状态。

当一条指令完成计算后,其结果值会存入对应的重排序缓冲区条目中。该值将一直保留在此处,直到指令被提交。提交时,该值才会被写入架构寄存器文件。

指令队列的修改

接下来,我们修改指令队列。指令队列现在负责在指令执行前获取源操作数的值。

指令队列中的每个条目包含其源操作数的信息。变化在于,这些源字段现在可以存储两种内容:

  1. 数据值:如果该源操作数依赖的指令已经完成(即值已产生并存入重排序缓冲区),那么我们可以直接将该值存储在这里。

  2. 重排序缓冲区标识符:如果依赖的指令仍在执行中(值未就绪),则在此存储一个指针,指向生产该值的重排序缓冲区条目。

这种设计类似于Tomasulo算法中的结果前递。当一条指令完成时,其重排序缓冲区条目中的值就绪。这个“完成”事件可以被广播。正在等待此值的指令在指令队列中监听到广播,就可以根据标识符找到正确的重排序缓冲区条目,取出值并存储在自己的源字段中。一旦指令的所有源操作数值都就绪,该指令就可以离开指令队列去执行。

重命名表的修改

最后,我们来看重命名表的修改。它的变化相对较小,但作用至关重要。

重命名表仍然以架构寄存器号索引。对于每个架构寄存器,它需要指出在哪里可以找到其最新的值。由于值可能位于两个地方,因此重命名表需要提供以下信息:

  • 位置指示:指示最新值是在架构寄存器文件中,还是在飞行中(即位于重排序缓冲区)。

  • 指针:如果值在飞行中,则提供一个指向具体重排序缓冲区条目的标识符。

例如,如果很久没有指令写入架构寄存器R2,那么R2的最新值很可能就保存在架构寄存器文件中。后续需要读取R2的指令,通过查询重命名表,就会被告知去架构寄存器文件中获取该值。反之,如果最近有指令写入了R2且该指令尚未提交,那么最新值就在重排序缓冲区中,重命名表会提供对应的条目指针。

空闲管理的不同

这种方案的一个主要特点是不再需要显式的空闲列表。

在基于指针的方案中,物理寄存器被释放(变为空闲)后,其编号会加入空闲列表以供后续分配。在此方案中,物理寄存器与重排序缓冲区条目合二为一。当一个重排序缓冲区条目被释放时,我们通过将重命名表中对应条目的“有效位”置为0来等效地标记其“空闲”。

分配方式也有所不同。由于指令必须按序提交,重排序缓冲区条目本质上是按顺序分配和回收的。因此,新指令分配到的“物理寄存器”(即重排序缓冲区条目)并不是随机的,而是当前可用的下一个顺序条目。

这里有一个重要的细微差别:释放的时机。一个重排序缓冲区条目(即一个值存储位置)的释放,发生在它所对应的指令提交之时。提交意味着该值被写入架构寄存器文件,成为新的规范状态。此时,该重排序缓冲区条目才停止被使用(即“释放”),这与后续指令覆盖写入该寄存器的行为是不同的概念。

总结

本节课中,我们一起学习了第二种寄存器重命名方案:在指令队列和重排序缓冲区中存储值。

我们了解到,这种方案通过移除独立的物理寄存器文件和空闲列表,简化了部分硬件结构。它将数据值直接存储在重排序缓冲区中,并通过修改指令队列和重命名表来动态管理数据依赖和值获取。指令队列直接缓存已就绪的值或指向未就绪值的指针,实现了高效的前递。重命名表则扮演了“导航”角色,指引后续指令去正确的位置(架构寄存器文件或重排序缓冲区)读取最新值。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/bf8f72163c322464f4ba6e78ac85b043_2.png

这种设计的优点在于结构相对简洁,但需要更复杂的数据前递和广播机制。它与基于指针的方案在逻辑功能上是等价的,但具体实现和资源管理策略各有特点,为处理器设计提供了不同的优化选择。

036:内存歧义消除 🧠

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/d8bcc96a409a77211b08ff48636a6a28_0.png

在本节课中,我们将要学习处理器中的一个重要概念:内存歧义消除。这是处理内存访问顺序问题,特别是“写后读”冒险的关键技术。我们将探讨为何需要它、保守的解决方案、以及更先进的推测执行方法。


内存歧义消除问题

上一节我们介绍了指令级并行中的冒险问题。本节中我们来看看一个特殊类型:通过内存系统发生的“写后读”冒险。

我们有一个基本的指令序列:一个存储指令后跟着一个加载指令。我们何时可以执行这个加载指令?如果加载指令不依赖于存储指令,我们可以在乱序执行机器中,甚至在存储指令之前执行加载指令。然而,如果加载指令依赖于存储指令(例如,它们的地址寄存器相同),我们就会遇到问题。


保守的解决方案:顺序内存访问

在此之前,我们的流水线采用了一种简单的解决方案:让加载和存储指令按顺序执行。这可以解决所有问题。我们允许其他指令围绕内存指令乱序执行,但内存指令本身保持顺序。这保证了不会出现问题,但牺牲了性能。


提升性能:推测执行

为了追求更高性能,我们希望能乱序执行加载和存储指令,前提是它们的地址不同。但这需要额外的硬件结构来支持。

以下是保守的乱序加载执行方案:

  1. 将存储指令拆分为两个部分:地址计算和实际数据写入。

  2. 通过提前计算存储地址,我们可以判断后续的加载指令是否与其地址冲突。

  3. 如果地址不同,我们就可以在存储指令之前执行加载指令。

然而,这种方法仍然需要将每个加载指令与所有先前未提交的存储指令进行地址比对,这需要复杂的硬件结构。并且,如果我们不知道前面存储指令的地址,就无法安全地执行任何加载指令。


更优方案:内存依赖预测

我们能否做得更好?答案是进行推测。我们可以猜测地址不相等,并提前执行加载指令。然后,我们暂缓提交加载和存储指令,并按顺序提交它们。如果猜测错误(地址实际相等),我们必须回滚所有相关指令。

这种回滚的代价很高。因此,一种启发式方法被提出,称为内存依赖预测。其核心思想是:

  • 我们仍然猜测加载和存储不冲突。

  • 但如果发现它们冲突并导致回滚,我们会对这个特定的加载指令做一个特殊标记。

  • 当下次遇到相同的加载指令时,预测器会“记住”它可能依赖于前面的存储,从而强制它等待所有前面的存储完成,避免再次回滚。

这类似于分支预测,是一种基于历史行为的启发式优化,可以减少因错误推测而导致的整体流水线刷新。


推测执行的支持结构:存储缓冲区

为了实现推测性的加载和存储,我们需要引入一个存储缓冲区来保存推测状态。

让我们快速看一下存储缓冲区的结构:

  • 所有发往L1数据缓存的地址也会被发送到这个缓冲区。

  • 缓冲区内部有标志位,指示条目是否有效,以及数据是否已就绪。

  • 当加载指令乱序执行时,它首先检查存储缓冲区。如果命中且数据有效,它可以从缓冲区读取最新值,而无需访问缓存。

  • 如果推测的存储指令后来被取消(例如,位于错误的分支路径上),只需将其从缓冲区中移除即可。

  • 一个重要的问题是:如果数据同时存在于缓冲区和缓存中,以缓冲区中的新值为准。

  • 另一个复杂性是:缓冲区中可能有多个对同一地址的存储。读取时必须按程序顺序找到最新的(最年轻的)值。


总结

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/d8bcc96a409a77211b08ff48636a6a28_2.png

本节课中我们一起学习了内存歧义消除技术。我们从保守的顺序执行内存访问开始,了解了其可靠性但性能受限。接着,我们探讨了通过拆分存储指令和地址比对来实现有限的乱序执行。然后,我们介绍了更先进的内存依赖预测技术,它通过历史学习来减少错误推测的代价。最后,我们了解了支持这些推测机制的关键硬件结构——存储缓冲区,它临时保存推测的存储结果,使乱序的加载指令能访问到最新数据。这些技术是现代高性能处理器实现指令级并行的重要组成部分。

037:VLIW处理器与乱序设计的复杂性限制 🚀

在本节课中,我们将要学习VLIW(超长指令字)处理器。这是一种利用处理器中指令级并行性的替代方法。与我们在乱序处理器和超标量处理器中讨论的动态调度不同,VLIW通过编译器进行静态调度,并由相应的体系结构来利用这种调度。但在深入VLIW之前,我们需要先理解传统超标量乱序处理器设计的复杂性及其限制。

乱序处理器的复杂性来源 🔍

上一节我们介绍了超标量处理器的基本概念,本节中我们来看看其内部,特别是乱序执行部分,为何会变得如此复杂。

这里有一张描述超标量处理器中指令生命周期的示意图。处理器的发射宽度表示为 W,指令的生命周期长度表示为 L。这形成了一个处理器中“在途”指令的矩阵。这些指令可能位于指令队列中。当我们尝试发射一条指令时,需要检查它与指令窗口中所有其他指令以及所有寄存器的依赖关系。

我们构建了一些结构来避免全对全(all-to-all)的检查,以降低复杂度。例如,记分牌(scoreboard)通过跟踪每个寄存器最近一次写入的位置和延迟,将全对全比较转化为基于寄存器的索引查找,这简化了操作。

然而,在指令发射队列(issue queue)中,当一个指令退休时,它仍然需要广播其完成的寄存器,唤醒指令队列中所有等待该寄存器的指令,并检查哪些指令已就绪可以发射。这个过程大致按 W * L 的规模扩展,因为每个周期可能退休多条指令,并且需要检查队列中的所有指令。随着处理器宽度(W)的增加,这变得非常复杂。

以下是乱序控制逻辑复杂性的几个关键点:

  • 唤醒与选择逻辑:一个主要挑战是,单个周期内可能有过多指令被同时唤醒。例如,如果指令队列中所有指令都在等待寄存器5,那么当一条指令写入寄存器5时,所有这些指令都需要被唤醒。然后,硬件必须从中选择恰好符合功能单元混合要求(例如,2个加法、2个乘法、1个加载/存储)的指令进行发射。这变成了一个巨大的组合逻辑难题。

  • 电路级实现:为了应对这个挑战,全定制处理器设计会使用称为“选择器(pickers)”的电路。这不是纯粹的组合逻辑,而是更接近模拟电路或类CAM(内容可寻址存储器)的结构,它能根据某种启发式方法(通常是选择最旧的指令以防止死锁)快速选择就绪指令。

  • 宽度与深度的权衡:增加处理器宽度(W)通常也需要增大指令窗口的深度(L),以找到足够的并行性来喂饱所有功能单元。这导致控制逻辑的复杂度大致在 W² 到 W³ 之间增长,使得前端设计(指令获取、解码、发射)的流水线级数越来越多,设计极具挑战性。

现实案例:MIPS R10000处理器 🖥️

为了具体理解这种复杂性,让我们看一个真实处理器的例子:MIPS R10000。

这是一款用于SGI工作站的乱序超标量处理器。从其芯片显微照片中,我们可以清晰地看到控制逻辑所占用的面积相当可观。数据通路(整数单元、浮点单元等)本身相对适中,而指令缓存、数据缓存等大型结构也很显眼。

然而,芯片中间区域的大量面积被控制逻辑占据:

  • 寄存器重命名逻辑和空闲寄存器列表。

  • 分布式的指令队列(或保留站),分别用于地址计算、整数和浮点指令。

  • 各种调度和控制电路。

与执行单元相比,所有这些用于实现乱序调度的“开销”硬件占据了芯片面积的很大一部分。这直观地说明了乱序设计在硬件复杂度上的代价。

指令集表达的局限性 🤔

乱序超标量处理器还存在另一个根本性问题:如何表达并行性。

让我们看看顺序代码在乱序超标量处理器上的执行流程:

  1. 一段C代码被送入先进的编译器。

  2. 编译器分析代码,生成数据流图和控制流图,它清楚地知道指令间的所有依赖关系和可用的并行性。

  3. 然而,由于传统指令集架构(ISA)是顺序的,编译器必须强行生成一个顺序的指令序列,即使这个顺序可能并非最优,甚至不必要地约束了问题。

  4. 这个顺序指令序列被交给处理器。

  5. 处理器的前端硬件(取指、解码、重命名)必须重新解析这个顺序序列,通过复杂的硬件逻辑(如保留站、唤醒逻辑)尝试找出并行性,并动态地重新调度指令。

  6. 最终,硬件生成一个实际的执行调度,在保持读写依赖的前提下尽可能高效地执行。

这个过程存在一个明显的信息断层:编译器早已知道的并行性信息,在编译后的二进制代码中丢失了。处理器不得不耗费大量的硬件资源、功耗和设计努力,去重新发现编译器已经知道的信息。

寻找更好的方法 💡

综上所述,传统的乱序超标量设计面临着硬件复杂度高、控制逻辑开销大以及编译器与处理器间存在信息鸿沟等问题。那么,是否存在更好的方法?

答案是肯定的。一种思路是将调度并行性的责任从运行时硬件转移到编译时软件。如果编译器能将多个可以并行执行的操作打包到一条很长的指令中,由硬件直接执行,不就需要复杂的动态调度逻辑了。这正是VLIW(超长指令字)处理器的核心思想。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/8b69f7664ab80a66238ba72ef213bbaa_1.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/8b69f7664ab80a66238ba72ef213bbaa_2.png

本节课中我们一起学习了乱序超标量处理器设计复杂性的来源,包括其控制逻辑的规模、在真实芯片中的面积体现,以及因顺序指令集导致的信息效率低下问题。认识到这些限制,为我们接下来探索一种不同的设计哲学——VLIW架构——奠定了重要的基础。

038:超长指令字处理器简介 🚀

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/1e6a356309bc0eaaa6922f528e3657cd_0.png

在本节课中,我们将要学习一种称为超长指令字处理器的架构。这种架构试图通过改变指令的编码和执行方式,来简化硬件设计并提升性能。我们将探讨其基本概念、工作原理、两种主要的调度模型,并简要回顾其发展历史。

概述

上一节我们介绍了超标量和乱序执行处理器中复杂的硬件依赖检测机制。本节中我们来看看一种不同的思路:超长指令字处理器。这种架构将多个独立操作打包在一个指令束中,并将依赖检测的工作从硬件转移到了编译器。

超长指令字的基本概念

超长指令字处理器,简称VLIW,其核心思想是将多个可以并行执行的操作编码在一个很长的指令字中。一个VLIW指令,或称指令束,内部包含多个独立的操作。

以下是VLIW指令的一个示例格式:

[ 整数操作1; 整数操作2 | 内存操作1; 内存操作2 | 浮点操作1; 浮点操作2 ]

在这个例子中,一个指令束内可以包含两个整数操作、两个内存操作和两个浮点操作。程序在内存中仍然是一个顺序的指令束序列,但每个指令束内部编码了多个可以并行执行的操作。

VLIW的执行语义

在VLIW架构中,位于同一个指令束内的所有操作被视为可以并行执行,编译器负责保证它们的独立性。硬件不进行依赖检查。

考虑以下代码序列:

[ MUL R3, R1, R2; ADD R4, R5, R6 ]
[ SUB R7, R3, R8 ]

在这个例子中,第一条指令束包含一个乘法(MUL)和一个加法(ADD)。尽管乘法会写入寄存器R3,而第二条指令束中的减法(SUB)会读取R3,但位于同一个指令束内的MUL和ADD之间没有依赖关系。硬件不检查它们是否访问了相同的寄存器。依赖关系只存在于不同指令束的操作之间。

这种设计的优点是显著的:我们移除了用于动态调度、依赖检测(记分牌)、重排序缓冲区和寄存器重命名的大量复杂硬件。编译器在编译时完成了这些分析工作。

VLIW的挑战与调度模型

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/1e6a356309bc0eaaa6922f528e3657cd_2.png

然而,VLIW架构也面临挑战,主要是难以应对运行时动态事件,如缓存缺失和分支预测错误,因为它缺乏动态调度硬件。此外,编译器必须精确知道每个操作的执行延迟。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/1e6a356309bc0eaaa6922f528e3657cd_4.png

这就引出了VLIW中关于操作延迟和寄存器值读取的关键问题:当一个操作写入寄存器,但其结果需要多个周期才能就绪时,后续操作应该读取旧值还是新值?

以下是两种主要的调度模型:

1. 等于调度模型

在等于调度模型中,一个操作的结果严格在其指定的延迟周期结束时生效。在此之前,任何读取该寄存器的操作都将获得旧值。

例如,假设一个乘法操作需要4个周期完成,其目标寄存器是R1。如果一个在乘法发射后1个周期执行的AND操作试图读取R1,那么它将获得R1在乘法执行前的旧值。编译器必须了解这一规则并进行相应的指令调度。

这种模型的优点是可以减少寄存器压力,因为寄存器在结果实际生效前并未“失效”。但它有一个严重问题:在处理中断时可能引发状态不一致。如果乘法完成后、AND执行前发生中断,中断处理程序看到的R1是新值,但AND恢复执行后却会读取旧值,导致错误。

2. 小于等于调度模型

在小于等于调度模型中,一个操作的结果可以在其发射后到指定延迟结束前的任何时间点生效。编译器仍然不能假设结果会提前就绪(因此不能提前调度依赖该结果的操作),但它保证了结果的生效时间不会晚于指定延迟。

这种模型的好处是支持精确中断,并且当处理器升级、操作延迟缩短时,能保持二进制代码的兼容性。即使乘法从4周期加速到3周期,原有代码仍能正确执行。

VLIW架构简史

VLIW的研究相对较新,始于20世纪80年代末。

  • FPS处理器:最早的LIW处理器之一,是VAX机器的浮点协处理器,没有互锁机制,也不处理中断。

  • Multiflow Trace处理器:由耶鲁大学Josh Fisher的研究衍生而来,采用非常长的指令字(最长1024位),每个指令可包含7、14或28个操作,通过编译器配置支持不同宽度的机器型号。

  • Cydrome Cydra 5:由Bob Rau开发,采用了一种创新的“旋转寄存器文件”设计来替代寄存器重命名,用于高效处理函数调用和循环。

近期(过去5-10年)的研究致力于结合VLIW的静态调度优势和超标量的动态调度能力,例如研究在多核上进行调度,或设计兼具静态和动态特性的“超级VLIW”架构。

总结

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/1e6a356309bc0eaaa6922f528e3657cd_6.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/1e6a356309bc0eaaa6922f528e3657cd_7.png

本节课中我们一起学习了超长指令字处理器。VLIW通过将多个操作打包进一个长指令字,并将依赖检测任务交给编译器,从而大幅简化了处理器硬件。我们探讨了其基本执行语义,分析了它无需复杂动态调度硬件的优点,以及难以应对运行时不确定性的缺点。最后,我们详细介绍了等于和小于等于两种关键的调度模型,并简要回顾了VLIW架构的发展历程。理解VLIW有助于我们思考编译器与硬件之间职责划分的不同设计哲学。

039:VLIW编译器优化 🚀

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/15334243c7bceeb5a66254ed2e8e02dd_0.png

在本节课中,我们将要学习超长指令字(VLIW)架构中,编译器如何通过一系列优化技术来弥补硬件简化带来的性能挑战。我们将重点探讨循环展开、软件流水线和踪迹调度等核心编译优化方法。


编译器在VLIW架构中的核心作用 🧠

上一节我们介绍了VLIW处理器的基本概念。本节中我们来看看,当我们将超标量处理器中的复杂硬件(如动态调度和依赖检查逻辑)移除后,性能责任如何转移。

VLIW处理器依然追求高性能。既然我们移除了超标量设计中的大量硬件,就必须有东西来弥补这个空缺。这个弥补者就是一个非常、非常、非常智能的编译器。因此,在这类架构中,我们非常强调编译器的作用。

编译器必须承担起调度的责任。它需要完成所有的依赖检查,并可能必须避免各种数据冒险。这仅仅是个开始。我们将在后续课程中主要讨论编译器可以进行的各种优化,以尝试近似实现动态超标量处理器所能做到的事情,但它是通过静态方式完成的。

这是一个相当巧妙的技巧。如果你把所有那些硬件功能放到编译器中,只需运行一次编译过程。然后,每次执行代码时,你都不需要重新计算所有的依赖关系。


基础循环代码的执行与性能分析 🔄

让我们看看如何在这里执行一些代码,以及在VLIW处理器上执行循环代码的性能方面是怎样的。

这里有一个非常基础的数组递增操作。我们将取数组的每个元素,将其增加一个值 C。代码进入我们的编译器,这里是一个顺序的代码序列。这段代码尚未为VLIW架构进行调度。

以下是顺序代码:

LOAD   F0, 0(R1)      ; 从内存加载数组元素到F0
ADDI   R2, R2, #1     ; 递增计数器
ADDF   F2, F0, F4     ; 浮点加法:F2 = F0 + C (F4中存有C)
STORE  F2, 0(R1)      ; 将结果存回内存
ADDI   R1, R1, #4     ; 递增数组索引(假设每个元素4字节)
BLT    R2, R3, LOOP   ; 如果R2 < R3,跳回循环开始

由于架构和编译器了解指令延迟,假设这里的加载指令有几周期的延迟。因此,编译器实际上可以稍后调度这个加法指令。而加法是浮点加法,它也有几个周期的延迟。所以,编译器将那个结果的消费者(即 F2 的使用)调度到更后面。同时,编译器可以将数组索引和计数器的递增操作安排在空闲的槽位中,比如可能与加载和存储指令同时调度。

这很酷。我们实际上在这里实现了两路并行执行。我们不需要超标量处理器中所有的额外开销。

第一个问题是:我们每个周期执行多少次浮点操作?看起来情况不太好。我们这里只有一个浮点操作(就是这个加法)。而我们总共有7个周期。所以,我们每周期只有大约0.125次浮点操作。这并不理想。我们确实有一些并行性,执行了三条指令,比没有好,但我们并没有很好地利用机器。一个动态超标量处理器可能会尝试获取下面的指令,也许混合它们,尝试重新排序一堆东西以运行得更快。


循环展开优化 🔄➡️🔄🔄🔄

那么,如何提高性能呢?正如之前所说,在本单元中我们非常强调编译器的作用。编译器可以做的一件事就是实际展开循环。

这里我们有我们的循环,我们将其展开四次。现在,我们试图将循环开销分摊出去,因为它每四次迭代才发生一次。这听起来不错。你试图在循环的每次迭代中做更多的工作。

但事情变得稍微复杂一些。如果循环终止值 N 不是4的倍数怎么办?我们需要对此进行处理。我们可能需要在执行前检查,我们是否处于方便的四的倍数且足够大的情况。我们可能可以在很长一段时间内以四的倍数运行,只有在最后一次迭代时才需要清理。但我们需要生成一些清理代码,而编译器负责完成这项工作。这些编译器优化确实需要一些努力。

现在让我们看看循环展开后代码的调度情况。

我们可以在这里预先进行一堆加载操作。我们已经将这些循环迭代交织在一起。很酷的一点是,我们实际上可以将加载操作提取出来放到顶部,将存储操作推到底部,然后将所有加法操作放在中间,也许将数组更新操作穿插在其他地方。

当我们实际调度时,我们会做类似的事情:先执行加载,然后执行浮点加法,再进行存储。但你可以注意到,我们实际上开始获得一些重叠。因为我们已经展开了循环,我们可以重叠这个加载和第一个浮点加法。因为我们有效地通过在延迟时间内放入其他循环迭代,覆盖了功能单元的延迟。

如果你将这个调度与之前的调度进行比较,我们只是将这些空闲周期利用起来,并在那些空闲周期中放入了其他循环迭代。

在这个循环展开的情况下,我们递增这些计数器和索引不再是每次加4。我们现在递增的是 循环展开次数 * 数据大小。所以我们现在递增16。这说得通吗?因为在这段代码中,我们之前是将 R2 递增4,因为单个值的大小是4字节。但现在,因为我们把所有这些工作批量处理在一起,我们实际上必须将索引移动一个更大的值。所以我们移动了 4(展开次数) * 4(数据大小) = 16。

这里的一个好处是,在加载和存储中,我们都使用了寄存器间接寻址模式,并加上一些偏移量。所以我们实际上是在用基址寄存器 R1 加上偏移量(比如12)来计算我们实际加载的地址。这是一种方便的方式,我们不需要计算一堆地址。

回到这里,我们可以看到我们正试图将实际操作与其他循环迭代重叠。这真的很酷。所以我们开始在这里获得一些性能。

让我们看看性能。这里问同样的问题:每周期多少次浮点操作?希望它更高。1, 2, 3, 4次操作除以11个周期,得到大约0.36,比0.125好多了。所以循环展开对我们有帮助。


软件流水线优化 ⏩

但这就是全部吗?我们的编译器还能做更多吗?于是,编译器专家们想出了一个更巧妙的主意,称为软件流水线。这是一个我们之前见过的术语——流水线,但这是在软件中实现的。

这里的想法是,不仅仅展开循环并重叠迭代,我们实际上要取多个这样的调度并将它们交错,尝试填补一些空槽。

让我们看看这个。我们将代码展开四次。这和上一张幻灯片中的代码相同。我们将画出之前的调度图(用紫色表示)。现在,我们将用另一个完全相同的四次展开循环迭代来调度它(用绿色表示)。我们只是将这个迭代与另一个循环迭代重叠。我们完成了吗?还没有,这里仍然有一些空槽。所以让我们尝试重叠甚至另一个迭代(用红色表示)。

现在,为了正确执行此操作所需的修复代码变得更加复杂。因为突然间,你基本上重叠了多个迭代。但只要你没有修改某些值,没有投机性地进行存储,你可能就没问题,因为你只是在做额外的加载,做额外的工作,你在填补槽位,并认为不会有任何问题,或者认为索引变量 N 是4的倍数且足够大,并且你不在循环末尾。

让我们给这些东西起个名字。我们称开始部分为前导段或序幕。这里实际上是我们真正的迭代主体。你可以看到(绿色部分,可能显示不太清楚),这里有指令,有加法,相当满。我们实际上在VLIW机器上做了很多工作。然后这里的后导段或尾声,是我们完成的时候。这是当我们退出时,可以说是外部循环的最后一次迭代。

让我们做一些数学计算,看看这种方法的性能。问题是:每周期多少次浮点操作?我们看这里,在紧凑循环部分有四个周期内执行了四次浮点操作。这看起来非常好。我们刚刚获得了一大堆性能。但我们必须做大量的编译器优化才能使这工作。我们能够利用机器中的并行性,并且能够重叠这个循环的三个不同迭代,同时我们还通过软件转换将循环展开了四次。这就是所谓的软件流水线。

这里有一张很好的图,直观地展示了正在发生的事情。我们在横轴上表示时间,在纵轴上表示执行指令的活动量或性能。当你运行多个循环展开迭代时,你会有一些启动开销,然后实际运行循环,最后从循环中退出。这比有大量启动开销,然后在中间只有很小的循环迭代部分要好。

但当我们看软件流水线时,我们可以基本上将一个迭代循环的执行与另一个循环的迭代重叠。我们实际上可以执行我们的序幕,非常紧凑地执行多个迭代,然后在这里有我们的尾声。在软件流水线中,我们只为整个循环的执行支付一次启动和结束成本,而不是为循环的每次迭代都支付。所以这很有趣。在学校里,我们获得了性能。


处理控制流:基本块与踪迹调度 🧩

如果世界只有密集循环,生活将很容易。唉,世界并不全是循环。如果我们只有一个处理器,只做密集数组计算,并且我们世界上的所有问题都只是密集数组计算,生活将非常容易,但事实并非如此。很多时候,代码有很多分支。它有 if-then-else 子句。

这里,我们以图形方式展示了类似 if-then-else 的结构。我们有一段代码,它做出一个决定,要么执行左边的代码,要么执行右边的代码,这基于一个 if 语句。这是 if 为真的语句,这是 else 子句。

数据依赖的分支通常是超长指令字处理器的一个问题。为什么?在一个乱序处理器中,你可以尝试围绕分支执行代码,并将指令移动到分支之上或之下。但如果你进行静态调度,当你遇到这个分支时,假设它是一个难以预测的分支,你真的做不了什么,因为你已经将一堆指令打包在一起,它们需要原子性地执行。所以,你无法轻易地将代码跨分支上下移动。超标量处理器可以做到这一点,因为它有指令窗口,有一堆技术和硬件来实现。但在我们的 VLIW 处理器中,这是个问题。

这里我想介绍一个对编译器非常重要的术语,对这个课程也很重要,它叫做基本块。什么是基本块?基本块是一段具有单一入口和单一出口的代码。所以这是一个基本块。它有一个入口和一个出口。为什么单一入口很重要?因为如果你可以跳转到这段代码的中间,编译器不一定能重新排序这个块内的指令。如果你有多个出口,比如说你在这里退出,编译器可以将指令推送到那个出口点之后。但如果你有一个基本块,编译器基本上知道这个指令序列将有效地(虽然不是真正原子性地)执行。从编译器的角度来看,它可以重新排序这里的指令以获得更好的性能。

所以,循环很容易。我们可以进行软件流水线,可以展开。但包含 if-then-else 的“面条代码”更难处理。因此,编译器专家想出了一些巧妙的技巧,以使 VLIW 工作得更好,并利用乱序超标量处理器所做的一些代码移动,但这是在编译器中静态完成的,而不是在硬件中动态完成的。

其中一种更著名的方法是踪迹调度,这是 John Ellis(Josh Fisher 的学生之一)的论文工作,出自耶鲁大学的 Bulldog 编译器。

你这样做:首先对代码进行性能分析,得出这些分支走向某一方向或另一方向的概率。这里的性能分析不是硬件在运行时做的事情,而是在编译器阶段对程序进行的。编译器在给定的输入集上运行程序,得出分支走向的概率。

然后,你利用这些性能分析信息,猜测出最可能的执行路径。我们在这里圈出它,并说这些加粗的边是给定入口点后,通过这段“面条代码”的最可能路径。这并不意味着你不能有分支跳出这个路径。但如果你这样做了,你需要一些修复代码。因为我们接下来要做的是,我们将取这一大块代码,移除所有的分支,并将其作为一个大的、整体的代码块为我们的 VLIW 处理器进行调度。

通过这样做,我们可以移动指令。比如说,下面这里有空闲槽位,可以在这个代码序列的早期部分执行,我们可以将它们上移。同样,你可以将使用长延迟指令结果的指令从这里上移,并跨分支下推。

我们的乱序超标量处理器通过分支预测来做到这一点。但我们的编译器可以在 VLIW 处理器上使用踪迹调度来做到这一点。但当我们这样做时,必须小心,因为尽管可能性很小,你仍然可能走向另一个分支。因此,通常的做法是,你有某种形式的修复代码。如果你分支到其他路径,你必须修复任何在分支之后对处理器状态进行了“提交”更改的东西,你需要以某种方式回滚它。所以,我们基本上是在软件中做乱序超标量处理器中的回滚操作。

因此,不是在分支预测错误时将架构寄存器文件复制到物理寄存器文件,而是我们的编译器生成一个代码序列,如果你在这里分支到其他路径,它会执行相同的操作。我们只回滚需要回滚的特定寄存器,以及需要回滚的内存状态。这很酷,因为我们可以基本上将乱序超标量处理器中完成的许多功能,通过踪迹调度放入软件中实现。


总结 📚

本节课中我们一起学习了VLIW架构下编译器优化的核心思想与技术。我们了解到,在简化了硬件动态调度能力后,性能优化的重任转移到了编译器上。

我们首先分析了基础循环在VLIW上的低效调度,然后探讨了循环展开如何通过增加每次迭代的工作量并重叠不同迭代的操作来提升指令级并行性。接着,我们深入学习了更高级的软件流水线技术,它通过交错多个展开的循环迭代,进一步填满指令槽,显著提高了每周期浮点操作数。

最后,我们面对现实世界中复杂的控制流,引入了基本块的概念,并学习了踪迹调度这一强大技术。踪迹调度允许编译器基于性能分析信息,将最可能执行的路径调度为一个大的代码块,并生成修复代码来处理不常见的分支路径,从而在软件中实现了类似硬件分支预测和推测执行的效果。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/15334243c7bceeb5a66254ed2e8e02dd_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/15334243c7bceeb5a66254ed2e8e02dd_3.png

这些优化技术共同展示了编译器如何通过静态分析,在VLIW架构上逼近甚至实现动态超标量处理器的性能,体现了软硬件协同设计的精妙之处。

040:经典VLIW架构的挑战与应对策略

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/bd46dd5b7a7f9a839cda844a15ffb09f_0.png

在本节课中,我们将探讨经典超长指令字架构面临的主要挑战,并了解为解决这些问题而发展出的硬件与编译器技术。


概述

上一节我们介绍了VLIW架构的基本原理。本节中,我们来看看经典VLIW架构在实际应用中遇到的一系列挑战,例如代码兼容性、代码体积膨胀以及处理不确定事件(如缓存未命中)的困难。我们还将探讨为解决这些问题而提出的一些技术方案。


主要挑战

以下是经典VLIW架构面临的核心问题:

目标代码兼容性

在超标量架构中,指令序列是串行的,由硬件负责调度。因此,微架构中功能单元数量的变化对上层软件是透明的,代码仍能执行(尽管可能不是最优)。然而,在经典VLIW中,指令编码直接规定了每个指令束中各类操作(如整数、访存、浮点)的确切数量。如果微架构的功能单元组合发生变化,原有的指令调度将完全错误,导致性能低下甚至无法执行,通常需要重新编译代码。这导致了架构与微架构之间的紧密耦合。

代码体积膨胀

可以想象,VLIW指令束中常包含相当数量的空操作指令。在超标量架构中,如果无法填充一个槽位,只需不发射指令。但在VLIW中,如果无法填充槽位,必须放置一个NOP(空操作)指令。这导致了代码体积的显著增加。此外,我们讨论过的诸如循环展开、软件流水线等高级优化技术会复制代码,进一步增大了代码体积,对指令缓存容量和占用率造成压力。

处理可变延迟操作

处理具有可变延迟的操作非常困难。例如,对于一次加载操作,编译器无法预知其是否会发生缓存未命中。调度方案可能基于猜测(例如,“此加载通常不会未命中”),但猜测可能错误。分支预测失败也存在类似问题,使得静态调度对于不可预测的分支变得极具挑战性。

精确中断的实现

根据具体设计,实现精确中断至少是具有挑战性的。如果采用按序执行模型,当单步执行或分支指令与未完成的操作交织时,情况会变得复杂且未定义。一个关键问题是:如果一个指令束中的某个操作发生异常,是整个指令束异常,还是仅该操作异常?通常,为了实现更简单的异常处理,整个指令束被设计为原子性的。如果束中任何操作发生异常,则不允许该束中任何子操作提交。虽然也有其他实现方式,但会使设计变得复杂。


应对策略

在本次讲座的剩余部分及下一次讲座中,我们将讨论用于解决上述许多挑战的技术。这些方案有些属于编译器优化,有些属于硬件设计,有些则需要两者协同。

压缩或更智能的指令编码

人们首先尝试设计压缩或更复杂的指令编码格式。但这会使处理器前端设计变得更复杂。例如:

  • 分组执行:指令内部可以包含不同的组,组内操作并行,组间则串行。英特尔的安腾处理器就采用了类似的设计。

  • 动态解压缩:采用压缩指令格式存储,在执行前将NOP解压缩到指令内存中。Multiflow Trace处理器采用了这种方法。

  • 特殊编码格式:例如,Cydrome公司有一个有趣的解决方案,他们为单操作VLIW指令设计了特殊的编码格式,节省了大量编码空间。

  • 可变宽度编码:例如Tilera T64处理器(我曾参与此项目),它支持在一个指令束中执行两条或三条指令。当执行两条指令时,可以使用功能更丰富的指令集,从而在代码密度和复杂度之间取得更好平衡。

增大指令缓存或总线宽度

一个更简单直接的解决方案是使用更大的指令缓存,或者采用更宽的总线从指令缓存连接到内存系统。这确实能缓解代码体积增大带来的性能问题,但代价是增加了硬件成本。相对于前面列出的复杂“智能”方案,这是一种“简单粗暴”但有效的解决方案。


总结

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/bd46dd5b7a7f9a839cda844a15ffb09f_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/bd46dd5b7a7f9a839cda844a15ffb09f_3.png

本节课我们一起学习了经典VLIW架构面临的核心挑战,包括目标代码兼容性差、代码体积膨胀、处理可变延迟操作困难以及实现精确中断的复杂性。我们也初步了解了为解决这些问题而发展出的一些技术方向,例如更智能的指令编码和增大硬件资源。下一讲我们将继续深入探讨其他解决方案,如谓词执行等技术。

041:谓词执行简介 🧠

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/d771423952a234c590d220d71ab31644_0.png

在本节课中,我们将探讨超长指令字架构面临的一个关键问题——难以预测的分支,并学习一种名为“谓词执行”的解决方案。我们将从基本概念入手,逐步理解如何通过将控制流转换为数据流来提升指令级并行性。


概述

超长指令字架构在处理难以预测的分支时,其指令级并行性会受到限制。本节将介绍“谓词执行”技术,它通过添加特殊的硬件指令来消除分支,从而避免分支预测错误带来的性能损失。我们将首先学习“部分谓词执行”,然后探讨其扩展形式“完全谓词执行”。


部分谓词执行

上一节我们讨论了VLIW架构的挑战,本节中我们来看看如何通过“谓词执行”来解决分支预测难题。其核心思想是移除分支指令,将控制流依赖转化为数据依赖。

为此,硬件需要添加两条特殊的指令。这两条指令的功能类似于C语言中的三元条件运算符(? :)。

核心指令公式如下:

  • move_if_zero Rd, Rs: 如果源操作数为0,则将Rs的值移动到Rd。

  • move_if_not_zero Rd, Rs: 如果源操作数不为0,则将Rs的值移动到Rd。

C语言中的三元运算符 a = c ? d : e; 可以理解为:如果条件c为真,则a赋值为d,否则赋值为e。上述两条指令实现了类似但更基础的功能。

这种设计的巧妙之处在于,它允许我们将控制流转换为数据指令。考虑以下使用分支的代码片段:

slt  t0, a0, a1    # 计算条件 (a0 < a1),结果存入t0
beqz t0, ELSE      # 如果t0为0(即条件为假),跳转到ELSE标签
add  t1, a2, a3    # if 块内的指令
j    END           # 跳过else块
ELSE:
sub  t1, a2, a3    # else 块内的指令
END:

这段代码包含两个控制流操作:一个条件分支beqz和一个无条件跳转j。使用谓词指令后,我们可以将这段if-then-else逻辑压缩到一条指令流中,从而完全消除分支。

这对于超长指令字处理器至关重要,因为分支预测错误会导致大量无效指令,编译器无法在预测错误路径上调度指令。而使用谓词后,编译器可以尝试调度非依赖操作,前提是它能在编译时生成并行代码序列。

以下是关于部分谓词执行的两个关键问题及其解答:

1. 如果if或else块内包含多条指令怎么办?

上述例子中每个块只有一条指令,情况比较简单。对于更复杂的情况,部分谓词执行的典型做法是:交错执行if和else两个分支的所有指令,然后通过条件移动指令在最后选择正确的结果。例如,在x86架构中这类指令常被称为CMOV,在MIPS中则称为MOVZ。这种做法并非世界末日,但意味着你可能需要执行一些原本不需要执行的额外指令。

2. 如果if和else块的长度严重不平衡怎么办?

这也不是无法解决的问题。如果一边有1000条指令,另一边只有2条,那么采用谓词执行可能会导致在常见情况下(执行2条指令的那一侧)额外执行1000条无用指令,这显然效率低下。此时,更明智的选择可能是保留一个实际的分支指令,而不是尝试进行谓词化。


完全谓词执行

部分谓词执行通过添加简单的条件移动指令来工作。现在,让我们看看它的扩展形式——完全谓词执行。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/d771423952a234c590d220d71ab31644_2.png

完全谓词执行的理念更进一步。它不再仅仅添加几条特殊指令,而是允许指令序列中的几乎每一条指令(除了分支等少数指令外)都能根据一个谓词寄存器的值来决定是否执行(即被“无效化”)。

它的工作原理如下:首先,架构会有一组额外的谓词寄存器,用于存储条件判断的结果。然后,在每条指令前,可以附加一个括号,里面指定一个谓词寄存器。只有当该谓词寄存器的值为“真”时,这条指令才会实际执行。

与部分谓词执行相比,完全谓词执行可以实现更复杂的操作。你不需要在最后进行数据移动或簿记操作,而是可以直接只执行所需分支路径上的指令,避免了执行额外工作。

Scott Mahlke在1995年的ISCA会议上指出,如果配合足够智能的编译器(例如他当时在UIUC的IMPACT编译器),使用完全谓词执行可以消除程序中大约50%的分支,特别是那些短小的分支。这项技术后来出现在HP的PlayDoh架构及其编译器中,该编译器正是基于UIUC的IMPACT项目。由此可见,这项技术能带来显著的性能收益。


总结与下节预告

本节课我们一起学习了如何通过“谓词执行”技术来处理动态事件,并在静态调度的超长指令字架构中获得类似推测执行的优点。我们详细探讨了部分谓词执行和完全谓词执行的原理与优劣。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/d771423952a234c590d220d71ab31644_4.png

在接下来的课程中,我们将继续深入讨论如何进行代码移动,包括如何让指令跨越分支移动,以及如何让内存操作跨越其他内存操作移动。然后,我们将探讨在静态调度环境中如何处理一些难以应对的动态事件。

042:从传统VLIW到超标量处理器的演进 🚀

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/fb91cd17c22385b569f9045367a2f714_0.png

在本节课中,我们将继续探讨超长指令字处理器,并学习如何将一个传统的VLIW处理器,转变为能够实际获取大量指令级并行性(ILP)的处理器,这种并行性通常存在于乱序超标量处理器中。为了实现这一目标,我们需要为传统VLIW添加许多额外功能。我们将逐步展开,系统地列举乱序超标量处理器中可能存在的各种指令级并行性来源,并探讨如何在VLIW处理器中增加相应特性来达到类似效果。不过,需要提前说明的是,并非所有乱序超标量处理器的特性都能轻易地在VLIW处理器中实现,有些甚至至今都难以构建。

调度模型回顾 📝

在开始新内容之前,我们先回顾一下上节课的内容,并澄清关于调度模型中EQ(等于)模型和LEQ(小于等于)模型的一些概念。

需要明确的是,VLIW调度中的EQ模型和LEQ模型仅仅是调度模型。它们本身并非硬件实现,但会影响硬件需要完成的任务或提供的功能。

例如,在EQ模型中,如果你的VLIW处理器中有一条指令紧跟着另一条指令,并且后一条指令读取了某个寄存器的值,而这个值恰好在前一条指令的“阴影”(即延迟周期)内被计算,那么后一条指令将读取到该寄存器的旧值。

让我们通过一个简单的代码示例来说明:

{ MUL R1, R3, R4 ; ... }  // 乘法指令,结果写入R1,假设延迟为4个周期
{ ADD R5, R1, R2 ; ... }  // 加法指令,读取R1
...
{ LD  R6, R1     ; ... }  // 加载指令,读取R1

在这个例子中,MUL指令将结果写入R1,但其延迟为4个周期。在EQ模型下,紧随其后的ADD指令读取R1时,将得到乘法执行前的旧值。只有等到4个周期后,例如LD指令执行时,才能读取到乘法计算的新结果。

这个例子想要说明的是,我们讨论的这些模型是编译器进行代码调度时需要遵循的软件调度模型,而非硬件问题。如果我们在LEQ模型下运行相同的代码,主要区别在于:那条读取R1的ADD指令将不被允许出现在乘法指令的阴影区(即其延迟周期内)。因为如果发生中断或指令被重排,导致ADD指令被移到LD指令之后(或移动超过3个周期),它就会读取到新值,从而改变程序的语义。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/fb91cd17c22385b569f9045367a2f714_2.png

因此,在LEQ模型中,这条ADD指令必须被安排在MUL指令之前,或者用空操作填充延迟槽。核心要点是:这些模型是编译器调度器使用的软件模型,硬件需要实现与调度模型相匹配的机制,但它们本身并非硬件架构。

探索指令级并行性来源 🧩

回顾了调度模型后,现在让我们回到今天的主题,开始探索新的内容。

上一节我们回顾了VLIW的基本调度模型,本节中我们来看看乱序超标量处理器能够挖掘哪些类型的指令级并行性,以及我们如何在VLIW框架下尝试实现它们。

乱序执行处理器能够动态地发现并利用以下多种并行性:

  1. 指令间并行:执行没有数据依赖关系的独立指令。

  2. 流水线并行:通过多级流水线同时处理不同指令的不同阶段。

  3. 多发射并行:每个时钟周期发射并执行多条指令。

  4. 推测执行:在分支结果确定前,提前执行可能需要的指令。

  5. 寄存器重命名:消除由寄存器重用引起的虚假数据依赖。

为了在VLIW处理器中获取类似的并行性,我们需要系统地为其增加功能。这通常意味着编译器需要承担更多分析、调度和优化的责任,因为VLIW处理器本身缺乏硬件的动态调度能力。

为VLIW添加超标量特性 ⚙️

以下是我们可以考虑为传统VLIW处理器增加的一些关键特性,以提升其指令级并行性:

  • 增强的编译器静态调度:编译器需要进行更激进的分析,包括更精确的依赖关系分析和跨更大范围代码的指令调度。

  • 支持推测执行的硬件机制:例如,增加检查点或恢复机制,以支持编译器安排的推测性加载或分支预测后的指令执行。

  • 更复杂的寄存器文件与重命名支持:提供大量的物理寄存器,并由编译器在静态代码中模拟寄存器重命名,以消除写后写和读后写依赖。

  • 高级分支预测集成:虽然VLIW依赖编译器进行分支预测和调度,但可以集成硬件分支预测器为编译器提供信息,或支持预测执行路径的调度。

需要再次强调的是,并非所有乱序执行的特性都能完美映射到VLIW的静态调度范式中。例如,完全动态的、基于运行时信息的依赖解析和指令调度,就是VLIW架构难以直接实现的。

总结 📚

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/fb91cd17c22385b569f9045367a2f714_4.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/fb91cd17c22385b569f9045367a2f714_5.png

本节课中,我们一起学习了从传统超长指令字处理器向更高并行性处理器演进的思想。我们首先回顾并澄清了EQ和LEQ调度模型,明确了它们是指导编译器工作的软件模型。接着,我们系统地探讨了乱序超标量处理器所能利用的指令级并行性来源。最后,我们分析了如何通过为VLIW处理器增加诸如更智能的编译器、推测执行支持、寄存器重命名等特性,来逼近超标量处理器的性能。理解这些概念有助于我们看清不同处理器架构设计哲学之间的权衡与联系。

043:谓词执行技术回顾 🧠

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/a81557dd633b09829fc2133e99c8bcc6_0.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/a81557dd633b09829fc2133e99c8bcc6_2.png

在本节课中,我们将学习一种称为“谓词执行”的技术,它主要用于解决超长指令字处理器中因分支预测错误导致的性能瓶颈问题。我们将从基本概念开始,逐步深入到完全谓词执行,并分析其优缺点。


概述

上一节我们讨论了VLIW指令的编码方式及其面临的挑战,例如指令字中存在大量空操作。本节中,我们来看看如何通过“谓词执行”技术来提升VLIW处理器的性能,特别是处理难以预测的分支时。

VLIW中的分支问题

在乱序超标量处理器中,硬件可以动态调度指令,围绕分支预测错误进行工作。然而,在VLIW处理器中,所有调度决策都由编译器在编译时静态完成。当遇到难以预测的数据依赖分支时,这会导致显著的性能下降,因为编译器无法准确预知分支方向。

谓词执行简介

谓词执行是一种将控制依赖(即分支)转换为数据依赖的技术。其核心思想是:通过添加条件,让指令仅在特定条件下执行,从而消除或减少对分支的依赖。

部分谓词执行:条件移动

我们首先引入两种指令作为部分谓词执行的例子:move if zero 和 move if not zero。它们的语义如下:

  • move if zero Rt, Rs, Rd:如果寄存器 Rt 的值等于0,则将寄存器 Rs 的值复制到 Rd;否则,保持 Rd 不变。

  • move if not zero Rt, Rs, Rd:如果寄存器 Rt 的值不等于0,则将寄存器 Rs 的值复制到 Rd;否则,保持 Rd 不变。

以下是这两种指令的伪代码描述:

// move if zero
if (Rt == 0) {
    Rd = Rs;
}
// move if not zero
if (Rt != 0) {
    Rd = Rs;
}

谓词执行示例分析

考虑一个寻找最小值的C代码片段:x = (a < b) ? a : b;。

  • 传统MIPS汇编(使用分支):需要3-4条指令,且如果分支预测错误,会产生额外的惩罚周期。

  • 使用条件移动的MIPS汇编:通过set less than、move if zero和move if not zero三条指令,总是在3个周期内完成,消除了分支预测错误的影响。

当分支难以预测且分支两侧的代码块(称为“代码块”)很短时,这种转换能带来显著的性能提升。

谓词执行的适用场景

以下是谓词执行技术适用性的一些考量:

  • 代码块大小:谓词执行最适合短小的代码块。如果if或else分支中的指令很多,强制并行执行所有指令反而会增加总执行时间,不如承受分支预测错误的惩罚。

  • 分支平衡性:如果分支两侧的代码长度严重不平衡(例如一边3条指令,另一边1000条),且分支预测准确率尚可,使用传统分支可能更高效。

  • 核心优势:谓词执行将控制流转换为了数据流,在VLIW架构中,这允许编译器将原本串行的分支两侧代码填充到同一个长指令字的不同槽位中并行执行,从而缩短关键路径。

完全谓词执行

部分谓词执行需要显式使用CMOV类指令。而完全谓词执行则更进一步,它为几乎所有指令都增加了一个条件执行字段。

工作机制

  1. 谓词寄存器:处理器增加一个独立的单比特谓词寄存器文件,用于存储真/假值。

  2. 条件化指令:每条指令都关联一个谓词寄存器。仅当该谓词寄存器的值为真(或假,取决于约定)时,该指令才会执行并产生效果;否则,该指令相当于空操作。

  3. 谓词设置:需要专门的指令来设置谓词寄存器。例如,一条比较指令可以同时设置两个谓词寄存器:P1(条件为真)和P2(条件为假,即P1的反)。

完全谓词执行示例

考虑一个包含if-then-else的基本块序列。通过完全谓词执行,编译器可以:

  1. 使用一条比较指令设置谓词P1(条件成立)和P2(条件不成立)。

  2. 将then块中的指令与else块中的指令并行排列在VLIW指令字中。

  3. then块指令以(P1)为条件,else块指令以(P2)为条件。

  4. 这样,无论分支条件如何,硬件都会同时发射两侧的指令,但只有条件成立的那一侧会实际执行,从而在保持语义正确的同时实现了指令级并行。

研究表明,完全谓词执行平均可以消除约50%的分支。

实现挑战

尽管强大,完全谓词执行也带来了硬件复杂性:

  • 需要额外的谓词寄存器文件。

  • 需要谓词值的旁路网络。

  • 增加了指令编码的复杂度。

  • 因此,仅有少数指令集(如Intel Itanium IA-64)尝试实现了近似完全谓词执行的特性。


总结

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/a81557dd633b09829fc2133e99c8bcc6_4.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/a81557dd633b09829fc2133e99c8bcc6_5.png

本节课我们一起学习了谓词执行技术。我们从VLIW处理器的分支难题出发,首先介绍了通过条件移动指令实现的部分谓词执行,它能够有效消除短小、难以预测的分支。接着,我们探讨了更激进的完全谓词执行,它通过为所有指令添加条件执行前缀,允许编译器更大胆地调度指令,将分支两侧的代码并行化,从而挖掘更多指令级并行。最后,我们也指出了完全谓词执行在硬件实现上的额外开销。谓词执行是编译器与指令集架构协同设计以提升性能的一个经典范例。

044:谓词执行(Predication)的实现 🧠

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/ec772a7eb17fa52b2522c1b9c2382a3f_0.png

在本节课中,我们将学习如何在经典的5级MIPS流水线数据通路中实现谓词执行(Predication),特别是条件移动(Conditional Move)指令。我们将分析其设计挑战,包括对寄存器文件端口和旁路逻辑的影响。


数据通路中的谓词执行

上一节我们介绍了谓词执行的概念,本节中我们来看看如何将其集成到数据通路中。我们将重点关注条件移动指令的实现,因为它揭示了核心的设计问题。

指令进入流水线并向下流动。一个直观的想法是:如果条件不满足,我们只需在写回阶段抑制对寄存器文件的写入。这听起来很巧妙,我们只需要在写回路径上加一个与门,由条件信号控制即可。

写回数据 = 计算结果 AND 条件信号

这看起来很简单,只需添加一个带“X”标记的与门就完成了。


旁路(Bypassing)带来的挑战

这个方案看起来不错,但它能支持旁路吗?假设有一条指令紧跟在条件移动指令之后,并需要读取条件移动指令的目标寄存器RD。问题在于,我们是在写回阶段才决定是否更新RD的。在更早的流水级(如执行阶段),我们还没有做出这个决定,因此无法正确地为后续指令提供旁路数据。

所以,我们需要思考如何为条件移动指令实现旁路。条件移动指令的比较操作(与0比较)很简单,应该在一个周期内完成,我们不应该等到流水线末尾才做决定,并且我们希望支持背靠背指令的旁路。


条件移动指令的独特语义

条件移动指令比标准的加法指令更复杂。让我们仔细审视其语义:

if (R[T] == 0) {
    R[D] = R[S]
} else {
    R[D] = R[D] // 关键点:需要读取RD的旧值
}

在传统的RISC流水线中,一条指令通常读取两个源寄存器(如RS和RT),并写入一个目标寄存器(如RD)。然而,对于条件移动指令,当条件为假时,我们需要将RD的旧值写回RD本身。这意味着指令实际上需要读取三个寄存器:

  1. RS(条件为真时的新值)

  2. RT(用于条件判断)

  3. RD(条件为假时需要保留的旧值)

为了在旁路逻辑中提供正确的未来RD值,我们需要这个原始的RD值。


对寄存器文件的影响

这导致了一个直接的问题:我们的寄存器文件原本只有两个读端口,现在需要支持三个读端口。增加一个读端口会带来硬件成本。

以下是需要考虑的成本因素:

  • 增加读端口会使寄存器文件的设计更复杂、面积更大、速度可能更慢。

  • 对于超标量(VLIW)处理器,这个问题会被放大。例如,一个3路VLIW处理器,原本每条流水线需要2个读端口和1个写端口,总共需要6读3写(9端口)。如果支持条件移动,每条流水线可能需要3个读端口,总端口数将变为9读3写(12端口),设计难度和成本显著增加。


完整谓词执行的额外开销

对于完整的谓词执行(而不仅仅是条件移动),我们还需要考虑以下问题:

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/ec772a7eb17fa52b2522c1b9c2382a3f_2.png

  1. 谓词旁路:计算出的谓词需要尽快被下一条指令使用。这意味着我们需要一个谓词寄存器文件,并为其设计旁路逻辑,以便谓词能快速传递。

  2. 传递旧值的流水线寄存器:在许多实现中,处理器会始终写入寄存器文件,但同时通过流水线寄存器将旧值向前传递。最终,在执行路径的某个点根据谓词决定是使用新值还是旧值进行旁路或最终写回。这需要额外的流水线寄存器来暂存旧值(例如上述例子中的RDi)。


总结

本节课中我们一起学习了谓词执行(特别是条件移动指令)在流水线数据通路中的实现挑战。核心结论是:

  • 实现高效的谓词执行(支持旁路)并非零成本。

  • 关键难点在于条件移动指令需要读取三个寄存器(RS, RT, RD),这要求寄存器文件增加读端口。

  • 完整的谓词执行还需要谓词旁路逻辑和额外的流水线寄存器来传递旧值,增加了硬件设计的复杂性。

  • 这些开销在多发射超标量处理器(如VLIW)中会被放大,因为需要高度多端口的寄存器文件。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/ec772a7eb17fa52b2522c1b9c2382a3f_4.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/ec772a7eb17fa52b2522c1b9c2382a3f_5.png

因此,架构师需要在通过消除分支提升性能和增加硬件复杂度与成本之间进行权衡。

045:VLIW处理器中的推测执行技术

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/72359a6a5c92dd9a745881d24cf30f30_0.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/72359a6a5c92dd9a745881d24cf30f30_2.png

在本节课中,我们将学习如何在VLIW处理器中,通过引入推测执行技术来提升性能。我们将探讨两种主要的推测类型:控制推测(跨越分支移动指令)和数据推测(重新排序内存访问指令)。这些技术借鉴了乱序超标量处理器的思想,但需要在VLIW的静态编译环境中实现。

控制推测:跨越分支移动指令

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/72359a6a5c92dd9a745881d24cf30f30_4.png

上一节我们介绍了VLIW处理器的基本概念。本节中我们来看看如何通过控制推测来提升指令级并行度。

我们的核心挑战在于,分支指令会限制编译器所能进行的代码移动形式。例如,一个加法指令很容易移动到分支之前。但对于可能触发异常(如缺页错误)的指令(例如加载指令),移动它就可能带来问题。

如果我们将一个加载指令移动到分支之上,而该分支原本会绕过这个加载指令,那么程序在正确的执行顺序中本不应执行此加载。然而,由于指令被提前执行,如果它触发了异常,程序就会崩溃,即使程序逻辑本身没有错误。

我们的解决方案是引入特殊的指令,这些指令不会触发异常。具体来说,我们使用推测性加载指令(例如 load.s)。如果该指令访问内存时本应触发异常(如缺页错误),它不会立即中断进程,而是将目标寄存器标记为一个特殊状态(在安腾架构中称为“非数”或“毒药位”)。

随后,在原始加载指令的位置(即分支保护之后),我们插入一个检查指令(例如 check.s)。该指令会检查目标寄存器的值。如果寄存器处于“毒药位”状态,则跳转到修复代码;否则,程序正常继续。修复代码会重新执行该加载指令及其所有依赖的推测性指令,然后跳回主流程。

这种“毒药位”状态会在寄存器之间传播。这意味着,我们可以将依赖于推测性加载的多个指令也一并移动到分支之前。只要在最终使用该值之前进行检查,如果发现错误,修复代码可以重新执行整个推测指令序列。

以下是推测性加载和检查的示意流程:

// 原始代码(分支保护了加载)
if (condition) {
    // 分支跳转,不执行加载
} else {
    value = load[address]; // 可能异常的加载
    result = value + 10;
}

// 使用推测执行优化后的代码
value_spec = load.s[address]; // 推测性加载,不触发异常
result_spec = value_spec + 10; // 依赖指令也可上移
if (condition) {
    // 分支跳转
} else {
    check.s value_spec; // 检查,若为“毒药位”则跳转修复代码
    // 正常使用 result_spec
}

数据推测:重新排序内存访问

上一节我们介绍了如何安全地跨越分支移动指令。本节中我们来看看另一种推测:数据推测,它主要解决内存访问指令(加载和存储)的重排序问题。

在静态编译中,如果编译器无法证明两个内存访问地址不同,它就必须保守地假设它们存在依赖关系,从而限制指令调度。这严重影响了性能。乱序超标量处理器可以动态地推测加载和存储的地址不同,如果推测错误则回滚。我们希望在VLIW处理器中也能实现类似的效果。

我们的解决方案是增加硬件检查机制,以确保当编译器重排序加载和存储指令时,它们的访问地址确实不同。这类似于乱序处理器中的内存歧义消除逻辑,但需要在指令集架构层面进行修改。

具体实现需要新增两条指令:高级加载(load.a)和加载检查(load.c),同时修改存储指令的语义。

以下是数据推测的工作流程:

  1. 编译器将一条加载指令移动到某个存储指令之前,推测它们的地址不同。

  2. 将原来的加载指令改为 load.a 指令。该指令执行加载操作,同时将其访问的地址和大小记录到一个特殊的硬件表中(在安腾中称为高级加载地址表)。

  3. 程序继续执行,包括那个存储指令。存储指令执行时,会将其访问地址与ALAT表中的条目进行比较。

  4. 如果地址匹配(即推测错误),存储指令会将该地址从ALAT中移除。

  5. 在原始加载指令的位置(即存储指令之后),编译器插入一条 load.c 检查指令。该指令检查ALAT中是否还存在对应的地址条目。

  6. 如果地址仍在表中(说明没有冲突存储发生),推测成功,指令流继续。

  7. 如果地址不在表中(说明发生了地址冲突),推测失败,load.c 指令会跳转到修复代码。修复代码会重新执行该加载指令及其所有依赖指令。

通过这种方式,VLIW处理器在静态编译的框架下,实现了类似动态乱序执行的数据推测能力。

其他性能增强技术:多路分支

除了推测执行,VLIW架构还可以通过其他方式提升性能,例如处理多路分支。

在VLIW中,一个指令束包含多个操作。如果程序中有密集的条件分支,每个短分支都可能浪费指令束中的多个槽位。为了解决这个问题,可以引入多路分支指令。

其核心思想是,在一个指令束中并行执行多个条件比较,将结果写入不同的谓词寄存器。然后,在下一个指令束中,可以包含多个基于这些谓词寄存器的分支指令,它们可以跳向不同的目标地址。

这要求指令集定义明确的分支优先级顺序。例如,当同一个指令束中的多个分支条件都满足时,可以规定第一个分支的优先级最高,其次为第二个,以此类推,从而确保执行结果的确定性。

总结

本节课中我们一起学习了VLIW处理器中提升性能的关键推测技术。

  • 控制推测 通过引入不会触发异常的推测性加载指令(load.s)和后续的检查指令,允许编译器将加载及其依赖指令安全地移动到分支之前,以隐藏访存延迟。

  • 数据推测 通过新增高级加载(load.a)、加载检查(load.c)指令以及一个硬件地址表(ALAT),使编译器能够推测性地重排序加载和存储指令,即使无法静态证明地址无关性。

  • 多路分支 通过在一个指令束中支持多个条件分支,减少了因分支频繁造成的指令槽浪费,提高了代码密度和执行效率。

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/72359a6a5c92dd9a745881d24cf30f30_6.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/72359a6a5c92dd9a745881d24cf30f30_8.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/72359a6a5c92dd9a745881d24cf30f30_9.png

这些技术扩展了经典VLIW架构的能力,使其能够更有效地挖掘指令级并行性,接近乱序超标量处理器的性能水平。

046:动态事件与集群式超长指令字

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/aad6d41bbc6d3f851317f201683f1b93_0.png

概述

在本节课中,我们将探讨指令级并行性面临的挑战,特别是编译器难以处理的动态事件。我们将了解这些事件为何难以解决,并介绍几种硬件和编译技术来应对它们,例如通知式加载和集群式VLIW设计。最后,我们将学习如何构建更宽的VLIW处理器。


动态事件的挑战

上一节我们讨论了指令级并行的基本概念。本节中,我们来看看一些更棘手的挑战,即动态事件。动态事件是指编译器无法预测或做出反应的事件,这使得静态调度的VLIW处理器处理起来尤为困难。

缓存未命中

第一个动态事件是缓存未命中。对于编译器而言,预测一个加载指令是否会缓存命中极其困难。超标量处理器可以动态地重新调度指令,绕过未命中的加载指令,执行不依赖于该加载结果的其他指令。然而,静态调度的VLIW处理器无法做到这一点。

以下是应对缓存未命中的两种技术方案:

  • 通知式加载:这是一种学术提案。其核心思想是,当加载指令缓存未命中时,处理器可以无效化后续的某些指令,从而根据缓存命中与否改变代码执行序列。公式上可以表示为:if (load_miss) { nullify(subsequent_instructions); }。

  • 双路径代码生成:由Elbrus处理器采用。编译器为同一段代码生成两个不同的调度序列:一个针对缓存命中的情况优化,另一个针对缓存未命中的情况优化。处理器根据实际加载结果动态选择执行路径。

分支预测错误

第二个动态事件是分支预测错误。虽然我们可以使用谓词执行来消除一些分支,但对于大型代码块,完全谓词化并不现实。

一种解决方案是在VLIW指令集中加入分支延迟槽,并在延迟槽中使用谓词执行。

以下是其工作原理:

  1. 假设一个3路宽的VLIW处理器,指令集包含分支延迟槽。

  2. 在延迟槽中放置的指令,使用与分支判断相同的条件进行谓词化。

  3. 无论分支实际走向如何,延迟槽中的指令都会执行,但其效果(是否写回)取决于谓词条件。

  4. 编译器可以从分支的两个目标路径中提取指令放入延迟槽,从而部分规避分支预测错误的惩罚。

这项技术曾在MIT的M-machine研究处理器中实现。

异常

第三个动态事件是异常。异常几乎无法预测,对编译器和超标量处理器都是难题。但好在异常发生频率不高,对整体性能影响相对较小,通常不是优化的首要焦点。


构建更宽的VLIW处理器

当我们试图构建更宽(例如8路、16路)的VLIW处理器时,会面临新的挑战:庞大的寄存器文件和复杂的前馈网络会带来极高的成本和延迟。

解决方案是采用集群式VLIW设计。这与集群式超标量处理器的思想类似,但在VLIW中,这是在指令集架构(ISA)层面进行的划分。

以下是集群式VLIW的关键特点:

  • 划分集群:将整个处理器划分为多个集群,每个集群拥有自己的本地寄存器文件和功能单元(如ALU)。

  • 集群内通信:集群内部的功能单元之间,前馈旁路速度很快、带宽很高。

  • 集群间通信:不同集群之间的数据传递带宽较低、延迟较高。通常需要显式的移动指令在集群间传递数据,例如:MOV clusterA.reg1, clusterB.reg2。

  • 统一执行:所有集群仍然同时执行同一条超长指令字中的不同部分,它们是一个统一的处理器,而非多个独立核心。

这种设计被应用于德州仪器的C6000系列DSP、惠普与意法半导体合作的LX处理器等产品中,在需要高指令级并行的嵌入式领域(如数字信号处理、打印机)取得了成功。


https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/aad6d41bbc6d3f851317f201683f1b93_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/aad6d41bbc6d3f851317f201683f1b93_3.png

总结

本节课中我们一起学习了指令级并行中动态事件带来的挑战,包括缓存未命中、分支预测错误和异常。我们探讨了通知式加载、双路径代码生成、带谓词的分支延迟槽等应对技术。最后,我们了解了如何通过集群式VLIW设计来构建更宽的处理器,以在保持性能的同时管理硬件复杂度。这些方案体现了在编译器静态调度与硬件动态灵活性之间寻求平衡的设计智慧。

047:案例研究 - IA-64 安腾处理器 🖥️

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/debc07c82f6d6e93f375b12867191932_0.png

在本节课中,我们将要学习一个现代超长指令字架构的案例研究:英特尔安腾处理器。我们将探讨其设计理念、关键特性、实现挑战以及它最终未能成为主流的原因。


概述

安腾处理器,也被称为 IA-64 或 EPIC 架构,是英特尔与惠普合作开发的一款现代 VLIW 处理器。它旨在成为统一的 64 位解决方案,以取代 x86 架构,并整合当时的工作站市场。然而,由于多种原因,它最终未能达到预期的市场接受度。


背景与目标

上一节我们介绍了 VLIW 架构的基本概念,本节中我们来看看一个具体的、也是最具争议的现代 VLIW 实现。

安腾处理器是英特尔选择的 64 位指令集架构,意图逐步淘汰 x86。然而,历史表明,市场最终选择了 AMD 提出的 AMD64(即对现有 x86 架构的 64 位扩展),而非一个全新的架构。


实现历程与挑战

安腾的研发和上市过程充满了挑战。

  • 首款实现(Merced):原计划 1997 年上市,但首次客户发货延迟至 2001 年。其时钟频率低,性能未达预期,甚至被同期的高端 x86 处理器超越。

  • 后续改进(McKinley):第二代实现性能有所改善,但构建此类处理器依然困难。

  • 巅峰产品(Poulson):2011年推出的 Poulson 处理器是一个庞然大物,采用 32 纳米工艺,拥有 8 个核心,超过 30 亿个晶体管,是当时最大的商用处理器。它主要应用于高端服务器市场,而非最初设想的工作站领域。


核心架构特性

安腾架构包含多项旨在提升指令级并行性的设计。

指令束与模板位

安腾使用 128 位指令束,每个束内可容纳三个操作。束内包含“模板位”,用于描述束的内容以及束与束之间的并行执行关系。这使得指令边界可以灵活调整,以便混合不同格式的指令(例如,将包含立即数的指令与不含立即数的指令打包)。

庞大的寄存器文件

由于是 VLIW 架构且依赖编译器进行静态调度(如上一讲所述),这增加了对通用寄存器的需求。硬件不进行寄存器重命名,因此需要大量架构寄存器供编译器使用。

以下是安腾的寄存器配置:

  • 128 个通用寄存器

  • 128 个浮点寄存器

  • 谓词寄存器:支持近似完全谓词执行,通过一个需要旁路的谓词寄存器文件来控制后续指令是否执行。

旋转寄存器文件

这是一个解决软件流水线中寄存器压力问题的创新特性。

在上一讲的例子中,为了获得高性能,我们需要展开循环并进行软件流水线调度,但这会增加对寄存器数量的需求,并产生不同于循环体的序幕和收尾代码。

旋转寄存器文件的解决方案是:将一部分寄存器空间设置为可“旋转”的。访问寄存器(如 R1)时,会加上一个名为“旋转寄存器基址”的架构可见寄存器的值(使用模运算),从而指向物理寄存器文件中的不同位置。

关键机制:每开始一次新的循环迭代,就改变 RB 的值,使其指向一组不同的物理寄存器。这样,编译器可以通过编码寄存器编号之间的偏移量,来指定跨循环迭代的数据依赖关系。

例如,一次循环中的存储指令可能需要使用三个迭代周期前加载的数据。编译器可以这样编码:加载指令写入 F1,而存储指令读取 F4(假设偏移为3)。当循环迭代、RB 改变后,F4 实际指向的物理寄存器正是之前 F1 写入的值。

通过这种方式,单条指令(配合循环分支和 RB 更新)就能实现包含序幕、收尾和主循环体的完整软件流水线,无需展开生成大量代码。


失败原因分析

尽管技术上有诸多创新,但安腾可以被认为是一款失败的产品。以下是其失败的主要原因:

  1. 限制了微架构师的发挥:IA-64 加入了大量架构级特性(如完全谓词、旋转寄存器文件、复杂的指令束)来获取静态并行性。这增加了处理器的复杂性和状态,将许多决策权交给了编译器,束缚了硬件微架构优化的手脚。

  2. 首款产品性能不佳:Merced 处理器时钟频率低,性能未达预期,严重损害了其市场声誉。

  3. 未能解决所有动态调度问题:纯编译器方法无法处理所有动态情况,例如无法根据缓存命中/失效来动态调整指令调度。

  4. 编译器复杂度高:需要依赖性能剖析来优化,并非所有开发者都愿意或能够进行。

  5. 静态并行性有限:并非所有程序中都存在大量可被编译器静态发掘的指令级并行性。

  6. 动态乱序执行超标量处理器的成功:当业界争论复杂乱序处理器是否太难建造时,市场投入最终证明了它们可以被成功建造并具备高性能。这成为了当今桌面处理器的主流。

  7. AMD64 的崛起:AMD 推出的 x86-64(即对现有 x86 的 64 位扩展)满足了市场对 64 位计算且保持向后兼容的强烈需求,最终被英特尔采纳并成为行业标准。

  8. 代码膨胀:VLIW 指令通常会导致代码体积显著增大。

一个颇具讽刺意味的故事是:曾设计 DEC Alpha 处理器的团队在加入英特尔后,评估安腾架构时认为过于复杂。他们甚至考虑过为安腾构建一个乱序执行版本,这实际上会试图“撤销”编译器所做的所有静态调度工作,代之以硬件动态调度。这凸显了架构设计初衷(顺序执行)与后期性能优化思路(乱序执行)之间的根本矛盾。


总结

本节课中我们一起学习了英特尔安腾处理器的案例。我们了解了其作为现代 VLIW 架构的设计目标、核心特性(如指令束、大寄存器文件、旋转寄存器),并深入分析了导致其未能赢得市场的一系列关键因素,包括实现挑战、性能问题、编译器复杂性以及来自兼容性解决方案(AMD64)和动态调度硬件的竞争。安腾的故事生动地展示了处理器架构设计中技术理想、工程实现与市场现实之间的复杂博弈。


https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/debc07c82f6d6e93f375b12867191932_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/debc07c82f6d6e93f375b12867191932_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/debc07c82f6d6e93f375b12867191932_3.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/debc07c82f6d6e93f375b12867191932_3.png

048:分支预测的动机与重要性 🎯

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/733806718857945ea3d2e5c998270979_0.png

在本节课中,我们将探讨为什么分支预测对于现代处理器至关重要。我们将分析长流水线和复杂流水线设计如何放大了分支预测错误带来的性能损失,并理解预测分支方向和目标地址的必要性。

为什么分支预测很重要?💡

上一节我们介绍了流水线的基本概念,本节中我们来看看分支预测的核心动机。随着处理器设计变得更加复杂,流水线变得更长、更宽,准确预测程序的控制流方向变得极其关键。

长流水线与分支误判惩罚 ⏱️

在顺序取指、乱序发射、乱序执行、顺序提交的流水线中,指令会在发射队列或指令缓冲区中堆积。如果直到执行阶段才确定分支是否跳转,那么一旦预测错误,就需要清空流水线中大量已进入但未提交的指令。这造成了巨大的性能浪费。

对于乱序处理器,如果等到指令提交阶段才解析分支,误判惩罚会变得更长。虽然可以等到最后才完全清理流水线状态,但必须尽快重定向取指阶段的程序计数器(PC),以避免继续取错误的指令,浪费时钟周期。

误判惩罚的量化分析 📊

回顾之前关于超级流水线的讲座,以奔腾3和奔腾4处理器为例,其分支误判惩罚可能高达20多个周期。如果分支预测错误频繁发生,如此高的惩罚将对性能造成严重影响。

我们可以采用等待策略,即不预测分支,直到它在流水线后期被解析。但这意味着每个分支指令都需要等待(例如)20个流水级之后才能取后续指令,这同样非常低效。因此,我们讨论了推测执行。

推测执行与预测方向 🔮

到目前为止,我们主要讨论了推测“顺序执行”(fall-through)的情况,即预测分支不跳转,继续执行PC+4(在MIPS类32位定长指令集中)。然而,当分支大概率会跳转时,仅推测顺序执行并无帮助。我们需要有能力推测“非顺序执行”(non-fall-through)的情况,即预测分支会跳转并预测其目标地址。本节课后续将讨论实现这一功能的硬件机制。

处理器宽度的影响 ↔️

处理器宽度(每周期发射的指令数)加剧了分支误判的问题。以下是一个双发射处理器的示例:

以下是流水线示意图的简要说明:

  • 在取指阶段,每个周期取两条指令。

  • 即使流水线相对较短,一次分支误判也可能导致多达7条指令作废。

因此,因误判而作废的指令数量大致等于 处理器宽度 乘以 分支解析前的流水线长度(即误判惩罚)。

用公式表示即:

作废指令数 ≈ 流水线宽度 × 分支解析延迟

核心挑战与动机 🎯

综上所述,分支预测的核心动机源于两点:

  1. 减少浪费:我们需要在分支解析前的“空窗期”内填充有用的工作(即取指并开始执行正确的后续指令)。

  2. 提高准确性:我们需要尽可能降低一开始就取错指令的概率。

因此,设计高效的分支预测机制,以缩短有效分支解析延迟并提高预测准确率,对于提升现代高性能处理器的效率至关重要。

总结 📝

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/733806718857945ea3d2e5c998270979_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/733806718857945ea3d2e5c998270979_3.png

本节课中我们一起学习了分支预测的重要性。我们了解到,长流水线和宽发射设计显著放大了分支误判的惩罚。分支预测的目标是双重的:既要预测分支的方向(跳转或不跳转),也要预测跳转时的目标地址。通过减少因误判而作废的指令数量,分支预测是维持现代处理器高性能运行的关键技术之一。

049:分支预测简介 🧠

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/d00ed86ad3b42a204c5b58ae5a3da9af_0.png

在本节课中,我们将要学习分支预测技术。分支预测是现代处理器设计中的一项关键技术,用于解决控制依赖带来的流水线停顿问题。我们将探讨预测的两个核心方面:分支结果(是否跳转)和目标地址(跳转到哪里)。

分支预测概述

上一节我们讨论了分支指令带来的流水线冒险。本节中我们来看看如何通过预测来缓解这个问题。

分支预测包含两个必须预测的要素:

  1. 预测结果:即判断条件分支指令是“跳转”(taken)还是“不跳转”(not taken)。

  2. 预测目标地址:即预测如果跳转,程序计数器(PC)将指向的具体内存地址。

许多人认为分支预测只关乎第一点,但实际上两者都至关重要。预测结果相对直观,即使随机猜测也有50%的正确率。然而,预测目标地址则要求精确性。在32位地址空间中,你需要从2^32个可能地址中准确猜中一个,这极具挑战性。因此,我们将花时间讨论预测分支和跳转目标地址的策略。

指令解析时机回顾

这部分内容是对之前第2讲内容的回顾。我们来分析一下在流水线中,何时能确定分支的相关信息。

考虑一个稍长的六阶段流水线(包含取指、译码、执行等阶段)。以下是各信息变得已知的时间点:

  • 分支、跳转及跳转链接指令的目标地址:通常在译码(Decode)阶段末尾可以计算得出。这是因为这些指令的目标地址通常由指令中的立即数偏移量加上当前PC值计算而来。

  • 分支结果(是否跳转):对于条件分支,结果通常要到执行(Execute)阶段末尾才能确定,因为需要等待比较运算完成。虽然对于像“与零比较”这类简单分支,可以通过在译码阶段添加特殊比较器来提前判断,但这并非通用解决方案。对于需要比较两个通用寄存器的分支(如 beq $r1, $r2),必须等待执行阶段完成完整的32位比较(相当于一次减法)。

  • 跳转寄存器与跳转链接寄存器指令的目标地址:这类指令(如 jr $ra)的目标地址存储在寄存器中。因此,在译码阶段结束时,我们甚至无法获得备选地址。我们需要从寄存器文件中读取值,并可能经过旁路网络,因此必须等待到执行阶段才能获得目标地址。

综上所述,在简单的流水线设计中,关键信息(尤其是条件分支的结果)要到流水线较深阶段才可知,这会导致流水线出现停顿(气泡)。分支预测的目的就是尝试在流水线更早的阶段(如取指或译码阶段)对这些信息做出尽可能准确的猜测。

总结

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/d00ed86ad3b42a204c5b58ae5a3da9af_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/d00ed86ad3b42a204c5b58ae5a3da9af_3.png

本节课中我们一起学习了分支预测的基本概念。我们明确了分支预测需要同时预测“是否跳转”和“跳往何处”这两个关键问题。通过回顾指令在流水线中的解析时机,我们理解了为何需要预测:为了填补因等待分支结果和目标地址计算而产生的流水线空隙。在接下来的课程中,我们将深入探讨具体的分支预测策略与实现机制。

050:静态分支预测

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/8406ff79747cea1279e1803dcae3cee8_0.png

在本节课中,我们将要学习如何处理分支指令的结果预测。我们将首先聚焦于静态领域的技术,即不依赖于动态指令序列的观察,而是通过静态分析指令序列,以较高的概率预测分支的走向。

概述:静态预测技术

上一节我们介绍了分支预测的基本概念。本节中,我们来看看几种静态分支预测技术。静态预测不依赖于程序运行时的信息,而是在编译时或硬件设计时就确定预测策略。

延迟槽技术

首先介绍的技术实际上并非预测,而是延迟槽。我们在之前的课程中讨论过,其核心思想是在分支指令后插入若干条无论分支是否跳转都会执行的指令,以填充因等待分支结果而产生的空闲周期。

以下是延迟槽的示意图:

指令 B, E, Q, Z (地址 100)
延迟槽指令 1
延迟槽指令 2

无论分支跳转与否,延迟槽中的指令都会执行。

然而,找到合适的指令填充延迟槽的概率并不高。填充一个延迟槽的概率约为70%,填充两个则降至约40-50%。相比之下,现代动态分支预测的准确率可达95%以上。因此,对于追求高性能的系统,更复杂的预测机制通常是更好的选择。

静态预测的经验法则

接下来,我们看看关于静态预测的一些基本经验法则。

总体而言,在类似SPECint的基准测试中,分支被执行的总体概率约为60%至70%。但这个概率分布并不均匀。

以下是关键观察:

  • 向后跳转的分支(如循环)被执行的概率非常高。因为循环的定义就是需要跳回之前的代码。

  • 向前跳转的分支(如if-then-else语句)被执行的概率接近50%。这通常是数据依赖的条件判断。

程序倾向于在循环中执行较长时间,这是理解静态预测的关键。

编译器提示的静态预测

基于以上观察,一种技术是扩展指令集,允许编译器向硬件提示分支的预测方向。

具体实现是定义两种分支指令:

  • branch.T:预测为“执行”的分支。

  • branch.NT:预测为“不执行”的分支。

这只是一个性能提示,如果预测错误,处理器仍会确保正确执行,只是性能会下降。

许多真实架构采用了类似思想。如果编译器能结合代码剖析信息(即运行一次程序来收集分支历史),这种方法的准确率可以相当高。例如,编译器可以将循环的向后分支标记为branch.T,而将极少触发的错误检查分支也标记为branch.T(跳过错误处理代码)。

在流水线中,这种提示能带来好处:

  • 对于预测“执行”的分支,虽然目标地址在解码阶段才知道,但我们可以提前开始从预测目标取指,减少空闲周期。

  • 对于预测“不执行”的分支,我们可以直接顺序取指(PC+4, PC+8),完全没有停顿。

如果预测错误,则会产生误预测惩罚,需要清空流水线并重新取指。

硬件静态预测启发式

最后,我们看看完全由硬件实现的静态预测启发式方法,无需编译器提示。

以下是几种简单的硬件策略:

  1. 总是预测不执行

    • 这是我们目前设计的流水线中默认采用的方法。

    • 实现简单,直接取顺序地址(PC+4)。

    • 准确率不高,会错误预测所有循环(向后分支)。

  2. 总是预测执行

    • 实现上有困难,因为在解码阶段我们可能还不知道分支目标地址。

    • 对于许多向前跳转的if-then-else分支效果也不好。

  3. 向后执行,向前不执行

    • 这是一种更优的启发式方法:所有向后分支预测为“执行”,所有向前分支预测为“不执行”。

    • 这很好地契合了“程序在循环中停留时间长”的特点,准确率比前两种高。

    • 然而,其准确率仍然低于由编译器提供提示的静态预测,因为编译器可以运用更复杂的算法进行分析。

总结

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/8406ff79747cea1279e1803dcae3cee8_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/8406ff79747cea1279e1803dcae3cee8_3.png

本节课中我们一起学习了静态分支预测技术。我们首先了解了通过填充延迟槽来隐藏分支延迟的方法及其局限性。接着,我们探讨了基于程序行为统计的静态预测经验法则,即向后分支(循环)的执行概率远高于向前分支。基于此,我们介绍了两种主要的静态预测实现方式:一是由编译器提供预测提示给硬件,这能获得较高的准确率;二是由硬件实现简单的预测启发式规则,如“向后执行,向前不执行”。静态预测虽然不如动态预测准确,但在简化硬件设计或特定架构中仍有其价值。下一节,我们将深入探讨更强大的动态分支预测技术。

051:动态分支预测

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/de12ddc8def43ffb569bbec5a1441a70_0.png

在本节课中,我们将要学习动态分支预测技术。我们将探讨如何通过硬件机制,在程序执行过程中动态地预测分支指令的走向,以提高处理器的性能。我们将从简单的1位预测器开始,逐步深入到更复杂的模式历史表和锦标赛预测器。

概述

分支预测是处理器设计中的关键技术,旨在减少因分支指令带来的流水线停顿。静态预测方法简单但准确率有限。本节我们将重点介绍动态分支预测,它通过分析程序运行时的历史行为来做出更准确的预测。

上一节我们介绍了静态分支预测的基本概念,本节中我们来看看动态预测方法。

动态预测硬件基础

动态分支预测通过在流水线前端添加额外的硬件单元来实现。其核心是在计算下一条指令地址(PC+4)的路径上插入一个多路选择器。

该多路选择器的选择信号由预测硬件产生,用于决定下一条指令是顺序地址(PC+4)还是分支目标地址。然而,分支目标地址在流水线较后的阶段才能计算出来,无法在一个周期内反馈,因此目标地址的获取是动态预测需要解决的一个关键问题。

首先,我们需要解决如何控制这个决定下一条PC的多路选择器。

1位预测器:利用时间局部性

一种动态预测方案是尝试利用程序行为的内在规律。我们首先尝试利用的是分支的时间相关性,即同一个分支指令的历史结果对其未来结果有预测作用。

例如,在循环中,一个通常被采取的分支,其未来的结果很可能也是被采取。我们可以用一个1位预测器来实现这个想法,它本质上是一个有限状态机。

该状态机包含两个状态:预测不采取和预测采取。初始状态为“预测不采取”。如果某个分支的实际结果是“不采取”,则保持在该状态;如果结果是“采取”,则切换到“预测采取”状态。反之亦然。这个预测器只需要在控制逻辑中增加1个额外的比特位。

以下是其工作原理的简化描述:

状态: 预测不采取 <-> 预测采取
转移: 实际结果“不采取”则保持或切回“预测不采取”
       实际结果“采取”则保持或切到“预测采取”

让我们分析一下这个预测器的性能。假设有一个循环迭代4次。

以下是其预测表现的分析:

  • 第一次迭代:预测器初始为“预测不采取”,但循环需要“采取”,因此预测错误。之后状态变为“预测采取”。

  • 第二、三次迭代:预测为“采取”,实际也是“采取”,预测正确。

  • 第四次迭代(循环退出):预测为“采取”,但实际是“不采取”,因此预测错误。

在这个例子中,预测准确率是50%,表现并不理想。如果循环体执行很多次(例如100次),中间部分预测正确,但进入和退出循环时都会预测错误。

2位饱和计数器预测器

为了提升预测准确率,我们可以使用更多比特位。接下来我们介绍2位饱和计数器预测器。

我们在处理器架构中增加一个2位的计数器。所谓“饱和”,是指该计数器的值有上下限(例如00到11),达到极值后不再增加或减少。

这个计数器可以处于四种状态:强不采取、弱不采取、弱采取、强采取。预测逻辑通常认为“强采取”和“弱采取”状态时预测“采取”,反之预测“不采取”。每次分支执行后,根据实际结果更新计数器:如果实际“采取”则增加计数值(向“强采取”方向移动),如果实际“不采取”则减少计数值(向“强不采取”方向移动)。

状态转移示例(一种常见设计):
强不采取 (00) -> 实际“采取” -> 弱不采取 (01)
弱不采取 (01) -> 实际“采取” -> 弱采取 (10)
弱采取 (10) -> 实际“采取” -> 强采取 (11)
强采取 (11) -> 实际“不采取” -> 弱采取 (10)
(反向过程类似)

这种设计引入了滞后效应,即需要连续两次不同的结果才能完全改变预测方向。这有助于避免因单次异常分支结果(如循环末尾的一次“不采取”)而破坏对规律性模式的预测。

对于长循环,2位预测器在循环体内可以达到很高的预测准确率,并且对循环结束时的“不采取”不敏感,性能优于1位预测器。

分支预测缓冲表(BHT)

单一的2位预测器只能跟踪一个分支的历史。为了同时预测程序中众多的分支指令,我们引入分支预测缓冲表。

我们利用程序计数器(PC)的一部分地址位作为索引,访问一个预测器表格。表格的每个条目可以存储一个2位饱和计数器。这样,不同的分支指令(通过PC索引到不同或相同的条目)可以拥有独立的预测历史。

预测流程:
下一条指令PC -> 取部分位(索引) -> 查BHT表 -> 获得2位计数器状态 -> 做出预测(采取/不采取)
执行后更新:根据实际结果更新该PC索引对应的2位计数器。

这类似于缓存的组织方式。我们可以设计成直接映射,也可以设计成组相联映射,以减少不同分支地址冲突带来的“颠簸”。一个拥有4096个条目的2位BHT,其存储开销约为8KB(4096 * 2位)。这样的预测器可以达到80%-90%的预测准确率。

基于模式历史表(PHT)的预测器

以上方法主要利用时间相关性。接下来我们探讨利用空间相关性的预测器,即利用附近其他分支指令的结果来预测当前分支。

考虑以下代码模式:

if (x < 7) { ... }
if (x < 5) { ... }

这两个条件分支在逻辑上是相关的。如果第一个条件(x<7)成立,那么第二个条件(x<5)更可能成立。这种不同分支之间的关联就是空间相关性。

耶尔·帕特等人提出了利用分支历史寄存器(BHR)和模式历史表(PHT)的预测器。BHR是一个移位寄存器,记录最近执行的多个分支的全局结果(例如,1表示采取,0表示不采取)。每次处理一个分支指令,其实际结果就被移入BHR。

BHR更新示例:
BHR初始: 0000
分支1结果“采取”(1): BHR变为 0001
分支2结果“不采取”(0): BHR变为 0010
分支3结果“采取”(1): BHR变为 0101

PHT是一个以BHR当前值为索引的表格。PHT的每个条目可以是一个简单的预测位(如1位),也可以是一个状态机(如2位饱和计数器)。当遇到一个分支时,我们用当前的BHR值索引PHT,得到对该分支的预测。

这种方法的优势在于能捕捉跨分支的复杂模式。例如,对于一个固定迭代4次的循环(模式:采取,采取,采取,不采取),在循环体内部,BHR会逐步形成“111”的模式。当PHT学习到“111”模式后,下一次遇到此模式时,它就能准确预测出接下来的分支将是“不采取”(即循环退出),从而完美预测整个循环。

两级自适应预测器与锦标赛预测器

为了融合时间与空间相关性,我们可以构建两级自适应预测器。第一级可能由PC索引选择不同的BHR(局部历史),第二级则由BHR(可能结合PC哈希)索引PHT。这允许预测器针对不同的分支指令灵活选择是利用其自身历史(局部历史)还是全局分支历史。

为了追求超过97%的极高准确率,现代高性能处理器采用了锦标赛预测器(或称混合预测器)。其核心思想是:同时维护多个不同的预测器(例如,一个基于全局历史的预测器,一个基于局部历史的预测器),并增加一个元预测器(或选择器)。

元预测器本身也是一个预测器,它学习在特定的分支指令处,哪一个子预测器的历史准确率更高。当该分支再次出现时,元预测器决定使用哪个子预测器的结果作为最终预测。

工作流程:
遇到分支 -> 所有子预测器(全局/局部)均产生预测 -> 元预测器根据该分支历史选择其中一个预测 -> 输出最终预测
执行后更新:1. 用实际结果更新所有子预测器。2. 更新元预测器:哪个子预测器这次对了,就强化选择它的倾向。

通过这种“预测器的预测器”机制,系统可以动态地为每个分支选择最合适的预测策略,从而将预测准确率提升至98%甚至99%以上,这对于具有超长流水线的现代处理器至关重要。

预测器的训练与更新时机

训练这些复杂的预测器需要注意时机。通常,预测动作发生在流水线前端的取指阶段。但预测器的更新(即根据分支实际结果修改BHR、PHT、计数器等状态)往往被推迟到流水线后端的提交或退休阶段。

这样做的优点是确保用于训练的结果是已提交的、确定正确的分支结果,避免了因误预测路径上的指令或流水线冲刷而污染预测器状态。缺点是训练反馈有延迟,新分支需等待一段时间后才能从最近的历史中受益,但这在可接受的范围内。

总结

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/de12ddc8def43ffb569bbec5a1441a70_2.png

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/de12ddc8def43ffb569bbec5a1441a70_3.png

本节课中我们一起学习了动态分支预测技术的演进。我们从简单的1位和2位饱和计数器预测器开始,理解了利用时间局部性的原理。接着,我们探讨了通过分支预测缓冲表(BHT)为不同分支维护独立预测状态的方法。然后,我们引入了基于分支历史寄存器(BHR)和模式历史表(PHT)的预测器,它能捕捉分支间的空间相关性。最后,我们介绍了融合多种策略的两级自适应预测器和更高级的锦标赛预测器,它们通过元预测机制动态选择最佳预测策略,从而实现了接近99%的高预测准确率,以满足现代深度流水线处理器的性能需求。

052:目标地址预测

https://github.com/OpenDocCN/cs-notes-pt3-zh/raw/master/docs/prct-comp-arch/img/2aff4a8349fafdd1f966b3308758eaf9_0.png

在本节课中,我们将学习如何预测分支和跳转指令的目标地址。这是实现高性能流水线的关键,因为我们需要在指令实际执行之前就知道下一条指令的地址。我们将探讨静态和动态的预测方法,并深入了解分支目标缓冲区和返回地址栈等核心机制。

上一节我们讨论了分支方向(是否跳转)的预测。本节中我们来看看如何预测跳转的目标地址。

目标地址预测的挑战

分支方向预测解决了“是否跳转”的问题。目标地址预测则要解决“跳到哪里去”的问题。对于条件分支、跳转和跳转链接指令,我们需要在解码阶段通过计算 PC + 偏移量 才能知道目标地址。对于跳转寄存器和跳转链接寄存器指令,目标地址的计算则更晚。预测目标地址比预测方向更难,因为我们需要从 2^32 个可能的地址中选出一个。

静态预测方法

除了动态预测,我们还可以通过编译器使用静态算法来辅助目标地址预测。一些架构(例如日立/Renesas SH-5)采用了特殊指令来提前加载分支目标。

以下是这种方法的原理:

  • 架构中引入了一条名为 prepare target 的指令。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐