计算机组成
7 流水线处理器
7.4 流水线的冒险
流水线技术之所以能提高性能,究其本质是利用了时间上的并行性。它让原本应该先后执行的指令,在时间上一定程度的并行起来,然而这也会带来一些冲突和矛盾,进而可能引发错误。这就是我们这一节所需要探讨的问题。
首先我们来看什么是冒险。在流水线当中我们希望每一个时钟周期都有一条指令进入流水线开始执行,但是在某些情况下,下一条指令无法按照预期开始执行,这种情况就被称为冒险。冒险分为三种。
一是结构冒险。在这里结构是指硬件电路当中的某个部件,如果这条指令所需要的硬件部件还在为之前的指令工作无法为这条指令提供服务,那就产生了结构冒险。
第二种是数据冒险。如果这条指令需要某个数据而之前的指令正在操作这个数据,那这条指令就无法执行,这种情况称为数据冒险。
第三种是控制冒险。如果现在要执行哪条指令,是由之前指令的运行结果来决定的,而现在之前指令的结果还没有产生,就导致了控制冒险。
下面我们会逐个分析这些冒险。
首先来看结构冒险。我们还是通过一个指令执行的例子来讲,这是一条指令在流水线中执行的步骤,我们是用每一步动作的缩写来表示的,那现在我们换一种描述的方式用这每一步所用到的硬件部件来表示。那么注意到取指阶段需要用到指令存储器(IMem),译码阶段需要用到寄存器堆(Reg),执行阶段需要用ALU,访存阶段需要用数据存储器(DMem),而写回阶段也需要用寄存器堆(Reg)。用这样的方式我们比较容易看出在哪些时刻会有可能出现硬件部件的争抢情况。那么假设执行的这么一段指令的序列,第一条是一个Load的指令,后面是若干其他的指令。那么期望这些指令依次进入流水线开始执行,注意的是第四个时钟周期Load的指令要从存储器中读取数据,而与此同时取直部件也要从存储器当中读取第三条指令的编码,那如果我们这个系统当中指令和数据是存放在同一个存储器当中的,而对于一个存储器在同一个时刻只能接受一个读操作,那这里就会发生结构冒险。那如果我们只能使用同一个存储器怎么来解决这个问题呢?
其实方法很简单,既然不能同时读,那就不读好了。那在这个时钟周期首先让Load的指令去读存储器获得它所需要的数据,而取指部件这时不读存储器而是让流水线停顿。所谓停顿,也不是什么都不管,必须要将相关的控制信号设为不改变机器状态的值,那这种设置我们就称为一个空泡。这个表示也是形象化的描述了流水线停顿这一事情。这样这个结构冒险就被消除了,而第三条指令的取指被延后到下一个周期才开始。当然我们也会说这样会不会和第一条指令的访存产生冲突呢?那当然如果第一条指令也是访存指令,那还是会发生结构冒险。那流水线还需要再停顿一个周期,第三条指定要等到下一个周期再进行取指。如果连续出现几条访存指令,那后面流水线就会连续的停顿,这样效率很低。但是从另一个角度讲这是一种非常安全又简便的方法,用这种方法其实可以解决各种冒险,当然既然它效率比较低,我们还是要尽量避免让流水线停顿。
所以,在现在的处理器当中我们通常还是将指令和数据分别放在不同的存储器当中,就是靠在存储器当中设置独立的指令高度缓存(I-Cache)和数据高度缓存(D-Cache)来实现的。我们还是要强调的在计算机中主存储器也就是内存是统一存放指令和数据的,这也是冯诺依曼结构的要求,只是在CPU当中的一级高速缓存会采用指令和数据分别存放的方式。
那这种结构冒险我们现在就已经解决了。
我们再来看下一种情况。还是这段代码,如果再过了一个时钟周期,这时候Load指令就要将从存储器中读出的数写入寄存器堆了。而与此同时我们注意到第三条指令也在读寄存器堆,那这里就出现了两条指令同时要对一个硬件部件进行操作的情况。要解决这个冒险我们就得让寄存器堆同时支持读寄存器和写寄存器,那我们是如何让寄存器堆提供这样的功能的呢?
其实这和寄存器堆本身的特性有关,相对来说寄存器堆的读写速度比较快。我们假设读或者写寄存器的延迟为100ps,而其他部件比如说ALU的延迟就就比较大,视为200ps。那么我们就可以在前半个时钟周期用于完成寄存器堆的写,后半个时钟周期用来完成读操作,并且在寄存器堆上设置独立的读写口。这样就可以在一个时钟周期内同时完成了读和写的操作。 那这两种机构冒险的情况在我们设计这个处理器的初期就已经避免了,我们在选择处理器的组件时就考虑到了不同指令对各个部件的需求,从而使得不同的指令不至于争抢同一个硬件部件。虽然从这个例子看来我们用这个处理器并没有结构冒险的情况,但是如果要设计一个新的处理器,结构冒险仍然是我们优先要考虑并解决的问题。
那好我们再来看一个数据冒险的例子。比如说这段指令的代码第一条减法指令,它的运算结果会放到t0寄存器当中,而下一条指令(add)需要将t0寄存器作为加法运算的一个源操作数,从这段代码的功能看来加法指令所用的t0寄存器的内容显然应该是减法指令的运算结果。但是在流水线处理器上,加法指令开始执行时,这条减法指令的运算结果可能还没有写到t0寄存器当中去,我们结合图示来进行说明。减法指令需要到第五个周期也就是写回这个周期才会将运算结果写到t0寄存器当中去,而加法指令在第三个周期也就是它自己的译码这个阶段就需要读出t0寄存器。那从这里就可以看出,这条加法指令需要用前一条指定的运算结果,但是在这个时刻这个运算结果还没有写回到寄存器当中去,这就产生了数据冒险。如果不做任何处理任由加法指令去读取寄存器堆,那此时得到的t0寄存器的值肯定不是由前面这条减法指令运算得出的,这样就会导致这个程序运行结果的错误。
那么如何来解决这个问题呢?还记得我刚才提过那个万能的方法吗?只要遇到冒险我们用上它就能解决。
对了,这个方法就是让流水线停顿。既然你结果还没有产生,那我就等,等到你结果产生。根据这个流水线的结构我们需要让流水线停顿两个周期,这样在加法指令读寄存器堆的时候,减法指令已经将运算的结果写回到了t0寄存器当中去,所以加法指令读到的是正确的数值。
那么再来看一个控制冒险的例子,这段代码第一条指令是条件分支指令,后续跟了若干的指令。那我们也结合图示来说明,我们要注意的是在第二个时钟周期,处理器就应该去取下一条指令了。但这个时候实际上并不知道是否真的会发生分支,这条分支指令一直要到执行阶段结束,才能知道分支的条件是否成立,也就在600ps这个时候(ALU才会计算出是否跳转的条件)。而处理器希望在200ps的时候就去取下一条指令(instruction 1),这里就产生了控制冒险,因为这个取指令的动作(IF)如何进行,应该由上一条指令(beq指令)的运行结果来决定,而上一条指令(beq指令)的运行结果至少要到两个时钟周期之后(ID和EX之后)才能产生,那在还没有确定是否发生分支的情况下如何进行下一次的取值呢?那如果单纯只想解决这个冒险,而不考虑性能的损失的话,我们还可以用那个万能的方法,就是让流水线停顿。
我们需要插入两个空泡。那么在执行阶段结束之后,我们就知道要从哪个地方开始取新的指令了。这样就可以解决这个控制冒险。
现在我们已经知道了这些冒险所带来的影响。如果不好好解决这些问题,流水线处理器就没有使用的价值。所以,我们之后还需要对其中的一些重点做深入的分析。