1. 流水线冒险 (Pipeline Hazards)
在流水线处理器中会有这样的情况,当前级流水线的指令可能会阻止下一条连续指令在流水线中预期的时钟周期内执行,这样的情况被称为流水线冒险 (Pipeline Hazards
)。当发生流水线冒险后将会降低流水化所带来的理想加速比。共有以下三类冒险。
[注]:流水化加速比 = 非流水化指令平均执行时间 / 流水化指令平均执行时间
(1) 结构冒险 (Structural Hazards
),当处理器以流水线方式工作时,指令会重叠执行,如果硬件无法同时支持指令的所有组合方式,就会出现资源冲突,从而导致结构冒险。即当前指令所需的硬件正在为之前的指令工作;
(2) 数据冒险 (Data Hazards
),当处理器以流水线方式工作时,指令会重叠执行,若前后指令存在依赖关系,就可能导致数据冒险;(如,当前进入流水线的指令需要用到上一条指令执行结束后的结果作计算,此时则发生数据冒险)
(3) 控制冒险 (Control Hazards
),分支指令以及其它改变程序计数器的指令实现流水化时可能导致控制冒险,也就是需要根据之前指令的执行结果决定下一步的行为。
本文先仔细研究一下控制冒险,其它冒险类型将在后续文章中研究,在此之前首先来回顾以下一些你熟悉的东西。
2. 分支、跳转指令 (Branches、Jumps)
你应该已经发现了分支指令和跳转指令与其它常规指令或者算术逻辑指令有些不同。这两类指令的执行都会影响到程序计数器 (PC-Program Counter
) 的值。
分支指令改变 PC
是根据当前 PC
的值加上偏移得到目标值——相对寻址指令。
跳转指令(无条件跳转)改变 PC
无需关心当前 PC
值,而直接改变 PC
寄存器——“绝对寻址指令”。需要注意的是这里加上了双引号,跳转指令本意为跳转到一个绝对地址,但事实上在指令长度固定且与地址长度相同的情况下,没有一条指令可以同时容下操作码和跳转的绝对地址,因此跳转指令也为当前 PC
值加上偏移量实现的。
- 无条件跳转 (Jumps)
指令需要关心的点:操作码 (Opcode
),偏移量 (Offset
),程序计数器 (PC
)
例如,MIPS
中的 J
指令跳转到的地址并不是直接指定 32
位的地址(所有 MIPS
指令都是 32
位长,不可能全部用于编址数据域,那样的指令是无效的,也许只有nop
):由于目的地址的最高 4
位无法在指令的编码中给出,32
位地址的最高 4
位取值当前 PC
的最高 4
位。对于一般的程序而言,28
位地址所支持的 256MB
跳转空间已经足够大了。
再如 RISC-V
架构 (RV32
) 中的 JAR
指令运用到 J
格式的立即数编码方式,符号位扩展之后 2
的整数倍的偏移量加上 PC
值作为跳转目标地址,因此可以控制指令跳转到当前位置前后 1 MiB
的范围内。JAL
指令将(PC + 4
)存放在目的寄存器 rd
中。标准的软件中系统调用约定用寄存器 x1
作为返回地址寄存器。
当 JAL
的 rd = x0
的时候,即为简单的 Jump
指令(汇编程序中的伪指令 J
)。
OK,当计算机取指 (IF
) 到一个 JUMP
指令,在 ID
阶段译码器通过该条指令的操作码 Opcode
确定当前指令是一条跳转指令,然后还需要确定指令中给的偏移量 Offset
和当前程序计数器 PC
值,然后需要为程序计数器加上该偏移量,这就需要在 ALU
或者是一个特殊的加法器中执行,最终改变 PC
值。
- 寄存器跳转 (Jump Register)
指令需要关心的点:操作码 (Opcode
),寄存器值
当计算机取指 (IF
) 到一个寄存器跳转指令,在 ID
阶段译码器通过该条指令的操作码 Opcode
确定当前指令是一条寄存器跳转指令,但这时候并不知道要跳转的地址只知道保存跳转地址的寄存器,之后再从该寄存器中获取跳转地址。这里没有任何偏移,而是直接跳转到跳转寄存器保存的地址。
- 条件分支跳转 (Conditional Branches)
指令需要关心的点:操作码 (Opcode
),程序计数器 (PC
),寄存器值 (用于判断条件),偏移量 (Offset
)
这就变得有点复杂了,当计算机取指 (IF
) 到一个条件跳转指令,在 ID
阶段译码器通过该条指令的操作码 Opcode
确定当前指令是一条条件跳转指令,然后需要获取 PC
值和查看对应的寄存器,通过寄存器的值得到条件结果(如,用寄存器的值与 0
比较,查看是否大于或小于),再通过指令给的偏移量加上程序计数器 PC
值得到跳转地址完成一个和 PC
相关的分支。
3. 控制冒险 (Control Hazards)
3.1. 跳转指令带来的流水线控制冒险
首先来了解下基本的控制冒险,最基本的控制冒险是如何保证下一条指令的正确执行,来看下面这个流水线图。
在上面个流水线中有两个指令 I1
和 I2
,指令 1
是从 r0
寄存器中取值并加上立即数 10
再将结果保存到 r1
寄存器中,指令 2
是从 r2
寄存器中取值并加上立即数 17
再将结果保存到 r3
寄存器中去。这是两条很简单的指令,这两条指令之间没有数据的依赖性,因此着这种情况下不会产生数据冒险。这里重点关注控制冒险。
指令 1
会按正常的五级流水线执行顺序执行,取指 IF
、译码 ID
、执行 EX
、存储 MEM
、写回 WB
。可能你发现了第二条指令这里的执行似乎有点不同,当第二条指令指令进入流水线中,第一条指令才刚刚完成取指阶段,这个时候就会产生一个问题:“第二条指令到底是否是我们需要执行的指令呢?”,产生这个问题的原因是第一条指令并没有被译码,因此这时并不清楚第一条指令到底是不是分支或跳转指令。所以这时候按顺序流进流水线的指令 2
不一定是程序需要执行的指令。直到第一个指令在 ID
阶段被译码器解码后,这时候我们了解到,“哦~ 上一条指令不是分支或跳转指令,或者的确是一条分支或跳转指令”。
那么如果指令 1
是一条分支或跳转指令呢?那么在译码阶段就会确定指令 1
是这样的指令,因此在指令 1
的 ID
阶段时钟周期将会改变程序计数器 PC
和改变指令寄存器中要被读取的指令地址。这样就会产生控制冒险,为了避免冒险我们会在 ID
这个阶段插入一个气泡 (Bubble
),让下一条指令的取值阶段延迟一个周期,如果继续这样下去,就会变成下面这样。
[注]:为避免这类冒险,常常会使流水线插入一个空操作 nop。这样的空操作通常被称为流水线气泡或直接称为气泡 (Pipeline Bubble/Bubble)。
每条指令流入流水线都会要考虑控制冒险,那么要避免冒险就要给每个 ID
阶段插入一个 Bubble
,那么每条指令的取值都需要两个时钟周期,你会意识到这将会是非常低效的流水线。现在我们具体的分析这样的流水线,让我们换一种流水线画法(坐标改变)再来看看。
这样就会很清楚的看到,这条流水线执行的顺序是 I1
、nop
、I2
、nop
、I3
、nop
、I4
…按照这个流水线计算它的 CPI = 2 (1 + 1)
,理想 CPI = 1
,按照这样设计的机器执行程序的性能严格减半。
[注]:
非流水化 CPI = 指令执行周期 / 执行指令个数;
流水化 CPI = 理想 CPI + 每条指令的流水线停顿时钟周期;(理想 CPI = 1)
3.2. 解决跳转指令带来的控制冒险(基本方法)
现在已经清楚了在流水线中会因为存在跳转指令的原因,而为流水线带来控制冒险,那么如何解决这个问题呢?其实说来方法也简单,那就是猜测 (Speculate
) 下一条指令不是跳转指令,因此直接将 PC
的值加 4
(若指令长度是 4
字节)。
现在的流水线处理方法就是图中紫色线圈出的部分,猜测吓一跳指令不是跳转指令,而直接用一个加法器为 PC
加 4
,让其指向连续的下一条指令,按照正常的顺序这里应该会执行 96
地址的指令、100
地址、104
地址。
但实际上来看这段指令代码,100
地址的指令是一条跳转指令,当 100
地址的指令被取值,到了 ID
译码阶段发现 100
地址的指令是一条跳转指令,但此时 104
地址的指令已经被取值(由于上次猜测该指令不是跳转指令因此会顺序取值)。而 100
地址的指令告诉我们应该去 304
地址取值执行,那么这时候就需要做两件事情,第一,要阻止已经被取值了的 104
地址指令继续在流水线中执行(即杀死现在的流水线);第二,改变 PC
值为要跳转到的地址。
为了完成上述第一个问题我们在流水线中加入一个选择器 IRSrc
,当上一条指令到译码阶段被解译为跳转指令时,该选择器便会切换到一条空指令 nop
。并且在周期结束时利用一个额外的加法器将指令的一部分和现有的 PC
相加计算得到新的跳转地址交给 PC
,从而完成第二点。
将上面的流水线电路执行程序的过程用时间线的表方式描述就像上图这样,第二条指令到了 ID
阶段被译码出是跳转指令,虽然 I3
已经完成取指,但是此时选择器切换到 nop
,那么 I3
指令在接下来流入流水线并不会执行实际的动作,同时在 I2
指令 ID
阶段时钟周期结束时完成对 PC
值的计算,到了 t3
这个时钟周期,IF
再取指,就成功的取到了 304
地址的指令完成指令的跳转。
3.3. 条件分支带来的流水线控制冒险
I1 096 ADD
I2 100 BEQZ r1 +200
I3 104 ADD
108 ...
I4 304 ADD
这里有一段指令代码,100
地址的指令会判断 r1
寄存器的值是否等于 0
如果是则跳转到以 200
为偏移的地址。事实上你发现这里已经产生了一条流水线的分支,那么带来的问题就是
- 1. 要怎么样知道是否采用这条分支;
- 2. 以及采用这个分支后要如何去做。
3.4. 解决条件分支指令带来的流水线的控制冒险(基本方法)
先来看第一个问题,如何知道是否采用这条分支,是否还能像跳转指令那样直接在译码阶段完成(即根据译码的指令类型判断),这样的方法用在条件分支指令似乎不合理,因为这时候只知道是条件分支指令,但并不清楚条件是否成立。因此这里需要用一个能够做比较的硬件逻辑单元,类似这样的减法运算或比较操作很适合用 ALU
完成,然后再引出一个零线 (wire
),如下图。
根据这样的做法,将会在译码阶段的下一个周期结束后才能确定是否选用分支(根据 zero wire
判断)。PC
的预测方案依然是猜测不跳转的方式,那么 IF
阶段会在 I2
的 ID
阶段取指一次地址为 PC + 4 = 104
,当计算 I2
分支这个时钟周期, IF
又会取指一次地址为 PC + 4 = 108
,因此当能够确定是否选用分支时,我们已经在提取下下一条的指令了。
你应该发现了,当我们能够确定是否选用分支的时候,已经提取了两条指令,在此之前并不清楚是否要 kill
掉(插入 nop
)这两个进入流水线的指令,直到获取到 zero wire
的信号。
那么到这里又会存在一个问题,考虑一下,我们可以使用 stall
信号停止寄存器移动(改变),然后可以利用选择器重定向流水线流入指令,通过这两个方法实现消除先前的流水线业务。那么这两个动作的优先级应该怎样选择?是随意还是必须有先后顺序?
ok,现在假设 stall
信号优先级更高一些,上图中红色的 stall
信号线会阻止寄存器改变,
小注:(文章未完,近期完善中…)