极客时间已完结课程限时免费阅读

23 | 冒险和预测(二):流水线里的接力赛

23 | 冒险和预测(二):流水线里的接力赛-极客时间

23 | 冒险和预测(二):流水线里的接力赛

讲述:徐文浩

时长09:11大小8.39M

上一讲,我为你讲解了结构冒险和数据冒险,以及应对这两种冒险的两个解决方案。一种方案是增加资源,通过添加指令缓存和数据缓存,让我们对于指令和数据的访问可以同时进行。这个办法帮助 CPU 解决了取指令和访问数据之间的资源冲突。另一种方案是直接进行等待。通过插入 NOP 这样的无效指令,等待之前的指令完成。这样我们就能解决不同指令之间的数据依赖问题。
着急的人,看完上一讲的这两种方案,可能已经要跳起来问了:“这也能算解决方案么?”的确,这两种方案都有点儿笨。
第一种解决方案,好比是在软件开发的过程中,发现效率不够,于是研发负责人说:“我们需要双倍的人手和研发资源。”而第二种解决方案,好比你在提需求的时候,研发负责人告诉你说:“来不及做,你只能等我们需求排期。” 你应该很清楚地知道,“堆资源”和“等排期”这样的解决方案,并不会真的提高我们的效率,只是避免冲突的无奈之举。
那针对流水线冒险的问题,我们有没有更高级或者更高效的解决方案呢?既不用简单花钱加硬件电路这样“堆资源”,也不是纯粹等待之前的任务完成这样“等排期”。
答案当然是有的。这一讲,我们就来看看计算机组成原理中,一个更加精巧的解决方案,操作数前推

NOP 操作和指令对齐

要想理解操作数前推技术,我们先来回顾一下,第 5 讲讲过的,MIPS 体系结构下的 R、I、J 三类指令,以及第 20 讲里的五级流水线“取指令(IF)- 指令译码(ID)- 指令执行(EX)- 内存访问(MEM)- 数据写回(WB) ”。
我把对应的图片放进来了,你可以看一下。如果印象不深,建议你先回到这两节去复习一下,再来看今天的内容。
在 MIPS 的体系结构下,不同类型的指令,会在流水线的不同阶段进行不同的操作。
我们以 MIPS 的 LOAD,这样从内存里读取数据到寄存器的指令为例,来仔细看看,它需要经历的 5 个完整的流水线。STORE 这样从寄存器往内存里写数据的指令,不需要有写回寄存器的操作,也就是没有数据写回的流水线阶段。至于像 ADD 和 SUB 这样的加减法指令,所有操作都在寄存器完成,所以没有实际的内存访问(MEM)操作。
有些指令没有对应的流水线阶段,但是我们并不能跳过对应的阶段直接执行下一阶段。不然,如果我们先后执行一条 LOAD 指令和一条 ADD 指令,就会发生 LOAD 指令的 WB 阶段和 ADD 指令的 WB 阶段,在同一个时钟周期发生。这样,相当于触发了一个结构冒险事件,产生了资源竞争。
所以,在实践当中,各个指令不需要的阶段,并不会直接跳过,而是会运行一次 NOP 操作。通过插入一个 NOP 操作,我们可以使后一条指令的每一个 Stage,一定不和前一条指令的同 Stage 在一个时钟周期执行。这样,就不会发生先后两个指令,在同一时钟周期竞争相同的资源,产生结构冒险了。

流水线里的接力赛:操作数前推

通过 NOP 操作进行对齐,我们在流水线里,就不会遇到资源竞争产生的结构冒险问题了。除了可以解决结构冒险之外,这个 NOP 操作,也是我们之前讲的流水线停顿插入的对应操作。
但是,插入过多的 NOP 操作,意味着我们的 CPU 总是在空转,干吃饭不干活。那么,我们有没有什么办法,尽量少插入一些 NOP 操作呢?不要着急,下面我们就以两条先后发生的 ADD 指令作为例子,看看能不能找到一些好的解决方案。
add $t0, $s2,$s1
add $s2, $s1,$t0
这两条指令很简单。
第一条指令,把 s1 和 s2 寄存器里面的数据相加,存入到 t0 这个寄存器里面。
第二条指令,把 s1 和 t0 寄存器里面的数据相加,存入到 s2 这个寄存器里面。
因为后一条的 add 指令,依赖寄存器 t0 里的值。而 t0 里面的值,又来自于前一条指令的计算结果。所以后一条指令,需要等待前一条指令的数据写回阶段完成之后,才能执行。就像上一讲里讲的那样,我们遇到了一个数据依赖类型的冒险。于是,我们就不得不通过流水线停顿来解决这个冒险问题。我们要在第二条指令的译码阶段之后,插入对应的 NOP 指令,直到前一天指令的数据写回完成之后,才能继续执行。
这样的方案,虽然解决了数据冒险的问题,但是也浪费了两个时钟周期。我们的第 2 条指令,其实就是多花了 2 个时钟周期,运行了两次空转的 NOP 操作。
不过,其实我们第二条指令的执行,未必要等待第一条指令写回完成,才能进行。如果我们第一条指令的执行结果,能够直接传输给第二条指令的执行阶段,作为输入,那我们的第二条指令,就不用再从寄存器里面,把数据再单独读出来一次,才来执行代码。
我们完全可以在第一条指令的执行阶段完成之后,直接将结果数据传输给到下一条指令的 ALU。然后,下一条指令不需要再插入两个 NOP 阶段,就可以继续正常走到执行阶段。
这样的解决方案,我们就叫作操作数前推(Operand Forwarding),或者操作数旁路(Operand Bypassing)。其实我觉得,更合适的名字应该叫操作数转发。这里的 Forward,其实就是我们写 Email 时的“转发”(Forward)的意思。不过现有的经典教材的中文翻译一般都叫“前推”,我们也就不去纠正这个说法了,你明白这个意思就好。
转发,其实是这个技术的逻辑含义,也就是在第 1 条指令的执行结果,直接“转发”给了第 2 条指令的 ALU 作为输入。另外一个名字,旁路(Bypassing),则是这个技术的硬件含义。为了能够实现这里的“转发”,我们在 CPU 的硬件里面,需要再单独拉一根信号传输的线路出来,使得 ALU 的计算结果,能够重新回到 ALU 的输入里来。这样的一条线路,就是我们的“旁路”。它越过(Bypass)了写入寄存器,再从寄存器读出的过程,也为我们节省了 2 个时钟周期。
操作数前推的解决方案不但可以单独使用,还可以和流水线冒泡一起使用。有的时候,虽然我们可以把操作数转发到下一条指令,但是下一条指令仍然需要停顿一个时钟周期。
比如说,我们先去执行一条 LOAD 指令,再去执行 ADD 指令。LOAD 指令在访存阶段才能把数据读取出来,所以下一条指令的执行阶段,需要在访存阶段完成之后,才能进行。
总的来说,操作数前推的解决方案,比流水线停顿更进了一步。流水线停顿的方案,有点儿像游泳比赛的接力方式。下一名运动员,需要在前一个运动员游玩了全程之后,触碰到了游泳池壁才能出发。而操作数前推,就好像短跑接力赛。后一个运动员可以提前抢跑,而前一个运动员会多跑一段主动把交接棒传递给他。

总结延伸

这一讲,我给你介绍了一个更加高级,也更加复杂的解决数据冒险问题方案,就是操作数前推,或者叫操作数旁路。
操作数前推,就是通过在硬件层面制造一条旁路,让一条指令的计算结果,可以直接传输给下一条指令,而不再需要“指令 1 写回寄存器,指令 2 再读取寄存器“这样多此一举的操作。这样直接传输带来的好处就是,后面的指令可以减少,甚至消除原本需要通过流水线停顿,才能解决的数据冒险问题。
这个前推的解决方案,不仅可以单独使用,还可以和前面讲解过的流水线冒泡结合在一起使用。因为有些时候,我们的操作数前推并不能减少所有“冒泡”,只能去掉其中的一部分。我们仍然需要通过插入一些“气泡”来解决冒险问题。
通过操作数前推,我们进一步提升了 CPU 的运行效率。那么,我们是不是还能找到别的办法,进一步地减少浪费呢?毕竟,看到现在,我们仍然少不了要插入很多 NOP 的“气泡”。那就请你继续坚持学习下去。下一讲,我们来看看,CPU 是怎么通过乱序执行,进一步减少“气泡”的。

推荐阅读

想要深入了解操作数前推相关的内容,推荐你读一下《计算机组成与设计:硬件 / 软件接口》的 4.5~4.7 章节。

课后思考

前面讲 5 级流水线指令的时候,我们说,STORE 指令是没有数据写回阶段的,而 ADD 指令是没有访存阶段的。那像 CMP 或者 JMP 这样的比较和跳转指令,5 个阶段都是全的么?还是说不需要哪些阶段呢?
欢迎留言和我分享你的疑惑和见解。你也可以把今天的内容,分享给你的朋友,和他一起学习和进步。
分享给需要的人,Ta购买本课程,你将得20
生成海报并分享

赞 20

提建议

上一篇
22 | 冒险和预测(一):hazard是“危”也是“机”
下一篇
24 | 冒险和预测(三):CPU里的“线程池”
unpreview
 写留言

精选留言(25)

  • 曾经瘦过
    2019-09-27
    感觉老师的讲课顺序特别好 方案都是一点点演变来的 那些看起来高大上的解决方案 都是从最笨的方法开始慢慢优化演变而来的 不是直接一步到位的讲解 能够更好的理解

    作者回复: 谢谢,这个也是我期望这个专栏达到的效果。

    共 4 条评论
    51
  • 刘桢
    2019-06-18
    今天考研冲北邮!
    共 6 条评论
    39
  • Geek
    2019-06-17
    后面这些就有点难了,对于非计算机专业的我来说,不过就当看小说了,会一直看下去。。。
    共 1 条评论
    14
  • 拯救地球好累
    2019-10-27
    ---总结--- 为了避免流水线停顿中插入过多的NOP指令,我们引入了操作数前推技术。 具有依赖关系的两条指令,后一条指令的输入往往来自于前一条指令执行之后写入的寄存器,而操作数前推则通过在硬件上制造一条旁路让前一条指令的执行结果直接作为后一条指令的输入,而无需等待前一条指令写回寄存器。 这就像工厂流水线中,上一道工序做好的半成品一般会先递送到某个储物柜上以供下个工序的工人使用,增加旁路后好比前一位工人直接将半成品递交给下一位工人,而减少了“放物品-取物品”这样的动作。
    展开
    12
  • 许先森
    2020-01-10
    cmp:取指令、译码、执行、访存、写回; jmp:取指令、译码
    9
  • 曾经瘦过
    2019-09-27
    cmp 指令是比较大小的指令 执行步骤感觉是 取指令 译码 取数据(访问内存 或者寄存器) 比较大小(执行) 出结果(写回) 应该是全的 jump指令是跳转的指令 执行步骤应该是 取指令 译码 跳转(执行) 没有访问内存和写回 的过程
    共 1 条评论
    7
  • haer
    2019-06-17
    我觉得:cmp没有“访存”,jmp没有“执行”和“访存”
    共 4 条评论
    7
  • Magic
    2020-09-28
    CMP需要使用ALU进行比较,比较结果需要写入状态寄存器,因此没有访存阶段。 JMP在控制器中直接修改PC寄存器,因此没有ALU执行和访存阶段
    6
  • 南山
    2019-06-17
    从指令作用理解,cmp是全的,jmp不需要回写~
    2
  • A君
    2020-06-30
    cmp指令需要把比较结果写入条件寄存器,所有有写回指令,如果是无条件jmp就只需要取指和译码即可,反之还需要执行。
    1
  • J.D.Chi
    2019-09-28
    1. 我可以这么理解吗?就是把一个方法: int main(int a , int b){ int c = a + b; return c; } 变成 int main(int a , int b){ return a + b; } 2. 看了之后有一个想法会不会那个时候的程序员的编程代码里是会考虑到汇编指令这一块,后来随着机器性能的发展,现在的程序员在日常写代码对这块就不太重视了,于是一些《代码简洁之道》一类的书就应运而生了。一方面包括了从视觉上对代码的规范,另一方面也回归到对指令考虑这一块。
    展开

    作者回复: J.D.同学 你好,第一个不能这么理解,只有没有前后依赖的指令才会在流水线里可以同时执行。实际上,你给的代码其实编译器通常会自动优化到后面那个状态。 早年的程序员的确会考虑得很底层,因为内存空间很小。

    共 2 条评论
    1
  • prader26
    2019-09-22
    1 为了解决,计算机指令中,有太多气泡的,可以采用操作数前推的办法。(上一条指令,的结果不再写入到寄存器中去,而是直接传递给一下需要使用数据的指令。)
    1
  • djfhchdh
    2019-07-02
    CMP没有访问内存的操作,JMP没有执行、访问内存的操作
    1
  • 浮石沉木
    2022-07-28 来自陕西
    老师,这里的指令对齐,我理解的是假设有14级的流水线,哪怕一个指令实际只有5个stage,但是为了对齐,剩余的9个stage都得用nohup补齐吗?
  • Geek_080ce7
    2022-04-28
    旁路建立好之后,那不是无论前后两个指令有没有数据依赖关系,都会收到前一个指令的计算机结果?那不是导致计算紊乱吗?这是怎么解决的?感觉很有难度呀
  • 竹林木
    2021-12-20
    cmp: 取指令,译码,执行,写回(访问内存应该是load指令阶段,这里只需要访问已经load到寄存器的值就行了) jump: 取指令,译码,写回(改写的是指令计数器的值)
  • 活着即是修行
    2021-11-11
    cmp我感觉需要访存,因为要比较数值的大小,需要从内存中读取数值。写回不用吧。 jmp我感觉不用访存,也不用写回吧
  • 林有有头号粉丝
    2021-08-23
    老师您好,我想请问一下PC值更新的时机是什么时候?是每一个时钟信号给出的时候就完成自身状态的更新,并且向外界传输的吗?
  • 李二木
    2021-03-29
    操作数前推,就是通过在硬件层面制造一条旁路,让一条指令的计算结果,可以直接传输给下一条指令,而不再需要“指令 1 写回寄存器,指令 2 再读取寄存器“这样多此一举的操作
  • 陌兮
    2021-03-07
    老师讲的真是太有趣了,非常好看