先读后写(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章