RISCV停顿与非停顿冒险/x0寄存器的数据冒险处理陷阱/寄存器同周期读写的解决方法/解决EX/MEM冒险

RISC-V

Posted by Bruce Lee on 2024-01-26

结构冒险的避免

这种硬件结构上的缺陷,导致流水线上的不同指令执行,对于数据通路单元的使用产生了矛盾.
加入处理器只有一种存储器,将指令存储器和数据存储器合并.
如果第一条指令的第4步骤是读数据,需要读存储器,第4条指令需要取指令,读存储器.这就是在硬件使用上的冲突.
所以在设计流水线时,从单周期处理器指令入手,避免在一条指令中使用重复的数据通路单元.避免不同指令的工序出现颠倒,如果出现工序减少,但是执行顺序是一致的,这也是正确的,但是如果顺序不一致,我们应该复制数据通路单元,避免在流水线设计中,出现结构冒险.

数据冒险问题

相比于结构冒险,数据冒险是对于数据的使用上,出现了矛盾.
如果单周期处理器的每条指令都看作是相对独立的任务,任务之间没有依赖关系.
那么不会出现在数据使用上的矛盾.
但是程序中经常有任务之间相互依赖数据的代码:

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

2指令中的x19的值在逻辑上需要add指令更新,2指令在执行读取寄存器/译码指令时,需要访问x19,但是此时x19并没有更新,x19在1指令的5阶段更新.
如果直接这样设计处理器,我们sub中x19的值就是旧值,计算出现差错.
一种简单的方法是等待,x19的值没有更新,我们等待1指令执行,一直到第5阶段执行完毕.
但是这会导致另一种问题:如果2指令中的读取寄存器/译码指令工序停止了,其他正在处理其他指令的工序还会工作吗?

流水线工序传递问题

流水线上的每一个工序在传递时,需要全部工序完成后,同时传递.这一特点也导致了每一个工序的规定时间是最慢工序的执行时间.当出现数据冒险,一个工序的材料没有准备好,该工序无法继续完成逻辑上正确的工作,其所依赖的数据是前面还在流水线上的指令的工序结果.如果想等待前指令完成,除非那正好是前指令的最后一道工序,否则,我们永远无法获得正确的数据.那么就只能清理流水线.
这里的数据冒险特点就是:如果前指令的某道工序的结果需要放在寄存器中,便于后指令的访问,那么在出现前指令数据结果出来后,还没有放置寄存器中,后指令就需要访问寄存器读取最新的结果.(目前阶段在时间上晚于源阶段)
解决方法就是,前指令的数据结果,不再寻求一定要放置寄存器中,因为这会导致流水线停顿.而是直接添加硬件逻辑关系,将其结果送到后指令的工序中.

几种可处理的数据冒险

非停顿RAW冒险

第一种是上述那样的数据冒险,后指令需要的数据已经在前指令中计算出来,但是还没有将数据放在目标寄存器中,我们可以采用前递技术来处理.
这类就是当一条指令要读取的寄存器,是上一条指令的写回目标寄存器,即两条指令访问同一个寄存器,并且存在“读”在“写”的后面时,这两条指令之间就存在RAW相关。
通常只需要前递即可,不会产生流水线停顿

停顿load-use数据冒险

第二种,就是如果后指令需要的数据还没有计算出来,这分为两种情况.
第一种是:正在执行的后指令所需要的数据,前指令也正在计算该数据,那么我们可以在该周期内,后指令工序停止执行,等待前指令工序执行完毕,然后传递过来,再执行,等后指令执行完毕,同时移动流水线.术语是:载入-使用型数据冒险
第二种是:正在执行的后指令所需要的数据,前指令还没有也没正在计算,这会导致流水线停顿.

通常读取寄存器的时候,数据还没有从数据寄存器读出,一般相差两个时钟,所以需要停顿和前递.

普通情况下PC移到下一个地址,发生在什么时候.

在很多专业书籍里面都提及到pc自增的知识,一般描述都是在pc指向的地址取出指令,然后pc自动默认移向下一条指令.
这个移动发生在计算阶段:第一阶段需要取出指令,然后进入译码/读取寄存器阶段,同时,pc默认的递增值也在计算.但是这个时候控制器也在计算控制信号,当这个阶段完成后,进入alu计算阶段时,pc的值才被更新.

重述流水线停顿与流水线指令流

单周期处理器指令,以分割出多道工序加工.这里以"指令"指代单周期处理器指令,以"工序"指代流水线处理器指令.
指令内部的工序,无法停顿,如果出现差错,这就是一个失败的处理器.
指令之间可以出现停顿,这种情况包括上述讲的指令结果依赖,前递,旁路一样.
设计流水线并且想象流水线工作场景,更好的方式是以指令数据流移动的方式,而不是以工序硬件移动的方式.

解决控制冒险的简单思路

当出现条件分支时,条件分支指令还没有计算出下一条指令地址时,就已经有未来指令在流水线上执行了,为了避免这种情况,最简单的就是停止后续指令流水线的载入,等待条件分支指令的结果.在整点程序中,条件分支指令数量大,性能大幅度减低.有几个优化方案.
首先应该想到最简单的优化就是加速条件分支指令的执行,争取在第二阶段就进行译码/计算得出结果,这样流水线只需要停顿一个周期就可以,但是往往有些条件分支指令的执行,需要流水线停顿不止一个周期.

第二个就是想到,为了避免流水线停顿,造成的其他工序闲置,减低性能,我们就在这下一条指令加载流水之前,加载一些安全指令.
这就是MIPS指令系统所做的.在添加一条安全指令在分支条件指令后面,这个往往是编译器所做的安全指令排序,对汇编程序员不可见.在执行分支条件指令时,同时也在执行安全指令,总体上计算机性能并没有减低,但是程序中能够被认为安全的指令可能较少,所以有时候也会造成性能减低.(不管条件分支是否跳转,都会执行,并且不被位置和时间的不同而造成结果不同的指令)

第三个就是分支预测,根据大量的观察来区分不同的分支指令的跳转概率,来假定接下来一定是这个地址的指令,进行加载执行.

流水线寄存器实现工序隔离以及一些逻辑变换

在原来单周期数据通路中,要想做到流水线那样的工序加工,并且为了避免某一个工序加工完成之后盲目的传递到下一道工序,我们需要将工序之间隔离,使用寄存器来存储数据,隔离工序.
在每一个工序之间加一个寄存器"墙",用来存储每一个工序目前的数据.
同一时间段,每一个阶段处理的对象都属于不同的指令,所以任何包含该指令的信息需要随着自己在流水线寄存器中传递.没有的寄存器负责记录你.因此某些使用与单周期数据通路的控制器和控制信号连接都需要作出适当的调整.

寄存器同周期读写的解决方法

我们在设计时,总是架设寄存器在任何时候都是可读的,但是不是在任何时候都是可写的.
所以我们的寄存器需要一个写信号,但是在数据存储器里,我们不仅需要写信号,也需要读信号.
如果在同周期内,写信号来临,这时候也有别的数据通路单元来读取,我们应该怎么处理这种数据竞争呢?
在寄存器实现内部,实现这两种动作的区分.
1.在上升沿来临时,使用写动作,在周期的后半部分(下降沿来临),使用读动作.
2.使用前递技术,也就是当读取动作来临的时候,同时写信号也来临,我们直接将写入数据前递到输出端口.

将发生数据冒险的数据直接传递到ALU输入

我们所有的指令,在执行计算阶段时,需要的输入是之前指令(该指令也还在流水线上)的写入寄存器的值时,我们直接将哪个数据前递到ALU输入前.
这个也是解决其他冒险的通用方法:在需要的时候处理

特殊的x0寄存器的数据冒险处理陷阱

我们都知道,在一条指令需要前一条指令的计算结果时,我们可以在前一条指令的alu输出后,直接将EX/MEM寄存器的值前递到EX的输入端口.这是普通冒险的处理方式.
但是这种普通冒险中有一种特殊的情况:
当前者的目的寄存器是x0,后者的源寄存器也是x0时.
正确逻辑:后者的源寄存器采用x0,是想利用x0的0值.
实际逻辑:后者使用x0时,被识别为需要前递,然后将前者的中间计算结果直接传递的后者的EX阶段输入,这个中间计算结果按理说在被传入x0寄存器后消失,但是我们并没有直接使用x0的结果,而是在其还在EX/MEM阶段时,就直接使用了.
所以在设计前递控制单元逻辑时,需要警惕,前者的RD不能是x0,否则不产生前递.

设计逻辑控制时应该在产生数据端做逻辑输出还是在应用数据端做逻辑采纳

我们为了保持一种简单设计,其实是将产生数据的单元直接广播出去,需要对应单元的数据通路单元来根据实际情况来采纳.
不仅是总线是这样设计的,在前递技术设计时,尤其是ALU计算阶段的数据输入,多选器都是放置在ALU端口前,做控制.

ALU第二数据输入的控制和前递数据控制的安排逻辑

在单周期数据通路中,ALU使用了一个二选一多选器(a多选器)来选择第二个输入是来自寄存器堆还是来自立即数生成器.
在添加了流水线和前递技术后,我们在ALU的每一个输入端口都添加了三选一多选器(b多选器)(1.选择原输入.2.选择EX/MEM前递.3.选择MEM/WB前递),这两个多选器如何安置呢?
首先的想法就是将a安排在b的前面:当指令选择立即数输入时,a会选择立即数,b会选择原输入.当指令选择寄存器时,a会选择寄存器,b会会根据数据冒险来调整选择.这都是合适的.
尝试逻辑思考一下b安排在a的前面:当指令选择立即数输入是,b会选择原输入,a会选择立即数输入.当指令选择寄存器时,b会根据数据冒险来调整选择,a会选择寄存器.这也都是合适的.
为何专家说要将b安排在a的前面呢:分析一下每一个多选器的本质,b的本质就是寄存器输入,不管选择谁,本质就是寄存器中的值,都是寄存器堆里的数据,只不过有些输入的值,还没来得及放入寄存器中.a的输入一个是寄存器,一个是立即数.
这样就清晰了,b确实应该安排在a的前面.

EX冒险与MEM冒险冲突

当出现一个冒险时,我们已经有了解决方案,但是当两个冒险同时出现时,应该选择哪一个前递数据呢?

1
2
3
add x1, x1, x2
add x1, x1, x3
add x1, x1, x4

第2条指令出现了EX冒险,因为前递单元检测到其依赖于上一条指令x1寄存器.
第3条指令出现了EX冒险,MEM冒险,因为前递单元检测到其依赖上一条指令的x1寄存器,这是发生在EX阶段,检测ID/EX寄存器和EX/MEM寄存器得出的.同时呢,也检测到了其依赖上上一条指令,这是发生在MEM阶段,检测ID/EX寄存器和MEM/WB寄存器得出的.
显然我们需要区分这两种情况,根据计算逻辑,我们显然需要选择EX冒险的前递,并且忽略MEM冒险的前递.
有一个EX冒险和MEM冒险的简单的设计逻辑:

解决EX/MEM冒险冲突的设计逻辑

当发生EX冒险时,任何情况下我们都是直接选择EX/MEM寄存器的前递,所以可以很直接的写出逻辑:

1
2
3
4
5
6
7
8
9
10
//A input
if EX/MEM.RegWrite
and EX/MEM.RegisterRd == ID/EXE.RegisterRs1
and EX/MEM.RegisterRd != 0 //避免x0陷阱
ForwardA = 10
//B input
if EX/MEM.RegWrite
and EX/MEM.RegisterRd == ID/EXE.RegisterRs2
and EX/MEM.RegisterRd != 0 //避免x0陷阱
ForwardB = 10

当出现MEM冒险时,我需要甄别一下,是否真的需要EX/MEM前递.
于是有了该MEM冒险逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
//A input
if MEM/WB.RegWrite
and MEM/WB.RegisterRd == ID/EXRegisterRs1
and MEM/WB.RegisterRd != 0
and not (EX/MEM.RegWrite and (EX/MEM.RegisterRd == ID/EX.RegisterRs1) and (EX/MEM.RegisterRd != 0))
Forward = 01
//B input
if MEM/WB.RegWrite
and MEM/WB.RegisterRd == ID/EXRegisterRs2
and MEM/WB.RegisterRd != 0 //避免x0陷阱
and not (EX/MEM.RegWrite and (EX/MEM.RegisterRd == ID/EX.RegisterRd2) and (EX/MEM.RegisterRd != 0)) //避免与EX冒险冲突
Forward = 01

If you like this blog or find it useful for you, you are welcome to comment on it. You are also welcome to share this blog, so that more people can participate in it. All the images used in the blog are my original works or AI works, if you want to take it,don't hesitate. Thank you !