605-数据冒险的处理
# 605-数据冒险的处理
在程序当中, 我们经常会对同一个变量进行反复的使用和修改。 那这样对于流水线处理器来说,就会经常出现数据冒险的情况。 我们必须很好的应对和解决。 在这一节,我们就来看一看有哪一些不同的解决方法。
我们先来看这个数据冒险的例子, 产生这个数据冒险,是因为第二条加法指令会用到第一条减法指令的运算结果。 但是在流水线当中,这条加法指令在读取 t0 寄存器的时候, 它前一条减法指令还没有把运算结果写到 t0 寄存器当中去, 所以这里就存在一个数据冒险。要解决这个数据冒险,最简单的方法,实际上是在软件层面进行解决。 假设我们这个处理器的流水线并不能解决这样的数据冒险, 那其实,我们只要通过编程的手段,人为的将这条加法指令退后执行, 让他读取寄存器堆的时间,退后到减法指令寄存器堆之后。 那这应该怎么做呢,我们有一条指令叫做 nop,它的作用是什么也不干,我们就在这个减法指令和加法指令之间插入两个 nop 指令。
这两个 nop 指令只是简单的通过流水线,并占用了相对的时间。 那这样刚才的这个数据冒险至少是不存在了。 而因为这两个 nop 指令的作用,加法指令退后了两个周期才进入流水线, 那么当这条加法指令需要读寄存器堆堆时候,前面堆减法指令已经完成了对寄存器堆堆写。 那加法指令就可以从寄存器堆当中读到正确堆 t0 的值,从而完成正确的加法运算。 所以解决这个数据冒险最简单的方法就是插入 nop 指令,但是这个方法也有很大的问题。 首先,到底应该插入几个 nop 指令,这是和流水线的结构相关的。 如果我们这一段程序放在这个 5g 流水线上是正常运行的,那过几天, 又出了一个更新的处理器,它的流水线是 8 级的,那这个程序放上去,可能运行就会发生错误。 因为流水线变深之后,解决数据冒险需要的周期数可能会变多。 所以插入 nop 的这个方法可行,但是并不好。
在一般情况下,我们还希望对软件屏蔽硬件的这些实现细节。 那既然加了两个 nop 指令能够解决问题,那么就可以尝试在硬件上完成相同的工作。
那刚才通过插 nop 的方法,其实已经给我们提供了借鉴, 我们只要发现存在这样的数据冒险,我们就在硬件的流水线上让各个控制信号都变成执行 nop 指令一样的值。 那在这两个周期,就会产生流水线停顿的效果。而这些和 nop 指令效果一样的控制信号, 它们所产生的状态,就成为一个空泡。那这个空泡随着时钟周期一级一级往后面穿, 从效果上来看,和 nop 指令在流水线当中一级一级的执行是一样的。 只是区别在于,这样的信号是由硬件来产生的。
那现在又有了一个新的问题,如果刚才是在软件中插入了 nop 指令, 那对于这个流水线来说,它是严格的按照取回一条指令进行执行,这样的方式来运转的。 那现在需要在硬件上自动的插入空泡,那就需要一个方式来检测是否出现了数据冒险。 当然这也不难, 如果我们不是看这一段程序代码,而是看处理器当中的这五个部件,那我们怎么来判断存在数据冒险呢。 所谓数据冒险,就是当前有一条指令要读寄存器, 而它之前的指令要写寄存器,但又没有完成, 所以我们只用检查,在译码这个阶段,需要读的寄存器的编号, 这个通过链接在寄存器读口的信号就可以得到。 然后我们再检查后面各个阶段, 其实在每一级,都有些信号能够表明这条指令是否要写某个寄存器,以及要写哪个寄存器。 因此,我们只需要检查后面每一个阶段所要写的寄存器的编号,和当前译码阶段, 所要读读寄存器的编号,是否有相同。如果存在相同,那就是有数据冒险。 那只要出现来数据冒险,我们就在流水线中插入空泡。 这样我们就能通过硬件来解决数据冒险的问题。
但是,在实际的编程当中,这种先写了一个寄存器,然后很快使用的状况是经常出现的。 如果说每次出现,我们都要让流水线停顿的话,对性能的影响就太大了。 所以我们不能只追求做对,还要要求做好。我们还是希望流水线不要停顿。 那这个就是最初我们分析的样子,
减法指令在 800 频秒之后才开始写寄存器, 而加法指令最晚在 500 频秒的时候就要去读寄存器。 我们无法逆转这个时间,所以我们肯定不能把 800 频秒才有的数送到 500 频秒的这个时间去。 但是我们可以换一个角度想一想。这条减法指令的运行结果真的是在这个时候才有的吗? 实上减法运算是在执行阶段由 ALU 这个部件完成的, 所以最晚在 600 频秒的这个时候,要写到 t0 寄存器当中到这个数已经运算完成了。 所以从时间角度来看,在 600ps 之后,我们都可以得到 t0 寄存器的最新的值, 而对于这条加法指令,它真的需要使用 t0 寄存器的值是在它的执行阶段, 也就是 ALU 的部件需要用 t0 的值作为其中的一个输入, 那这个阶段是在 600ps 之后才开始的,我们完全可以将减法运算的结果交给这个加法运算作为输入。
那这种方法,就叫做数据前递。也就是 上一条指令将自己的运算结果往前传递到下一条指令去
那我们刚才已经分析过,在 600ps 的时候,ALU 的输出结果已经是 t0 的值了, 那在 600 频秒的这个时钟上前过去之后,t0 的这个值会被保存到执行和访存之间的这个流水线寄存器当中去。 我们如果把它传递给 ALU 的输入,就可以正确的完成后面这条加法运算了。
那既然从时间上是可行的,我们就可以来看一看硬件上怎么来修改。 这条减法指令在执行完运算以后,运算结果已经保存到了这个寄存器当中。 那现在,这条减法指令进入到访存阶段, t0 的值将会通过这个阶段传到下一级流水线寄存器。 而与此同时,加法指令正在执行阶段,它需要将 t0 寄存器的值送到 ALU 的一个输入端。 那显然,它的上一个阶段从寄存器堆当中读到的值,肯定不是最新的。
现在这个最新的值在访存阶段的连线上。 所以我们从硬件连线上可以把这个信号引回来,从新引到 ALU 的输入端。
当然,这里我们还需要增加一个多选器, 而且我们刚才也讲过,如何去判断在流水线当中出现了数据冒险。 那我们就可以用这样的判断结果作为这个多选器的选择信号, 在出现数据冒险的时候,我们选择这个前递的信号, 那当然,这条加法指令也有可能在第二个原操作数上使用了 t0 寄存器。 所以这个前递的信号还应该传送到 ALU 的另一个输入端
当然在这里也需要加上多选器来进行选择, 那这样的方式就被成为前递。它还有个名称叫作旁路。 那从根本上来说,前递和旁路指的都是这件事情。只不过是观察和描述的角度不同而已。 前递是从指令执行顺序的角度来描述的,而旁路则是从电路的结构角度来描述。 本来前一条指令应该将运行的结果写入到寄存器堆,然后再交给后一条指令使用, 而我们现在搭建来一条新堆通路,相当于绕过了寄存器堆,直接进行了数据堆传递, 所以从硬件实现的角度来看,这是一个旁路。那这就是前递和旁路的关系。
那我们进一步来看,其实不仅仅在这个点可以建立旁路,我们在下一个流水级也可以建立旁路。 那这条旁路在什么情况下会用上呢?我们还是结合一个例子来看。 这个例子前两条指令和刚才的那个例子是一样的, 在此基础上我们又写出了第三条指令, 这是一个与操作,那么它其中的一个原操作数也是 t0,那我们结合实践来看, 对于这条与操作指令,它真的要开始运算的时候,是在 800 频秒之后。那在这个时候,前面这条减法指令已经完成了访存阶段,所以 t0 寄存器的最新值,现在是放在访存阶段和写回阶段之间的流水线寄存器当中的, 那我们就需要用到刚才的结构图当中紫色的旁路的线, 用来将 t0 的内容传递到 ALU 到输入端,从而让这条与运算指令及时的运行。
那如果再往后一条指令又用到了 t0,都会怎么样呢? 那么这个标着 3 的指令在 800 频秒之后的这个时钟周期正好进入了译码阶段, 它会在这个周期的后半部分读取寄存器,那么在这个时候, 减法指令已经将 t0 的值写入到了寄存器堆中, 所以对于这个 3 号指令,如果它用到了 t0 这个寄存器, 它就可以按照正常的操作,从寄存器堆当中读出 t0 寄存器的值,而不需要使用前递的技术。 所以对于这样运算指令,我们建立的这两组旁路的通路,就已经可以解决数据冒险了。
但是还是有一种例外的情况, 我们通过一个新的例子来看,在这个例子当中, 前三条指令还是和刚才一样,第四条是一个 low 的指令, 它也会用到 t0 寄存器,但是我们刚才已经分析过了,这个时候并不存在数据冒险。 而这条 low 的指令是要把存储器当中的一个数取出来,存放到 t1 寄存器当中去。 而它之后,一条或运算指令会使用 t1 寄存器的值, 那这种情况就是一条 low 的指令之后跟了一条指令, 会使用 low 的指令的目的寄存器。那在这种情况下,也会发生数据冒险。 它有个专门的名称,叫作 load-use 冒险。那么这种冒险是否也可以用前递的技术来解决呢?
实际上是做不到的,那我们来分析一下为什么做不到。 对于这一条 low 的指令,我们来看要保存到 t1 寄存器的值,究竟是什么时候才得到的, 对于刚才的运算指令,需要写回寄存器的值,是在执行阶段,也就是通过 ALU 运算而得。 但是对于 low 的指令,用 ALU 是计算要访存的地址, 而要写回寄存器堆堆数,是在访存阶段的结束才会得到, 所以是在 1400 频秒这个地方,我们才会得到 t1 寄存器的值。 而对于下面这一条或运算指令,我们最晚也得在 1200 频秒这个地方, 得到 t1 这个寄存器的值,从而让 ALU 可以进行正确的运算。 因此,这就要求我们将 1400 频秒 这个地方得到的数,传递到之前 1200 频秒这个时刻。 那时光倒流的事情我们是做不到的。 所以我们只能让信号沿着时间轴向前传递,而绝不可能向后传递,
因此,无论我们怎么修改电路,也无法构造出 这样一条前递的通路。那我们应该怎么来解决这个 load-use 的这个冒险呢? 其实说难很难,说简单也就很简单。还是用我们那个万能的方法, 既然我们不能返回到更早的时间,那我们只能让这条或运算指令多等一个周期, 这样它就可以在 1400 频秒之后才需要这个 t1 寄存器的值。 而此时,low 的已经完成了从数据存储器当中取出数的操作, 这就可以通过刚才我们已经建立的第二组旁路通道,也就是用紫色的连线表达的这个旁路通道,将 t1 寄存器的内容传送到 ALU 的输入端口。 那当然,既然我们要让或运算指令延后一个周期,
我们就必须在流水线中插入空泡,让流水线产生一次停顿, 所以对于这种冒险,我们需要用流水线停顿再加上数据前递的方式来解决。 那这个解决方案没有让流水线获得最高的指令吞吐率, 这当然是一个遗憾,但是保证指令执行正确才是我们的首要目标。 所以我们也只能接受这样的方案了。
现在,对于一个基本的流水线结构,我们已经能够处理数据冒险了。 但是,如果继续增加流水线的深度, 或者扩展成超标量流水线,又会出现新的数据冒险的情况。 当然,与之对应的又有很多精巧的解决方案。 如果你对此感兴趣,还可以进一步的深入学习。