CPU是如何解决冒险问题的?

x33g5p2x  于2021-12-30 转载在 其他  
字(3.7k)|赞(0)|评价(0)|浏览(392)

想通过流水线设计来提升CPU的吞吐率,我们需要冒哪些风险。

流水线设计需解决的三大冒险:

  • 结构冒险(Structural Hazard)
  • 数据冒险(Data Hazard)
  • 控制冒险(Control Hazard)

CPU流水线设计里,会遇到各种“危险”,使得流水线的下一条指令不能正常运行。但还是通过“抢跑”,“冒险”拿到一个提升指令吞吐率的机会。
流水线架构的CPU,是主动进行的冒险选择。期望能够通过冒险带来更高回报,所以,这不是无奈之下的应对之举,自然也算不上什么危机。

对于各种冒险可能造成的问题,其实都准备好了应对方案。

结构冒险

本质上是一个硬件层面的资源竞争问题,即一个硬件电路层面的问题。

CPU在同一个时钟周期,同时在运行两条计算机指令的不同阶段。但是这两个不同的阶段,可能会用到同样的硬件电路。

最典型的例子就是内存数据访问。

  • 同一个时钟周期,两个不同指令访问同一个资源(5级流水线的示意图)

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

类似的资源冲突最常见的就是薄膜键盘“锁键”。
薄膜键盘不是每一个按键背后都有独立线路,而是多个键共用一个线路。如果在同一时间,按下两个共用一个线路的按键,这两个按键信号就没法都传输出去。
重度键盘用户,都要买机械键盘或电容键盘。因为按键都有独立传输线路,“全键无冲”,大量写文章、写程序,还是打游戏,都不会按下键却没生效。

“全键无冲”本质就是增加资源。同样可用在CPU结构冒险。
对访问内存数据和取指令的冲突,把我们的内存分成两部分,各有各的地址译码器。这两部分分别是存放指令的程序内存和存放数据的数据内存。

这样把内存拆成两部分的解决方案,在计算机体系结构里叫作哈佛架构(Harvard Architecture)。
冯·诺依曼体系结构,又叫作普林斯顿架构(Princeton Architecture)。

如今的CPU仍是冯·诺依曼体系结构,并未将内存拆成程序内存、数据内存。
因为那样拆分,对程序指令和数据需要的内存空间,就无法根据实际应用去动态分配。虽然解决了资源冲突,但也失去灵活性。


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

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

结构冒险是一个硬件层面的问题,我们可以靠增加硬件资源的方式来解决。然而还有很多冒险问题,是程序逻辑层面的事儿。其中,最常见的就是数据冒险。

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

同时在执行的多个指令之间,有数据依赖。

这些数据依赖,可分成三类:

  • 先写后读(Read After Write,RAW)
  • 先读后写(Write After Read,WAR)
  • 写后再写(Write After Write,WAW)

先写后读(Read After Write)

C语言代码编译出来的汇编指令。

int main() {
  int a = 1;
  int b = 2;
  a = a + 2;
  b = a + 3;
}
int main() {
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
  int a = 1;
   4:   c7 45 fc 01 00 00 00    mov    DWORD PTR [rbp-0x4],0x1
  int b = 2;
   b:   c7 45 f8 02 00 00 00    mov    DWORD PTR [rbp-0x8],0x2
  a = a + 2;
  12:   83 45 fc 02             add    DWORD PTR [rbp-0x4],0x2
  b = a + 3;
  16:   8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  19:   83 c0 03                add    eax,0x3
  1c:   89 45 f8                mov    DWORD PTR [rbp-0x8],eax
}
  1f:   5d                      pop    rbp
  20:   c3                      ret
  • 内存地址为12的机器码,把0x2添加到 rbp-0x4 对应内存地址
  • 内存地址为16的机器码,又要从rbp-0x4内存地址,把数据写入eax寄存器。

所以,需要保证内存地址为16的指令读取rbp-0x4的值前,内存地址12的指令写入到rbp-0x4的操作必须完成。
这就是先写后读所面临的数据依赖。这顺序保证不了,程序就是错的!

这种先写后读的依赖关系称为数据依赖,Data Dependency。

先读后写(Write After Read)

这次我们先计算 a = b + a,然后再计算 b = a + b。

int main() {
  int a = 1;
  int b = 2;
  a = b + a;
  b = a + b;
}
int main() {
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
   int a = 1;
   4:   c7 45 fc 01 00 00 00    mov    DWORD PTR [rbp-0x4],0x1
   int b = 2;
   b:   c7 45 f8 02 00 00 00    mov    DWORD PTR [rbp-0x8],0x2
   a = b + a;
  12:   8b 45 f8                mov    eax,DWORD PTR [rbp-0x8]
  15:   01 45 fc                add    DWORD PTR [rbp-0x4],eax
   b = a + b;
  18:   8b 45 fc                mov    eax,DWORD PTR [rbp-0x4]
  1b:   01 45 f8                add    DWORD PTR [rbp-0x8],eax
}
  1e:   5d                      pop    rbp
  1f:   c3                      ret

内存地址为15的汇编指令里,要把 eax 寄存器值读出,加到 rbp-0x4 的内存地址里。
在内存地址为18的汇编指令里,再写入更新 eax 寄存器里面。

如果在内存地址18的eax的写入先完成了,在内存地址为15的代码里面取出 eax 才发生,程序计算就错。这里,我们同样要保障对于eax的先读后写的操作顺序。

这个先读后写的依赖,一般被叫作反依赖,Anti-Dependency。

写后再写(Write After Write)

先设置变量 a = 1,再设置变量 a = 2。

int main() {
  int a = 1;
  a = 2;
}
int main() {
   0:   55                      push   rbp
   1:   48 89 e5                mov    rbp,rsp
  int a = 1;
   4:   c7 45 fc 01 00 00 00    mov    DWORD PTR [rbp-0x4],0x1
  a = 2;
   b:   c7 45 fc 02 00 00 00    mov    DWORD PTR [rbp-0x4],0x2
}

内存地址4所在的指令和内存地址b所在的指令,都是将对应的数据写入到 rbp-0x4 的内存地址里面。
如果内存地址b的指令在内存地址4的指令之后写入。那么这些指令完成之后,rbp-0x4 里的数据就是错误的。这就会导致后续需要使用这个内存地址里的数据指令,没有办法拿到正确的值。
所以,也需要保障内存地址4的指令的写入,在内存地址b的指令的写入之前完成。

这个写后再写的依赖,叫输出依赖,Output Dependency。

流水线停顿

除了读之后再进行读,对同一寄存器或内存地址的操作,都有明确强制顺序。而这个顺序操作的要求,也为使用流水线带来挑战。
因为流水线架构的核心,就是在前一个指令还没有结束时,后面的指令就要开始执行。

所以,需要有解决这些数据冒险的办法。
最简单也是最笨的就是流水线停顿(Pipeline Stall),或流水线冒泡(Pipeline Bubbling)。

若发现后面执行的指令,会对前面执行的指令有数据层面的依赖关系,就“再等等”。
进行指令译码时,会拿到对应指令所需访问的寄存器和内存地址,这时就能判断这个指令是否会触发数据冒险。
会触发,就能决定让整个流水线停顿一或者多周期。

时钟信号会不停地在0、1之间自动切换。所以,其实没法真停顿,流水线的每个操作步骤必须要干点事。
所以,实际上并非让流水线真停下来,而是在执行后续操作步骤前,插入一个NOP操作,即执行一个只负责摸鱼的操作。

这插入的指令,就好像一个水管(Pipeline)里进了个空气泡。在水流经过时,并没有真的传送水到下一个步骤,而是给了个啥都没有的空气泡,因此得名流水线冒泡(Pipeline Bubble)。

总结

  • 可通过增加资源解决结构冒险问题。
    现代CPU体系结构,也是在冯·诺依曼体系结构下,借鉴哈佛结构的一个混合结构解决方案。内存虽然没有按功能拆分,但在高速缓存层面拆分成指令缓存和数据缓存,从硬件层面,使得同一个时钟下对于相同资源的竞争不再发生。

  • 也可通过“等待”,即插入NOP操作解决冒险问题,即流水线停顿。
    不过,流水线停顿这样的解决方案要牺牲CPU性能。因为,实际上在最差的情况下,我们的流水线架构的CPU,又会退化成单指令周期的CPU。
    参考

  • 《计算机组成与设计:硬件/软件接口》的第4.5~4.7章

相关文章