这个问题可能需要吸三袋烟的时间才能想明白——《福尔摩斯探案集》
它是一种将多条指令重叠执行的实现技术。一般来说,我们按照严格的先后顺序来执行各个指令,那么执行的时间就大约是执行每一条指令的时间之和。而流水线的思想和工业中汽车装配线较为类似,只是装配的是不一样的汽车(不一样的指令)。
我们定义处理器周期为流水线下移一步需要的时间。由于各级同时进行,处理器周期的长度由最缓慢的流水线级来决定。
由此看来,流水线设计者的目标应该是平衡各个流水线的长度。
首先是RISC指令集的特点:
我们一般将其中的指令分为三类:
要想理解如何用流水线来实现\(\text{RISC}\)指令集,我们就需要理解它是如何实现没有流水线的形式的。在这里,我们把每条指令分成五个部分,这种方法未必是性能最高的,但是能方便我们引向流水线。五个时钟周期分别干的事情如下:
指令提取(\(\text{Instruction Fetch}\)):将程序计数器发送到存储器,从那里提取当前指令,并让计数器\(+4\)(可能会因为\(\text{branch}\)指令的存在,可能出现\(pc'\neq pc + 4\))。
指令译码(\(\text{Instruction Decode}\)):对指令进行译码,并取出对应的寄存器。注意到,在\(\text{RISC}\)指令集中,寄存器/立即数的位置都是固定的,所以译码和取寄存器值的过程可以并行。必要的时候,我们可能需要判断是否为分支/是否要进行符号为拓展......以完成分支目标地址的计算。
执行/有效周期地址(\(\text{Execute}\)):这里\(\text{ALU(arithmetic logic unit)}\)会对上一周期准备的操作数进行操作,根据指令类型执行下面三条指令之一:
这里的三条操作可以放进同一时钟机器是用到了"只有\(\text{load/store}\)两种操作会访问内存"的性质,这保证我们不会从内存中拿出个东西然后对其进行运算。
存储器访问(\(\text{MEM}\)):从内存中读取数据/将寄存器中读取的数据写入存储器。
写回(\(\text{Write Back}\)):将结果写入寄存器堆。
在这种实现中,分支指令需要两个周期,存储指令需要四个周期,所有其他指令需要五个周期。假设分支频率为\(12\%\),存储频率为\(10\%\),那么\(\text{CPI}=4.54\)(每条指令的时钟周期数)。
这个流水线的设计看起来很简单,但是我们还有很多问题没有想清楚。第一个问题是,我们如何确保在同一时钟周期内,不会堆相同数据路径执行两个不同的操作。幸运的是,\(\text{RISC}\)指令集比较简单,我们可以保证这件事情。这里给出三条主要的理由:
另外一个问题是,我们如何确保不同流水级中的指令不会相互干扰。这种分离是通过在连续流水级之间引入流水线寄存器来完成的。具体而言,在时钟周期的末尾,我们将一个给定流水级得出的所有结果都存储到寄存器中,在下一个周期用作下一级的输入。(再想想)
如果流水线中的每条指令独立于所有其他指令,那这种简单的\(\text{RISC}\)流水线对于整数指令可以正常运行。实际上,流水线中的指令可能是相互依赖的。这便是下一节我们要讨论的事情。
冒险是什么?就是说这条指令的存在会阻止指令流后面的指令在其指定的时钟周期内执行,这降低了流水线的加速比。共有以下三类冒险:
一个直观的解决方法是,如果一条指令会出现冒险,就将其延迟,同时这条指令之后发射的所有指令也被停顿。而在它之前发射的指令必须执行,为了解决冒险。
数据相关根据冲突访问读写的顺序可以分为三种(read after read当然不会引起冲突):read after write(后面指令要用到前面写的数据,真相关),write after write(两条指令写在同一个单元,输出相关),write after read(后面的指令覆盖前面指令读的单元,反相关)。在五级流水线中,只有\(\text{RAW}\)会导致流水线冲突,而另外两种在乱序执行流水线中还是有可能导致冲突的。
下面讨论\(\text{RAW}\)导致的流水线冲突以及对应的解决办法。对于如下的指令序列:
add r2, r1, r1
add r3, r2, r2
lw r4, r3, 0
add r5, r4, r4
这里的三条线都代表了\(\text{RAW}\)危险。如果第二条指令需要执行,那么必须等第一条指令写回。为了保证执行正确,一个简单的方法是让第二条指令在译码阶段阻塞,直到第一条指令写回。下面给出了这样操作的图示:
在底层电路中,实现是:被阻塞流水级的寄存器的值保持不变,同时向被阻塞流水级的下一级流水级发送无效信号,用流水线空泡(bubble)填充。
可以想象,这种操作会让流水线执行效率大大降低,我们需要更为高效的解决方式。那么下一章介绍的前递技术就是一种更好的方案。
注意到,对于刚才的第二条指令,虽然其所需要的运算数还不在寄存器中,但是它可能已经在执行阶段算出来了。那么,能不能直接在这个值算好了以后拿过来用呢?由此,我们便有了前递技术。
它的具体实现方式是,在流水线中读取指令源操作数的地方通过多路选择器直接把前面指令的运算结果作为后面指令的输出。考虑到执行级能算出一些运算数,将这个结果前递到读寄存器的地方,可以减少bubble的时长。类似地,我们可以设计访存级、写回级、译码级的前递道路。新的流水线时空图如下:
控制冒险本质上是因为对程序计数器\(pc\)的冲突访问引起的。比方说前面有一条指令是jirl r0, r1, 0
。如果我们不在下一条指令取指令的时候加入两拍的阻塞,就会取到错误的指令。
但这里我们其实还能做一个优化。如果增加专用的运算资源将转移指令条件判断和计算下一条指令\(pc\)的位置放到译码阶段 ,那么下一条指令只需要等一拍。具体操作如下:
如果我们希望更进一步地减少控制冒险导致的阻塞,可以采用转移指令的延迟槽技术。我们明确,延迟槽内指令的执行不依赖于转移指令的结果,这样一拍也不用等。
这种问题出现的原因是因为两条指令要同时访问流水线中的同一个功能部件。比方说,流水线中只有一个译码部件,所以某一条指令就需要等一会儿。这种问题比较严重,因为它甚至会阻塞在数据层面没有任何冲突的指令。
上图画出了新加入的逻辑。为了解决数据相关,加入了寄存器的判断逻辑,考虑执行、访存、写回的最多三条指令的目的寄存器信息,与译码级的源寄存器比较,并通过比较结果来决定是否阻塞译码级。为了解决控制相关,加入了译码级和执行级能够修改 PC 级有效位的通路;为了解决结构相关,加入了译码级到 PC 级的阻塞控制逻辑;为了支持前递,加入了从执行级、访存级到译码级的数据通路,并使用寄存器相关判断逻辑来控制如何前递。 可以看出,大多数机制都加在了前两级流水线上。
回顾之前讨论的\(CPI\)计算公式,我们发现,要想提高流水线效率,可以改变理想\(CPI\),或者可以从降低流水线阻塞的角度入手。
这里主要讨论多发射数据通路。它的意思很简单,就是每一拍用\(pc\)从指令存储器中取多条指令,在译码等操作中也进行多条指令的操作。自然地,它需要更多的资源,例如采用支持双端口的存储器,也需要更复杂的阻塞判断逻辑。(即只有同一级流水线的两条指令都不被更早的指令阻塞时,才能让这两条指令一起继续执行)
[1] 计算机体系结构:量化研究方法
[2] 计算机体系结构基础