指令流水线
如果不对指令的执行过程进行拆分的话,那么指令的执行的粒度则是指令本身,如下图所示:
这样会导致 CPU 的执行效率很低,这节谈到的流水线方案就是对以上方式的优化:在 CPU 中将指令的执行过程拆分为多个阶段,每个阶段由不同的部件执行。
如上图所示,指令执行的五个阶段由 CPU 中不同的部件处理,下一阶段的执行部件的执行结果依赖上一个阶段的输入,不同阶段的部件可以并行工作。 这样 CPU 中不同部件的利用率就得到了提高,CPU 执行指令的吞吐也会因此提高。
指令执行阶段
一般而言,指令执行包含五个阶段:
- 取指(IF,Instruction Fetch): 从内存中取出指令。
- 从 PC 中获取下一条指令的地址 addr
- 从 addr 读取该指令,传输到 IR 中存储指令
- 将 PC 值增加,指向下一条指令的地址
- 译码(ID,Instruction Decode):
- 将 IR 中的指令解码,以确定操作码和操作数
- 寄存器读取:根据指令中的寄存器字段,读取相关寄存器的值。
- 执行(EX,Execute):
- 算数逻辑运算:ALU 执行算术或逻辑运算,操作数来自寄存器文件或立即数
- 分支/跳转计算: 对于分支指令,计算分支目标地址
- 访存(MEM,Memory Access):
- 数据加载:对于加载指令,从计算得出的内存地址读取数据,并将数据暂存
- 数据存储:对于存储指令,将寄存器中的数据写入计算得出的内存地址
- 写回(WB,Write Back):
- 结果写回寄存器
- 更新标志寄存器
不过内存访问(MEM)的操作常常也被包含在执行(EX)和写回(WB)阶段中,所以有的时候也将指令的 MEM 阶段忽略,在这种情况下,指令执行只包含四个阶段:取指、译码、执行、写回。
指令流水线的基本概念
若将指令执行的多个阶段由不同的硬件单独执行,那么各个阶段可以并行执行。 这样,虽然每条指令的执行时间没有缩短,但是CPU的吞吐量得到了提升,也就是说,每单位时间内,CPU可以执行更多的指令。
上图包含四条指令执行的流水线,在 CPU 的每个时钟周期内,不同的器件可以并行运行。如果不同的指令之间不存在任何冲突,那么 CPU 执行 N 条指令总共需要 5 + (N - 1) 个时钟周期。若不采用流水线结构,执行 N 条指令共需要 5N 个时钟周期。
流水线的冒险和处理
上图中的指令流水线是一种理想情况,然后在实际情况中,情况不会这么简单。 指令的流水线执行必须满足两个前提:
第一个前提是指令重叠执行时不会存在任何流水线资源冲突问题,即流水线的各段在同一个时钟周期内不会使用相同的数据通路资源。
第二个前提是指令通过流水线方式指令的结果与串行执行的结果应该相同。
违背以上前提的指令流水线调度方式即发生了 “冒险”,这些冒险总共可以分为三类:
- 第一种是结构冒险,是指令在重叠执行的过程中,硬件资源满足不了指令重叠执行的要求,发生硬件资源冲突而产生的冲突。
- 第二种是数据冒险,是指在同时重叠执行的几条指令中,一条指令依赖于前面指令执行结果数据,但是又得不到时发生的冲突。
- 第三种是控制冒险,它是指流水线中的分支指令或者其他需要改写PC的指令造成的冲突。
结构冒险
结构冒险是由于 CPU 的硬件资源有限而引起的。当两条或多条指令需要使用同一硬件资源时,就会发生结构冒险。
上图中画出了不同指令在每个时钟周期所需要使用到的硬件结构,其中指令 0 和指令 1 在第 4 个时钟周期分别需要读和写寄存器,但是 CPU 的架构却并不一定支持这种场景。同样,指令 0 和指令 3 在第 3 个时钟周期分别需要写和读存储器,存储器架构也不一定支持这种场景。若硬件不支持上述场景的话,指令间就发生了结构冒险。
其 处理方法 也很简单,主要分为两种:
- 资源重复:既然结构冒险是资源受限所导致的,我们就增加硬件资源的数量,这样不同的指令在同一个时钟周期就可以去访问不同的硬件资源了。
- 流水线停顿:如果指令 A 和指令 B 发生了结构冒险,那么我们就推迟指令 B 的执行,直到两者不发生结构冒险,如下图所示。
数据冒险
数据冒险是由指令之间的依赖性引起的。一条指令可能需要使用另一条指令的结果,如果这些指令过早地进入流水线,它们可能会尝试在数据准备好之前使用数据。
数据冒险可以分为三类:
- 写后读(RAW, Read After Write):下一条指令的源操作数恰好是上一条指令的目的操作数,正常的逻辑是上一条指令写完该寄存器下一条指令才能读,如果下一条指令在上一条指令写完前就读了,就发生了 RAW 数据冒险。
- 读后写(WAR, Write After Read):下一条指令的目的操作数恰好是上一条指令的源操作数,正常的逻辑是上一条指令读完下一条指令才能写,如果下一条指令在上一条指令读完前就写了,就发生了 RAW 数据冒险。
- 写后写(WAW, Write After Write):两个指令写入同一个数据项,正常的逻辑是下一条指令比上一条指令更晚写,如果出现了相反的情况,就发生了 WAW 数据冒险。
以上的中文名词由于翻译关系可能有些绕,建议大家优先记住英文表示。如果上一条指令叫做 A,下一条指令叫做 B,A 写 B 读并且发生了冒险,就叫做读后写(Read After Write),其他冒险命名以此类推。
关键点在于理解流水线调度执行的结果应该与串行执行相同,如果关于某些数据的 读/写 逻辑出现了与串行执行不一致的地方,就发生了数据冲突。
上图中给出了数据冒险的几种情况,
数据冒险的 处理方法 如下所示:
- 流水线停顿(Pipeline Stall):暂停流水线直到数据准备好。
- 重新排序指令(Instruction Reordering):编译器在编译时对指令进行重新排序,以减少数据冒险。
- 数据前推(Data Forwarding):设置相关专用通路,直接将前一条指令的结果传递给需要它的下一条指令,不等结果写回寄存器。
下面通过一个实际的例子说明在如何在题目中画出 解决了冒险的指令流水线。
假设高级语言一条赋值语句被汇编微如下四条指令:
I1 LOAD R1, [a]
I2 LOAD R2, [b]
I3 ADD R1, R2
I4 STORE R1, [x]
其中 I3
和 I1
之间存在 WAW 数据冒险,
I3
和 I2
之间存在 RAW 数据冒险,
I4
和 I3
之间存在 WAR 数据冒险。
我们可以直接通过流水线停顿解决数据冒险:假设指令 A 和 B 发生了数据冲突,指令 A 在前,指令 B 在后,那么将 B 的 ID 放在 A 的 WB 之后就可以简单粗暴地简单冲突,在考试中画流水线都应采用这种方式。
解决冲突后,四条指令对应的流水线执行如下图所示:
控制冒险
控制冒险是由分支和跳转指令引起的。因为CPU需要在执行分支和跳转指令后,才能知道下一条要执行的指令在哪里,这导致了流水线的暂停或者无效的指令进入流水线。
以下举例说明控制冒险是如何发生的:
100: ADD R1, R2, R3 ; R1 = R2 + R3
104: BEQ R1, #0, 200 ; 如果 R1 等于 0,则跳转到地址 200
108: SUB R4, R5, R6 ; R4 = R5 - R6
112: MUL R7, R8, R9 ; R7 = R8 * R9
...
200: OR R10, R11, R12 ; R10 = R11 | R12
在 BEQ 指令的 EX 阶段完成之前,流水线已经开始取下一条指令(地址 108 的 SUB 指令)。问题在于,如果 BEQ 指令的条件成立,应该跳转到地址 200,而不是继续执行地址 108 的指令。 这就产生了控制冒险。
控制冲突的 处理方法 主要包含以下几种:
- 流水线停顿(Pipeline Stall/Bubble): 在条件跳转指令之后,停止后续指令的执行,插入空操作。
- 分支预测(Branch Prediction): 预测分支的结果(跳转或不跳转),并提前取指。如果预测正确,则可以避免停顿;如果预测错误,则需要清空流水线并重新取指。
- 延迟分支(Delayed Branch):编译器或处理器对代码进行优化,将分支指令后的一些不依赖于分支结果的指令先执行,从而减少因分支预测错误造成的开销。