7.6 控制冒险的处理
计算机组成
7 流水线处理器
7.6 控制冒险的处理
转移指令由于其自身的特殊性,总是会给我们带来一些麻烦。那对于流水线处理器来说,更是如此,转移指令会带来更多不良的影响。那我们应该如何应对和解决呢?这一节我们就来探索这个问题。
我们先来看一看转移指令对流水线的影响。这是一条时间轴,每一小格都代表着一个时钟周期。那对于5级流水线来说,5个时钟周期执行完一条指令,但是每一个时钟周期都可以读入一条指令,并且从第五个时钟周期之后,每个时钟周期也都可以完成一条指令。如果流水线始终处于这样充满的状态,那就达到了我们流水线的性能目标,也就是获得最大的指令吞吐率。
那我们来看一看这样一段程序代码。我们假设在T1的这个时钟周期去取指的就是这条add指令,那么到了T2周期,取指部件取回add指令,并交给译码部件进行译码。与此同时,取指部件开始取下一条指令,也就是这条sub指令。然后到T3周期,接着取下一条指令,就是这条beq指令。那通过这段程序我们可以看出,这里很可能会有一段循环。但这只是从我们旁观者的视角,我们能看到所有的程序代码。而对于处理器来说,现在正处于T3这个周期时,它正在去取下一条指令,它根本不知道这条指令是什么。当T3这个周期的取指工作完成之后,虽然这条beq指令的指令编码被取回,也是在T4周期这条beq指令被送到译码部件。而取指部件则会依次去取下一条指令,那就是这条load指令,当取回这条load指令的时候,beq指令译码也已经完成,但我们仍然不知道它的转移条件是否满足,也就是s3、s4这两个寄存器是否相等。所以,这时候我们只能继续取指,那再往下取回的就是这条store指令,就是当T5这个时钟周期完成的时候,beq这条指令也完成了执行的工作,也就是比较完成S3和S4这两个寄存器的值,这时我们才能知道是否要发生这次转移。那我们假设转移的条件是满足的,这样已经进入流水线的这条load和store指令,实际上是不应该被执行的。我们只能把它清除,然后重新从正确的地址开始取指,也就是取回这条减法指令,然后再依次取到这条条件转移指令。但是现在我们所构造出的处理器并不能记住刚才曾从某个地方取回了这条条件转移指令。所以,在这时处理器只是简单地去取指令,因此,它会仍然继续往下取指,再会取到这条load指令,然后再取到一条store指令。只有当这条store指令被取回的时候,刚才取到的这条beq指令才会执行完成,处理器可能又发现原来是要发生转移的,必须把load和store指令清除掉,然后重新取指。
那么就发现在这个循环的执行过程中,总是反复地执行了两条正确的指令,然后取回了两条不应该被执行的指令。从这个图上来看,就有两个红色的椭圆和两个黑色的椭圆交替出现。那在这段循环执行的过程中,实际上有50%的性能就被浪费了。这也是因为转移指令本身和流水线的模式是冲突的,因为转移指令会改变指令的流向,而流水线则希望能够依次地取回指令,将流水线填满。
如果这种情况是非常罕见的,也许我们还可以容忍,但实际上转移指令是非常常用的指令。
通过对大量程序的分析可以看出,大约每隔4到7条指令就会有一条转移指令。转移指令所占的比例大约为15%-25%,而且转移指令往往会导致若干条不应该被执行的指令进入流水线,而清除这些指令则会带来时钟周期的损失。
我们把转移指令所占的比例乘上转移指令带来的时钟周期的损失,就可以大致地测算出转移指令对性能的影响。那对于比较简单的流水线来说,转移指令带来的损失可能还不大。但是我们知道现代的处理器都是超标量深度流水的处理器。
例如像Core i7是4发射16级流水,我们可以简单地认为,流水线在充满的时候,可能会有4乘以16,总共64条指令在流水线中。而再看智能手机当中经常使用的ARM Contex-A15处理器,这是一个3发射15级流水线的处理器,我们也可以简单地认为在流水线中,总共有3乘以15,45条指令在同时执行。一旦出现转移指令,就有可能导致其后的几十条指令都是不应该被执行的。所以说,流水线越深,超标量数越多,转移指令带来的影响就越大。如果不解决这个问题,那我们花费大量的精力设计的这些深度流水线和超标量结构都将失去意义。
那我们就来深入地分析一下转移指令的影响。在执行转移指令的时候,如果确实发生转移,那就需要将其后按顺序 预取进入流水线的这些指令废除,也被称为“排空流水线”。然后从转移目标地址重新获取指令。那细分来看,我们主要要做两项工作,一是要判断要不要转移,也就是转移的条件是否成立。如果执行了一条转移指令,但实际不需要发生转移,那刚才按顺序进入流水线的指令就不需要被废除;第二个问题是转移到哪里,也就是我们为生成目标地址所需要做的工作。
那想要消除转移指令带来的影响,我们就要对每一条转移指令都解决这两个问题。
现在我们把转移指令进行分类的列举。我们按两种分类的方法,可以把转移指令分为4类。
首先来看无条件转移当中的直接转移。这里我们列举了X86和MIPS当中的无条件直接转移指令,其中我们来看一个例子。这条 j 指令,它是一条无条件转移指令,也就是执行到这条指令时,一定会发生转移,而且它还是叫直接转移指令。这就是说,它转移的目标地址是在指令编码中直接给出的。
与之相对的是间接转移。这里也有一些例子,我们来看其中的一条。 这条jr指令,它也是无条件转移指令。执行到它的时候,一定会发生转移,但它转移的目标地址并没有在指令编码中直接给出,而是放在了一个寄存器当中。所以,需要先去读取这个寄存器的内容,才能得到转移的目标地址,这就是间接转移。
然后我们再来看条件转移。像这条beq指令,它需要比较t0和t1这两个寄存器的内容是否相等,如果相等则转移,不相等就不转移。而且它也是直接转移类的指令,它的转移目标地址是直接在指令编码当中给出的。
那我们就以这三条指令为例,来看一看如何消除转移指令带来的影响。
首先来看无条件直接转移。在MIPS当中,这是一条j型指令。对于这条指令,我们不用判断要不要转移,我们只需要考虑转移到哪里这个问题。
那这条指令目标地址的计算方法是这样的。首先这条指令的编码当中,带有一个26位的立即数。这个数就是要转移的目标地址的主体部分,但是我们的目标地址应该是32位的。所以,还差6位,在差的6位当中,低两位我们用0补上,因为目标地址肯定是四字节对齐的,地址的低两位肯定是0。然后还缺4位,我们通过当前的PC寄存器计算而得,先将PC寄存器的内容加4,得到的这个32位数,取其高4位,和26位地址以及最低的两位的0连接起来,构成了一个32位的数,这就是转移的目标地址。
我们可以看到,在这个目标地址的计算方法只与两个内容有关:
- 当前PC的值;
- 这条指令本身的编码。
那我们结合处理器的结构图进行分析。当这条j指令处在取指阶段的时候,指令存储器会送出指令的编码,如果我们增加一些简单的电路,就能判断出这是一条j指令。同时我们将这条指令编码当中的低26位取出来,在低位加2个0,然后在这个PC更新的部件当中,已经会完成PC加4的工作,那我们再将这个PC加4的高4位取出,然后拼接而得到一个32位的数,这就是我们要更新的PC的值,也就是这条转移指令的目标地址。那这些工作都可以在一个时钟周期内完成,并将这个要更新的PC值送到PC寄存器的输入端,在下一个时钟上升沿到来的时候,PC寄存器就可以采样到这个要更新的PC的值。在下一个时钟周期,PC寄存器送出的就是这条转移指令的目标地址了。这样对这条j指令来说,它所需要的转移目标地址在取指阶段就可以获得,流水线不用停顿。
我们再看来无条件的间接转移,也就是jr指令。这条指令是一条R型指令,它的转移目标地址的计算方法是用指令编码当中的rs域指定一个寄存器的编号,用这个编号从寄存器堆当中取出对应寄存器的内容。
那我们还是结合这个流水线的结构图来看。因为这是间接转移,所以在取指阶段得到指令编码之后,并不能获得转移的目标地址,因此取指部件至少要等待一个周期。当这条JR指令进入到译码阶段后,指令编码当中的rs域就会送到寄存器堆,然后得到对应的寄存器的内容,那如果我们在这里把busA这个信号连接到PC的更新部件,那在JR这条指令译码阶段结束的时候,转移的目标地址就可以送到PC寄存器的输入端了。当下一个时钟上升沿来临的时候,这个地址就可以存到PC寄存器当中去,然后在下一个时钟周期,送到指令存储器。因此,对于这条指令来说,因为我们在译码阶段才能获得转移目标地址,所以流水线需要停顿一个周期。
那暂时就先这样,我们先接着看其他指令。
条件转移指令,它是一条I型指令。这条指令目标地址的计算方法是这样的。首先比较rs和rt所指向的寄存器的内容。如果它们相等,它们目标地址是在指令编码当中的16位立即数,进行符号扩展,然后乘以4,再加上当前PC值加4;而如果这两个寄存器的比较结果是不相等,那新的PC的值就只是当前PC值加4。不管寄存器比较的结果是否相等,这个新的PC的值都只跟当前的PC值和指令编码的内容相关,而这两项内容在取指阶段都是可以确定的。所以这么看来,目标地址的生成不会造成流水线的停顿。而问题在于,是否要转移,这个条件的判断。我们还是结合结构图来看一看。
因为要判定转移是否成立,需要比较两个寄存器的内容,而寄存器的内容,我们只能在译码阶段才能获得。这样与刚才的间接转移类似,我们也得让流水线停顿一个周期,才可以获得这两个寄存器的内容。但是与刚才间接指令不同的是,即使到译码阶段的结束,我们依然不能知道转移的条件是否成立,因为我们还需要到执行阶段,用ALU来对这两个数进行比较,从而得到比较的结果。所以在这个结构下,我们需要让流水线停顿两个周期,才能知道转移条件的判定结果。其实要等到执行阶段结束,无非是要对两个32位数进行比较,而比较两个数相等是一个非常简单的功能,不需要用到ALU这么复杂的部件。那我们就可以在译码阶段进行一些小的改造。
我们在寄存器堆的输出,busA和busB这两个信号给它连接一个额外的比较电路。这个电路是很简单的,速度也很快,不至于影响整个译码阶段的时间。那我们把比较的结果再送到PC的更新部件,这样在译码阶段结束的时候,我们就可以将下一条指令的地址送到PC寄存器了。
经过这样的改动,条件转移指令也只需要让流水线停顿一个周期就可以让指令正确地执行了。
那通过上面的分析,我们发现,不同的转移指令带来的控制冒险是不一样的。经过我们的改进之后,无条件的直接转移可以让流水线不停顿;而无条件的间接转移以及条件转移都不得不让流水线停顿一个周期,才能消除控制冒险的影响。但是如果我们还想进一步地消除这个影响,不让流水线停顿是否可以做到呢?
那我们就来介绍一个简单的方法,就是延迟转移技术。我们结合这张代码来进行分析,这里有一条条件转移指令(左图中的beq指令),在它之前依次是减法、加法和异或指令。那按照通常的规则,这些指令依次进入流水线执行,当执行到这条beq指令的时候,如果t1、t2两个寄存器的内容相同,就会跳到Next所指向的地方。在beq进入流水线之后,必须还需要再等一个周期,才能知道转移条件是否满足,那流水线必须停顿一个周期。我们现在就是想办法把这个浪费的周期重新利用起来。既然我们从硬件上现在无法解决这个问题,我们不妨就修改这指令行为的定义,我们就规定它之后的那条指令是一定会被执行的。如果是这样,流水线中就不会出现被浪费的那个周期了。但是我们还要注意,这样的修改不应该改变程序本来想要达到的结果,所以我们就需要修改一下这段代码(左图),我们要在这个beq指令之后填上一条一定会被执行的指令。那我们只能往上找,但是之前的这条减法指令和加法指令,它们的运算结果正好是beq指令所要比较的这两个寄存器。所以,这条加法指令和减法指令必须在beq指令之前执行。而我们再往上看,这条异或指令与我们的判定条件没有关系,现在我们就把这条异或指令挪到beq指令之后,因为我们现在已经修改了转移指令的定义,那我们在流水线的硬件结构上,就可以确定地将beq之后的这条指令(xor,异或指令)进入流水线。而当这条异或指令完成取指进入译码阶段的时候,这条beq指令的条件判断也已经完成。如果条件成立,这时候就可以从Next所指向的这个地方开始取下一条指令了,否则也可以顺序地取下一条指令。但不论是哪一种情况,流水线都不会发生停顿。
对于一个流水线处理器来说,流水线级数越深,流水线结构越复杂,转移指令带来的影响就会越大。而且,我们现在也没有非常完美的解决方案。但也正因为如此,在如何处理转移指令这个方面,有了很多很有意思的研究成果。大家如果感兴趣,可以进一步深入地学习和了解。