从零开始写riscv处理器(五)数据冒险:停顿与前递

在流水线中会有一种情况,在下一个时钟周期中下一条指令不能执行,这种情况被叫做流水线冒险。这里主要考虑两种情况,分别是数据冒险(Data hazards)控制冒险(Control hazards)
由于篇幅较长,怕你看累了hh,所以将数据冒险与控制冒险分开写,控制冒险另起。

1. 数据冒险

1.1 定义

数据冒险:无法提供指令所需数据而导致指令不能在预定的时钟周期内执行的情况。即一条指令的执行需要等待另一条指令执行完成后所产生的数据。

1.2 引例

上一节的示例中展示了流水线的强大功能以及硬件如何通过流水线的方式执行任务。现在从一个更实际的例子出发,看看在程序真正执行的时候会发生什么。
下图是依次执行五条指令的多时钟周期流水线示意图:

后四条指令( and、or、add、sd)都相关于第一条指令( sub)中得到的存放在寄存器 x2 中的结果。假设寄存器 x2 在 sub 指令结果回写之前的值为 10, 在结果回写之后值为 -20, 那么程序员希望在后续指令中引用寄存器 x2 时得到的值为 -20。

在第五个时钟周期回写结果完成之前,对寄存器 x2 的读操作并不能返回 sub 指令的结果。因此,图中的and和or指令不能读到正确的x2寄存器值。对于sd指令,在ID阶段x2寄存器的值已完成回写,显而易见能够读到正确的x2寄存器值。而add指令较特殊:add指令对x2寄存器的读取与aub指令对x2寄存器的回写在同一时钟周期。
那这种情况读取x2寄存器值的结果是否是-20呢?在寄存器的实际实现过程中,写操作发生在一个时钟周期的前半部分,而读操作发生在后半部分。所以读操作会得到本周期内被写人的值,在这种情况下不会发生数据冒险。
因此,图中的 add 和 sd 指令可以得到正确结果 -20, 但是 and 和 or 指令却会得到错误的结果 10。在图中表示为指向左下方的箭头,这种情况称为发生了数据冒险

1.3 数据冒险分类

图中的and 和 or 指令都发生了数据冒险,但是它们的情况又有些差别:

  • and指令的数据冒险是发生在EX阶段指令的源操作数和MEM阶段指令的结果之间,需要将上一阶段(EX阶段)的结果前递,所以将这种数据冒险称为EX冒险
  • or指令的数据冒险是发生在EX阶段指令的源操作数和WB阶段指令的结果之间,需要将上一阶段(MEM阶段)的结果前递,将这种数据冒险称为MEM冒险

2. 停顿(流水线暂停)

既然数据冒险是当前寄存器无法提供指令所需数据,那么最简单的办法就是让当前等待另一条指令执行完成后再执行。可以通过插入气泡(bubble) 的方式来达到此效果:

插入气泡使得流水线“暂停”,当插入两个气泡后,add指令在译码阶段时,sub指令的结果已经完成回写,这两条指令就不存在数据冒险了。但是,这种办法浪费了两个时钟周期,损失较大,一般解决数据冒险都不用插入气泡的方式,而是用前递。

3. 前递

另一种解决数据冒险的方法是前递(Forwarding):也叫旁路(Bypass):仔细分析指令的执行,sub指令的回写结果在执行阶段后就已经知道,and指令在执行阶段开始前才真正需要x2寄存器值,因此,只要可以一得到相应的数据就将其前递给等待该数据的单元,而不是等待其可以从寄存器堆中读取出来,就可以不需要停顿地执行这段指令了。
总结一下:当一个指令试图在 EX 阶段使用的寄存器是一个较早的指令在 WB 阶段要写入的寄存器时,可将较早的指令EX 阶段产生的操作数作为当前指令 ALU 的输入。

3.1 前递如何工作

对于and指令,在EX阶段开始前,需要将上一条指令(sub指令)EX阶段的结果作为EX的输入,因此将EX/MEM寄存器堆中的ALU运算结果与ALU输入相连。因为在第四个时钟周期是and指令的EX阶段,是sub指令的MEM阶段,上一条指令也就是sub指令的执行结果被保存在了流水线寄存器堆EX/MEM中),如下图所示。
对于or指令,在EX阶段开始前,需要将上上一条指令(sub指令)也就是sub指令EX阶段的结果作为EX的输入,因此将MEM/WB寄存器堆中的ALU运算结果与ALU输入相连。因为第五个时钟周期是and指令的执行阶段,是sub指令的WB阶段,各流水线寄存器之间的相关关系会随着时间向前移动,上上一条指令也就是sub指令的执行结果被保存在了流水线寄存器堆MEM/WB中,如下图所示。

3.2 前递的检测

前递条件

对于EX冒险,可以得到两对前递条件:

  1. EX/MEM.RegisterRd = ID/EX.RegisterRs1
  2. EX/MEM.RegisterRd = ID/EX.RegisterRs2
    表示ID/EX寄存器堆中的rs1或rs2与EX/MEM寄存器堆中的rd相同。
    对于MEM冒险,可以得到两对前递条件:
  3. MEM/WB.RegisterRd = ID/EX.RegisterRs1
  4. MEM/WB.RegisterRd = ID/EX. RegisterRs2
    表示ID/EX寄存器堆中的rs1或rs2与MEM/WB寄存器堆中的rd相同。

此处对寄存器关系的命名补充说明一下:
命名流水线寄存器字段是一种更精确的表示相关关系的方法。例如,ID/EX. RegisterRs1表示一个寄存器的编号,它的值在流水线寄存器ID\EX中,也就是这个寄存器堆中第一个读端口的值。该名称的第一部分,也就是点号的左边,是流水线寄存器的名称;第二部分是寄存器中字段的名称。

前递条件的进一步完善1

似乎现在能够对是否需要前递进行判断了?但是考虑更多一点,对于如下指令:

addi x0, x1, 2

首先,并不是所有指令都会写回寄存器,所以要判断RegWrite 信号是否有效。另外,是否记得前面文章说过,riscv架构处理器有一个特殊的寄存器x0,它的值恒为0。所以,即便要写回,如果写回寄存器是x0寄存器,那么寄存器值仍是0。如果流水线中的指令以 x0 作为目标寄存器,要避免前递非零的结果值。
那么如何避免呢?再添加一条检测条件即可:

  • 将 EX/MEM.RegWrite = 1 & EX/MEM.RegisterRd ≠ 0 添加到EX类冒险条件里面
  • 将 EX/MEM.RegWrite = 1 & MEM/WB.RegisterRd ≠ 0 添加到MEM冒险条件里面

现在,进一步将前递检测条件完善为:
EX冒险

  1. EX/MEM.RegisterRd = ID/EX.RegisterRs1 & EX/MEM.RegWrite = 1 & EX/MEM.RegisterRd ≠ 0
  2. EX/MEM.RegisterRd = ID/EX.RegisterRs2 & EX/MEM.RegWrite = 1 & EX/MEM.RegisterRd ≠ 0
    表示要写回且回写的目标寄存器不是x0寄存器,ID/EX寄存器堆中的rs1或rs2又与EX/MEM寄存器堆中的rd相同。

MEM冒险

  1. MEM/WB.RegisterRd = ID/EX.RegisterRs1 & EX/MEM.RegWrite = 1 & MEM/WB.RegisterRd ≠ 0
  2. MEM/WB.RegisterRd = ID/EX. RegisterRs2 & EX/MEM.RegWrite = 1 & MEM/WB.RegisterRd ≠ 0
    表示要写回且回写的目标寄存器不是x0寄存器,ID/EX寄存器堆中的rs1或rs2又与MEM/WB寄存器堆中的rd相同。

前递条件的进一步完善2

现在两种冒险的前递检测条件已经知道了,不妨考虑更加复杂的冒险方式:EX冒险与MEM冒险同时发生,即在 WB 阶段指令的结果、MEM 阶段指令的结果和 ALU 阶段指令的源操作数之间发生。
例如,在一个寄存器中对一组数据做求和操作时,一系列的指令将会读和写一个相同的寄存器:

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

根据思维习惯,第二条add指令所用的源操作数x1应该是第一条add指令回写结果;第三条add指令所用的源操作数x1应该是第二条add指令回写结果。这是写代码时所期望的执行方式,画出期望指令执行的硬件结构图如下:

但事实上若采用前述的前递方式,硬件的执行结果并非如此。在第四个时钟周期,前递能够正常处理第一条add指令与第二条add指令之间的数据冒险,将第一条add指令的ALU结果前递到第二条add指令的ALU输入。但是,在第五个时钟周期,会同时检测到两种冒险,因为第三条add指令与前面两条指令均发生了冒险,此刻的硬件结构图如下:

此时,就会发生所不期望的前递:

在EX冒险与MEM冒险同时发生的情况下,结果应该是来自处理EX冒险的前递数据,因为该数据就是最近的结果。换句话说,当EX冒险与MEM冒险同时发生时,前递具有优先性,应该先处理EX冒险。 因此,需要将MEM冒险的前递条件进一步完善为:

蓝色字体部分为EX冒险的前递检测条件,加上not表示排除EX冒险。
综上,MEM前递条件表示要写回且回写的目标寄存器不是x0寄存器,ID/EX寄存器堆中的rs1或rs2又与MEM/WB寄存器堆中的rd相同,且没有EM冒险同时发生

最后,得到终极版的冒险检测条件如下:


EX冒险
if (EX/MEM.RegWrite
and (EX/MEM.RegisterRd ≠ 0)
and (EX/MEM.RegisterRd = ID/EX.RegisterRs1))

if (EX/MEM.RegWrite
and (EX/MEM.RegisterRd ≠ 0)
and (EX/MEM.RegisterRd = ID/EX.RegisterRs2))
表示要写回且回写的目标寄存器不是x0寄存器,ID/EX寄存器堆中的rs1或rs2又与EX/MEM寄存器堆中的rd相同。



MEM冒险
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))

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))
表示要写回且回写的目标寄存器不是x0寄存器,ID/EX寄存器堆中的rs1或rs2又与MEM/WB寄存器堆中的rd相同,且没有EM冒险同时发生


另一种特殊冒险:Load-use型冒险

在数据冒险的情况中,ld类指令的前递方式相较复杂,因为load指令回写的数据是从data memory中取出,ALU结果并不是最终回写结果。所以,当一条指令试图在加载指令写入一个寄存器之后读取这个寄存器时,前递不能解决此处的冒险。此种情况的数据冒险称为Load-use型冒险

当加载指令后跟着一条需要读取加载指令结果的指令时,流水线必须被阻塞以消除这种指令组合带来的冒险。 将流水线暂停一个周期后,前递逻辑就可以处理这个相关并继续执行程序了。

因此,除了一个前递单元外,还需要一个冒险检测单元。该单元在 ID 流水线阶段操作,从而可以在加载指令和相关加载指令结果的指令之间加入一个流水线阻塞。这个单元检测加载指令,冒险控制单元的控制逻辑满足如下条件:
if ( ID/EX.MemRead
and( ( ID/EX.RegisterRd = IF/ID.RegisterRs1) or (ID/EX.ReglsterRd = IF/ID.RegisterRs2) ) )
stall the pipeline
表明如果IF/ID寄存器堆中的rs1、rs2与ID/EX寄存器堆中的rd相同,且检测为load指令(只有load指令的MemRead=1),指令会停顿一个时钟周期。

流水线暂停是个什么效果呢?简单来说就是将IF和ID阶段暂停一个时钟周期、EX及后面阶段延后一个时钟周期。
对于IF和ID阶段:将PC程序计数器与IF/ID“暂停”,禁止 PC 寄存器和 IF/ID 流水线寄存器的改变以阻止这两条指令的执行。如果这些寄存器被保护,在 IF 阶段的指令就会继续使用相同的 PC 值取指令,同时在 ID 阶段的寄存器就会继续使用 IF/ID 流水线寄存器中相同的字段读寄存器,以达到流水线“暂停”的效果。
对EX 阶段开始的流水线后半部分:执行没有任何效果的指令,也就是空指令(nop)。

硬件中的具体实现细节如下图:

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

为便于理解,以下链接是我制作的动画示意图(制作属实不易,观众老爷赏个币吧😿):
流水线暂停机制动画_哔哩哔哩_bilibili

3.3 前递的硬件实现

3.3.1 多路选择器

根据前面前递的方式,可知进入ALU的操作数可能来自三个途径:

  1. 寄存器堆中取出的rs data(来自ID/EX)
  2. 上一个ALU计算结果的前递(来自EX/MEM)
  3. 数据存储器或者更早的ALU计算结果的前递(来自MEM/WB)
    因此,在ALU的输入路径上增加三选一多路选择器:

输入输出数据有了,还差控制信号,先定义控制信号如下:

BUG

多选器控制 解释
ForwardA = 00 ID/EX ALU的第一个操作数来自寄存器堆
ForwardA = 10 EX/MEM ALU的第一个操作数来自上一个ALU计算结果的前递
ForwardA = 01 MEM/WB ALU的第一个操作数来自数据存储器或者更早的ALU计算结果的前递
ForwardB = 00 ID/EX ALU的第二个操作数来自寄存器堆
ForwardB = 10 EX/MEM ALU的第二个操作数来自上一个ALU计算结果的前递
ForwardB = 01 MEM/WB ALU的第二个操作数来自数据存储器或者更早的ALU计算结果的前递

将定义好的前递信号补充进前递检测条件如下:


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



MEM冒险
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


3.3.2 前递单元

前递单元主要负责对不同冒险的前递条件进行判断,然后给出相应的前递信号(ForwardA、ForwardB)。设计如下:

根据前递条件知:输入需要IF/ID寄存器堆中的rs1、rs2,EX/MEM寄存器堆中的rd与RegWrite信号,MEM/WB寄存器堆中的rd与RegWrite信号。输出为两个前递信号ForwardA、ForwardB。

3.3.3 Load-use型冒险检测单元

根据流水线停顿的要求,设计一个冒险检测单元,它能够控制 PC 和 IF/ID 流水线寄存器的写入。如果Load-use型冒险被检测为真,则冒险检测单元会停顿并清除所有控制字段。

冒险控制单元的控制逻辑:
if ( ID/EX.MemRead
and( ( ID/EX.RegisterRd = IF/ID.RegisterRs1) or (ID/EX.ReglsterRd = IF/ID.RegisterRs2) ) )
stall the pipeline

根据冒险控制逻辑设计冒险检测单元如下:输入为IF/ID.RegisterRs1、IF/ID.RegisterRs2、ID/EX.RegisterRd、MemRead,输出为PC_hold、IF/ID_clear。PC_hold为1,PC寄存器值保持一个clk、IF/ID_stall为1,清空IF_ID流水线寄存器。

将这几个单元添加进已有的硬件架构中如下图所示(这里我将前递单元与Load-use型冒险检测单元合并了hh):

来自广东

评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇