想通过流水线设计来提升CPU的吞吐率,我们需要冒哪些风险。
流水线设计需解决的三大冒险:
CPU流水线设计里,会遇到各种“危险”,使得流水线的下一条指令不能正常运行。但还是通过“抢跑”,“冒险”拿到一个提升指令吞吐率的机会。
流水线架构的CPU,是主动进行的冒险选择。期望能够通过冒险带来更高回报,所以,这不是无奈之下的应对之举,自然也算不上什么危机。
对于各种冒险可能造成的问题,其实都准备好了应对方案。
本质上是一个硬件层面的资源竞争问题,即一个硬件电路层面的问题。
CPU在同一个时钟周期,同时在运行两条计算机指令的不同阶段。但是这两个不同的阶段,可能会用到同样的硬件电路。
最典型的例子就是内存数据访问。
第1条指令执行到访存(MEM)时,流水线第4条指令,在执行取指令(Fetch)的操作。访存和取指令,都要进行内存数据的读取。内存只有一个地址译码器的作为地址输入,那就只能在一个时钟周期里读取一条数据,没法同时执行第1条指令的读取内存数据和第4条指令的读取指令代码。
类似的资源冲突最常见的就是薄膜键盘“锁键”。
薄膜键盘不是每一个按键背后都有独立线路,而是多个键共用一个线路。如果在同一时间,按下两个共用一个线路的按键,这两个按键信号就没法都传输出去。
重度键盘用户,都要买机械键盘或电容键盘。因为按键都有独立传输线路,“全键无冲”,大量写文章、写程序,还是打游戏,都不会按下键却没生效。
“全键无冲”本质就是增加资源。同样可用在CPU结构冒险。
对访问内存数据和取指令的冲突,把我们的内存分成两部分,各有各的地址译码器。这两部分分别是存放指令的程序内存和存放数据的数据内存。
这样把内存拆成两部分的解决方案,在计算机体系结构里叫作哈佛架构(Harvard Architecture)。
冯·诺依曼体系结构,又叫作普林斯顿架构(Princeton Architecture)。
如今的CPU仍是冯·诺依曼体系结构,并未将内存拆成程序内存、数据内存。
因为那样拆分,对程序指令和数据需要的内存空间,就无法根据实际应用去动态分配。虽然解决了资源冲突,但也失去灵活性。
现代CPU架构,借鉴了哈佛架构,在高速缓存层面拆分成指令缓存和数据缓存
不过,借鉴了哈佛结构的思路,现代的CPU虽然没有在内存层面进行对应的拆分,却在CPU内部的高速缓存部分进行了区分,把高速缓存分成了指令缓存(Instruction Cache)和数据缓存(Data Cache)两部分。
内存的访问速度远比CPU的速度要慢,所以现代的CPU并不会直接读取主内存。它会从主内存把指令和数据加载到高速缓存中,这样后续的访问都是访问高速缓存。而指令缓存和数据缓存的拆分,使得我们的CPU在进行数据访问和取指令的时候,不会再发生资源冲突的问题了。
结构冒险是一个硬件层面的问题,我们可以靠增加硬件资源的方式来解决。然而还有很多冒险问题,是程序逻辑层面的事儿。其中,最常见的就是数据冒险。
同时在执行的多个指令之间,有数据依赖。
这些数据依赖,可分成三类:
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
所以,需要保证内存地址为16的指令读取rbp-0x4的值前,内存地址12的指令写入到rbp-0x4的操作必须完成。
这就是先写后读所面临的数据依赖。这顺序保证不了,程序就是错的!
这种先写后读的依赖关系称为数据依赖,Data Dependency。
这次我们先计算 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。
先设置变量 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章
版权说明 : 本文为转载文章, 版权归原作者所有 版权申明
原文链接 : https://javaedge.blog.csdn.net/article/details/121571388
内容来源于网络,如有侵权,请联系作者删除!