03-原理篇:处理器(22-25)

601 阅读16分钟

22 | 冒险和预测(一):hazard是“危”也是“机”

流水线设计需要解决的三大冒险:结构冒险(Structural Hazard)、数据冒险(Data Hazard)以及控制冒险(Control Hazard)。

结构冒险:为什么工程师都喜欢用机械键盘?

结构冒险,本质上是一个硬件层面的资源竞争问题,也就是一个硬件电路层面的问题。

CPU 在同一个时钟周期,同时在运行两条计算机指令的不同阶段。但是这两个不同的阶段,可能会用到同样的硬件电路。 最典型的例子就是内存的数据访问。

image.png

在第 1 条指令执行到访存(MEM)阶段的时候,流水线里的第 4 条指令,在执 行取指令(Fetch)的操作。访存和取指令,都要进行内存数据的读取。我们的内存,只有 一个地址译码器的作为地址输入,那就只能在一个时钟周期里面读取一条数据,没办法同时 执行第 1 条指令的读取内存数据和第 4 条指令的读取指令代码。

“全键无冲”这样的资源冲突解决方案,其实本质就是增加资源。同样的方案,我们一样可 以用在 CPU 的结构冒险里面。对于访问内存数据和取指令的冲突,一个直观的解决方案就 是把我们的内存分成两部分,让它们各有各的地址译码器。这两部分分别是存放指令的程序内存和存放数据的数据内存

image.png 借鉴了哈佛结构的思路,现代的 CPU 虽然没有在内存层面进行对应的拆分,却在 CPU 内部的高速缓存部分进行了区分,把高速缓存分成了指令缓存(Instruction Cache) 和数据缓存(Data Cache)两部分。

内存的访问速度远比 CPU 的速度要慢,所以现代的 CPU 并不会直接读取主内存。它会从 主内存把指令和数据加载到高速缓存中,这样后续的访问都是访问高速缓存。而指令缓存和 数据缓存的拆分,使得我们的 CPU 在进行数据访问和取指令的时候,不会再发生资源冲突 的问题了。

数据冒险:三种不同的依赖关系

结构冒险是一个硬件层面的问题,我们可以靠增加硬件资源的方式来解决。然而还有很多冒险问题,是程序逻辑层面的事儿。其中,最常见的就是数据冒险。 数据冒险,其实就是同时在执行的多个指令之间,有数据依赖的情况。这些数据依赖,我们 可以分成三大类,分别是先写后读(Read After Write,RAW)、先读后写(Write After Read,WAR)和写后再写(Write After Write,WAW)。下面,我们分别看一下这几种情况。

先写后读(Read After Write)

先写后读的依赖关系,我们一般被称之为数据依赖,也就是 Data Dependency。

int main() { 
    int a = 1; 
    int b = 2; 
    a = a + 2; 
    b = a + 3; 
}

先读后写(Write After Read)

先读后写的依赖,一般被叫作反依赖,也就是 Anti-Dependency。

int main() { 
    int a = 1; 
    int b = 2; 
    a = a + b; 
    b = a + b; 
}

写后再写(Write After Write)

写后再写的依赖,一般被叫作输出依赖,也就是 Output Dependency。

int main() { 
    int a = 1; 
    a = 2; 
}

再等等:通过流水线停顿解决数据冒险

除了读之后再进行读,你会发现,对于同一个寄存器或者内存地址的操作,都有明确强制的 顺序要求。而这个顺序操作的要求,也为我们使用流水线带来了很大的挑战。因为流水线架 构的核心,就是在前一个指令还没有结束的时候,后面的指令就要开始执行。
所以,我们需要有解决这些数据冒险的办法。其中最简单的一个办法,不过也是最笨的一个 办法,就是流水线停顿(Pipeline Stall),或者叫流水线冒泡(Pipeline Bubbling)。

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

NOP 操作和指令对齐

以 MIPS 的 LOAD,这样从内存里读取数据到寄存器的指令为例,来仔细看看,它需要经历的 5 个完整的流水线。STORE 这样从寄存器往内存里写数据的指令,不需要有写回寄存器的操作,也就是没有数据写回的流水线阶段。至于像 ADD 和 SUB 这样的加减法指令,所有操作都在寄存器完成,所以没有实际的内存访问(MEM)操作。 image.png 有些指令没有对应的流水线阶段,但是我们并不能跳过对应的阶段直接执行下一阶段。不然,如果我们先后执行一条 LOAD 指令和一条 ADD 指令,就会发生 LOAD 指令的 WB 阶段和 ADD 指令的 WB 阶段,在同一个时钟周期发生。这样,相当于触发了一个结构冒险事件,产生了资源竞争。 image.png 所以,在实践当中,各个指令不需要的阶段,并不会直接跳过,而是会运行一次 NOP 操作。通过插入一个 NOP 操作,我们可以使后一条指令的每一个 Stage,一定不和前一条指令的同 Stage 在一个时钟周期执行。这样,就不会发生先后两个指令,在同一时钟周期竞争相同的资源,产生结构冒险了。 image.png

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

通过 NOP 操作进行对齐,我们在流水线里,就不会遇到资源竞争产生的结构冒险问题了。除了可以解决结构冒险之外,这个 NOP 操作,也是我们之前讲的流水线停顿插入的对应操作。
但是,插入过多的 NOP 操作,意味着我们的 CPU 总是在空转,干吃饭不干活。

add $t0, $s2,$s1 
add $s2, $s1,$t0
  1. 第一条指令,把 s1 和 s2 寄存器里面的数据相加,存入到 t0 这个寄存器里面。
  2. 第二条指令,把 s1 和 t0 寄存器里面的数据相加,存入到 s2 这个寄存器里面。

后一条的 add 指令,依赖寄存器 t0 里的值。而 t0 里面的值,又来自于前一条指令的计算结果。所以后一条指令,需要等待前一条指令的数据写回阶段完成之后,才能执行。就像上一讲里讲的那样,我们遇到了一个数据依赖类型的冒险。于是,我们就不得不通过流水线停顿来解决这个冒险问题。我们要在第二条指令的译码阶段之后,插入对应的 NOP 指令,直到前一天指令的数据写回完成之后,才能继续执行。

这样的方案,虽然解决了数据冒险的问题,但是也浪费了两个时钟周期。我们的第 2 条指令,其实就是多花了 2 个时钟周期,运行了两次空转的 NOP 操作。

image.png

不过,其实我们第二条指令的执行,未必要等待第一条指令写回完成,才能进行。如果我们第一条指令的执行结果,能够直接传输给第二条指令的执行阶段,作为输入,那我们的第二条指令,就不用再从寄存器里面,把数据再单独读出来一次,才来执行代码。

我们完全可以在第一条指令的执行阶段完成之后,直接将结果数据传输给到下一条指令的 ALU。然后,下一条指令不需要再插入两个 NOP 阶段,就可以继续正常走到执行阶段。

image.png

这样的解决方案,我们就叫作操作数前推(Operand Forwarding),或者操作数旁路(Operand Bypassing)。其实我觉得,更合适的名字应该叫操作数转发。这里的 Forward,其实就是我们写 Email 时的“转发”(Forward)的意思。不过现有的经典教材的中文翻译一般都叫“前推”,我们也就不去纠正这个说法了,你明白这个意思就好。

转发,其实是这个技术的逻辑含义,也就是在第 1 条指令的执行结果,直接“转发”给了第 2 条指令的 ALU 作为输入。另外一个名字,旁路(Bypassing),则是这个技术的硬件含义。为了能够实现这里的“转发”,我们在 CPU 的硬件里面,需要再单独拉一根信号传输的线路出来,使得 ALU 的计算结果,能够重新回到 ALU 的输入里来。这样的一条线路,就是我们的“旁路”。它越过(Bypass)了写入寄存器,再从寄存器读出的过程,也为我们节省了 2 个时钟周期。

操作数前推的解决方案不但可以单独使用,还可以和流水线冒泡一起使用。有的时候,虽然我们可以把操作数转发到下一条指令,但是下一条指令仍然需要停顿一个时钟周期。

image.png

24 | 冒险和预测(三):CPU里的“线程池”

对于结 构冒险,由于限制来自于同一时钟周期不同的指令,要访问相同的硬件资源,解决方案是增加资源。对于数据冒险,由于限制来自于数据之间的各种依赖,我们可以提前把数据转 发到下一个指令。

能不能让后面没有数据依赖的指令,在前面指令停顿的时候先执行呢?

填上空闲的 NOP:上菜的顺序不必是点菜的顺序

无论是流水线停顿,还是操作数前推, 归根到底,只要前面指令的特定阶段还没有执行完成,后面的指令就会被“阻塞”住。
但是这个“阻塞”很多时候是没有必要的。因为尽管你的代 码生成的指令是顺序的,但是如果后面的指令不需要依赖前 面指令的执行结果,完全可以不必等待前面的指令运算完 成。

image.png 因为第三条指令并不依赖于前两条指令的计算结果,所以在第二条指令等待第一条指令的访存和写回阶段的时候,第三条指令就已经执行完成了。

乱序执行 (Out-of-Order Execution,OoOE)

CPU 里的“线程池”:理解乱序执行

  1. 在取指令和指令译码的时候,乱序执行的 CPU 和其他使用流水线架构的 CPU 是一样的。它会一级一级顺序地进行取指令和指令译码的工作。
  2. 在指令译码完成之后,就不一样了。CPU 不会直接进行指令执行,而是进行一次指令分发,把指令发到一个叫作保留站(Reservation Stations)的地方。顾名思义,这个保留站,就像一个火车站一样。发送到车站的指令,就像是一列列的火车。
  3. 这些指令不会立刻执行,而要等待它们所依赖的数据,传递给它们之后才会执行。这就好像一列列的火车都要等到乘客来齐了才能出发。
  4. 一旦指令依赖的数据来齐了,指令就可以交到后面的功能单元(Function Unit,FU),其实就是 ALU,去执行了。我们有很多功能单元可以并行运行,但是不同的功能单元能够支持执行的指令并不相同。就和我们的铁轨一样,有些从上海北上,可以到北京和哈尔滨;有些是南下的,可以到广州和深圳。
  5. 指令执行的阶段完成之后,我们并不能立刻把结果写回到寄存器里面去,而是把结果再存放到一个叫作重排序缓冲区(Re-Order Buffer,ROB)的地方。
  6. 在重排序缓冲区里,我们的 CPU 会按照取指令的顺序,对指令的计算结果重新排序。只有排在前面的指令都已经完成了,才会提交指令,完成整个指令的运算结果。
  7. 实际的指令的计算结果数据,并不是直接写到内存或者高速缓存里,而是先写入存储缓冲区(Store Buffer 面,最终才会写入到高速缓存和内存里。

整个乱序执行技术,就好像在指令的执行阶段提供一个“线程池”。指令不再是顺序执行的,而是根据池里所拥有的资源,以及各个任务是否可以进行执行,进行动态调度。在执行完成之后,又重新把结果在一个队列里面,按照指令的分发顺序重新排序。即使内部是“乱序”的,但是在外部看起来,仍然是井井有条地顺序执行。

乱序执行,极大地提高了 CPU 的运行效率。核心原因是,现代 CPU 的运行速度比访问主内存的速度要快很多。如果完全采用顺序执行的方式,很多时间都会浪费在前面指令等待获取内存数据的时间里。CPU 不得不加入 NOP 操作进行空转。而现代 CPU 的流水线级数也已经相对比较深了,到达了 14 级。这也意味着,同一个时钟周期内并行执行的指令数是很多的。

而乱序执行,以及我们后面要讲的高速缓存,弥补了 CPU 和内存之间的性能差异。同样,也充分利用了较深的流水行带来的并发性,使得我们可以充分利用 CPU 的性能。

问题:在现代 Intel 的 CPU 的乱序执行的过程中,只有指令的执行阶段是乱序的,后面的内存访问和数据写回阶段都仍然是顺序的。这种保障内存数据访问顺序的模型,叫作强内存模型(Strong Memory Model)。你能想一想,我们为什么要保障内存访问的顺序呢?在前后执行的指令没有相关数据依赖的情况下,为什么我们仍然要求这个顺序呢?(乱序执行但是结果会顺序提交)

多核CPU数据一致性问题:多核访问相同的内存,但有自己的缓存寄存器。

我觉得强内存模型是为了保证不同指令对同一内存地址的读写正确性,不同指令的执行不仅仅有寄存器数据依赖,还会有内存数据依赖。(?)

乱序执行是说,对于一串给定的指令,为了提高效率,处理器会找出非真正数据依赖的指令,让他们并行执行。但是,指令执行结果在写回到寄存器的时候,必须是顺序的。也就是说,哪怕是先被执行的指令,它的运算结果也是按照指令次序写回到最终的寄存器的。

很多人都不知晓的CPU访问内存知识!

25 | 冒险和预测(四):今天下雨了,明天 还会下雨么?

取指令和指令译码不会需要遇到任何停顿,这是基于一个假 设。这个假设就是,所有的指令代码都是顺序加载执行的。 不过这个假设,在执行的代码中,一旦遇到 if…else 这样的 条件分支,或者 for/while 循环,就会不成立。

条件跳转指令。可以看到,在 jmp 指令发生的时候,CPU 可能会跳转去执行其他指令。jmp 后的那一条指 令是否应该顺序加载执行,在流水线里面进行取指令的时候,我们没法知道。要等 jmp 指令执行完成,去更新了 PC 寄存器之后,我们才能知道,是否执行下一条指令,还是跳 转到另外一个内存地址,去取别的指令。 这种为了确保能取到正确的指令,而不得不进行等待延迟的情况,就是今天我们要讲的控制冒险(Control Harzard)。

分支预测:今天下雨了,明天还会继续下雨么?

缩短分支延迟

条件跳转指令其实进行了两种电路操作。
第一种,是进行条件比较。这个条件比较,需要的输入是, 根据指令的 opcode,就能确认的条件码寄存器。
第二种,是进行实际的跳转,也就是把要跳转的地址信息写 入到 PC 寄存器。无论是 opcode,还是对应的条件码寄存器,还是我们跳转的地址,都是在指令译码(ID)的阶段就 能获得的。而对应的条件码比较的电路,只要是简单的逻辑门电路就可以了,并不需要一个完整而复杂的 ALU。

可以将条件判断、地址跳转,都提前到指令译码阶段进行,而不需要放在指令执行阶段。对应的,我们也要在 CPU 里面设计对应的旁路,在指令译码阶段,就提供对 应的判断比较的电路。
这种方式,本质上和前面数据冒险的操作数前推的解决方案类似,就是在硬件电路层面,把一些计算结果更早地反馈到 流水线中。这样反馈变得更快了,后面的指令需要等待的时间就变短了。

不过只是改造硬件,并不能彻底解决问题。跳转指令的比较结果,仍然要在指令执行的时候才能知道。在流水线里,第 一条指令进行指令译码的时钟周期里,我们其实就要去取下 一条指令了。这个时候,我们其实还没有开始指令执行阶 段,自然也就不知道比较的结果。

分支预测

如果分支预测是正确的,我们自然赚到了。这个意味着,我 们节省下来本来需要停顿下来等待的时间。如果分支预测失 败了呢?那我们就把后面已经取出指令已经执行的部分,给 丢弃掉。这个丢弃的操作,在流水线里面,叫作 Zap 或者 Flush。CPU 不仅要执行后面的指令,对于这些已经在流水 线里面执行到一半的指令,我们还需要做对应的清除操作。 比如,清空已经使用的寄存器里面的数据等等,这些清除操 作,也有一定的开销。
所以,CPU 需要提供对应的丢弃指令的功能,通过控制信 号清除掉已经在流水线中执行的指令。只要对应的清除开销 不要太大,我们就是划得来的。

动态分支预测

一级分支预测(One Level Branch Prediction), 1 比特饱和计数;
2 比特饱和计数,或者叫双模态预测器(Bimodal Predictor)。

总结

结构冒险、数据冒险(三种依赖)(增加资源、停顿等待);操作数前推(旁路、转发)减少冒泡;乱序执行;控制冒险(缩短分支延迟、分支预测)