虽然流水线设计相较于单周期处理器带来了成倍的性能提升,但这个提升并不是绝对的。
并不是所有的指令都是可以按照流水线设计的那样部分并行,因此在结构上、数据上以及控制上,都会给流水线设计带来挑战进而造成性能无法达到预期的成倍提升。
在这篇博文中,我们首先给出一种通用的流水线冒险解决方案:流水线停顿,然后我们依次给具体的三种冒险和更加针对化的解决方案。
.
流水线停顿
解决流水线冒险的一个万能方法是流水线停顿,毕竟两个指令只要离得够远,各个阶段就不会发生重叠,退化为单周期处理器也就不会有流水线冒险的问题了,而它主要有两种实现方案:
-
软件实现方案:在编译时手动在两条指令之间插入N个NOP指令,从而使流水线停顿N个时钟周期,进而让两条指令之间相差N个时钟周期
-
硬件实现方案:在译码阶段通过比对当前指令需要读取的寄存器以及之前指令写入的寄存器的控制信号来检查是否有数据冲突的情况,并在有数据冲突时输出停顿控制信号,控制部分寄存器(PC寄存器,译码阶段输入寄存器,执行阶段输入寄存器等)在时钟上升边沿不得写入以及部分逻辑电路不工作,从而达到硬件插入NOP的效果,进而使得当前以及之后的指令都往后延迟一个时钟周期执行(流水线停顿效果)
结构冒险
结构冒险,本质上是一个硬件层面的资源竞争问题,也就是一个硬件电路层面的问题。CPU 在同一个时钟周期,同时在运行两条计算机指令的不同阶段,但是这两个不同的阶段,可能会用到同样的硬件电路。最典型的例子就是内存的数据访问,A指令的取指令阶段与B指令的内存访问阶段都会使用到内存这以及对应的地址译码器,此时就会发生冲突。
一个直观的解决方案就是把内存分成两部分,各有各的地址译码器,这两部分其实就是现在的L1 Cache中的指令缓存与数据缓存两个Cache快
数据冒险
数据冒险,其实就是同时在执行的多个指令之间,有数据依赖的情况。这些数据依赖,我们可以分成三大类,分别是先写后读(Read After Write,RAW)、先读后写(Write After Read,WAR)和写后再写(Write After Write,WAW),简单点来说就是下一条指令要用到的数据,由于流水线并发,上一条指令还没准备好对应的数据。
针对数据冒险,常用的一种解决方案就是操作数前传或者操作数旁路,也就是说执行指令时将执行、访存阶段的输出除了连接到下一个阶段之外,还同时连接到上一个阶段并通过控制信号进行控制,从而使得下一条指令不必等到上一条指令完成了写回阶段才能读到对应的值。
控制冒险
控制冒险,其实是对于jmp等跳转指令而言的,CPU无法立即知道jmp其后的那一条指令是否应该顺序加载执行,因此流水线就要停顿了直到jmp指令确定下一个PC所在的地址
针对控制冒险,我们可以发现对于无计算的直接跳转我们可以通过在取指令阶段接上一个译码器而直接得到跳转结果,从而无需流水线停顿,而对于需要计算的直接跳转(计算跳转目的地)或者条件跳转,那么我们至少需要等待1个时钟周期,那么一种解决方案就是乱序执行,将一些原先在jmp指令之前的但是不影响jmp结果的指令放在jmp指令的下面执行,这样不管jmp结果怎么样,本来用来停顿的时钟周期用来执行了应该执行的指令,从而减少了流水线停顿的时间,另外乱序执行也可以用来解决数据冒险的问题。
另外一种解决方案就是分支预测了,也就是说让 CPU 来猜一猜,条件跳转后执行的指令应该是哪一条并直接执行,反正等着也是等着,猜对了皆大欢喜,完美地利用了时间,猜错了则把已经取出指令已经执行的部分给丢弃掉,这个丢弃操作在流水线里面叫做Zap或者Flush,比如,清空已经使用的寄存器里面的数据等等,这些清除操作,也有一定的开销。
题外话:缓存
众所周知,为了弥补内存和CPU速度之间的差距,现代计算机体系结构采用了三层缓存的方式去解决该问题,这里需要注意的是,这里采用了一个分级处理的方式,CPU仅仅问L1 Cache去要数据,在Cahe Miss时,L1 Cache要负责把对应的数据拿到再返回给CPU,而不是让CPU再问L2 Cache要。
另外有一个问题是:Cache Miss导致了流水线阶段(取指令以及访存)大大超过了一次时钟周期时会怎么样,因为理论上每个阶段都要在一个时钟周期内完成。我的理解是:在Cache Miss时,CPU会通过控制信号去阻止寄存器的写入,从而空转并将流水线停顿,一直等到取指令或者访存成功,其实CPU也不知道Cache Miss是否发生了,它只需要在内存访问时将空转控制信号开启直到内存来数据了再关闭空转控制信号即可。
参考
- 《深入浅出计算机组成原理》
- 《编码:隐匿在计算机软硬件背后的语言》
- Coursera-北京大学-《计算机组成》