要想通过流水线设计来提升CPU的吞吐率,我们需要冒哪些风险。
- 结构冒险(Structural Hazard)
- 数据冒险(Data Hazard)
- 控制冒险(ControlHazard)
hazard(冒险)为什么不翻译成“危机”,而是要叫“冒险”呢?
在CPU的流水线设计里,固然我们会遇到各种“危险”情况,使得流水线里的下一条指令不能正常运行。但是,我们其实还是可以通过“抢跑”的方式,“冒险”拿到了一个提升指令吞吐率的机会。流水线架构的CPU,是我们主动进行的冒险选择。我们期望能够通过冒险带来更高的回报,所以,这不是无奈之下的应对之举,自然也算不上什么危机了。
事实上,对于各种冒险可能造成的问题,我们其实都准备好了应对的方案
结构冒险
- 结构冒险,本质上是一个硬件层面的资源竞争问题,也就是一个硬件电路层面的问题。
- CPU在同一个时钟周期,同时在运行两条计算机指令的不同阶段。但是这两个不同的阶段,可能会用到同样的硬件电路。
最典型的例子就是内存的数据访问。如下图:
-
可以看到,在第1条指令执行到访存(MEM)阶段的时候,流水线里的第4条指令,在执行取指令(Fetch)的操作。
-
访存和取指令,都要进行内存数据的读取。
-
我们的内存,只有一个地址译码器的作为地址输入,那就只能在一个时钟周期里面读取一条数据,没办法同时执行第1条指令的读取内存数据和第4条指令的读取指令代码
解决方案: -
把我们的内存分成两部分,让它们各有各的地址译码器。这两部分分别是存放指令的程序内存和存放数据的数据内存
-
这样把内存拆成两部分的解决方案,在计算机体系结构里叫做哈佛结构。(而冯·诺依曼体系结构,又叫作普林斯顿架构)
不过,我们今天使用的CPU,依然是冯·诺依曼体系结构的,并没有把内存拆成程序内存和数据内存这两部分。因为如果这样拆的话,对程序指令和数据需要的内存空间,我们就没有办法根据实际的应用去分配动态内存了。虽然解决了资源冲突问题,但是也失去了灵活性。
-
不过,借鉴了哈佛结构的思路,现代的 CPU 虽然没有在内存层面进行对应的拆分,却在CPU 内部的高速缓存部分进行了区分,把高速缓存分成了指令缓存(Instruction Cache)和数据缓存(Data Cache)两部分。
-
内存的访问速度远比CPU的速度要慢,所以现代的CPU并不会直接读取主内存。它会从主内存把指令和数据加载到高速缓存中,这样后继的访问但是访问高速缓存。而指令缓存和数据缓存的拆分,使得我们的CPU在进行数据访问和取指令的时候,不会再发生资源冲突的问题了
数据冒险
结构冒险是一个硬件层面的问题,我们可以通过增加硬件资源来解决。然而还是有很多冒险问题,是程序逻辑层面的事情。其中,最常见的就是数据冒险
- 数据max,其实就是同时在执行的多个指令之间,有数据依赖的情况。
- 这些数据依赖,可以分为三大类:
- 先写后读(Read After Write,RAW)
- 先读后写(Write After Read,WAR)
- 写后再写(Write After Write,WAW)
也就是说,数据冒险要保障【数据依赖、反依赖以及输出依赖】这三种依赖
先写后读
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。
先读后写
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。
写后再写
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)。
简单来说,就是如果我们发现了后面执行的执行,会对前面执行的指令有数据层面的依赖关系,那么最简单的方法就是“等一等”。我们在进行指令译码的时候,会拿到对应指令所需要访问的寄存器和内存地址。所以,在这个时候,我们能够判断出来,这个指令是否会触发数据冒险。如果会触发数据冒险,我们就可以决定,让整个流水线停顿一个或者多个周期。
其实,我们并没有办法真的停顿下来,流水线的每一个操作都必须要干点事情。所以,在实践过程中,我们并不是让流水线停下来,而是在执行后面的操作步骤前面,插入一个NOP操作,也就是执行一个其实什么也不干的操作。
这个插入指令的操作,就好像一个水管(pipeline)里面,进了一个空的气泡。在水流经过的时候,没有传送谁的下一个步骤,而是给了一个什么都没有的空气泡。这也是为什么,流水线停顿,又被叫作流水线冒泡(Pipeline Bubble)的原因。
总结
关于结构冒险和数据冒险所要保障的三种依赖:
- 一方面,我们可以通过增加资源来解决结构冒险问题。我们现代的CPU体系结构,其实也是在冯·诺依曼体系结构下,借鉴哈佛结构的一个混合结构的解决方案。我们的内存虽然没有按照功能拆分,但是在高速缓存层面做了拆分,也就是拆分成指令缓存和数据缓存这样的方式,从硬件层面,使得同一个时钟下对于相同资源的竞争不再发生
- 另一方面,我们也可以通过“等待”,也就是插入无效的NOP操作的方式,来解决冒险问题。这就是所谓的流水线停顿。不过,流水线停顿这样的解决方案,是以牺牲CPU性能为代价的,因为,实际上在最差的情况下,我们流水线架构的CPU,又会退化成单指令周期CPU了