IV. Processor

本篇笔记详细介绍了处理器的两种主要实现方式:单周期 CPU 和流水线 CPU。对于单周期 CPU,讲解了指令执行的五个阶段(IF、ID、EX、MEM、WB)、数据通路的设计以及控制单元的实现,包括一级译码和二级译码的过程。在流水线 CPU 部分,重点讨论了三种冒险(hazard)及其解决方案:结构冒险(通过分离指令和数据内存解决)、数据冒险(通过前递技术和处理 load-use hazards)以及控制冒险(通过分支预测处理)。通过对比这两种实现方式,深入理解了处理器的工作原理、性能优化方法以及各种设计权衡。(由 claude-3.5-sonnet 生成摘要)

1. Single-Cycle CPU

1.1. Prelude

  • 单周期 CPU:每条指令都在一个时钟周期内完成,故 CPI=1\text{CPI}=1。时钟周期的长度取决于花费时间最长的指令。

  • 内存需要被分成 指令内存(instruction memory)数据内存(data memory) 两个部分,分开进行读写。因为我们在这里默认的 memory 实现是只能在一个时钟周期内读或写一个位置的,所以需要把对 data memory 的访问和从 instruction memory 取出指令这两个部分分开到两块内存中进行。

  • 注意:不能简单地把线连在一起,需要用 MUX 实现选择功能。——控制信号

1.2. Instruction Execution Overview

CPU 执行指令一般分为以下五个阶段:

  • 取址(Instruction Fetch, IF):根据 PC\texttt{PC} 所给地址,从存储器中取出指令。
  • 译码(Instruction Decode, ID):分析指令字段,从寄存器堆中读取一个或两个寄存器的值。
  • 执行(Execute, EX):实验中需要实现的指令只需要用到 ALU。
    • 对于 R 型指令:ALU 执行相应的算数逻辑运算,并输出结果;
    • 对于访存指令:ALU 计算基地址和立即数的和,得到数据的真正地址;
    • 对于分支指令:ALU 将两源操作数相减,根据结果是否为 0,判断两数是否相等。
  • 访存(Memory, MEM):从数据内存中读或写数据,用于 load / store 指令中。(注意:不同于 x86 指令集架构,Risc-V 指令集中没有一条指令既从内存中读数据有对读出来的数据进行操作。)
  • 写回(Write Back, WB):将结果写回寄存器堆中。

除了前两个阶段是所有指令所共有的,其他阶段根据具体指令而定,部分指令就不需要 MEM 或 WB 阶段。

1.3. Imm Gen

Imm Gen 是 ID 阶段中的一个模块,用于将指令中的立即数进行 符号拓展 到 32 位或 64 位以进行后续运算。

  • branch 指令的立即数需要补 0(Shift left 1 部分),这是因为 branch 指令的立即数最低位的 0 是省略的,需要补上。

1.4. Datapath

全数据通路如下,实现了基本都 R 型指令和 I 型指令,以及 ldsdbeq 指令。

Example: 在全 datapath 中画出所用到的部分

  • R-type
  • ld
  • sd
  • beq
  • jal

1.5. Control Unit

通过不同的 控制信号(control signal) 控制多个 MUX,可以让在同一条数据通路上实现不同功能的指令,上一小节的途中蓝色的部分就是控制信号。而生成这些控制信号的原件就是 控制单元(control unit)

Signal name Effect When deasserted (=0) Effect when asserted (=1)
RegWrite - 寄存器写入:将寄存器组 Write data 的输入写入到 Write register (rd\texttt{rd}) 中
ALUSrc ALU 的第二个操作数从寄存器第二个输出(Read data 2)中来 ALU 的第二个操作数从符号拓展后的立即数(Imm Gen 生成)
PCSrc (Branch) PC\texttt{PC} 的下一个值是 PC+4\texttt{PC+4} PC\texttt{PC} 的下一个值为分治语句的运算结果
Jump PC\texttt{PC} 的下一个值是 PC+4\texttt{PC+4} PC\texttt{PC} 的下一个值为计算出来的跳跃地址
MemRead - 数据会从数据内存的 Read data 输出,然后交由 MemtoReg 所控制的 MUX
MemWrite - 在数据内存 Write data 输入的数据会被写入 address 输入对应的内存
MemtoReg
(2 位)

00:ALU 的计算结果会被交给寄存器堆的 Write data 输入(R 型、I 型) 01:交给寄存器堆的数据为从数据内存中的 Write data 输出(load 指令)
10:交给内存器堆的数据为 PC+4\texttt{PC+4}jal\texttt{jal}jalr\texttt{jalr} 指令)

1.5.1. ALU Control

多种指令中需要用到 ALU。我们可以进行 二级解码(2-level decode)。在一级译码后,我们得到 4+34+3 个控制信号,和 2 位的 ALU_op

在二级译码时,我们通过上一级的 ALU_op 和指令中的 func7func3 共同决定 ALU 所需操作。

二级译码的方式不是唯一的,这里主要起到一个抛砖引玉的作用。

Example: 粗略估计周期时间

Calculate cycle time assuming negligible delays except:

  • memory (200ps), ALU and adders (200ps), register file access (100ps)

Answer

2. Pipeline CPU

我们以小学就学过的洗衣服为例,引出流水线技术的核心思想:

流水线技术并没有减少所有工作的总时间,甚至单个工作的时间还会增加,但是我们可以让多个工作的不同阶段并行执行,从而提升指令的 吞吐率(throughput),从而减少了完成所有工作的时间。

  • 流水线的平衡性:指不同阶段的操作所需要的时间是否一致。可以通过对指令阶段的适当划分让流水线更为平衡。
  • 流水线的级数(深度):流水线的级数并不是越大越好,因为流水线寄存器会有一个固定延迟。

所以说,流水线的级数是一个 trade-off 的设计。

RISC-V 架构是为流水线设计的

  • 所有指令都是 32 位的。
  • Few and regular instruction formats
    • 源寄存器和目标寄存器的字段相同,可以在同一步进行译码和读寄存器的操作。
  • Load/store addressing
    • 只在 EX 阶段计算地址,只在 MEM 阶段访问内存。

对于流水线 CPU,CPI1\text{CPI} \approx 1,因为在每个时钟周期我们将一条指令送入 IF 阶段;而在下面会讲到,因为 冒险(hazard) 的问题,有时我们必须要插入一些 bubble。

2.1. Pipelined Datapath

  • 从右到左的流容易导致 冒险(hazard),也就是这里标出的 WB 和 Branch 部分。

2.2. Structure Hazards

结构冒险(structure hazard):需要的资源正忙。

  • 我们必须将内存拆分为指令内存和数据内存两个部分,不然在同一时刻,分别处于 IF 和 MEM 阶段的两条指令需要对内存进行操作,只能被迫暂停其中一条。
    • 所以我们将内存分为了 inst memory 和 data memory 两个部分。

2.3. Data Hazards

数据冒险(data hazard):一条指令依赖前面指令所得到的数据。在本课程介绍的五级流水线架构中,我们只需要考虑当前指令和前两条指令是否会产生 data hazards 即可;另一方面,在没有进行任何实现任何优化(如 forwarding)的流水线 CPU 中,出现 data hazards 时只需要 stall 至多两个周期。

为什么只用考虑上两条指令的影响?

这其实关乎到我们实现的一个细节:对寄存器组的读写:WB 阶段的写入发生在时钟上边沿,ID 阶段的读取发生在时钟下边沿。允许我们把相互关联的两条指令的 WB 和 ID 放在同一个时钟周期进行。如果没有实现这一优化,上面的 2 就要改为 3。

观察下一小节 forwarding 中的配图,阴影区域的位置不同正表现了这一点。

2.3.1. Forwarding

考察这个例子,第二条指令的源寄存器是上一条指令的目标寄存器 x1\texttt{x1},我们可以提前把数据取出来给到第二条指令,也就是蓝色的这条通路,这种技术被称为 前递(forwarding)旁传(bypassing)。如果不这样做,则需要等两个时钟周期,这样 EX 阶段得到的数据才是对的。

通过以下条件实现对 data hazards 的判断从而提供 forwarding:

  • 这里是因为像 add 这种指令在 EX 阶段结束后就能得到结果,但是像 load 这种指令要到 MEM 阶段结束后才能得到。
  • 具体判断时,还要注意两个附加条件(即上面截图的这段判断条件是不完整的):
    • 对应的 EX/MEM.RegWrite 信号或 MEM/WB.RegWrite 信号为真,即真的写入了那个目标寄存器。
    • 对应的 EX/MEM.RegisterRd 目标寄存器或 MEM/WB.RegisterRd 目标寄存器不为 x0

我们使用 MUX 来实现对 forwarding 的数据通路控制。在哪个阶段可以得到结果的数据,就从那个阶段直接 forwarding 过来。

还需要注意 double data hazard 的问题,考虑以下示例:

add x1, x1, x2add x1, x1, x3add x1, x1, x4\begin{aligned} &\texttt{add {\color{blue}x1}, x1, x2}\\ &\texttt{add {\color{blue}x1}, {\color{blue}x1}, x3}\\ &\texttt{add x1, {\color{blue}x1}, x4} \end{aligned}

在第三条语句执行到 EX 阶段时,同时存在与第一条指令的 MEM Hazard 和与第二条指令的 EX Hazard,我们需要使用最新的(most recent)结果。即,在这两者同时存在时,应处理 EX Hazard 的 forwarding;也就是说,进行对 MEM Hazard 的 forwarding 当且仅当不能进行对 EX Hazard 的 forwarding 时。

最后得到的判断条件如下:

// EX hazard
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

// MEM hazard
if (MEM/WB.RegWrite and (MEM/WB.RegisterRd != 0)
    and not(EX/MEM.RegWrite 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.RegWrite and (EX/MEM.RegisterRd != 0)
        and (EX/MEM.RegisterRd == ID/EX.RegisterRs2))
    and (MEM/WB.RegisterRd == ID/EX.RegisterRs2))
        ForwardB = 01

加入 forwarding 后得到的数据通路如下:

2.3.2. Load-Use Hazards Detection

考察这个例子,当前指令用到了上一条 load 指令读取出的数据。这种情况被称为 载入-使用型数据冒险(Load-Use Data Hazard)。即使使用了 forwarding 的技术,也必须插入一个 bubble——因为我们要到 MEM 阶段才能得到结果(而不是 EX 阶段)并 forwarding 给下一个 EX。因此,我们不光需要控制 forwarding,还需要控制 stall 一个周期。

考虑数据冒险的场景下计算时钟周期数

如果不考虑数据冒险的问题,只需要 7+(51)=117+(5-1)=11 个时钟周期,这里 55 是因为我们的 RISC-V 架构是五级流水线。但是因为数据冒险的问题,我们必须要 stall 两个周期。可以通过打乱指令顺序的方式,在指令结果相同的情况下,规避掉 Load-Use Data Hazrad 必须 stall 一个周期的问题。

通过以下条件判断是否存在 load-use hazard,如果确实存在,则插入一个 bubble。为什么考察的是 ID/EX.RegisterRd?因为在检测 load-use hazard 时,对应的目标寄存器编号刚好传递到这一阶段。

ID/EX.MemRead && ((ID/EX.RegisterRd=IF/ID.RegisterRs1)   (ID/EX.RegisterRd=IF/ID.RegisterRs2))\begin{aligned} \text{ID/EX.MemRead}\ \&\&\ ((\text{ID/EX.RegisterRd} &= \text{IF/ID.RegisterRs1})\ ||\ \\\ (\text{ID/EX.RegisterRd} &= \text{IF/ID.RegisterRs2})) \end{aligned}

通过将指令转变为 nop 的方式实现 stall,从而将这一指令的剩余阶段转化为 bubble(可以看做是将其包裹起来,不让其产生影响),具体可以参见实验课的课件。

2.3.3. Code Scheduling to Avoid Stalls

前面提到了遇到 load-use hazards 时即使实现了 forwarding 也必须插入一个 stall,这显然是我们不乐意见到的,一种方法是进行 指令重排(code scheduling):在不改变指令执行结果的情况下调整指令顺序,从而避免发生 load-use hazards。

2.4. Control Hazards

控制冒险(control hazard):在有分支语句时,下一条执行的指令是什么必须依赖于上一条指令的计算结果。也因此,控制冒险也被叫做 分支冒险(branch hazard)

解决 control hazard 的方法是使用 分支预测(branch prediction),即提前预读一条指令过来处理的。当然预测可能有错误的时候,这时则需要把错误指令带来的影响 flush 掉,此时必须 stall 并重新获取正确的指令。

静态的(static) 分支预测方法比如基于典型分支行为(循环大概率会跳回去接着循环);动态的(dynamic) 分支预测方法比如通过硬件测量实际分支行为(记录跳转指令是否跳转的次数),这一部分将在《计算机体系结构》课程中进一步讨论。

发生 control hazards 时需要插入几个 bubble?

类似于对 data hazards 的处理,发生 control hazards 时需要插入几个 stall 和控制语句的跳转在哪个阶段决定是有关的。观察下面课本给出的 datapath 可以发现,默认认为对 branch 的决策(即决定是否需要跳转)是在 MEM 阶段处理的。这种时候,一般认为需要添加 stall 使得下一条指令的 IF 阶段在这条指令的 MEM 阶段之后进行,即需要插入 3 个 bubble。

但容易发现,我们其实可以更早地知道跳转结果,比如在 EX 阶段,我们根据 ALU 的 Zero 输出就已经知道是否需要跳转。甚至,我们可以通过添加一个 ALU 的方式将这一判断提前到 ID 阶段进行。相关的内容在书上有专门的一小节作介绍,感兴趣的读者可以自行阅读。所以严谨的题目应该给出跳转发生的阶段,我们据此才能推定需要插入的 bubble 数。

2.5. Pipelined Datapath & Control

控制信号在 ID 阶段全部译出,随后逐级传递下去。当一个信号在之后不再用到时就不需要传递了。

3. References

评论

TABLE OF CONTENTS

1. Single-Cycle CPU
1.1. Prelude
1.2. Instruction Execution Overview
1.3. Imm Gen
1.4. Datapath
1.5. Control Unit
2. Pipeline CPU
2.1. Pipelined Datapath
2.2. Structure Hazards
2.3. Data Hazards
2.4. Control Hazards
2.5. Pipelined Datapath & Control
3. References