CPU 工作原理 | 指令执行 / ALU 控制 / 流水线…

注:本文为 “RISC-V CPU 工作原理” 相关合辑。
图片清晰度受引文原图所限。
略作重排,如有内容异常,请看原文。


你了解 CPU 吗?(一)

了不起的盖茨比。于 2022-03-07 22:56:42 发布

1. 写在前面

本节将重点介绍 CPU 的相关原理与技术。

2. 概述

众所周知,计算机性能主要取决于三个因素:指令数、时钟周期长度以及每条指令的时钟周期数(CPI)。本节将从一个高度抽象且简化的角度出发,介绍处理器的基本原理与技术。在此基础上,我们将为 RISC-V 指令系统构建数据通路,并设计一种简单的处理器以实现指令系统。然而,更接近实际情况的是流水线 RISC-V,因此本章的大部分内容将围绕这种实现方式展开。

2.1 一种基本的 RISC-V 实现

我们将实现 RISC-V 的一个核心子集,具体包括:

  • 存储器访问指令 load doubleword(ld)和 store doubleword(sd)。
  • 算术逻辑指令 add、sub、and 和 or。
  • 条件分支指令 branch if equal(beq)。

该子集并未涵盖所有的定点指令,也未包含任何浮点指令。然而,这一子集足以说明建立数据通路和设计控制的关键原理,其余指令的实现与之类似。

2.2 实现概述

实现每条指令的前两个步骤是相同的:

  1. 程序计数器(PC)被发送到指令所在的存储单元,并从中取出指令。
  2. 根据指令的某些字段选择要读取的一个或两个寄存器。对于 ld 指令,仅需读取一个寄存器,但大多数其他指令需要读取两个寄存器。

在完成这两个步骤后,剩余的操作取决于指令类别。幸运的是,对于三类指令(存储器访问指令、算术逻辑指令和分支指令)中的每一种,剩余操作基本相同,与具体指令无关。RISC-V 指令系统的简单性和规整性使得不同类别的指令具有类似的执行过程,从而简化了实现。

例如,所有类型的指令在读取寄存器后均使用算术逻辑单元(ALU)。存储器访问指令利用 ALU 进行地址计算,算术逻辑指令借助 ALU 执行运算,而条件指令则通过 ALU 进行比较。然而,在经过 ALU 后,完成各类指令所需的操作则有所不同。存储器访问指令需要访问存储器以读取数据或存储数据。算术逻辑指令或载入指令需要将来自 ALU 或存储器的数据写回寄存器。而条件分支指令需要根据比较结果更改下一条指令的地址,否则,下一条指令的地址将通过 PC 加 4 来获得。

可以参考如下抽象图:

在这里插入图片描述

上图中有两个来自不同源的数据流向同一个单元。例如:写入 PC 的值可能来自两个加法器中的一个,写入寄存器堆的数据可能来自 ALU 或数据存储器,而 ALU 的第二个输入可能来自寄存器或指令的立即数字段。实际上,这些数据线不能简单地连接在一起。我们必须添加一种逻辑单元以从多个数据源中选择一个送给目标单元。这种选择通常由称为多选器的设备来完成,虽然它可能更合适的称为数据选择器。

一些功能单元的控制依赖于当前执行的指令类型。数据存储器必须在指令是 load 时被读,在指令是 store 时被写。寄存器堆只能在指令是 load 或算术逻辑指令时被写。但是,ALU 的控制不依赖于指令类型,它一定会做某种运算。与多选器类似,ALU 的控制线也根据指令的某些字段来设置,进而控制 ALU 做哪种运算。

改进版如下:
在这里插入图片描述

增加了三个必要的多选器,以及主要功能单元的控制线。图中还增加了一个控制单元,它以指令作为输入,为功能单元及两个多选器设置控制信号。图中最上面的多选器确定写入 PC 的是 PC+4 还是分支目标地址,在执行 beq 指令时,该多选器的控制信号由 ALU 进行比较时设置的 Zero 输出来设置。RISC-V 指令系统的规整性和简单性使得只需要简单的译码过程即可确定如何设置控制线。

3. 逻辑设计的一般方法

RISC-V 实现中的数据通路包含两种不同类型的逻辑单元:处理数据值的单元和存储状态的单元。处理数据值的单元是组合逻辑,它们的输出仅依赖于当前输入。给定相同的输入,组合逻辑单元总是产生相同的输出。

设计中的其他单元不是组合逻辑,而是包含状态的。如果一个单元有内部存储功能,它就包含状态,称其为状态单元。这是因为关机后重启计算机,通过恢复状态单元的原值,计算机可继续运行,就像没有发生过断电一样。一个状态单元至少有两个输入和一个输出。必需的输入是要写入状态单元的数据值和决定何时写入数据值的时钟信号。状态单元的输出提供了在前一个时钟周期写入单元的数据值。

包含状态的逻辑部件也被称为时序的,因为其输出取决于输入和内部状态。表示寄存器的功能单元的输出取决于所提供的寄存器号和之前写入寄存器的内容。

时钟同步方法

时钟同步方法规定了信号可以读出和写入的时间。规定信号的读写时间非常重要,因为如果在读信号的同时写信号,那么读取的值可能是该信号的旧值,也可能是新写入的值,甚至可能是二者的混合。计算机设计无法容忍这种不可预测性。时钟同步方法就是为避免这种情况而提出的。

为简单起见,假定我们采用边沿触发的时钟,即存储在时序逻辑单元中的所有值仅在时钟边沿更新,这是从低电平快速跳到高电平的过程。因为只有状态单元能存储数据值,所有组合逻辑单元都必须从状态单元集合接收输入,并将输出写入状态单元集合。其输入是之前某时钟周期写入的值,输出的值可以在后续时钟周期使用。

请添加图片描述

一个组合逻辑单元及其相连的两个状态单元。组合逻辑单元的操作在一个时钟周期完成。所有信号在一个时钟周期内从状态单元 1 经组合逻辑单元到达状态单元 2。信号到达状态单元 2 所需的时间决定了时钟周期的长度。

为简单起见,如果状态单元在每个有效时钟边沿都进行写入,则可忽略写控制信号。相反,如果状态单元不是在每个时钟边沿都更新,那么它需要一个写控制信号。时钟信号和写控制信号都是输入。仅当时钟边沿到来并且写控制信号有效时,状态单元才改变状态。

我们将用术语“有效”表示信号为逻辑高,“使有效”表示信号为逻辑高,“无效”或“使无效”表示信号为逻辑低。我们使用术语“有效”和“无效”,是因为在进行逻辑实现时,数字 1 有时表示逻辑高,有时表示逻辑低。

在边沿触发的时钟同步方法中,需在一个时钟周期内读取寄存器的值,并使之经过组合逻辑单元,将新值写入该寄存器。

在这里插入图片描述

选择在时钟上升沿(从低到高)还是下降沿(从高到低)进行写操作无关紧要,因为组合逻辑的输入只能在所规定的时钟边沿才可能发生变化。

对于 64 位 RISC-V 指令系统体系结构,几乎所有的状态单元和逻辑单元的输入和输出都是 64 位,因为处理器处理的大部分数据的宽度是 64 位。如果某个单元的输入或输出不是 64 位宽,我们会特别指出。

4. 建立数据通路

设计数据通路的合理方法是,先分析每类 RISC-V 指令需要哪些主要执行单元。首先讨论每条指令需要哪些数据通路单元(一个用来操作或保存处理器中数据的单元),然后逐渐降低抽象的层次。在设计数据通路单元的同时,也会设计它们的控制信号。我们将自底向上地使用抽象的思想对此进行说明。

在这里插入图片描述

第一个单元是存储单元,用于存储程序的指令,并根据给定地址提供指令。

第二个单元是程序计数器,它用于保存当前指令的地址。

最后还需要一个加法器来增加 PC 的值以获得下一条指令的地址。这个加法器是一个组合逻辑电路。

要执行任意一条指令,首先从存储器中取出指令。为准备执行下一条指令,必须增加程序计数器的值,使其指向下一条指令,即向后移动 4 个字节。

在这里插入图片描述

上图就是将三个单元组合起来,可以取出指令并增加 PC 以获取下一条指令的地址。

现在考虑 R 型指令。这类指令读两个寄存器,对它们的内容执行 ALU 操作,再将结果写回寄存器。这些指令被称为 R 型指令或算术逻辑指令。这类指令包括 add、sub、and、or 指令。此类指令典型如 add x1,x2,x3,它读取寄存器 x2 和 x3 并将总和写入 x1 寄存器。

处理器的 32 个通用寄存器位于被称为寄存器堆的结构中。寄存器堆是寄存器的集合,其中的寄存器可以通过指定相应的寄存器号来进行读写。寄存器堆包含了计算机的寄存器状态。另外,还需要一个 ALU 对从寄存器读出的值进行运算。

由于 R 型指令有三个寄存器操作数,每条指令需要从寄存器堆中读出两个数字,再写入一个数据字。为读出一个数据字,需要一个输入指定要读的寄存器号,以及一个从寄存器堆读出的输出。为写入一个数据字,寄存器堆需要两个输入:一个输入指定要写的寄存器号,另一个提供要写入寄存器的数据。寄存器堆根据输入的寄存器号输出相应寄存器的内容。而写操作由写控制信号控制,在写操作发生的时钟边沿,写控制信号必须是有效的。

在这里插入图片描述

上图的 ALU,它读取两个 64 位输入并产生一个 64 位输出,还有一个 1 位输出指示其结果是否为 0。

下面考虑 RISC-V 的存取指令,其一般形式为 ld x1,offset(x2)或 sd x1,offset(x2)。这类指令通过将基址寄存器 x2 与指令中包含的 12 位有符号偏移量相加,得到存储器地址。对于存储指令,从寄存器 x1 中读出要存储的数据。如果是载入指令,那么从存储器中读出的数据要写入指定的寄存器 x1 中。

此外,还需要一个单元将指令中的 12 位偏移量符号扩展为 64 位有符号数,以及一个执行读写操作的数据存储单元。数据存储单元在存储指令时被写入,所以它有读写控制信号、地址输入和写入存储器的数据输入。

在这里插入图片描述

beq 指令有三个操作数,其中两个寄存器用于比较是否相等,另一个是 12 位偏移量,用于计算相对于分支指令所在地址的分支目标地址。它的指令格式是 beq x1,x2,offset。为实现 beq 指令,需将 PC 值与符号扩展后的指令偏移相加以得到分支目标地址。

分支指令的定义中有两个必须注意的细节:

  • 指令系统体系结构规定了计算分支目标地址的基址是分支指令所在地址。
  • 指令系统体系结构还说明了计算分支目标地址时,将偏移量左移 1 位以表示半字位单位的偏移量,这样偏移量的有效范围就扩大到 2 倍。

为了处理这种复杂情况,需要将偏移量左移 1 位。

在计算分支目标地址的同时,需要确定是顺序执行下一条指令,还是执行分支目标地址处的指令。当分支条件为真时,分支目标地址称为新的 PC,我们就说分支发生。如果条件不成立,自增后的 PC 称为新的 PC,这时候就说分支未发生。

因此,分支指令的数据通路需要执行两个操作:计算分支目标地址和检测分支条件。为了计算分支目标地址,分支指令数据通路包含一个立即数生成单元和一个加法器。为执行比较,需要额外提供两个寄存器操作数。此外,是用 ALU 完成相等性比较。由于该 ALU 提供一个标识结果是否为 0 的输出信号,故可将两个寄存器操作数发送给 ALU,并将控制设置为减法。如果 ALU 输出的零信号有效,可知两个寄存器值相等。尽管零输出信号总是指示结果是否为 0,但我们仅用它来实现条件分支指令的相等测试。

在这里插入图片描述

分支指令将指令中的 12 位偏移量左移一位与 PC 相加。

建立一个简单的数据通路

我们已经分别讨论了几类指令需要的数据通路单元,现在可将它们组合成一个完整的数据通路并添加控制信号以完成实现。这个最简单的数据通路在每个时钟周期执行一条指令。这意味着每条指令在执行过程中任何数据通路单元都只能使用一次,如果需要多次使用某数据通路单元,则要将其赋值多分。因此,需要一个指令存储器和一个与之分开的数据存储器。尽管还有一些功能单元需要多份,但很多功能单元可以在不同的指令流动中被共享。

为在两个不同类指令之间共享数据通路单元,需要允许一个单元有多个输入,我们用多路选择器和控制信号在多个输入中进行选择。

建立数据通路

算术逻辑指令和存储类指令的数据通路非常相似。主要的区别如下:

  • 算术逻辑指令使用 ALU 时,输入 ALU 的数据来自两个寄存器。存储类指令也使用 ALU 进行地址计算,但是第二个输入的是对指令中 12 位偏移量进行符号扩展后的值。
  • 存入目标寄存器的值来自 ALU(R 型指令)或存储器(载入指令)

为存储指令和算术逻辑指令的操作部分建立数据通路,只能使用一个寄存器堆和一个 ALU,并添加必要的多路选择器。

在这里插入图片描述

为建立只有一个寄存器堆和一个 ALU 的数据通路,需支持 ALU 的第二个输入和要存入寄存器堆的数据都有两个不同的来源。因此,在 ALU 的输入端和寄存器堆的数据输入端添加一个多路选择器。

最后我们看一个完整的数据通路。具体如下:

在这里插入图片描述
上图就是 RISC-V 指令系统核心集一个简单数据通路,由于分支指令使用主 ALU 来比较两个寄存器操作数是否相等,所以要保留计算分支目标地址的加法器。增加一个多路选择器,用于选择是将顺序的指令地址(PC+4)还是分支目标地址写入 PC。

4. 写在最后

本篇博客主要介绍了 RISC-V 指令系统核心集的一个简单数据通路。


你了解 CPU 吗?(二)

了不起的盖茨比。于 2022-03-11 10:58:38 发布

1. 写在前面

上一篇博客我们简单介绍了 CPU 的一些核心指令集,本节将继续介绍剩余部分。

2. 一个简单的实现方案

2.1 ALU 的控制

下图定义了四根输入控制线的以下四种组合:

在这里插入图片描述

根据不同的指令类型,ALU 需执行以上四种功能中的一种。对于 load 和 store 指令,ALU 做加法计算存储地址。对于 R 型指令,根据指令的 7 位 funct7 字段和 3 位 funct3 字段,ALU 需执行四种操作(与、或、加、减)中的一种,对于条件分支指令,ALU 将两个操作数做减法(本质还是作差法)并检测结果是否为 0。

4 位 ALU 的输入控制信号可由一个小型控制单元产生,其输入是指令的 funct7 和 funct3 字段以及 2 位的 ALUOP 字段,ALUOP 指明要执行的操作是 load 和 store 指令要做的加法(00),还是 beq 指令要做的减法并检测是否 0(01)或是由 funct7 和 funct3 字段决定(10),该控制单元输出一个 4 位信号,即前面介绍的 4 位组合之一来控制 ALU。

下图说明如何根据指令中的 2 位 ALUOP 控制字段、funct7 和 funct3 字段设置 ALU 的输入控制信号。

在这里插入图片描述

这种多级译码的方式 — 主控制单元生成 ALUOp 位用作 ALU 的控制信号,再生成实际信号来控制 ALU — 是一种常见的实现方式。多级控制可以减少主控制单元的规模。多个小的控制单元可能潜在地减少控制单元的延迟。这样的优化很重要,因为控制单元的延迟是决定时钟周期的关键因素。

有几种不同的方法把 2 位 ALUOp 字段和 funct 字段映射到四位 ALU 输入控制信号。由于只有少数 funct 字段有意义,并且仅在 ALUOp 位等于 10 时才使用 funct 字段,因此可以使用一个小逻辑单元来识别可能的取值并生成恰当的 ALU 控制信号。

为设计这个逻辑单元,有必要为 funct 字段和 ALUOp 信号的有意义组合生成一张真值表。

在这里插入图片描述

上边给出了如何根据这些输入字段设置 4 位 ALU 输入控制信号。由于完整真值表非常大,我们并不关心所有的输入组合,所以只列出了使 ALU 控制信号有值的部分表项。

在很多情况下不关心某些输入的取值,为了简化真值表,我们也列出无关项。真值表中的无关项(在输入列中用 X 表示)表明输出不依赖于与该列对应的输入。

2.2 设计主控制单元

前面我们已经描述了如何使用操作码和 2 位信号作为输入进行 ALU 控制单元的设计,现在考虑控制的其他部分。我们主要看四类指令的格式:算术、载入、存储和条件分支指令。

RISC-V 指令格式遵循以下规则:

  • 操作码字段总是 0~6 位。根据操作码,funct3 字段和 funct7 字段作为扩展的操作码字段。
  • 对于 R 型指令和分支指令,第一个寄存器操作数始终在 15~19 位。该字段也可用来定义载入和存储指令的基址寄存器。
  • 对于 R 型指令和分支指令,第二个寄存器操作数始终在 20~24 位。该字段也可用来定义载入和存储指令中的寄存器,该寄存器保存了写入存储器的操作数。
  • 对于分支指令、载入指令和存储指令,另一个操作数可以是 12 位立即数。
  • 对于 R 型指令和载入指令,目标寄存器始终在 7~11 位。

在这里插入图片描述

然后我们就可以得到如下的设计图,具体如下:

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

2.3 数据通路操作

我们需要了解每条指令是如何使用数据通路的。接下来的几张图说明了三类不同指令在数据通路中的流动。有效的控制信号和数据通路单元已标出。请注意,多选择器在控制信号为 0 时也有对应的动作,即使其控制信号没有着重标出。对于多位信号,只要其中任何信号有效,就着重标出。

我们先看 R 型指令的数据通路操作,例如 add x1,x2,x3。虽然所有操作都发生在一个时钟周期内,但我们认为执行该指令分为四个步骤,这些步骤按照信息的流动排序:

  1. 取出指令,PC 自增。
  2. 从寄存器堆读出两个寄存器 x2 和 x3,同时主控制单元在此步骤计算控制信息。
  3. 根据部分操作码确定 ALU 的功能,对从寄存器堆读出的数据进行操作。
  4. 将 ALU 的结果写入寄存器堆中的目标寄存器(x1)。

在这里插入图片描述

例如 ld x1,offset(x2),可将 load 指令执行分为五个步骤:

  1. 从指令存储器中取出指令,PC 自增。
  2. 从寄存器堆读出寄存器 x2 的值。
  3. ALU 将从寄存器堆中读出的值和符号扩展后的指令中的 12 位(偏移量)相加。
  4. 将 ALU 的结果用作数据存储器的地址。
  5. 将从存储器读出的数据写入寄存器堆(x1)。

在这里插入图片描述
最后例如:beq x1,x2,offset 指令,执行的步骤如下:

  1. 从指令存储器中取出指令,PC 自增。
  2. 从寄存器堆中读出两个寄存器 x1 和 x2。
  3. ALU 将从寄存器堆读出的两数相减。PC 与左移一位、符号扩展的指令中的 12 位(偏移)相加,结果是分支目标地址。
  4. ALU 的零输出决定将哪个加法器的结果写入 PC。

在这里插入图片描述

2.4 控制的结束

我们已经了解了指令如何按步骤操作,现在继续讨论控制单元的实现。控制单元的功能可根据前面的内容进行定义,其输出是控制线,输入是几位操作码。因此,可以根据操作码的二进制编码为每个输出建立一个真值表。

将控制单元的逻辑定义为一个大的真值表,它将所有输出与输入组合在一起,输入为操作码,并且完整地描述了控制单元的功能,可以自动地转换为门电路实现。

2.5 为什么现在不使用单周期实现

单周期的效率太低。在单周期设计中,时钟周期对于每条指令必须等长。这样,处理器中的最长路径决定了时钟周期。这条路径很可能是一条 load 指令,它连续使用 5 个功能单元:指令存储器、寄存器堆、ALU、数据存储器和寄存器堆。早期具有简单指令集的计算机确实采用这种实现方式。但是,如果要实现浮点单元或更复杂的指令集,单周期设计根本无法正常工作。

在这里插入图片描述

由于时钟周期必须满足所有指令中最坏的情况,所以不能使用那些缩短常用指令执行时间而不改变最坏情况的实现技术。

3. 写在最后

本篇博客主要简单介绍了指令集的一种简单的设计方案。下节博客我们将介绍 CPU 的流水线的操作。


你了解 CPU 吗?(三)

了不起的盖茨比。于 2022-03-17 17:27:11 发布

1. 写在前面

前面我介绍了如何简单构建一个 CPU,但是我们似乎没有了解指令在 CPU 中是如何流转的,是单个时间周期中就执行一个指令吗?还是什么方式呢?

2. 流水线概述

流水线是一种能使多条指令重叠执行的实现技术。目前,流水线技术广泛应用。我们先来看一个非流水线的例子。

  1. 将一批脏衣服放入洗衣机。
  2. 洗衣机洗完后,将湿衣服取出并放入烘干机。
  3. 烘干机完成后,将干衣取出,放在桌子上并叠起来。
  4. 叠好后,请你的舍友帮忙把衣服收好。

但是如果换一种思路,当第一批衣服洗完过后,这个时候洗衣机就是空着的了,为什么我们不能去洗第二批的衣服呢。于是有了下面的方式。

当第一批衣服从洗衣机中取出并放入烘干机后,就可以把第二批衣服放入洗衣机。当第一批衣服烘干完成后,就可以把它们放在桌上叠起来,同时把洗衣机中洗好的衣服放入烘干机,再将下一批脏衣服放入洗衣机。接着让你的舍友把第一批衣服从桌上收好,你开始叠第二批衣服,烘干机开始烘干第三批衣服,同时可以把第四批衣服放入洗衣机。

在这里插入图片描述

流水线的矛盾在于,对于一双脏袜子,从把它放入洗衣机到被烘干、叠好和收起的时间在流水线中并没有缩短;然而对于多负载来说,流水线更快的原因是所有工作都在并行的执行。所有单位时间能够完成更多工作,流水线提高了洗衣系统的吞吐率。因此,流水线不会缩短一次洗衣的时间,但是当有很多衣物需要洗时,吞吐率的提高减少了完成整个任务的时间。

同样的道理我们可以试用计算机的 CPU,具体如下五个步骤:

  1. 从存储器中取出指令。
  2. 读寄存器并译码指令。
  3. 执行操作或计算地址。
  4. 访问数据存储器中的操作数(如有必要)。
  5. 将结果写入寄存器(如有必要)。

我们可以看下如下的例子:

假设指令或数据存储器访问为 200ps,ALU 操作为 200ps,寄存器堆的读或写为 100ps,在单周期模型中,每条指令的执行需要一个时钟周期,所以时钟周期必须满足最慢的指令。

在这里插入图片描述
非流水和流水的执行的效率如下:

在这里插入图片描述

所有的流水线阶段都需要一个时钟周期,所以流水线的时钟周期必须足够长以满足最慢的操作。就像单周期设计中,即使某些指令的执行可能只需要 500ps,但时钟周期要满足最坏的情况 800ps。流水线的时钟周期也必须满足最坏情况 200ps,尽管有些阶段只需要 100ps。流水线仍然提高了 4 倍的性能改进:第一条和第四条指令之间的时间是 (3 \times 200\text{ps} = 600\text{ps})。

总结:流水线技术通过提高指令的吞吐率来提高性能,而不是减少单个指令的执行时间。由于真实程序会执行数十亿条指令,所以指令吞吐率是一个重要指标。

2.1 面向流水线的指令系统设计

第一,所有 RISC-V 指令长度相同。这个限制简化了流水线第一阶段取指令和第二阶段指令译码。

第二,RISC-V 只有几种指令格式,源寄存器和目标寄存器字段的位置相同。

第三,存储器操作数只出现在 RISC-V 的 load 或 store 指令中。这个限制意味着可以利用执行阶段来计算存储器地址,然后在下一阶段访问存储器。

2.2 流水线冒险

流水线中有一种情况,在下一个时钟周期中下一条指令无法执行。这种情况被称为冒险,主要有如下三种冒险。

  1. 结构冒险

第一种冒险叫做结构冒险。即硬件不支持多条指令在同一时钟周期执行。在洗衣例子中,如果用洗衣烘干一体机而不是分开的洗衣机和烘干机,或者如果你的舍友正在做其他事情而不能收好衣服,都会发生结构冒险。

  1. 数据冒险

由于一个步骤必须等待另一个步骤完成而导致的流水线停顿叫做数据冒险。在计算机流水线中,数据冒险源于一条指令依赖于前面一条尚在流水线中的指令。

那么如何解决呢?

不需要等待指令完成就可以尝试解决数据冒险。对于上面的代码序列,一旦 ALU 计算出加法的和,就可以将其作为减法的输入。向内部资源添加额外的硬件以尽快找到减少运算项的方法,称为前递或旁路。

我们可以看下如下的例子:

add x19,x0,x1
sub x2,x19,x3

在这里插入图片描述

仅当目标阶段在时间上晚于源阶段时,前递路径才有效。例如,从第一条指令存储器访问阶段的输出到下一条指令执行阶段的输入不能存在有效前递路径,否则意味着时间倒流。

前递的效果很好,但不能避免所有的流水线停顿。假设第一条指令是 load x1 而不是加法指令,这个时候就会在第一条指令的第四个阶段之后,sub 指令所需的数据才可用,这对于 sub 指令第三个阶段的输入来说太迟了。因此,即使使用前递,流水线也不得不停顿一个阶段来处理载入 - 使用型的数据冒险。我们可以看下如下的图:

在这里插入图片描述

该图包含流水线的一个重要概念,正式叫法是流水线停顿,但通常俗称为气泡。那么如何处理这种复杂的情况,我们应该由软件对代码进行重新排序以尽量避免载入 - 使用型流水线停顿。

可以看下如下的例子:

重排代码以避免流水线停顿

考虑以下 C 语言代码段:

a = b + e;
c = b + f;

最后生成的汇编代码如下:

ld x1,0 (x31) // Load b
ld x2,8 (x31) // Load e
add x3,x1,x2 //b + e
sd x3,24 (x31) //store a
ld x4,16 (x31) // Load f
add x5,x1,x4 //b + f
sd x5,32 (x31) //store c

两条 add 指令都有冒险,因为它们分别依赖于上一条 ld 指令。请注意,前递消除了其他几种潜在冒险,包括第一条 add 指令对第一条 ld 指令的依赖,以及 sd 指令带来的冒险。把第三条 ld 指令提前为第三条指令可以消除这两个冒险:

ld x1,0 (x31) // Load b
ld x2,8 (x31) // Load e
ld x4,16 (x31) // Load f
add x3,x1,x2 //b + e
sd x3,24 (x31) //store a
add x5,x1,x4 //b + f
sd x5,32 (x31) //store c

在具有前递的流水线处理器上,执行重新排序的指令序列将比原始版本快两个时钟周期。

最后引出一个特点,即每条 RISC-V 指令最多写一个结果,并在流水线的最后一个阶段执行写操作。如果每条指令有多个结果要前递,或者需要在指令执行的更早阶段写入结果,前递设计会复杂得多。

控制冒险

需要根据一条指令的结果做出决定,而其他的指令正在执行。

现在有如下的情况,假设洗衣店的工作人员接到一个令人高兴的任务:清洁足球队队服。根据衣服的污浊程度,需要确定清洗剂的用量和水温设置是否合适,以致能洗净衣服又不会由于清洗剂过量而磨损衣物。在洗衣流水线中,必须等到第二步结束,检查已经烘干的衣服,才知道是否需要改变洗衣机的设置。这种情况怎么办?

方法一:停顿,等第一批衣物被烘干之前,按顺序操作,并且重复这一个过程直到找到正确的洗衣设置位置。(速度慢)但是如果计算机中停顿的话,那么效率会很慢。所以有没有其他的方法。

方法二:预测,如果你确定清洗队服的设置是正确的,就预测它可以工作,那么在等待第一批衣物被烘干的同时清洗第二批衣服。

如果预测正确,这个方法不会减慢流水线。但是如果预测错误,就需要重新洗做预测时所清洗的那些衣服。

计算机确实采用预测来处理条件分支。一种简单的方法是总是预测条件分支指令不发生跳转。如果预测正确,流水线将全速前进。只有条件分支指令发生跳转时,流水线才会发生停顿。但是这种方案有种不好,于是衍生出了一种动态预测方法。

动态预测的一种常用实现方法是保存每条条件分支是否发生分支的历史记录,然后根据最近的过去行为来预测未来。当历史记录的数量和类型足够多时,动态分支预测的正确率过 90%。当预测错误时,流水线控制必须确保预测错误的条件分支指令之后的指令执行不会生效,并且必须从正确的分支地址处重新启动流水线。

方法三:延迟决定。在洗衣例子中,每当需要做出有关洗衣的决定时,只需在等待足球队服被烘干的同时,向洗衣机中放入一批非足球队服的衣服。只要有足够多不受决定影响的脏衣服,这个方案就是可以正常工作。

在计算机中这种方法被称为延迟转移,也就是 MIPS 架构实际使用的解决方案。延迟转移顺序执行下一条指令,并在该指令后执行分支。由于汇编器可以自动排序指令,使用分支指令的行为达到程序员的期望,所以这个过程对 MIPS 汇编语言程序员来说不可见。MIPS 软件会在延迟转移指令的后面放一条不受该分支影响的指令,并且发生转移的分支指令会改变这条安全指令后的指令地址。

2.3 总结

流水线技术是一种在顺序指令流中开发指令间并行性的技术。与多处理编程相比,其优点在于它对程序员是不可见的。那么对于结构冒险通常出现在浮点单元的周围,而浮点单元可能不是完全流水线化的而控制冒险通常出现在定点程序中,因为其中条件分支指令出现的频率更高,也是更难预测数据冒险在定点和浮点程序中都可能成为性能瓶颈

对于流水线设计者来说,指令系统既可能将事物简单化,也可能将事物复杂化。流水线设计者必须解决结构冒险、控制冒险和数据冒险。分支预测和前递能够在保证得到正确结果的前提下提高计算机性能。

3. 流水线数据通路和控制

我们先看下单时钟周期的指令执行的流程,主要分为 5 个阶段意味着五级流水线,还意味着在任意单时钟周期里最多执行五条指令。具体可以分成如下的 5 个部分。

  1. IF:取指令
  2. ID:指令译码和读寄存器堆
  3. EX:执行或计算地址
  4. MEM:数据存储器访问
  5. WB:写回

在这里插入图片描述
上面的指令是从左往右执行的,然而,在从左往右的指令流动过程中存在两个特殊情况:

  • 在写回阶段,它将结果写回位于数据通路中段寄存器堆中。(会导致数据冒险)
  • 在选择下一 PC 值时,在自增 PC 值与 MEM 阶段的分支地址之间进行选择。(会导致控制冒险)

一种表示流水线数据通路如何执行的方法是假定每一条指令都有独立的数据通路,然后将这些数据通路放在同一时间轴上来表示它们之间的关系。

在这里插入图片描述

三条指令需要三条数据通路,但事实上,我们可以通过引入寄存器保存数据的方式,使得部分数据通路可以在指令执行的过程中被共享。

指令存储器只在指令的五个阶段中的一个阶段被使用,而在其他四个阶段中允许被其他指令共享。为了保留在其他四个阶段的指令的值,必须把从指令存储器中读取的数据保存在寄存器中。类似的理由适用于每个流水线阶段,所以我们必须将寄存器放置在每个阶段之间的分隔线上。再回到洗衣例子中,我们会在每两个步骤之间放置一个篮子,用于存放为下一步所准备的衣服。

在这里插入图片描述

需要注意的是,在写回阶段的最后没有流水线寄存器。所有的指令都必须更新处理器中的某些状态,如寄存器堆、存储器或 PC 等,因此,单独的流水线寄存器对于已经被更新的状态来说是多余的。

当然,每条指令都会更新 PC,无论是通过自增还是通过将其设置为分支目标地址。PC 可以被看作一个流水线寄存器:它给流水线的 IF 阶段提供数据。不同于被标记阴影的流水线寄存器,PC 是可见体系结构状态的一部分。在发生例外时,PC 中的内容必须被保存,而流水线寄存器中的内容则可以被丢弃。在洗衣的例子中,你可以将 PC 看作在清洗步骤之前盛放脏衣服的篮子。

在这里插入图片描述

IF 和 ID:一条指令在指令流水线中的第一和第二步,读写寄存时不会发生混乱,这是因为寄存器中的内容仅在时钟边沿上发生变化。尽管在阶段二中加载指令只需要寄存器 1 中的值,但是处理器测试并不知道当前是哪一条指令正在被译码,因此处理器将符号扩展后的 16 位常量以及两个寄存器中的值都存入 ID/EX 流水线寄存器中。我们并不一定需要全部的这三个操作,但是保留全部的三个操作数可以简化控制。

在这里插入图片描述

在这里插入图片描述

ld 指令具体的五个阶段如下:

  1. 取指:使用 PC 中地址从存储器中读取指令,然后将指令放入 IF/ID 流水线寄存器中。PC 中的地址自增 4,然后写回 PC,为下一时钟周期准备。这个 PC 值也保存在 IF/ID 流水线寄存器中,以备后续的指令使用(例如 beq)。计算机并不知道当前正在提取的是哪一种指令,因此它必须为任何一种指令做好准备,并且将所有可能有用的信息沿流水线传递出去。
  2. 指令译码和读寄存器堆:IF/ID 流水线寄存器的指令部分,该指令提供一个 64 位符号扩展的立即数字段,以及两个将要读取的寄存器编号。所以这三个值都与 PC 地址一起存储在 ID/EX 流水线寄存器中。在这里我们再次向右传递在之后的时钟周期里可能用的的所有信息。
  3. 执行或地址计算:加载指令从 ID/EX 流水线寄存器中读取一个寄存器的值和一个符号扩展的立即数,并且使用 ALU 部件将它们相加,它们的和存储在 EX/MEM 流水线寄存器中。
  4. 存储器访问:加载指令使用赖在 EX/MEM 流水寄存器中的地址读取数据存储器,并将数据存入 MEM/WB 流水线寄存器中。
  5. 写回:从 MEM/WB 流水线寄存器中读取数据,并将它写入图中间的寄存器堆中。

再来看下 sd 的指令,具体五个的阶段如下:

  1. 取指:使用 PC 中的地址从存储器中读取指令,然后将其放入 IF/ID 流水线的寄存器中。
  2. 指令译码和读寄存器堆:IF/ID 流水线寄存器中的指令提供了用户读取寄存器的两个寄存器编号以及一个符号扩展的立即数。这三个 64 位的值都存储在 ID/EX 流水线寄存器中。
  3. 指令执行和地址计算:显示了指令流水中的第三步,有效地址被存放在 EX/MEM 流水线寄存器中。
  4. 存储器访问:包含要被存储的数据的寄存器在较早的流水线阶段就已经被读取并存储在 ID/EX 流水线寄存器中。在 MEM 阶段获得这个数据的唯一方法就是在 EX 阶段中将该数据放入 EX/MEM 流水线寄存器中,就像我们将有效地址存储在 EX/MEM 中那样。
  5. 写回:对存储指令来说,在写回阶段不会发生任何事情。由于存储指令之后的每一条指令都已经进入流水线中,所以我们无法加速这些指令。因此,任何指令都要经过流水线中的每一个阶段,即使它在这个阶段没有任何事情要做,因为后续指令已经按照最大速率在流水线中进行处理了。

在这里插入图片描述

在这里插入图片描述

存储指令再次说明了如果要将相关信息从之前的流水线阶段传递到后续的流水线阶段,就必须将它们放置在流水线寄存器中。否则,当下一条指令进入流水线时,该信息就会丢失。对于存储指令来说,我们需要将在 ID 阶段读取的寄存器信息传递到 MEM 阶段,然后写入存储器中。这些数据最初放置在 ID/EX 流水线寄存器中,之后被传递到 EX/MEM 流水线寄存器中

加载和存储指令还说明了第二个关键点:在流水线数据通路设计中每一个逻辑部件只能在单个流水线阶段中被使用,否则就会发生结构冒险。因此,这些部件以及对它们的控制只能与一个流水线阶段相关联。

但是我们发现上面的加载指令设计中的一个错误。在加载指令流水的 WB 阶段改写了哪个寄存器?更具体说,此时的寄存器号是哪条指令提供的?IF/ID 流水寄存器中指令提供了写入寄存器编号。但是,这条指令是加载指令之后的指令

因此,我们需要在加载指令的流水线寄存器中保留目标寄存器编号。就像存储指令为了 MEM 阶段的使用而将寄存器值从 ID/EX 中传递到 EX/MEM 流水线寄存器中那样,加载指令需要为了 WB 阶段的使用而将寄存器编号从 ID/EX 通过 EX/MEM 传递到 MEM/WB 流水线寄存器。换一个角度来看,为了共享流水线数据通路,我们需要在 IF 阶段保存读取的指令,因此每个流水线寄存器都要保存当前阶段和后续阶段所需的部分指令信息。

修改后的图如下:

在这里插入图片描述

在这里插入图片描述

3.1 流水线的图形化表示

掌握流水线技术可能会很困难,因为在每个时钟周期内同时有多条指令在一个单数据通路中执行。所以这儿我们简单的提供了两种基本的流水线图,分别是多时钟周期流水线图和单时钟周期流水线图。

我们可以先看如下的指令:

ld x10,40 (x1)
sub x11,x2,x3
add x12,x3,x4
ld x13,48 (x1)
add x14,x5,x6

在这里插入图片描述

在这里插入图片描述

单时钟周期流水线图显示了在一个单时钟周期内整个数据通路的状态,通常所有五条指令都在流水线中,被各自流水线阶段的标签所标识。我们使用这种类型的图来表示每个时钟周期内的流水线中所发生的事情的细节。通常,这种图以组的形式出现,以显示一系列时钟周期内的流水线的操作。单时钟周期图代表在一组多时钟周期图中一个时钟周期的垂直切片,展示了流水线在指定时钟周期上每个指令对数据通路的使用情况。

在这里插入图片描述

3.2 流水线控制

我们先来看如下的图:

在这里插入图片描述

与单周期实现的情况一样,我们假定 PC 在每个时钟周期被写入,因此 PC 没有单独的写入信号。同理,流水线寄存器(IF/ID、ID/EX、EX/MEM、MEM/WB)也没有单独的写入信号,因为流水线寄存器也在每个时钟周期写入。

为了详细说明流水线的控制,我们需要在每个流水线阶段上设置控制值。由于每条控制线都只与一个流水线阶段中功能部件相关,因此我们可以根据流水线阶段将控制线也划分成五组。

  1. 取值:读指令存储器和写 PC 的控制信号总是有效的,因此在这个阶段没有什么需要特别控制的内容。
  2. 指令译码 / 读寄存器:在 RISC-V 指令格式中两个源寄存器总是位于相同的位置,因此在每个阶段也没有什么需要特别控制的内容。
  3. 执行 / 地址计算:要设置的信号是 ALUOp 和 ALUSrc,这个信号选择 ALU 操作,并将读数据 2 或者符号扩展的立即数作为 ALU 的输入。

在这里插入图片描述

  1. 存储器访问:本阶段要设置的控制线是 Branch、MemRead 和 MemWrite。这些信号分别由相等则分支、加载和存储指令设置。除非控制电路标示这是一条分支指令并且 ALU 的输出为 0,否则将选择线性地址的下一条指令作为 PCSrc 信号。

在这里插入图片描述

  1. 写回:两条控制线是 MemtoReg 和 RegWrite,MemtoReg 决定时将 ALU 结果还是将存储器值发送到寄存器堆中,RegWrite 写入所选值。

由于流水线数据通路并没有改变控制线意义,因此可以使用单数据通路相同的控制值。

实现控制意味着在每条指令的每个阶段中将这七条控制线设置这些值。由于控制线从 EX 阶段开始,我们可以在指令译码阶段为之后的阶段创建控制信号。传递这些控制信号最简单的方式就是扩展流水线寄存器以包含这些控制信息。

在这里插入图片描述

在这里插入图片描述

4. 写在最后

本篇博客主要简单介绍了 CPU 的流水线的工作流程,以及影响流水线的几种情况,分别是数据冒险、结构冒险、控制冒险。下篇会详细介绍这几种冒险。


你了解 CPU 吗?(四)

了不起的盖茨比。于 2022-03-19 15:58:26 发布

1. 写在前面

前面我们已经介绍完了 CPU 的流水线的工作原理,以及影响流水线的操作的几个因素,结构冒险、数据冒险、控制冒险。但是我没有更深层次的介绍完。现在我们需要更深层次的介绍这些东西。然后如果篇幅够的话,我们可以介绍下中断的一些知识。

2. 数据冒险:前递与停顿

现在从一个更实际的例子出发,看看在程序真正执行的时候会发生什么。现在来看一个下面的指令序列,具体的指令序列如下:

sub x2,x1,x3 // Register x2 written by sub
and x12,x2,x5 //lst operand (x2) depends on sub
or x13,x6,x2 // 2nd operand (x2) depends on sub
add x14,x2,x2 // 1st (x2) & 2nd (x2) depend on sub
sd x15,100 (x2) // Base (x2) depends on sub

后四条指令都依赖于第一条指令(sub)中得到的存放在寄存器 x2 中的结果。假设寄存器 x2 在 sub 指令执行之前的值为 10,在执行之后值为 -20,那么程序员希望在后续指令中引用寄存器 x2 时得到的值为 -20.

在这里插入图片描述

最后的潜在危险,当一个寄存器在同一个时钟周期内既被读取又被写入时会发生什么?我们假定写操作发生在一个时钟周期的前半部分,而读操作发生在后半部分。所以读操作会得到本周期内被写入的值。这种假定与很多寄存器堆的实现是一致的。在这种情况下不会发生数据冒险。

如上图,在第五个时钟周期之前,对寄存器 X2 的读操作并不能返回 sub 指令的结果。因此,图中的 add 和 sd 指令得到正确结果 -20。但是 and 和 or 指令却会得到错误的结果是 10。在这种类型的图中,每当相关线在时间线上表示为后退时,这个问题就会变得很明显。

在第三个时钟周期也就是 sub 指令的 EX 指令阶段结束时就可以得到想要的结果。那么在 and 和 or 指令中是什么时候才真正需要这个数据呢?答案是在 and 和 or 指令的 EX 阶段开始的时候,分别对应第四和第五个时钟周期。因此,只要可以一得到相应的数据就将其前递给等待该数据的单元,而不是等待其可以从寄存器堆中的读取出来,就可以不需要停顿地执行这段指令了。

接下来的内容,我们只考虑如何解决将 EX 阶段产生的操作数前递出去的问题,该数据可能是 ALU 或是有效地址的计算结果。这意味着当一个指令试图在 EX 阶段使用的寄存器是一个较早的指令在 WB 阶段要写入的寄存器时。我们需要将数据作为 ALU 的输入。

命令流水线寄存器字段是一种更精确的表示相关关系的方法。例如,ID/EX.RegisterRs1 表示一个寄存器的编号,它的值在流水线寄存器 ID.EX 中,也就是这个寄存器堆中第一个读端口的值。该名称的第一部分,也就是点号左边,是流水线寄存器的名称;第二部分是寄存器中字段的名称。使用这种表示方法,可以得到两对冒险的名称:

1a.EX/MEM.RegisterRd = ID/EX.RegisterRs1
1b.EX/MEM.RegisterRd = ID/EX.RegisterRs2
2a.MEM/WB.RegisterRd = ID/EX.RegisterRs1
2b.MEM/WB.RegisterRd = ID/EX.RegisterRs2

在本节开头的代码中,指令序列中的第一个冒险发生在寄存器 x2 上,位于 sub 指令 sub x2,x1,x3 的结果和 and 指令 and x12,x2,x5 的第一个读操作数之间。这个冒险可以在 and 指令位于 EX 阶段、sub 指令位于 MEM 阶段时被检测到,因此这种冒险属于 1a 类型:

EX/MEM.RegisterRd = ID/EX.RegisterRs1 = x2

那么我们可以继续看之前的指令,来看看对应的冒险类型,具体的冒险类型如下:

  • sub 指令和 or 指令之间存在类型为 2b 的冒险:
2b.MEM/WB.RegisterRd = ID/EX.RegisterRs2 = x2
  • 在 sub 指令和 add 指令之间的两个相关性都不是冒险,因为在 add 指令的 ID 阶段寄存器堆已经可以提供 x2 的正确值了。

  • 在 sub 指令和 sd 指令之间不存在数据冒险,因为 sd 指令在 sub 指令将结果写回至 x2 之后才读取 x2 的值。

因为并不是所有的指令都会写回寄存器,所以这个策略是不正确的,它有时会不应该前递的时候也将数据前递出去。

一种简单的解决方案是检查 RegWrite 信号是否是有效的,检查流水线寄存器在 EX 和 MEM 阶段的 WB 控制字段以确定 RegWrite 信号是否有效。现在我们可以检测冒险了。一半的问题已经解决了,剩下一半的问题是前递正确的数据。

在这里插入图片描述

如果我们可以从任何流水线寄存器而不仅仅是 ID/EX 中得到 ALU 的输入,那就可以前递正确的数据。通过在 ALU 的输入上添加多选器再辅以适当的控制,就可以在存在数据冒险的情况下全速运行流水线。

现在,假设需要前递的指令只有这四种形式:add、sub、and 和 or 指令。下图是 ALU 和流水线寄存器在添加前递之前和之后的特写。

在这里插入图片描述

下面的图是 ALU 多选器的控制线的值,它选择寄存器堆的值或是被前递的值中的一个。

在这里插入图片描述

现在给出检测冒险的条件以及解决相应的控制信息:

  1. EX 冒险
if (EX/MEM.RegWrite and (EX/MEM.RegisterRd != 0) and (EX/MEM.RegisterRd = ID/EX.RegisterRs1)) ForwardA = 10
if (EX/MEM.RegWrite and (EX/MEM.RegisterRd != 0) and (EX/MEM.RegisterRd = ID/EX.RegisterRs2)) ForwardB = 10

这种情况是将前一条指令的结果前递到任何一个 ALU 的输入中。如果前一条指令想要写寄存器堆,并且将要写的寄存器的编号与 ALU 输入口 A 或 B 要读取的寄存器编号一致(前提是该寄存器编号不为 0),那么就控制多选择器直接从 EX/MEM 流水中取值。

  1. MEM 冒险
if (MEM/WB.RegWrite and (MEM/WB.RegisterRd != 0) and (MEM/WB.RegisterRd = ID/EX.RegisterRs1)) ForwardA = 01
if (MEM/WB.RegWrite and (MEM/WB.RegisterRd != 0) and (MEM/WB.RegisterRd = ID/EX.RegisterRs2)) ForwardB = 01

正如上文所述,在 WB 阶段不存在冒险,因为我们假定在 ID 阶段的指令读取的寄存器与 WB 阶段要写入的寄存器相同时,寄存器堆能够提供正确的结果。也就是说,寄存器堆提供了另外一种形式的前递,只不过这种前递发生在寄存器堆内部。

一种复杂的潜在数据冒险是在 WB 阶段指令的结果、MEM 阶段指令的结果和 ALU 阶段指令的源操作数之间发生的。例如:在一个寄存器中对一组数据做求和操作时,一系列的指令将会读和写一个相同的寄存器:

add x1,x1,x2
add x1,x1,x3
add x1,x1,x4

在这种情况下,结果应该是来自 MEM 阶段前递数据,因为 MEM 阶段中的结果就是最近的结果。因此,MEM 阶段中的结果就是最近的结果。因此,MEM 冒险的控制应该是:

if (MEM/WB.RegWrite and (MEM/WB.RegisterRd != 0) and not (EX/MEM.Register and (EX/MEM.RegisterRd != 0) and (EX/MEM.RegisterRd = ID/EX.RegisterRS1)) and (MEM/WB.RegisterRd = ID/EX.RegisterRs1)) ForwardA = 01
if (MEM/WB.RegWrite and (MEM/WB.RegisterRd != 0) and not (EX/MEM.Register and (EX/MEM.RegisterRd != 0) and (EX/MEM.RegisterRd = ID/EX.RegisterRS2)) and (MEM/WB.RegisterRd = ID/EX.RegisterRs1)) ForwardB = 01

在这里插入图片描述

为了支持前递 EX 阶段的结果,这个操作所需要添加的硬件。注意 EX/MEM.RegisterRd 字段是 ALU 指令或者加载指令的目标寄存器。

3. 数据冒险与停顿

当一条指令试图在加载指令写入一个寄存器之后读取这个寄存器时,前递不能解决此处的冒险。当加载指令后跟着一条需要读取加载指令结果的指令时,流水线必须被阻塞以消除这些指令组合带来的冒险。

因此,除了一个前递单元外,还需要一个冒险检测单元。该单元在 ID 流水线阶段操作,从而可以在加载指令和相关加载指令结果的指令之间加入一个流水线的阻塞。这个单元检测加载指令,冒险控制单元的控制逻辑满足如下条件:

if (ID/EX.MemRead and ((Id/EX.RegisterRd = IF/ID.RegisterRs1) or (ID/EX.RegisterRd = IF/ID.RegisterRs2))) stall the pipeline

我们在加载指令和 R 型指令中使用 RegisterRd 也就是指令的 7 至 11 位。第一行测试是为了查看指令是否是加载指令,只有加载指令需要读取数据存储器。接下来的两行检测在 EX 阶段的加载指令的目标寄存器是否与 ID 阶段的指令中某一个源寄存器相匹配。如果条件成立,指令会停顿一个时钟周期。在一个时钟周期后,前递逻辑就可以处理这个相关并继续执行程序了。

如果处于 ID 阶段的指令被停顿了,那么在 IF 阶段中指令也一定被停顿,否则已经取到的指令就会丢失。只需要简单地禁止 PC 寄存器和 IF/ID 流水线寄存器的改变就可以阻止这两条指令的执行。如果这些寄存器被保护,在 IF 阶段的指令就会继续使用相同的 PC 值取指令,同时在 ID 阶段的寄存器就会继续使用 IF/ID 流水线寄存器中相同的字段读寄存器。再回到我们的洗衣例子中,这就像是你重新开启洗衣机洗相同的衣服并且让烘干机继续空转一样。当然,就像烘干机那样,EX 阶段开始的流水线后半部分必须执行没有任何效果的指令,也就是空指令

那么如何在流水线中插入空指令?解除 EX、MEM 和 WB 阶段的七个控制信号就可以产生一个没有任何操作的指令,也就是空指令。通过识别 ID 阶段的冒险,我们可以通过将 ID/EX 流水线寄存器中 EX、MEM 和 WB 的控制字段设置为 0 来向流水线中插入一个气泡。这些不会产生负面作用的控制值在每个时钟周期向前传递并产生适当的效果,在控制值均为 0 的情况下,不会有寄存器或者存储器被写入数据。

具体的实现细节如下:

在这里插入图片描述

and 指令所在的流水线执行槽变成 nop 指令,并且所有在 and 指令之后的指令都被延后了一个时钟周期。就像水管中出现了一个气泡那样,这个停顿气泡延后了它之后的所有指令的执行,并且随着每个时钟周期沿着流水线及程序前进,直到其退出流水线。在上面的例子中,在冒险使得 and 指令和 or 指令在第 4 个时钟周期内重复了它们在第 3 个时钟周期内做过的事情:and 指令读寄存器和解码,or 指令从指令存储器中重新取了一条指令。这种重复看起来就像是停顿一样,它的影响是拉伸了 and 指令和 or 指令,并且延后了取第 2 个 and 指令的时间。

在这里插入图片描述

和原来一样,前递单元控制 ALU 多选择器,用相应的流水线寄存器中的值替换通用寄存器中的值。冒险检测单元控制 PC 和 IF/ID 流水寄存器的写入,以及在实际控制值和全 0 之间选择的多选器。如果加载 - 使用冒险被检测为真,则冒险检测单元会停顿并清除所有控制字段。

重点:尽管编译器通常依赖于硬件来解决冒险并保证指令正确执行,但编译器仍然需要理解流水线以获得最优性能。否则,未预料到的停顿就会降低编译后代码的性能。

4. 控制冒险

我们先看如下的图,具体如下:

在这里插入图片描述

控制冒险就是计算机对分支的执行的结果不确定性,而无法决定流水线的下一条指令的执行的地址。从而导致流水线的失效。

4.1 假设分支不发生

阻塞流水线直到分支完成的策略非常耗时。一种提升分支阻塞效率的方法是预测条件分支不发生并持续执行顺序指令流。一旦条件分支发生,已经被读取和译码的指令就将被丢弃,流水线继续从分支目标处开始执行。如果条件分支不发生的概率是 50%,同时丢弃指令的代价又很小,那么这种优化方式可以减少一半由控制冒险带来的代价。

想要丢弃指令,只需要将初始控制值变为 0 即可,这与指令停顿以解决加载 - 使用的数据冒险类似。不同的是,丢弃指令的同时也需要改变当分支指令到达 MEM 阶段时 IF、ID 和 EX 阶段的三条指令;而在加载 - 使用的数据停顿中,只需要将 ID 阶段的控制信号变为 0 并且将该阶段的指令从流水线中过滤出去即可。丢弃指令,意味着我们必须能够将流水线中 IF、ID 和 EX 阶段中的指令都清除。

4.2 缩短分支延迟

一种提升分支性能的方式是减少发生分支时所需要的代价。到目前为止,我们假定分支所需的下一 PC 值在 MEM 阶段才能被获取,但如果我们将流水线中的条件分支指令提早移动执行,就可以刷新更少的指令。需将分支决定向前移动,需要两个操作提早发生:计算分支目标地址和判断分支条件。其中,将分支地址提前进行计算是相对简单的。在 IF/ID 流水线寄存器中已经得到了 PC 值和立即数字段,所以只需将分支地址从 EX 阶段移动到 ID 阶段即可。当然,分支地址的目标计算将会在所有指令中都执行,但只有在需要时才会被使用。

困难的部分是分支决定本身。对于相等时跳转指令,需要在 ID 阶段比较两个寄存器中的值是否相等。相等的判断方法可以是先将相应位进行异或操作,再对结果按位进行或操作。将分支检测移动到 ID 阶段还需要额外的前递和冒险检测硬件,因为分支可能依赖还在流水线中的结果,在优化后依然要保证运行正确。

同时这儿还有两个复杂的因素:

  1. 在 ID 阶段需要将指令译码,决定是否需要将指令旁路至相等检测单元,并且完成相等测试以防指令是一条分支指令,此时可以将 PC 设置为分支目标地址。对分支指令的操作数进行前递的操作原先是由 ALU 前递逻辑处理的,但是在 ID 阶段引入相等检测单元后就需要添加新的前递逻辑。需要注意的是,旁路获得的分支指令的源操作数既可以从 EX/MEM 流水线寄存器中获得,也可以从 MEM/WB 流水线寄存器中获得。
  2. 在 ID 阶段分支比较所需的值可能在之后才会产生,因此可能会产生数据冒险,所以指令停顿也是必须的。

尽管这很困难,但是将条件分支指令的执行移动到 ID 阶段的确是一个有效的优化,因为这将分支发生时的代价减轻至只有一条指令,也就是分支发生时正在取的那条指令。

为了清除 IF 阶段的指令,我们添加了一条称为 IF.Flush 的控制线,它将 IF/IF 流水线寄存器中指令字段设置为 0。将寄存器清空的结果是将已经取到的指令转换成一条 nop 指令,该指令不进行任何操作,也不改变任何状态。

我们来看下面的一个例子,具体的汇编的指令如下:

36 sub x10,x4,x8
40 beq x1,x3,16 // PC-relative branch to 40 + 16 * 2 =72
44 and x12,x2,x5
48 or x13,x2,x6
52 add x14,x4,x2
56 sub x15,x6,x7
...
72 ld x4,50 (x7)

具体的图如下:

在这里插入图片描述

在第 3 个时钟周期的 ID 阶段决定分支执行必须被执行,因此选择 72 作为下一 PC 跳转地址,并且将下个时钟周期获取到的指令置 0。第 4 个时钟周期显示了地址 72 中的指令被获取,并且因为分支发生而在流水线中产生了一个气泡或者 nop 指令。

4.3 动态分支预测

假定条件分支不发生是一种简单的分支预测形式。在这种形式下,我们预测分支不发生,并在预测错误时清空流水线。对于简单的五级流水线来说,这种方法再结合基于编译的预测,就基本足够了。而对于更深的流水线,从时钟周期的角度来说,分支预测错误的代价会增大。与之相似,对于多发射的情况,从指令丢失的角度来说,分支预测错误的代价也会增大。两者组合起来意味着在一个激进的流水线中,简单的静态预测机制几乎在性能上造成非常多的浪费。

于是有了一种另外的方法,检查指令中的地址,查看上一次该指令执行时条件分支是否发生了跳转,如果答案是肯定的,则从上一次执行的地址中取出指令。这种技术称为动态分支预测。

这种方法的一种实现方案是采用分支预测缓存或分支历史表。分支预测缓存是一块按照分支指令的低位地址定位的小容量存储器。这块存储器包含了一个比特,用于表明一个分支最近是否发生了跳转。

该预测使用一种最简单的缓存,事实上,我们并不知道该预测是否是正确的这个位置可能已经被另一条拥有相同低位地址的条件分支指令的跳转的状态所替换。不过,这并不会影响这种预测方法的准确性。预测只是一种我们希望是正确的假设,所以我们会在预测发生的方向上进行取舍。如果这个假设最终证明是错误的,这个不正确的预测指令就会被删除,它的预测位也会被置为相反值,之后正确的指令序列会被取值并执行。

这种 1 位的预测机制在性能上有一个缺点:即使一个条件分支总是发生跳转,但一旦其不发生跳转时,就会造成两次预测错误,而不是只造成一次错误。尤其是循环中发生。

所以我们需要的是两位的分支预测机制。具体的如下:

在这里插入图片描述

2 位动态预测机制仅仅使用特定分支的信息。研究表明,对于相同的预测位来说,同时使用局部分支和最近执行分支的全局行为的信息能够获得更好的预测准确率。这种预测器被称为相关预测器。一个典型的相关预测器对于每个分支都提供 2 位预测器,预测器之间的选择基于分支的上一次执行时跳转还是不跳转。因此,全局分支行为可以被看作在预测查找表中添加了一个额外的索引位。

另一种分支预测方法是使用锦标赛预测器。锦标赛分支预测器对于每个分支使用多种预测器对于每个分支使用多种预测器,并最终给出一个最佳的预测结果。典型的锦标赛预测器对每个分支地址使用两个预测:一个基于局部信息,而另一个基于全局分支行为。一种选择器用于选择采取哪一个预测器的信息进行预测。这个选择器的操作与 1 位或 2 位预测器类似,选择两个预测器中更准确的那个。

最终的图如下:

在这里插入图片描述

5. 写在最后

我们逐步解释了指令的流水化,从单时钟周期数据通路开始,之后加入了流水线寄存器、前递路径、数据冒险检测、分支预测以及在分支预测错误或加载 - 使用数据冒险清楚指令的机制。由于篇幅的原因,这篇博客到此就结束了,下一篇博客我们要介绍另外一种控制冒险中断。


你了解 CPU 吗?(五)

了不起的盖茨比。于 2022-03-27 23:07:19 发布

1. 写在前面

前面我们已经介绍了 CPU 的一些基础的信息,以及如何构建一个数据通路,以及如何构建 CPU 的流水线,以及 CPU 流水线会带来那些问题,但是在历史的长河里面,CPU 做到这些还远远不够,还需要更快,也是一直奋斗的目标。今天我们会讲剩下的部分,还有一种比较重要的控制冒险,就是 CPU 的中断,同时会介绍一些新的机制来加速 CPU 的流水线。

2. 中断(例外)

控制逻辑是处理器设计中最有挑战的部分:验证正确性最为困难,同时也最难进行时序优化。exceptioninterrupt 是控制逻辑需要实现的任务之一。除分支指令外,它是另一种改变指令执行控制流的方式。最初,人们使用它们是为了处理 CPU 内部的意外事件。后续经过可处理与 CPU 进行通信的 IO 设备。

在 X86 中就是使用 interrupt。而在 RISC-V 中 exception 来指代意外的控制流的变化,而这些变化无须区分产生的原因是来自处理器内部还是外部。使用 interrupt 仅仅指代由处理器外部事件引发的控制流变化。具体我们可以看如下:

在这里插入图片描述

exception 处理的许多功能需求来自引发例外的特定的场合。检测和处理 exception 的控制逻辑会处于处理器的时序关键路径上,这对处理器时钟频率和性能都会产生影响,如果对控制逻辑中的 exception 处理不给于充分重视,一旦尝试在复杂设计中添加 exception 处理,将会明显降低处理器的性能。这和处理验证一样复杂。

2.1 RISC-V 体系结构中如何处理 exception

我们这儿只说两种 exception:未定义指令和硬件故障。如果在执行某条指令的时候,发生硬件的故障,这个时候在系统例外程序计数器(SPEC)中保存发生例外的指令地址,同时将控制权转交给操作系统。

之后,操作系统将做出相应动作,包括为用户程序提供系统服务,硬件故障时执行预先定义好的操作,或者停止当前程序的执行并报告错误。完成例外处理所有操作后,操作系统使用 SEPC 寄存器中的内容重启程序的正常执行。可能是继续执行原程序,也可能是终止程序。

操作系统进行例外处理,除了引发例外的指令外,还必须获得例外发生的原因。目前使用两种方法来通知操作系统。

RISC-V 中使用的方法是设置系统例外原因存储器,该寄存器中记录了例外原因。

另外一种方法是使用向量式中断。该方法用基址寄存器加上例外原因(作为偏移)作未目标地址来完成控制流转换。

基址寄存器中保存了向量式中断内存区域的起始地址

在这里插入图片描述

操作系统可根据例外向量起始地址来确定例外原因。如果不使用这种方法,如 RISC-V,就需要为所有例外提供统一的入口地址,由操作系统解析状态寄存器来确定例外原因。对于使用向量式例外的设计者,每个例外入口需要提供比如 32 字节或 8 条指令大小的区域,供操作系统记录例外原因并进行简单处理。

通过添加一些额外寄存器和控制信息,并稍微扩展控制逻辑,就可以完成对各种例外的处理。假设,我们使用统一入口地址的方式实现例外处理,设置地址为 0000 0000 1C09 0000。我们还需要添加两个额外的寄存器。

  • SEPC:64 位寄存器,用来保存引起例外的指令的地址
  • SCAUSE:用来记录例外原因的寄存器。在 RISC-V 体系结构中,该寄存器为 64 位,大多数位未被使用。假设对上述提及的两种例外类型进行编码并记录,其中未定义指令的编码为 2,硬件故障的编码为 12.

2.2 流水线实现中的例外

流水线实现中,将例外处理看成另一种控制冒险。

处理分支预测错误时,我们将取指阶段的指令变为空操作 (nop),以此来消除影响。对于进入译码阶段的指令,增加新逻辑控制译码阶段的多选择器输出为 0,流水线停顿。添加一个新的控制信号 ID.Flush,它与来自于冒险检测单元的 stall 信号进行或操作。使用该信号对进入译码阶段的指令进行清除。对于进入执行阶段的指令,我们使用一个新的控制信号 EX.Flush,使得多选择器输出为 0。RISC-V 体系结构中使用 0000 0000 1C09 0000 作为例外入口地址。为保证例外入口地址送到寄存器。于是有了如下的图:

在这里插入图片描述

上述例子我们需要注意一个问题:如果我们在 add 指令执行完毕后检测例外,程序员将无法获得 x1 寄存器中的原值,因为它已更新为 add 指令的执行结果。如果我们在 add 指令的 EX 阶段检测例外,可以使用 EX.Flush 信号避免该指令在 WB 阶段更新寄存器。有一些例外类型,需要最终完成引发例外的指令的执行。最简单的方法就是清除该指令,并在例外处理结束后从该指令重新开始执行。

最后一步是,在 SEPC 寄存器中保存引发例外的指令的地址。

同时还有一个问题,对于流水线处理器,每个周期同时有 5 条指令在流水线中执行,例外处理的挑战在于如何将各种例外与指令进行对应。而且,同一周期内可以同时发生多个例外。解决方法是,对例外进行优先级排列,便于判断服务顺序。

3. 指令间的并行性

流水线技术挖掘了指令间潜在的并行性,这种并行性被称为指令级并行(ILP)。提高指令级并行度主要有两种方法。第一种是增加流水线的级数,让更多的指令重叠执行另外一种提高指令并行度的方法是,增加流水线内部的功能件数量,这样可以每周期发出多条指令。这种技术被称为多发射

实现多发射处理器主要有两种方法,区别在于编译器和硬件的不同分工。如果指令发射与否的判断在编译时完成的,称为静态多发射如果指令发射与否的判断是在动态指令过程中由硬件完成的,称为动态多发射

在多发射流水线中,需要处理如下两个主要任务:

  • 将指令打包并放入发射槽,在大多数静态发射处理器中,编译器会完成这部分工作。而在动态发射器中,这部分工作通常会在运行时由硬件自动完成,编译器可以通过指令调度来提高发射效率。
  • 处理数据和控制冒险。在静态发射处理器中,编译器静态处理了部分或所有指令序列中存在的数据和控制冒险。相应的,大多数动态发射处理器在执行过程中使用硬件技术来解决部分或所有类型的冒险。
3.1 推测的概念

推测是另外一种非常重要的深度挖掘指令级并行的方法。以预测思想为基准,推测方法允许编译器或处理器来猜测指令的行为,并允许其他与被推测指令相关的指令提前开始执行。

推测的难点在于预测结果可能出现错误。因此,所有推测机制都必须包括预测结果正确性的检查机制,以及预测出错后的恢复机制,以消除推测式执行带来的影响。这种恢复机制的实现增加了结构设计的复杂度。

可以在编译时完成推测,也可以在执行时由硬件完成推测。

实现推测错误时的恢复机制非常困难。在软件实现的推测中,编译器经常需要插入额外的指令来检测的正确性,并在检测到推测错误时提供例程进行恢复。在硬件推测式执行中,处理器通常会保存推测的结果直到推测被确定是正确的。如果推测是正确的,将使用保存的推测结果更新寄存器或存储器,完成推测路径上的指令如果推测是错误的,硬件清除推测结果,并从正确的指令处重新开始执行,推测错误需要对流水线进行恢复或者停顿,这显然会极大地降低性能

推测式执行还会引入另一个问题:对某条指令进行推测还可能引入不必要的例外。比如某条 load 指令处于推测式执行,同时该 load 指令的访存地址发生了越界,则会引发例外。如果推测是错误的,这就意味着发生了本不该发生的例外。如果推测是错误的,这就意味着发生了本不该发生的例外。这个问题非常复杂,因为如果这条 load 指令不是推测执行,那么例外时一定会发生的。对于编译支持的推测式执行,可以通过添加特定支持来避免这样的问题,对此类例外一直延迟响应直到确认推测正确。对于硬件推测式执行,例外将被记录直到确认推测正确,这时被推测的指令将被提交,检测到例外,转入正常的例外处理程序进行执行。

如果推测正确,处理器的性能将被改善;一旦推测错误,处理器的性能会受到较大影响。

3.2 静态多发射

静态多发射处理器是由编译器支持打包和处理指令间的冒险。对于静态多发射处理器,可以将同一周期发射出去的指令集合(发射指令包)看成一条需要进行多种操作的“大指令”。因为静态多发射处理器通常会对同一周期发生的指令类型进行限制,将发射指令包看成一条预先定义好、需要进行多种操作的指令,这正符合超长指令字的设计思路。

同时,大多数静态发射处理器也依赖编译器来处理数据和控制冒险。编译器的任务包括静态分支预测和代码调度,以减少或消除所有的冒险。

举个例子:

为了解静态多发射技术,我们考察一个简单的双发射 RISC-V 处理器。其中,指令序列中的一条指令是定点 ALU 指令或者分支指令,另一条指令是 load 或者 store 指令。通常,一些嵌入式处理器正是如此来使用。单个周期内发射两条指令需要同时取指和译码 64 位指令。在许多静态发射处理器,特别是超长指令字处理器中,为简化指令的译码和发射,对可同时发射的指令组合做出了限制。例如:需要指令成对,指令地址需要 64 位边界对齐,ALU 指令和分支指令放在前面。而且,如果指令对中的一条指令无法发射,需要将其替换成 nop 指令。这样一来,就保证了指令总是成对发射,当然其中一条可能是 nop。

在这里插入图片描述

静态多发射处理器对于潜在的数据和控制冒险有不同的解决方法。在一些设计实现中,由编译器来实现所有冒险的解决、代码的调度以及插入相应的 Nop。因此在代码动态执行过程中,硬件可以完全不去关心冒险检测或者流水线停顿的产生。

另外一些设计实现中,使用硬件来检测两个指令包之间的数据冒险,并产生相应的流水线停顿。编译器只负责在单个指令包中检测所有类型的相关。即便如此,单个冒险也通常会导致整个指令包的发射停顿。不论是采用软件来解决所有的冒险,还是仅在两个指令包间降低冒险发生的比例,如果使用上文中提到的“单条大指令”的思想来进行分析,将更有助于加深理解。

如果想同时发射 ALU 和数据传输类指令,除了上文所说的冒险检测和流水线停顿逻辑,首先需要添加的硬件资源是寄存器堆的读写口。在同一个时钟周期内,ALU 指令需要读取两个源寄存器,store 指令可能需要读取两个以上的源寄存器,ALU 指令需要更新一个目标寄存器,load 指令也需要更新一个目标寄存器。由于 ALU 部件只负责 ALU 指令的执行,因此还需要额外增加一个加法器来进行访问地址的计算。如果不增加这些额外的硬件资源,我们的双发射流水线将产生大量的结构冒险。

很明显,这种双发射处理器最多提高两倍的性能,但这也需要程序中存在两倍的、可重叠执行的指令数目。而这种重叠执行又会因增加数据和控制冒险而导致性能损失。为什么会导致性能的损失?具体的如下,例如,在我们的简单五级流水线结构中,load 指令有一个周期的使用延迟。如果下一条指令需要使用 load 指令的结果,那么它必须停顿一周期。同样在双发射五级流水线结构中,load 指令也存在一个周期的使用延迟,而这时需要停顿后续两条指令的执行。而且,在单发射五级流水线中,ALU 指令本来是没有使用延迟的。但在双发射流水线中,需要同时发射 ALU 指令和 load 或 store 指令。如果这两条指令存在数据冒险,则 load 或 store 指令不能被发射,相当于 ALU 指令增加了一个周期的使用延迟。为有效挖掘多发射处理器中可用的并行性,需要使用更高级的编译器或硬件动态调度技术,静态多发射器对编译器提出了更高的要求。

在这里插入图片描述

举个简单例子,具体指令如下:

Loop:
ld x31,0 (x20) //x31 = array element
add x31,x31,x21 //add scalar in x21
sd x31,0 (x20) //store result
addi x20,x20,-8 //decrement pointer
blt x22,x20,Loop //compare to loop limit
//branch if x20 > x22

于是有了如下的指令的图,具体如下:

在这里插入图片描述

于是有了新的优化的方式,就是循环展开是一种专门针对循环体提高程序性能的重要编译技术。它将循环体展开多遍,从不同循环中训中可以重叠执行的指令来挖掘更多的指令级并行性。于是有了如下的执行的流程,具体如下:

在这里插入图片描述

在循环展开的过程中,编辑器使用了额外的寄存器 (x28、x29 和 x30),这样的过程称为寄存器重命名。寄存器重命名的目标是,处理真数据相关,消除指令间存在的其他数据相关。这些数据相关将会导致潜在的冒险,或者妨碍编译器进行灵活的代码调度。如果只使用 x31,考虑展开后的代码将会如何:ld x31,0 (x20),add x31,x31,x21,之后跟着 sd x31,8 (x20),这样的指令序列不断重复,除了都使用 x31,这些指令实际上是相互独立的。也就是说,不同循环的指令之间是没有数据依赖的。这种情况称为反相关或名字相关。

在循环展开时对寄存器进行重命名,可以允许编译器移动不同循环中的指令,以更好地调度代码。重命名的过程可以消除名字相关,但不能消除真相关。

3.3 动态多发射处理器

动态多发射处理器也称为超标量处理器或朴素的超标量处理器。在最简单的超标量处理器中,指令按序发射,由硬件来判断当前发射的指令数:一条还是更多,或者停顿发射。显然,如果想让这样的处理器获得更好的性能,仍然需要编译器进行指令调度,消除指令间的相关,提高指令的发射率。不过,即使编译器配合进行了指令调度,在这个简单的超标量处理器和超长指令字处理器之间仍然存在一个重要的差别,即不论软件调度与否,硬件必须保证代码运行的正确性。此外,编译生成代码的运行正确性应该与发射率或处理器的流水线结构无关。但是,在一些超长指令字处理器中,情况却不一样。代码需要重新编译才能正确运行在不同处理器实现上。还有一些静态多发射处理器,虽然代码在不同的处理器实现上应该能运行正确,但实际情况经常会比较糟糕,仍然可能需要编译器的支持。

许多超标量处理器扩展了动态发射逻辑的基础框架,形成了动态流水线调度技术。动态流水调度技术由硬件逻辑选择当前周期内执行的指令,并尽量避免流水线的冒险和停顿。

动态调度流水线

动态调度流水线由硬件选择后续执行的指令,并对指令进行重排来避免流水线的停顿。在这样的处理器中,流水线被分成三个主要部分:取指和发射单元,多功能部件以及提交单元。具体的如下图:

在这里插入图片描述

取指和发射单元负责取指令、译码、将各指令发送到相应的功能单元上执行。每一个功能单元前都有若干缓冲区,称为保留站。保留站中存放指令的操作和所需的操作数。只要缓冲区中指令所需操作数准备好,并且功能单元就绪,就可以执行指令。一旦指令执行结束,结果将被传送给保留站中正在等待使用该结果的指令,同时也传送到提交单元中进行保存。提交单元中保存了已完成指令的执行结果,并在指令真正提交时才使用它们更新寄存器或者写入内存。这些位于提交单元的缓冲区,通常被称为重排序缓冲。和静态调度流水线中的前递逻辑一样,重排序缓冲也可以用来为其他指令提供操作数。一旦指令提交,寄存器得到更新,就和正常流水线一样直接从寄存器获取最新的数据。

我们先来看如下的步骤:

  1. 发射指令时,指令会被拷贝到相应功能单元的保留站中。同时,如果指令所需的操作数已准备好,也会从寄存器堆或者重排序缓冲中拷贝到保留站中。指令会一直保存在保留站中,直到所需的操作数已被拷贝至保留站中,它们在寄存器堆中的副本就无须保存了,如果出现相应寄存器的写操作,那么该寄存器中的数值将被更新。
  2. 如果操作数不在寄存器堆或者重排序缓冲中,那它一定在等待某个功能单元的计算结果。该功能单元的名字将被记录。当最终结果计算完毕,将会直接从功能单元拷贝到等待该结果的保留站中,旁路了寄存器堆。

上面这些步骤充分利用了重排序缓冲和保留站来实现寄存器重命名。

从概念上讲,可以把动态调度流水线看作程序的数据流结构分析。处理器在不违背程序原有的数据流顺序的前提下以某种顺序执行指令,被称为乱序执行。这是因为这样执行的指令顺序和取指的顺序是不同的。

为使得程序行为与简单的按序单发射流水线一致,乱序执行流水的取指和译码都需要按序进行,以便正确处理指令间的相关。同样,提交阶段也需要按照取指的顺序依次将指令执行的结果写入寄存器和存储中。这种保守的处理方法称为按序提交。如果发生例外,处理器很容易就能找到例外前的最后一条指令,也会保证只更新在此之前的指令需要改写的寄存器。虽然流水线的前端和后端都是按序执行,但是功能部件是允许乱序执行的。任何时候只需要数据准备好,指令就可以被发射到功能部件上开始执行,目前,所有动态调度的流水线都是按序提交的。

更为高级的动态调度技术还包括基于硬件的推测式执行,特别是基于分支预测。通过预测分支指令的转移方向,动态调度处理器能够沿着预测路径不间断地取指和执行指令。由于指令都是按序提交的,在预测路径上的指令提交之前就已经知道分支指令是否预测成功。支持推测执行的动态调度流水线还可以支持 load 指令访存地址的推测。这将允许乱序执行 load store 指令,并使用提交单元来避免不正确的推测。

3.4 高级流水线和能效

通过动态发射和推测式执行深度挖掘指令级并行能力也会带来负面影响,其中最重要的就是降低了处理器的能效。每一个技术上的创新都可能产生新的结构,使用更多的晶体管来获取更高的性能。但是这种做法可能很低效。目前,我们已经撞上了功耗墙,因此转向设计单芯片多处理器架构,这样就无须像之前那样设计更深的流水线或者采用更激进的推测机制。

我们相信,虽然简单处理器运行速度不如复杂处理器,但是相同的性能下它们的能耗更低。因此,当结构设计受限于能量而非晶体管数量时,简单处理器能够在单芯片上获得更高的性能。具体的如下图:

在这里插入图片描述

4. 写在最后

通过这几章的简单的介绍,我们大概的理解了 CPU 的执行流程,简单的数据通路,以及 CPU 如何通过流水线来提高吞吐量,同时流水线带来了那些问题,以及我们是如何解决的,同时最后还简单介绍两种的发射器,一种是静态发射器,还有另外一种动态发射器。


via:

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值