为什么需要记分牌算法

为了提高处理器效率,人们先是提出了流水线,此时指令还是顺序执行的,而指令之间的执行周期不尽相同,例如访存指令在缓存未命中的情况下往往会阻塞在访存阶段很长一段时间,这时即使是后面不需要访存的指令也会被堵住,导致效率低下。

这时人们就又提出了记分牌算法,让指令不再老老实实的顺序执行,由此指令之间可以互相越过(发射阶段仍然是顺序的),提高流水线效率。当然也不是可以随意超车,在顺序执行时指令之间的数据相关是不会影响执行结果的,而一旦引入乱序执行,就会出现以下情形:

//初始状态r1 = 0, mem(r6) = 1, r3 = 1
//顺序执行
load r2 r6      //从r6中存放的内存地址处读取数据到r2
add r1 r2 r3    //执行后r1 = 2
sub r4 r5 r1    //sub指令读到的r1应为2
//乱序执行时可能出现的情况,add指令需要load指令读取到的r2由此被阻塞,为提高流水线利用率sub指令被提前
load r2 r6
sub r4 r5 r1    //sub指令此时读到的r1为0
add r1 r2 r3

(注意,上面的例子只是用来辅助解释的,并不代表乱序执行的实际情况,实际上,sub指令也会被add指令阻塞,我们需要知道这个例子只是想说明如果只是盲目地乱序执行而不考虑其他因素会产生什么后果,同时也不代表指令是乱序发射的。)

我们看r1寄存器,它是先被add写后被sub读,同理我们看r2寄存器,它是先被load写后被add读,这种情况下就是写后读数据相关,同理也存在写后写,读后写数据相关,至于为什么没有读后读🤔,我是不会告诉你数据相关只存在于有写操作的情况下,试想一下如果数据没有发生改变,先读还是后读又有什么区别呢?所以为什么我们要重视数据相关呢?试想一下你是一个程序员,你的程序在一个乱序执行的CPU上运行,你在你的程序中进行1 + 1的运算并将结果输出到屏幕上,但是由于这个CPU没有考虑数据相关,所以你的程序一会输出2,一会又输出1,接下来的一整天你都要坐在电脑前面思考人生了🧐。

所以我们来梳理一下思绪,首先我们因为顺序执行效率太低,产生了乱序执行的想法,但是乱序执行又会产生数据相关带来的问题,如果我们不理睬数据相关,那么我们的程序根本就无法正常运行啊🤯,于是记分牌算法应运而生。当然我们在后面会看到记分牌算法还是不够完美。

记分牌算法是如何解决乱序执行下的数据相关的?

既然我们要乱序执行,我们就要解决数据相关!

关于记分牌算法的详细说明可以看看【计算机体系结构】记分牌ScoreBoard,可以说本文就是对这篇文章的补充,想了解具体原理的一定要看!

记分牌算法如何解决写后读?

简单回顾指令的执行过程,首先需要读取源操作数,当然源操作数不是想什么时候读就什么时候读的,我们说过数据相关只存在于有写操作的情况下,那么什么时候能读就显而易见了,首先要读取的源操作数就只存在一个数据相关:写后读,也就是说只有在这条指令之前的指令完成了对该条指令的源操作数寄存器的写操作我们才能读,否则我们读到的就是旧值,程序就会与程序员的想法背道而驰(参考上一节的例子)。

那么记分牌算法的哪些机制解决了写后读数据相关呢?在发射阶段记分牌记录了指令需要读取的寄存器(源操作数寄存器),如果此时寄存器可直接读取,相应的Rj,Rk就会被标记为Yes,是否进入取数阶段就取决于Rj和Rk是否都是Yes状态。而如果在此之前的指令还没有完成对该条指令的源操作数寄存器的写入操作,相应的Rj,Rk就会被标记为No。注意在取数阶段完成后Rj,Rk也会被标记为No,因此不能通过标记判断源操作数寄存器是否可读,但是能通过标记判断是否进入取数阶段(对于取数阶段,取完数后Rj,Rk就没有意义了)。

那么如何知道在此之前的指令有没有完成对该条指令的源操作数寄存器的写入操作呢?记分牌算法除了记分牌还有一个部件:寄存器结果状态(Register result status),在发射阶段寄存器结果状态会记录哪个寄存器会被哪条指令写入(注意,在记分牌算法中并不是直接记录哪条指令,而是记录会写入寄存器的是哪个部件,其实很好理解,在解决数据相关时,我们并不会,也没必要在意是哪两条指令之间产生了数据相关,我们只在意什么时候能够读取/写入,以及从哪读/向哪写),也就是会把指令的目标寄存器与指令关联起来,当新发射一条指令时就会检查需要读取的源操作数寄存器是否即将被另一条指令写入以及会被哪条指令写入,并将这个信息记录在记分牌中。

记分牌算法如何解决写后写和读后写?

说完写后读,我们再来回顾一下指令的执行过程,在读取完源操作数后就开始进行运算了,执行运算期间没有对寄存器进行操作,执行完后就到了访存阶段(有的指令不访存),同样访存期间没有对寄存器进行操作,最后就是写回阶段了,所谓写回就是将结果写回寄存器,同样目标寄存器不是想什么时候写就什么时候写的,首先,在此前的指令没有完成对该条指令要写入的目标寄存器的读取操作前是不能进行写入的(读后写),否则前面指令读取到的值是后面指令的运行结果,这就好比未来的你告诉现在的自己一定要好好学习啊,想想都很奇怪,不是吗😱。其次,在此前的指令没有完成对该条指令要写入的目标寄存器的写入操作前是不能进行写入的(写后写),试想一下你考试考了第二名,另一个你的死对头却考了第一名,于是老师将他的名字写在黑板上以示表彰,后来你经过调查发现他考试作弊了,于是你向老师打小报告,结果黑板上的名字就变成你的了,听起来真不错,非常符合社会主义核心价值观🤗,但是一旦反过来就变成闹剧了,你考了第一,黑板上原来是你的名字,但是你的死对头捏造证据诬陷你,而且你还反驳不了,这下黑板上又变成他的名字了,你还背上了作弊的污名,这对吗铁铁💢?在上面的例子中黑板就相当于寄存器,往黑板上写名字就相当于对寄存器进行写入,可见如果调换写入的顺序,结果将会完全不同。

说了这么一大堆废话,那么记分牌算法又提供了什么机制来解决这两个数据相关呢?对于读后写数据相关,指令在写回阶段会检查对应于该条指令目标寄存器的所有Rj,Rk是否全为No状态,如果存在Yes状态表示还有其他指令在读取该条指令的目标寄存器,此时是不可以写入的,当全部为No状态时就代表其他指令要么已经完成了读取,要么就是被此条指令阻塞(此时就变成写后读数据相关了),至于为什么不可能是被其他指令阻塞?下面会解释。

那么对于写后写呢?我们自然而然的会想应该是在写回阶段解决的,其实不然,一条指令在发射前就会检查寄存器结果状态,如果它的目的寄存器已经名花有主了(存在其他已发射指令与此条指令具有相同的目标寄存器),它就自然不会被允许发射,所以写后写是在发射阶段解决的。回到上面提到的“为什么不可能是被其他指令阻塞?”,既然一条指令它的目的寄存器已经名花有主了,它自然也就不会被发射,所以对于一个寄存器,它只可能被已发射指令中的其中一条写入,也就不存在“被其他指令阻塞”。

问题完美解决了,吗?

到此为止我们已经解决了乱序执行下的数据相关,那么是不是就不存在其他问题了?又或者流水线的性能还能再提升吗?

我们先来看一下流水线的性能。众所周知流水线被发明出来是为了提高工厂的生产效率的,我们只要看一看工厂里流水线上的工人有多累,就会感叹我们CPU里的流水线还是太轻松了。工厂流水线上的工人由于干的都是一些简单重复的枯燥工作,而且工作流程比较固定,因此流水线的效率很高(工人休息时间越少,流水线效率越高),反观CPU中流水线要处理的指令类型就比较多样和复杂了,有要访存的,有会产生中断的,加之各指令之间的数据相关,注定其效率肯定是比不上工厂的(那很可悲了,不管是对CPU还是工厂里的工人☹️)。

那么要想最大程度的提高流水线的效率只需要满足一个条件:能上的都给我上!

我们回顾一下【计算机体系结构】记分牌ScoreBoard这篇文章的内容,细心的朋友可能发现了,从始至终指令都是按顺序发射的,如果我在原来的第二条LD和MULD之间插入一条MULD F0, F8, F4(以下简称指令N)指令呢,如果还是顺序发射,第二条LD由于部件被占用而被阻塞,指令N自然也被阻塞。而如果采用乱序发射,由于指令N与前两条LD指令不存在任何数据相关,所以是可以提前发射的。

我们再来回顾一下我们提到的数据相关,数据相关之所以重要是因为只有满足它才能保证执行结果的正确性,而另一方面它也一部分限制了乱序执行(可以乱,但要守规矩🤗)。现在我们重新审视他们仨:

//原代码
add r1, r2, r3
add r7, r1, r3  //一些需要读取r1的指令,写后读是真相关,不能乱序
...
sub r1, r4, r5  //在r1上两者存在写后写数据相关
//改动后的代码
sub r6, r4, r5
add r1, r6, r3  //通过使用了一个额外的寄存器消除了写后写数据相关,乱序执行,但结果不变
add r7, r1, r3  //一些需要读取r1的指令,写后读是真相关,不能乱序
...

可能有的朋友要长脑子了,“那你之前举的黑板的例子算怎么回事?”其实我们在当时并没有看到程序执行的本质。那么程序本质就是得到结果。一条指令,他的源操作数寄存器真的重要吗?恰恰相反,一点也不重要,重要的是数据。寄存器只是一个存储数据的地方,试想一下,如果内存的速度,延迟和寄存器一样快,我们还需要寄存器吗?一条load指令从内存地址A处取出数据存到r1,然后有一条add指令读取了r1,那么add指令需要的是r1吗?不,他需要的只是内存地址A处的数据罢了,你可以load到任何一个寄存器,而add只需要到相应的寄存器去读就行了。同样的在我们的例子中,add和sub都写入r1,那么他们需要的是r1吗?不,他们只是需要一个存放数据的位置,至于存在哪是可以变的嘛!

//原代码
add r1, r2, r3
sub r2, r4, r5  //在r2上两者存在读后写数据相关
//改动后的代码,但是实现的功能相同
sub r6, r4, r5 
add r1, r2, r3  //通过使用了一个额外的寄存器消除了读后写数据相关,乱序执行,但结果不变

从上面的例子可以看出来通过多使用了一个寄存器消除了读后写数据相关。

说完了性能的问题(当然性能瓶颈肯定不止上面提到的),我们再来看看记分牌算法的“bug”,严格来说这个bug和记分牌算法没关系,而是乱序执行带来的bug

在程序员眼中,程序始终是顺序执行的(冯诺依曼体系结构给程序员的保证就是顺序执行),既然我们使用了乱序执行,那么如何骗过程序员,让他以为程序仍然是顺序执行的呢?细想一下,一条指令的决定性瞬间是什么?没错,就是写回寄存器的那一刻!其实CPU可以看成是拥有无数种(不是真的无数,但也很多了)状态的状态机,各种状态在时间线上的排列组合就构成了一段程序在CPU上的执行流程。那么某个时刻的CPU处在哪种状态是怎么确定的呢?其实状态就是由寄存器决定的。寄存器的不同值对应不同的状态,再加上CPU中有那么多各种各样的寄存器,这样排列组合下来,CPU的状态数量就达到了一个很恐怖的数量。如果再将如此多的CPU状态在时间线上进行排列组合(当然其中也存在相当数量的无效状态),就构成了数也数不清的各种程序。

好了,扯的有点远了😋,我们说到的决定性瞬间其实就是能改变CPU状态的瞬间,既然CPU的状态由寄存器的值决定,那么这个瞬间毫无疑问就是写回寄存器的那一刻!

所以我们要营造顺序执行的假象,关键就在于让结果按顺序写回寄存器,同时精确中断也要求结果按顺序写回寄存器,试想一下如果乱序写回,在发生中断时(例如除以0引发的错误),中断现场将变得混乱不堪,这对接下来的中断处理将是灾难性的。

好了关于记分牌算法和乱序执行就先分享到这里,当然关于乱序执行的讨论还远没有结束,在下篇文章我们会看到Tomasulo算法和ROB重排序缓冲是如何发挥乱序执行的强大实力的。