第 10 章 提交
书内第 321–343 页;PDF 第 333–355 页。原书疑点见校注。
- 10.1 概述
- 10.2 重排序缓存
- 10.2.1 一般结构
- 10.2.2 端口需求
- 10.3 管理处理器的状态
- 10.3.1 使用 ROB 管理指令集定义的状态
- 10.3.2 使用物理寄存器管理指令集定义的状态
- 10.4 特殊情况的处理
- 10.4.1 分支预测失败的处理
- 10.4.2 异常的处理
- 10.4.3 中断的处理
- 10.4.4 Store 指令的处理
- 10.4.5 指令离开流水线的限制
10.1 概述
在程序员编写程序的时候,认为程序的执行都是按照串行的顺序进行的,只有前面的一条指令执行完了,后面的指令才可以执行,因此处理器在执行程序的时候,也必须要保证这种串行的结果 [44],否则就会和程序最初的预想不一致了。但是在现代的超标量处理器中,指令在处理器内部执行的时候,是不会按照这种严格的串行方式执行的,例如在流水线的处理器中,在同一个时刻其实是有很多条指令都存在于处理器中。但是,如果处理器想要正确地执行程序,就必须要维持程序当中的串行顺序,尤其是对于超标量处理器来说,由于它按照乱序 (out-of-order) 的方式执行指令,为了保持程序执行结果的串行性,一般在超标量处理器的流水线中增加最后一级阶段,称为提交 (Commit) 阶段,当一条指令到达流水线的这个阶段后,会将这条指令在重排序缓存 (ROB) 中标记为已完成的 (complete) 状态,需要注意的是,这个状态只表示这条指令已经计算完毕,并不表示它可以离开流水线。在前面已经提到过,流水线中所有的指令都按照进入流水线的顺序在重排序缓存中进行了记录,只有在重排序缓存中最旧的那条指令变为已完成的状态时,这条指令才允许离开流水线,并使用它的结果对处理器的状态进行更新,此时称这条指令退休 (retire) 了,从这个过程可以看出,一条指令可能需要在流水线的提交阶段等待一段时间,才可以从流水线中退休,而重排序缓存正是完成这个功能的关键部件,因此它也是流水线的提交阶段最重要的一个部件。一条指令到达流水线的提交阶段并不一定代表这条指令就一定是正确的,由于分支预测失败 (mis-prediction) 和异常 (exception) 等原因,一条处于已完成状态的指令很可能还会从流水线中被抹掉,只有在这条指令之前进入到流水线中的所有指令都已经退休了,并且这条指令已经处于已完成状态的时候,它才可以退休而离开流水线,这样可以使程序在超标量处理器中执行的时候有串行的效果。
当一条指令没有退休之前,它的状态都是推测的 (speculative), 只有当这条指令真正退休而离开流水线的时候,才可以将它的结果更新到处理器的状态中,这样即使有分支预测失败或者异常,也不会将错误的状态暴露给程序员。在前文讲过,流水线的分发 (Dispatch) 阶段是处理器从顺序执行 (in-order) 到乱序执行 (out-of-order) 的分界点,那么现在,流水线的提交阶段又将处理器从乱序的状态拉回到顺序执行的状态。不管超标量处理器内部发生了怎样的事情,从处理器外部看起来,它总是按照程序中指定的顺序执行的,任何预测技术所产生的错误,在处理器内部都会解决掉,不会将这些错误的状态表现出来,从这个角度来看,超标量处理器堪称是一个伪装高手。
同时,维持程序执行的串行性,也是精确的异常 (precise exception) 所要求的,精确异常的定义是当一条指令发现异常时,这条指令前面的所有指令都已经完成,而这条指令及其后面的所有指令都不应该改变处理器的状态。处理器当中的异常有很多种,这取决于 ISA 的定义,例如当程序出现 Page Fault、除零等异常时,都需要进行处理。如果能够维持精确的异常,会降低处理器对异常处理的难度,在超标量处理器中,流水线的提交阶段的一个重要任务就是要对异常进行处理,本章会对这部分内容进行介绍。
还需要注意的是,对于一个 N-way 的超标量处理器来说,因为每周期最少可以取得 N 条指令送入到流水线中,流水线的提交阶段每周期也最少需要将 N 条指令退休,这样才能保证流水线不会被堵塞,所有顺利地离开流水线的指令要根据程序中原始的顺序来更新处理器的状态,如图 10.1 所示。

图 10.1 超标量处理器也需要按照程序中规定的顺序更新处理器的状态
图 10.1(a) 表示了一个普通的标量处理器的状态,程序在处理器中是完全按照顺序 (in-order) 的方式执行的,指令也会按照程序中指定的顺序,逐条地从流水线中退休,从而对处理器的状态进行更改;图 10.1(b) 中表示了一个 2-way 的超标量处理器,每周期可以并行执行两条指令,也可以在每周期内从流水中退休两条指令,这些顺利离开流水线的指令也应该按照程序中指定的顺序去改变处理器的状态,从处理器外部看起来,它和图 10.1(a) 中普通的处理器执行的结果是一样的,只不过超标量处理器会更快得到结果而已。
10.2 重排序缓存
10.2.1 一般结构
在流水线的提交 (Commit) 阶段,之所以能够将乱序执行的指令变回程序中指定的顺序状态,主要是通过重排序缓存 (Reorder Buffer,ROB) 来实现的,ROB 本质上是一个 FIFO,在它当中存储了一条指令的相关信息,例如这条指令的类型、结果、目的寄存器和异常的类型等,如图 10.2 所示。

图 10.2 重排序缓存 (Reorder Buffer)
ROB 的容量决定了流水线中最多可以同时执行的指令个数,在图 10.2 中,每个 ROB 的表项包括的内容如下。
(1) Complete: 表示一条指令是否已经执行完毕;
(2) Areg: 指令在原始程序中指定的目的寄存器,它以逻辑寄存器的形式给出;
(3) Preg: 指令的 Areg 经过寄存器重命名之后,对应的物理寄存器的编号;
(4) OPreg: 指令的 Areg 被重命名为新的 Preg 之前,对应的旧的 Preg, 当指令发生异常 (exception) 而进行状态恢复时,会使用到这个值;
(5) PC: 指令对应的 PC 值,当一条指令发生中断或者异常时,需要保存这条指令的 PC 值,以便能够重新执行程序;
(6) Exception: 如果指令发生了异常,会将这个异常的类型写到这里,当指令要退休的时候,会对这个异常进行处理;
(7) Type: 指令的类型会被记录到这里,当指令退休的时候,不同类型的指令会有不同的动作,例如 store 指令要写 D-Cache、分支指令要释放 Checkpoint 资源等。
前文讲过,在流水线的分发 (Dispatch) 阶段,指令会按照进入流水线的顺序写到 ROB 中,同时 ROB 中对应的 complete 状态位会被置为 0, 表示这些指令没有执行完毕,在以后的某个时间,当某条指令执行结束了,它就变为了 complete 状态,此时会将 ROB 中对应的 complete 状态位置为 1, 这条指令的计算结果可以放在 ROB 中,也可以放在物理寄存器堆 (PRF) 中,这取决于架构的实现。一条指令在执行的过程中如果发生了异常,也会将异常的类型记录在 ROB 中,异常的处理会统一放在流水线的提交阶段。指令一旦在流水线的分发阶段占据了 ROB 中的一个表项,这个表项的编号会一直随着这条指令在流水线中流动,这样指令在之后的任何时刻,都可以知道如何在 ROB 中找到自己。
一条指令一旦变为 ROB 中最旧的指令 (在图 10.2 中,使用 head pointer 来指示最旧的指令 ) 并且它的 complete 状态位也为 1, 就表示这条指令已经具备退休 (retire) 的条件了。如果这条指令在之前没有发生过异常,也就是它在 ROB 中对应的 exception 部分为 0, 则这条指令可以顺利地离开流水线,它的结果可以对处理器的状态进行更新;如果这条指令发生过异常,那么就要启动异常的处理过程,这个过程在本章的后文会进行介绍。
下面通过一个例子来说明 ROB 的工作过程,本例子采用了统一的 PRF 进行寄存器重命名的架构,在图 10.3 中存在 i1、i2、i3 和 i4四条指令。

图 10.3 指令 i1~i4 经过寄存器重命名的过程
重命名前:
i1: r1 = r2 / r3
i2: r3 = r4 + r1
i3: r1 = r5 + r6
i4: r2 = r7 + r4
重命名后:
i1: p8 = p4 / p5
i2: p9 = p3 + p8
i3: p10 = p6 + p0
i4: p11 = p1 + p3
从图 10.3 的指令可以看出,因为指令 i1 是除法,指令 i2 使用指令 i1 的结果,所以指令 i1 和 i2 的执行时间会很长,由于乱序执行的原因,指令 i3 和 i4 会先执行完毕,但是指令 i3 和 i4 的结果不能够对处理器的状态进行更新,否则当 i3 将结果写到目的寄存器 r1 之后,指令 i1 在完成后也会将结果写到目的寄存器 r1 中,这样经过指令 i1~i4 的执行之后,寄存器 r1 最后得到了指令 i1 的结果,这显然是错误的。还有一种可能就是当指令 i1 发生了除零的异常时,指令 i3 即使将结果计算出来,也不能够写到目的寄存器 r1 中,这样保证了精确的异常。
如表 10.1 所示为 ROB 在三个时间点的情况。
表 10.1 ROB 在三个时间点的情况
(a) 指令 i1~i3 已经 renaming
| Complete | Areg | Preg | ||
|---|---|---|---|---|
| i1 | Not ready | r1 | p8 | Head |
| i2 | Not ready | r3 | p9 | |
| i3 | Not ready | r1 | p10 | |
| tail |
(b) 指令 i3 和 i4 已经 complete
| Complete | Areg | Preg | ||
|---|---|---|---|---|
| i1 | Not ready | r1 | p8 | Head |
| i2 | Not ready | r3 | p9 | |
| i3 | ready | r1 | p10 | |
| i4 | ready | r2 | p11 | |
| tail |
(c) 指令 i1 已经 retire
| Complete | Areg | Preg | ||
|---|---|---|---|---|
| i1 | ready | r1 | p8 | |
| i2 | Not ready | r3 | p9 | Head |
| i3 | ready | r1 | p10 | |
| i4 | ready | r2 | p11 | |
| tail |
当指令 i1~i3 经过寄存器重命名并且按照顺序写到 ROB 之后,ROB 看起来像表 10.1(a) 所示的那样,三条指令都得到了自己对应的物理寄存器,并按照程序指定的顺序写到了 ROB 中,当然在下个周期,指令 i4 以及后面的指令也会写到 ROB 中,表格中没有表示出来。
当经过一段时间之后,指令 i3 和 i4 已经将结果计算出来,它们会将这个状态写到 ROB 中,如表 10.1(b) 所示,此时指令 i1 和 i2 都没有完成。当然,由于指令 i3 和 i4 并不是 ROB 中最旧的指令,所以它们即使变为了 complete 状态,也不能离开 ROB。
当指令 i1 变为 complete 状态的时候,因为它是 ROB 中最旧的指令,因此它可以退休 (retire),寄存器 r1 对应的数值也就来自于这条指令执行的结果,指令 i1 退休之后,ROB 的样子如表 10.1(c) 所示。
随着程序的执行,当指令 i2 也变为 complete 状态的时候,指令 i2、i3 和 i4 此时都可以退休了,一般情况下,在流水线的分发 (Dispatch) 阶段,每周期最多可以进入 ROB 的指令个数会等于 ROB 每周期最多可以退休的指令个数,这样可以保持流水线的畅通,因此对于图 10.3 的例子来说,如果每周期最多有三条指令可以进入 ROB,那么每周期可以从 ROB 中离开的指令个数也是三条,因此指令 i2、i3 和 i4 就可以在一个周期内都退休,从而顺利地离开流水线。与此同时,每周期从流水线中退休的指令中,可能存在多条指令都写到同一个目的寄存器的情况,此时只有最后的那条指令才是真正需要的,例如上例中,如果指令 i4 的目的寄存器也是 r3,那么指令 i2 和 i4 就都写同一个寄存器 r3,当然寄存器 r3 最后得到的值就是指令 i4 的结果,也就是说,r3 最终对应物理寄存器是 P11。
10.2.2 端口需求
对于一个 4-way 的超标量处理器来说,在重排序缓存 (ROB) 中每周期可以退休的指令个数应该是不小于四条的,处理器在执行的过程中,从 ROB 中最旧的一些指令中 (由 head pointer 指定 ),选择那些已经变为 complete 状态的指令,使其从流水线中退休,例如图 10.4 给出的 ROB, 在那些最旧的指令中,有三条指令都已经变为了 complete 状态,那么在一个周期内就可以将这三条指令都退休。

图 10.4 三条指令都可以在一个周期内从 ROB 中退休
要实现如图 10.4 所示的功能,需要对 ROB 中最旧的指令开始 (由 head pointer 指定 ) 连续的四个 complete 信号进行判断,如果某个 complete 信号为 0, 那么它后面的所有指令都不允许在本周期退休,这个功能用如图 10.5 所示的电路可以实现。

图 10.5 判断 ROB 中哪些指令可以退休的电路图
在图 10.5 中,complete0~complete3 这四个信号来自于 ROB 顶部的四条连续的指令,而 en0~en3 这四个信号则是用来表示 ROB 顶部的这四条指令哪些被允许在本周期退休,例如 en0~en3 为 1100 表示本周期只有前两条指令允许退休。通过图 10.5 所示的这个简单的逻辑电路就可以实现如果 ROB 中的某条指令没有变为 complete 的状态,那么这条指令后面的所有指令都不允许在本周期退休。
由图 10.5 可以看出,对于一个 4-way 的超标量处理器来说,ROB 最少需要支持四个读端口,但是这远远不是 ROB 真正需要的端口个数,流水线的其他阶段还对 ROB 有着端口的需求,它和处理器所采用的架构有关系,如果处理器采用 ROB 进行寄存器重命名的方式,那么此时对 ROB 的端口需求是最多的,这些端口包括如下几个。
(1) 在流水线的寄存器重命名阶段,需要从 ROB 中读取四条指令的源操作数,因此需要 ROB 支持八个读端口 ( 假设每条指令有两个源寄存器 );
(2) 在流水线的分发 (Dispatch) 阶段,需要向 ROB 中写入四条指令,因此需要 ROB 支持四个写端口;
(3) 在流水线的写回 (Write Back) 阶段,需要向 ROB 中写入最少四条指令的结果,之所以使用“最少”这个词,是因为很多处理器的 issue width 是要大于 machine width 的,处理器会放置更多的 FU 来提高运算的并行度,这样导致每周期运算得到的结果是大于四个的,这需要 ROB 最少支持四个写端口。
通过上面的描述可以看出,ROB 需要支持 12 个读端口和最少 8 个写端口,这种多端口的 FIFO 在面积和延迟方面很难进行优化,这也是采用 ROB 进行寄存器重命名的架构所面临的最大问题之一:ROB 会成为整个处理器中最繁忙的部件,这需要在电路设计层面上对 ROB 进行仔细的设计和优化,增大了设计的复杂度。
10.3 管理处理器的状态
在超标量处理器内部有两个状态,一个是指令集定义的状态,例如通用寄存器的值、PC 值以及存储器的值等,将这些状态称为 “Architecture State”; 还有一个状态是超标量处理器内部的状态,例如重命名使用的物理寄存器、重排序缓存 (ROB)、发射队列 (Issue Queue) 和 Store Buffer 等部件中的值,这些状态是处理器在运行的过程中产生的,由于乱序执行的原因,它总是超前于指令集定义的状态。当然,这些状态也可能有一些是不正确的,但是处理器的硬件会将其内部解决掉,不会让外界看到这些不正确的状态,将处理器内部的这些状态称为 “Speculative State”。一条指令只有退休 (retire) 的时候才会更新处理器中指令集定义的状态,就好像处理器在按照串行的顺序执行程序一样,在此之前,指令只能改变处理器内部的状态,当然,由于存在分支预测失败 (mis-prediction) 和异常 (exception) 等原因,处理器内部的状态未必就一定是正确的。举例来说,如果在处理器内部发现了分支预测失败,处于错误路径上的所有指令都会被变为无效,这些指令对应的处理器内部状态就是错误的,它们不应该被更新到指令集定义的状态中。再例如一条指令发生异常时,这条指令以及后面的所有指令对应的处理器内部状态也都是错误的,这些状态不应该被外界看到,也就不能将它们更新到指令集定义的状态中。只有一条指令满足退休的条件:变为 ROB 中最旧的指令、已经处于 complete 状态,并且没有异常发生,这才能够将这条指令对应的处理器内部状态更新到指令集定义的状态中。
在超标量处理器内部包括了很多的部件,但是许多部件都不应该被外界看到,它们都是为乱序执行的特征而服务的,举例来说,物理寄存器的个数是远大于指令集中定义的逻辑寄存器的个数的,因此除了逻辑寄存器之外的其他寄存器都属于处理器内部的状态,都不应该从处理器外部被看到。而像 Store Buffer 或 ROB 等资源都是超标量处理器内部使用的,从处理器外部也是不应该被看到的。概括起来就是,除了指令集中定义的资源,例如 PC 寄存器、存储器或者通用寄存器等部件之外,其他的资源都属于处理器内部的状态,如图 10.6 所示。

图 10.6 普通处理器与超标量处理器模型
图 10.6(a) 表示一个普通处理器的模型,从处理器外部应该看到的内容包括 PC 值、存储器的值 (包括 Cache) 和通用寄存器;而对于图 10.6(b) 来说,虽然超标量处理器内部有很多的部件,但是当从处理器外面看时,除了 Architecture State, 其他的所有东西都将会被忽略,程序员并不会看到它们。
对于指令集中定义的绝大部分指令,都会更改处理器的状态,例如普通的运算指令改变通用寄存器的值、访问存储器的指令改变存储器和通用寄存器的值、分支指令改变 PC 寄存器的值等,因此在超标量处理器中,当一条指令退休的时候,需要将它的结果写到指令集定义的状态中。例如对于采用将通用寄存器扩展进行寄存器重命名的架构,需要将目的寄存器的值从物理寄存器搬移到通用寄存器中;对于采用统一的物理寄存器进行重命名的架构中,则需要将目的寄存器在物理寄存器堆中标记为对外界可见的状态:不管采用何种寄存器重命名的方式,如果退休的指令是 store 指令,需要将 Store Buffer 中对应的值写到 D-Cache 中;如果退休的指令是分支指令,当发现分支预测失败时,情况要更复杂一些,因为需要进行状态恢复,并且抛弃掉流水线中错误的指令,重新从正确的地址开始取指令,可能还需要将它所占用的 Checkpoint 资源进行释放。在指令集中还定义了各种的异常 (exception),在指令退休之前,还需要统一对异常进行处理,这是在流水线的最后一个阶段 (也就是提交阶段) 完成的,此时处理器会抛弃掉流水线当中的所有指令,跳转到相应的异常处理程序的入口地址,对这个异常进行处理。根据处理器架构的不同,可能还会有其他的事情需要在这个阶段完成,这些都增加了超标量处理器设计的复杂度。
在超标量处理器中,根据架构的不同,会对应着不同的方法来管理指令集定义的状态,本节介绍两种主要的方法。
(1) 使用 ROB 管理指令集定义的状态。
(2) 使用物理寄存器管理指令集定义的状态。
第一种方法被 Intel P6 架构、 Intel Core 架构等使用,第二种方法被 Intel Pentium 4、Alpha 21264 和 MIPS R10000 等处理器使用。
10.3.1 使用 ROB 管理指令集定义的状态
对于采用重排序缓存 (ROB) 进行寄存器重命名的架构,一条指令在没有退休 (retire) 之前,都会使用 ROB 来保存指令的结果,当指令退休的时候,这条指令的结果可以对指令集定义的状态进行更新,此时会将这条指令的结果从 ROB 中搬移到指令集中定义的逻辑寄存器中。在这种架构中,逻辑寄存器是物理上真实存在的,由于在逻辑寄存器中存储了所有退休的指令对应的目的寄存器的值,因此在这种方法中也将其称为 Retire Register File,简称 RRF。
如图 10.7 所示为这种方法的示意图。

图 10.7 使用 ROB 管理 Architecture State
前文说过,ROB 在本质上是一个 FIFO,在流水线的分发 (Dispatch) 阶段,指令按照程序中指定的顺序被写到 ROB 中;当指令退休的时候,或者由于分支预测失败和异常等原因而离开 ROB 的时候,这条指令在 ROB 中占据的空间就会被释放。ROB 包括了两个主要的部分,即指令信息和指令结果,在指令信息中记录了指令的类型、指令执行的状态以及指令的目的寄存器等内容,在指令结果中记录了指令产生的结果。在 ROB 中的所有指令产生的结果都代表了处理器内部的状态 (Speculative State),如图 10.7 所示中的灰色部分。当指令退休的时候,会将这条指令的结果从 ROB 中搬移到通用寄存器 (ARF/RRF) 中,同时将这条指令在 ROB 中占据的空间释放掉,这样,从处理器外部查看通用寄存器时,得到的结果总是正确的,这也是指令集中定义的状态 (Architecture State)。
这种设计本质上使用了 ROB 作为物理寄存器,在进行寄存器重命名之后,指令会被写到 ROB 中,例如对于 4-way 的超标量处理器,会同时将四条指令写到 ROB 中,此时可以直接将这四条指令在 ROB 中的地址作为它们经过重命名之后的物理寄存器,因此,使用 ROB 来管理指令集定义的状态,可以简化寄存器重命名的过程。
在一般情况下,使用 ROB 来管理指令集定义的状态,都会对应着使用数据捕捉 (data-capture) 的结构来进行发射 (issue),因为在这种方法中,当一条指令退休的时候,需要将这条指令的结果从 ROB 中搬移到通用寄存器中,以后的指令如果想使用这条指令的结果,就需要从通用寄存器中进行读取。这样,一个寄存器在它的生命周期内会存在于两个地方,如果使用数据捕捉的方法,当一条指令的结果在流水线的执行阶段被计算出来之后,会将这个结果送到旁路网络 (bypassing network) 中,这样 payload RAM 就可以捕捉到这个结果,也就是说,在发射队列 (Issue Queue) 中,所有使用这个结果作为操作数的指令都会得到这个值,这些指令被仲裁电路选中的时候,直接从 payload RAM 中就可以得到所有的源操作数了,而不需要关心这个寄存器此时是在 ROB 中还是在通用寄存器中。因此,payload RAM 是采用数据捕捉结构的必要部件,当指令被进行重命名的时候,会访问重命名映射表,如果发现源寄存器的值已经被计算出来,那么直接可以从 ROB 或者通用寄存器中读取这个值,然后写到 payload RAM 中;如果这个寄存器的值还没有被计算出来,就将这个寄存器在 ROB 中的地址写到 payload RAM 中,等待从旁路网络中捕捉这个值,也就是说,在 payload RAM 中要么得到源寄存器的值 (对应着这个寄存器的值已经被计算出来,并写到了 ROB/ARF 中的两种情况 ),要么就得到这个寄存器在 ROB 中的地址 (对应着这个寄存器的值没有被计算出来的情况 ), 此时的 payload RAM 需要从旁路网络中获得这个寄存器的值。因此,使用 ROB 进行寄存器重命名,并管理指令集定义的状态,是和数据捕捉的发射 (issue) 方式正好匹配的,图 10.8 表示了这种方法的执行过程。

图 10.8 数据捕捉的发射方式+使用 ROB 管理 Architecture State
在图 10.8 中,第一条指令 A 的结果被它后面的三条指令 (B、C、D 和 E) 使用,因此它们被形象地称为 “生产者 (producer)” 和 “消费者 (consumer)”。当指令 B 读取操作数的时候,由于操作数还没有准备好,因此它会将操作数在 ROB 中的地址保存到 payload RAM 中,等待从旁路网络中得到这个操作数;而对于指令 C 和 D 来说,它们可以直接从 ROB 中读取操作数并写到 payload RAM 中;对于指令 E来说,它读取操作数的时候,这个操作数已经被写到通用寄存器 (ARF) 中,因此可以直接从通用寄存器中读取操作数并写到 payload RAM 中。不管指令是从旁路网络、ROB 还是通用寄存器中取得操作数,最后都需要在进入到流水线的执行阶段之前,从 payload RAM 中得到操作数,因此在这种方式中,并不关心操作数位置的变化。
如果使用 ROB 进行寄存器重命名,但是使用了非数据捕捉 (non-data-capture) 的方式进行发射 (issue), 会有什么样的情况发生呢?
在采用非数据捕捉的方式中,没有 payload RAM 来存储指令的源操作数,指令被仲裁电路选中之后,直接从需要的位置读取源操作数的值,这个位置信息记录在发射队列 (Issue Queue) 中。指令在流水线的寄存器重命名阶段,通过读取重命名映射表就可以得到源操作数的位置信息,然后将这个位置信息跟随指令一起写到发射队列中,等待被唤醒。但是,如果这条指令在被唤醒的时候,它的操作数还位于 ROB 中,而等到它真正被仲裁电路选中的时候,这个操作数可能就已经被搬移到通用寄存器中了,那么这个位置变动的信息需要通知发射队列,这样指令在被仲裁电路选中的时候才知道从哪里取得正确的操作数,这需要发射队列支持额外的写端口,也需要增加额外的旁路网络,这些都增加了设计的复杂度,它的执行过程如图 10.9 所示。

图 10.9 非数据捕捉的发射方式+使用 ROB 管理 Architecture State
在之前使用数据捕捉 (data-capture) 的方式中,指令在执行之前,都是从 payload RAM 中直接取得操作数,因此它对于操作数位置的变化并不敏感,而采用非数据捕捉的方式后,指令在执行之前可以从 ROB/ARF 两个位置读取操作数,因此对于操作数位置的变化是非常敏感的,要处理好这个问题,就需要付出额外的硬件成本,这显然是不必要的。总结起来,使用 ROB 来管理指令集定义的状态,对应着使用数据捕捉的方式进行发射 (issue),这两者组合可以带来比较优化的设计,因为在这种组合中,所有指令的源操作数都会被统一放到 payload RAM 中,它并不关心指令结果的位置是否发生了变化,当然,这需要旁路网络的配合。而在非数据捕捉 (non-data-capture) 的结构中,由于在发射队列中没有存储源操作数的地方,这些操作数需要从它的源头进行读取,因此对于指令结果的位置变化,它是很在意的,为了减少这种麻烦,它只能和 10.3.2 节讲述的方法配合进行工作。
10.3.2 使用物理寄存器管理指令集定义的状态
这种方式使用一个统一的物理寄存器堆 (PRF),指令集中定义的所有逻辑寄存器都混在这个寄存器堆中,当然,为了进行寄存器重命名,在它当中包含了更多的寄存器,当一条指令被寄存器重命名之后,它的目的寄存器就和一个物理寄存器产生了对应关系,不过,这个关系此时并不能被外界看到,它只是处理器内部的状态。当这条指令的结果被计算出来的时候,这条指令的状态变为了 complete, 当然,此时它仍然属于处理器内部的状态;等到一条指令退休 (retire) 的时候,这条指令的结果仍然会占据着原来对应的物理寄存器,只不过此时它的状态会被标记为 Architecture state, 等到后续有另外一条指令也写到同一个目的寄存器,并且这条后续的指令退休的时候,才可以将当前的这条指令对应的物理寄存器进行释放。这种方式相当于将指令集定义中的逻辑寄存器融入到了物理寄存器中,比较来说,相对于 10.3.1 节基于 ROB 的管理方式,这种方式的优点有如下三个。
(1) 当指令从 ROB 中退休的时候,不需要将指令的结果进行搬移,它仍旧会存在于物理寄存器中,也就是说,一旦指令的源操作数被确定存在于哪里,以后就不会再变化了,这样便于处理器实现低功耗的设计。
(2) 在基于 ROB 进行状态管理的方式中,需要从 ROB 中开辟空间来存放指令的结果,但是在程序中,有相当一部分的指令并没有目的寄存器,例如 store 指令、比较指令和分支指令,因此 ROB 中会有一部分的空间是浪费的,而使用物理寄存器进行状态管理的方式就可以避免这样的问题,这种方式只会对存在目的寄存器的指令分配空间,其他不存在目的寄存器的指令,不会对应到任何物理寄存器。因此使用这种方式,占用的物理寄存器的个数小于此时 ROB 中的指令个数。
(3) ROB 是一个集中的管理方式,所有指令都需要从其中读取操作数,同时所有的指令也需要将结果写到其中,再加上对 ROB 空间的占用和回收都需要读写端口的支持,因此这需要大量的读写端口,会使 ROB 变得非常臃肿,严重拖累处理器的速度,而反观使用物理寄存器进行状态管理的方式,可以采用一些灵活的方式来避免多端口的负面影响,例如对物理寄存器堆 (PRF) 采用 Cluster 结构,这样可以提高处理器的速度。
当然,使用物理寄存器进行状态管理也不是没有缺点的,它会造成寄存器重命名的过程比较复杂,在使用 ROB 进行状态管理的方式中,只需要将一条指令写入到 ROB 就可以完成重命名的过程,当这条指令顺利地离开流水线的时候,就自然释放了这个映射关系;而使用物理寄存器进行状态管理的方式中,需要一个额外的表格来存放哪些物理寄存器是空闲的,并且重命名映射关系的建立和释放过程都会比较复杂。当处理器外部需要访问指令集中定义的寄存器时,由于这些寄存器是混在物理寄存器当中、无法直接进行分辨的,因此需要一个额外的表格来存放哪些物理寄存器是 Architecture state, 所有的这些功能在一定程度上都影响了处理器的速度。
10.4 特殊情况的处理
由于现代的超标量处理器采用了很多预测的方法来执行指令,并不是流水线中所有的指令都可以退休 (retire),例如当流水线中的某条分支指令发生了预测错误,或者某条指令发生了异常,那么在这条指令之后进入流水线的所有指令就不允许退休了,此时需要将这些指令从流水线中抹掉,并且对处理器进行状态恢复,然后从指定的地址开始重新取指令来执行。在流水线的提交 (Commit) 阶段需要重点关注的指令还有 store 指令,由于它只有在退休的时候才可以真正地改变处理器的状态 ( 即写 D-Cache),如果在这个过程中发生了 D-Cache 缺失,store 指令会阻碍流水线中所有位于它后面的指令继续退休,因此需要对 store 指令进行特殊的处理。在流水线的提交阶段还会对其他的指令有一些特殊的限制,以减少对处理器中其他部件的影响等,上述的这些内容都是在提交阶段需要考虑的特殊情况,在本节会进行介绍。
10.4.1 分支预测失败的处理
当在流水线中发现一条分支指令预测失败时,在它之后进入到流水线的所有指令都变得不正确了,但是这些指令可能已经完成了取指令,重命名甚至是执行的过程,所以当发现分支预测失败时,这些错误的指令都应该从流水线中被抹掉,同时处理器的状态 (包括 RAT、ARF 和 PC 值等内容 ) 都应该被恢复到开始执行分支指令时的状态。
发现分支预测失败后,处理器状态恢复的过程可以分为两个独立的任务,前端的状态恢复 (front-end recovery) 和后端的状态恢复 (back-end recovery),它们是以流水线的寄存器重命名阶段为分界的。前端的状态恢复是比较简单的,只需要将流水线中重命名阶段之前的所有指令都抹掉,将分支预测器中的历史状态表 ( 例如 GHR、BHR 等 ) 进行恢复,并使用正确的地址来取指令即可;后端的状态恢复就复杂了一些,需要将处理器的所有内部组件中 (例如 Issue Queue、Store Buffer 和 ROB 等 ) 错误的指令都抹掉,还要将重命名映射表 (RAT) 进行恢复,以便将那些错误指令对 RAT 的修改进行改正,同时被错误的指令所占据的物理寄存器和 ROB 的空间也需要被释放。图 10.10 表示了分支预测失败时,状态恢复的一个过程,从图中可以看出,前端的状态恢复很快就可以完成,处理器此时可以从正确的地址开始取指令,这些被取出来的指令可以一直执行到流水线的重命名阶段之前,在这段时间内,后端的状态恢复也在继续。当这个过程消耗的时间满足下面的关系时:
\[\mathrm{Time}_{\text{back-end recovery time}} < \mathrm{Time}_{\text{front-end recovery time}} + \mathrm{Time}_{\text{fetch}\sim\text{renaming}}\]此时流水线就不需要暂停,因为当新的指令到达流水线的重命名阶段时,后端的状态恢复已经完成了,新指令可以使用正确的处理器状态;相反,当上面的式子不能够满足时,就会出现这样的情形了,新取出来的指令在到达流水线的重命名阶段之时需要等待一段时间,直到后端的状态恢复完成为止,这个过程如图 10.10 所示。

图 10.10 Front-end Recovery 和 Back-end Recovery
在前文中,已经对分支预测失败时的状态恢复进行了介绍,本节对以前讲述的内容进行梳理和总结,更详细的内容参见之前的章节。
在发现分支预测失败时,大部分的恢复任务都是对寄存器重命名相关的部件进行的,因为大部分处于错误路径上的指令都经过了这一步,对重命名映射表 (RAT) 以及相关的部件进行了修改,为了保证后续的指令能够继续正确地进行寄存器重命名,需要对这些错误的状态进行修复。当然,流水线中其他的部件也需要进行修复,例如发射队列 (Issue Queue)、重排序缓存 (ROB) 和 Store Buffer 等,但是这些部件的修复相对比较容易。既然对寄存器重命名过程的修复是主要的任务,那么重命名的方式直接决定了使用何种方法进行状态恢复,在之前讲过,有三种方式可以进行寄存器重命名,即使用 ROB,使用扩展的 ARF 和使用统一的 PRF。这三种方式中,使用 ROB 和使用扩展的 ARF 进行重命名在本质上都是一样的,因此,本节主要总结两种分支预测失败时进行状态恢复的方法。
(1) 在基于 ROB 进行重命名的架构中进行状态修复。
在基于 ROB 进行寄存器重命名的架构中,存在一个真正的寄存器堆,它和指令集中定义的所有寄存器是一一对应的,它称为 ARF(Architecture Register File),每次当指令从 ROB 中退休的时候,都会将这条指令的结果从 ROB 复制到 ARF 中,因此一个寄存器在它的生命周期内,有两个位置可以存放它的值 (ROB 和 ARF)。为了便于后续的指令读取这个寄存器的值,在重命名映射表 (RAT) 中需要将这两个位置进行标记,当一个寄存器位于 ROB 中时,在表格中存储了它在 ROB 中的位置,当一个寄存器位于 ARF 中时,直接进行寻址就可以了,这个过程如图 10.11 所示 ( 这幅图在前文已经使用过,这里再次使用 )。

图 10.11 使用 ROB 进行寄存器重命名
需要注意的是,一条退休的指令将目的寄存器的值从 ROB 搬移到 ARF 中后,并不一定表示以后的指令就需要从 ARF 来读取这个寄存器的值,举例来说,对于下面的三条指令。
指令 A: ADD R1, R2, R3
指令 B: ADD R1, R1, R4
指令 C: ADD R1, R1, R5
经过寄存器重命名阶段后,只有指令 C 的映射关系才会真正被写到 RAT 中,因此即使在指令 A 从流水线中退休了,并将寄存器 R1 的值从 ROB 中搬移到 ARF 之后,后续的指令在使用寄存器 R1 的时候,仍然会使用指令 C 的结果,因此,即使指令 A 已经退休,也不能够将 RAT 中 R1 对应的映射关系变为如图 10.11 所示的 ARF 状态。为了实现这样的功能,在 ROB 中的每条指令都会检查自身是否是最新的映射关系,只有当一条指令从 ROB 中退休的时候,发现自身也是最新的映射关系,这样才能够将 RAT 中对应的内容改为 ARF 状态。例如图 10.11 中的指令 C 在退休的时候,会发现自身是逻辑寄存器 R1 最新的映射关系,因此它会将 RAT 中 R1 对应的内容标记为 ARF 状态。
从 ROB 中退休的一条指令如何才能够检查自身是不是最新的映射关系呢?可以在这条指令退休的时候,使用它的目的寄存器 (指令集中定义的 ) 来读取 RAT, 读出这个逻辑寄存器此时对应的 ROB pointer, 如果发现它和当前退休指令在 ROB 中所占据的地址是一样的,则表明这条退休的指令就是最新的映射关系,否则就不是了。
在这种架构的处理器中,在流水线中发现分支预测失败时 (一般是在执行阶段 ),此时流水线中有一部分指令是在分支指令之前进入到流水线的,它们可以被继续执行,因此当发现分支指令预测失败时,并不马上进行状态修复,而是停止取新的指令,让流水线继续执行,这个过程称为将流水线抽干 (drain out)。等到这条分支指令之前的所有指令 (包括分支指令本身 ) 都退休的时候,此时 ARF 中所有寄存器的内容都是正确的,同时在流水线中的所有指令都是处于错误的路径上,可以将流水线中的指令全部抹掉,然后将 RAT 中所有的内容都标记为 ARF 状态,这样处理器就从分支预测失败的状态恢复过来了,此时可以从正确的地址开始取指令执行。
这种基于 ROB 进行寄存器重命名的方法,不仅重命名过程本身易于实现,当分支预测失败时进行的状态恢复也是比较简单的,硬件消耗也比较少,在 Intel 的很多处理器中都采用了这种方法。但是,如果在分支指令之前进入到流水线的指令中存在 D-Cache 缺失的指令,例如 load 指令,则这条分支指令需要等待一段时间才能退休,这样就使分支预测失败时的惩罚 (mis-prediction penalty) 变大了。
基于扩展的 ARF 进行寄存器重命名的方式和上面的方法本质上是一样的,因此也可以采用这种方法进行分支预测失败时的状态恢复,此处不再赘述。
(2) 在基于统一的 PRF 进行重命名的架构中进行状态修复。
在这种架构中,流水线中存在两个重命名映射表 (RAT),一个在流水线的寄存器重命名阶段使用,它的状态是推测的 (speculative), 称做前端 RAT, 也称做 Speculative RAT; 另一个是在流水线的提交 (Commit) 阶段使用的,所有从流水线中退休的指令,如果它存在目的寄存器,都会更新这个表格,因此它永远都是正确的,称它为后端 RAT, 也称做 Architecture RAT。

图 10.12 使用 Architecture RAT 对 Speculative RAT 进行状态恢复
可以使用后端 RAT, 对分支预测失败时的处理器进行状态恢复,这个过程和上面讲述的方法基本上是类似的,当流水线的某个阶段发现预测失败的分支指令时,此时处理器中有一部分指令处于分支指令之前,它们可以被继续执行,因此并不会马上进行状态恢复,而是停止取新的指令,让流水线继续抽干 (drain out),等到在分支指令之前进入到流水线的所有指令 (包括分支指令本身 ) 都顺利地离开流水线的时候,此时在流水线中所有的指令都是处于错误的路径上了,可以将流水线中的所有指令全部抹掉,然后将后端 RAT 全部复制到寄存器重命名阶段使用的 RAT 中,这就完成了 RAT 的状态恢复,此时就可以从正确的地址开始取指令执行,这个过程如图 10.12 所示。
采用这种方式进行状态修复,也会遇到当分支指令之前存在 D-Cache 缺失的指令时,分支指令等待时间过长而导致分支预测失败时的惩罚 (mis-prediction penalty) 过大的情况。其实,上面介绍的两种方法本质上都是一样的,只是在不同的重命名方式中有着不同的表现形式而已,可以将这两种方法称为 Recovery at Retire, 因为它们都需要等待预测失败的分支指令退休 (retire) 的时候,才可以进行处理器的状态恢复。
如果在流水线中发现分支预测失败时 (例如在执行阶段 ),可以马上进行状态恢复,而不需要等待分支指令从流水线中退休,这样就可以加快速度,从而减少惩罚 (penalty)。前面讲过,使用 Checkpoint 的方法可以进行快速的状态恢复,这种方法会在每条分支指令改变处理器的状态之前,将处理器的状态保存起来,例如在分支指令以及后面的指令对重命名映射表 (RAT) 更改之前,将这个表格的内容保存起来,这样在以后的时间发现预测失败的分支指令时,可以使用这条分支指令的编号将流水线中错误路径上的指令都抹掉,同时使用 Checkpoint 资源将处理器的状态进行恢复,然后就可以从正确路径上取指令执行了。基于 Checkpoint 的方法进行分支预测失败时的状态恢复,所需要的时间都不会很长,但是对基于 SRAM 的重命名映射表来说,由于每个 Checkpoint 都需要将整个表格进行保存,这样会消耗不菲的硬件资源,因此无法支持个数很多的 Checkpoint,也就是说,在流水线中不能存在很多的分支指令,例如 MIPS R10000 处理器采用了基于 SRAM 的重命名映射表,并使用 Checkpoint 的方式进行状态恢复,但是在流水线中最多只能有四条分支指令,也就是它最多只能进行四个 Checkpoint。随着处理器性能的提高,流水线中会有更多的指令,因此分支指令的个数也会随之增多,但是 Checkpoint 的个数很难随着增长,否则就会导致无法接受的硬件消耗,所以可以采用一种新的解决方法,即对哪些分支指令需要进行 Checkpoint 也进行预测,因为很多分支指令的预测正确率很高时,为它分配 Checkpoint 资源,实际上绝大部分时间都是浪费的,因此可以对分支指令预测的正确率进行预测,此时仍可以通过两位的饱和计数器来实现,当一条分支指令的预测正确率比较高时,它对应的饱和计数器肯定处于饱和的状态,因此对于这样的分支指令就不需要分配 Checkpoint 资源了,只有那些经常预测错误的分支指令才会进行 Checkpoint, 这样即使流水线中的分支指令很多,只要它们当中的大部分指令的预测正确率很高,就不需要很多的 Checkpoint 资源了。
但是,既然是预测,就会存在预测错误的情况,需要进行修复,当一条分支指令没有分配 Checkpoint 资源,但是最后发现这条分支指令预测失败时,如何对处理器的状态进行恢复?当然可以等待这条分支指令退休的时候,然后采用上面介绍的 Recovery at Retire 的方式来解决。如果觉得这种方法太慢,这时候 ROB 就派上用场了,在 ROB 中记录着重命名映射表 (RAT) 被修改的历史,每当一条指令被重命名时,除了将这条指令当前的映射关系写到 ROB 之外,还需要将这条指令对应的旧的映射关系也写到 ROB 中,这样 ROB 中就记录着每条指令对重命名映射表的修改。当一条分支指令没有分配 Checkpoint 资源时,就可以通过 ROB 将重命名映射表进行恢复,较之使用 Checkpoint 的恢复方法,这种方法肯定是慢了很多,但是它对硬件的消耗比较少,可以作为一种补充的方法。
对基于 CAM 结构的重命名映射表来说,进行 Checkpoint 时只需要对重命名映射表中的状态位进行保存,因此所需要的硬件资源很少,在处理器中可以支持个数很多的 Checkpoint, 也就是在流水线中可以支持很多条分支指令,例如,Alpha 21264 处理器采用了基于 CAM 结构的重命名映射表,可以支持多达 80 个 Checkpoint,不仅是对分支类型的指令,而是对流水线中的全部 80 条指令都无差别地进行了 Checkpoint 保存,这样对分支预测失败和异常都可以采用 Checkpoint 的方式进行状态恢复。当然,使用这种重命名映射表的最大缺点是电路的面积和延迟会很大,由于这个表格的容量正比于物理寄存器的个数,当物理寄存器很多时,这个表格的电路面积就很大了,而且采用 CAM 方式进行寻址,导致的延迟也是很大的。因此,对基于 CAM 结构的重命名映射表来说,使用一般的综合工具是无法对其进行很好的优化的,需要在电路级上进行优化,这样就大大地增加了设计的复杂度。
10.4.2 异常的处理
处理器执行程序的过程中,在流水线的很多阶段都有可能发生异常 (exception), 但是由于存在流水线和乱序执行等原因,在时间点上先发生的异常未必在程序中也是靠前的,例如图 10.13 所示的流水线中,第一条指令在流水线的执行阶段 (Execute) 发生了 Page Fault 类型的异常,而第二条指令在流水线的解码阶段 (Decode) 就发现了未定义指令的异常,从时间上来看,肯定是发生在 t1 时刻的那个异常更早一些,但是放在程序中来看,它应该在 Page Fault 这个异常之后才进行处理。因此,需要一种方法来记录所有指令的异常,然后按照指令在程序中的原始顺序对所有的异常进行处理,能够胜任这个任务的,就是重排序缓存 (ROB)。

图 10.13 在流水线的两个阶段发生的异常
在 ROB 中按照程序中指定的顺序 (in-order) 记录着流水线中所有的指令 (确切地说,是记录了重命名阶段之后的所有指令 ),为了能够按照正常的顺序处理异常,一条指令的异常要被处理,必须保证在它之前的所有指令的异常都已经被处理完成了,最容易实现这个任务的时间点就是在一条指令将要退休 (retire) 的时候,此时这条指令之前的所有指令都已经顺利地离开了流水线;因此这些指令的异常也肯定被处理完毕。如果发现本周期要退休的指令被记录了异常,那么这条指令就不能够退休,而是需要对这个异常进行处理,如图 10.14 所示。

图 10.14 利用重排序缓存 (ROB) 来处理异常
现在的处理器一般都支持精确的异常,这能够极大地简化软件的工作。精确的异常是指处理器能够知道哪条指令发生了异常,并且这条发生异常的指令之后的所有指令都不允许改变处理器的状态,就好像这些指令从来没有发生过一样,这样在对这个异常处理完后,可以精确地进行返回,返回的地方可能有两种情况,可以返回到发生异常的指令本身,重新执行这条指令,也可以不重新执行这条发生异常的指令,而是返回到它的下一条指令开始执行。例如当一条访问存储器的指令发生 TLB 缺失的异常时,处理器将其处理完毕,并从对应的异常处理程序中返回的时候,会重新执行这条产生异常的指令,此时肯定就不会发生 TLB 缺失了。而对于产生系统调用的指令引起的异常,例如 MIPS 中的 SYSCALL 指令,则只需要返回到下一条指令就可以了,如果返回到这条指令本身,则又会产生异常,这样就形成了死循环。在超标量处理器中,为了支持精确的异常,当发现要退休的指令存在异常时,在跳转到对应的异常处理程序之前,需要将流水线中这条产生异常的指令后面的所有指令都抹去,并将它们对处理器状态的修改进行恢复,就好像这些指令从来没有发生过一样,如图 10.15 所示。

图 10.15 在进行异常处理之前,需要抹掉 ROB 中的全部指令
当一条指令要从 ROB 中退休之时,如果发现它在之前记录过异常的发生,此时 ROB 中所有的指令其实都不允许退休而改变处理器的状态,也就是说,此时流水线中的所有指令都是没有用的,可以将整个流水线中的指令都抹掉,并对处理器的状态进行恢复,此时要完成的任务其实和 10.4.1 节中 Recovery at Retire 的情形是一样的,即都需要清空流水线,并进行状态恢复。通过 10.4.1 节已经知道,对于采用 ROB 和扩展的 ARF 进行寄存器重命名的这两种方式,可以使用 ARF 对重命名阶段使用的映射表 (RAT) 进行恢复;对采用统一的 PRF 进行寄存器重命名的方式,使用后端的映射表对重命名阶段使用的映射表进行恢复。通过这些操作之后,此时处理器的状态已经没有这些错误指令的影响,因此可以跳转到对应的异常处理程序的入口地址,开始取新的指令来执行。
这种 Recovery at Retire 的异常处理方法还有一个好处,那就是很多指令的异常并非是真的需要被处理,如果这些指令处在分支预测失败 (mis-prediction) 的路径上,它们都会从流水线中被抹掉,因此它们的异常其实都是无效的。只有当一条指令变为流水线中最旧的指令,也就是马上要退休的时候,才可以保证这条指令不处于错误的路径上,此时它的异常才是真正有效的。
除了上面讲述的方法之外,还可以使用另外一种方法来完成这个修复的过程,这种方法也是基于重排序缓存 (ROB) 来进行的。前面说过,当一条指令被重命名之后,这条指令对应的旧映射关系也会保存到 ROB 中,这样一条指令在退休的时候,如果发现了异常,则从 ROB 中最新写入的指令开始 ( 即从图 10.16 中 tail pointer 指向的指令开始 ),逐个将每条指令对应的旧的映射关系写到重命名映射表中,这样就可以将这个表格进行状态恢复了,这个方法就是前文提到过的 WALK, 图 10.16 表示了使用 ROB 对重命名映射表进行恢复的过程。

图 10.16 使用 WALK 的方法,利用 ROB 对重命名映射表进行恢复
对于一个 4-way 的超标量处理器来说,重命名映射表 (RAT) 有四个写端口,因此在使用图 10.16 所示的方法对 RAT 进行恢复的时候,每周期也可以从 ROB 中取出四条指令对 RAT 进行修复,这样可以最大限度地利用 RAT 的写端口,加快恢复的时间。
在使用统一的 PRF 进行寄存器重命名的方式中,和 RAT 相关的内容还有两个表格,一个表格用来存储哪些物理寄存器是空闲状态的,称为 Free Register Pool; 另外一个表格用来存储每个物理寄存器的值是否已经被计算出来,称为 Busy Table。这两个表格都是配合 RAT 进行工作的,当异常发生的时候,这两个部件也需要进行恢复,不过它们的恢复有着自身的特点。
(1) 对于 Free Register Pool 来说,每当对一条指令进行重命名时,就会从这个表格中读取一个空闲的物理寄存器的编号,和指令的目的寄存器产生映射关系,然后这个表格的读指针会指向下一个地方,但是在这个表格中,刚刚被读取的内容并不会消失,也不会被那些退休 (retire) 的指令所释放的物理寄存器所覆盖,所以对于这个表格的状态恢复,只需要恢复它的读指针就可以了。刚刚讲过,可以利用 ROB 中存储的旧映射关系对 RAT 进行恢复,每当 ROB 中读取一条指令对 RAT 进行恢复时 (这条指令必须存在目的寄存器 ),就将 Free Register Pool 的读指针也变化一格,这样 ROB 中的所有指令对 RAT 的恢复完成时,这个表格也恢复完成了。
(2) 对于用来表示 PRF 中每个物理寄存器是否已经被计算出来的 Busy Table, 由于指令只要运算完成了,就可以在流水线的写回 (Write Back) 阶段将结果写到对应的物理寄存器中 ( 即目的寄存器 ),而不需要等到这条指令退休,所以当一条指令到达 ROB 的顶部而变为流水线中最旧的指令,并被发现存在异常的时候,这条指令后面的很多指令都已经将其结果写到了 PRF 中,也就是将 Busy Table 中对应的状态进行了修改。所以在进行状态恢复的时候,需要将这个表格也进行恢复,这个过程也是很简单的,在从 ROB 中读取指令进行状态恢复时,每读取一条指令,就将这条指令的目的寄存器 (此时以物理寄存器的形式存在 ) 在 Busy Table 中对应的内容置为无效,这样即使这条指令将结果已经写到 PRF 中,也可以将其变成无效了,后续的指令不会使用到错误的值。
在采用统一的 PRF 进行重命名的架构中,在处理异常的时候,利用 ROB 进行状态恢复是比较合适的。相反,如果使用 Recovery at Retire 方式对处理器的状态进行恢复,虽然可以使用提交阶段 architecture RAT 对重命名阶段的 speculative RAT 进行恢复,但是对 Free Register Pool 和 Busy Table 的状态恢复就没有那么直接了,有可能需要使用 architecture RAT 中记录的物理寄存器,逐个地对这两个表格进行修复,这样所需要的时间不会很短,使处理器对异常 (exception) 的响应时间变长,降低了执行效率。因此,对于异常 (exception) 发生时的处理器来说,如果采用 ROB 进行重命名的架构,使用 Recovery at Retire 方法是合适的,而对于采用统一的 PRF 进行重命名的架构,则需要使用本节介绍的 WALK 的方法。
相比于分支预测失败 (mis-prediction),异常发生的频率会更低,所以对异常的处理其实是可以慢一些的,上面的这种从 ROB 中逐条读取指令进行状态修复的方法也被应用于实际的处理器中,例如 MIPS R10000 处理器采用了统一的 PRF 进行寄存器重命名的架构,就使用了这样的方法来对异常发生时的处理器进行状态恢复。
10.4.3 中断的处理
在 MIPS 处理器的术语中,异常 (exception) 是由处理器内部执行指令产生的,因此异常总是和某条指令是同步的;而中断 (interrupt) 指的是处理器外部产生的,它和处理器内部执行的指令没有必然的对应关系,因此称中断是异步的。正因为如此,对于中断的处理并不能按照处理异常的方式进行,一般来讲,有两种方式对中断进行处理。
(1) 马上处理。当中断发生时,将此时流水线中的所有指令都抹掉,按照 10.4.2 节介绍的方法,对处理器的状态进行恢复,并将流水线中最旧的那条指令的 PC 值保存起来 ( 当然还要保存一些其他的状态寄存器 ),然后跳转到对应的中断处理程序 (interrupt handler) 中,当从其中返回时,会使用中断发生时所保存的 PC 值来重新取指令,也就是那些从流水线中被抹掉的指令都会重新取到流水线中。这种方式对于中断的响应是最快的,但是由于需要将流水线中所有的指令都抹掉,这些指令相当于在流水线中做了无用功,它们需要在中断处理完后重新执行,这样浪费了一些效率,因此,如果处理器对于中断的响应时间有严格的要求,可以采用这种方法。
(2) 延迟处理。当中断发生时,流水线停止取指令,但是要等到流水线中所有的指令都退休 (retire) 之后才对这个中断进行处理,这样能够保证流水线中这些已有的指令不被 “浪费”, 而且当流水线中所有的指令都退休之后,此时流水线的状态肯定是正确的,也就不需要进行状态恢复了。但是需要考虑的几个问题如下。
① 如果在流水线中的这些指令发生了 D-Cache 缺失,那么需要很长的时间才能够解决,这样导致了过长的中断响应时间。
② 如果在流水线中发现了一条预测失败的分支指令,那么首先需要对这个情况进行处理,将处理器的状态进行恢复,这需要消耗一定的时间,也造成了中断响应时间的增大。
③ 如果流水线中的这些指令中发生了异常 (exception),那么是先对异常进行处理,还是先对中断进行处理?这需要仔细地进行权衡,但是一般来说,应该是先对中断进行处理,因为很多类型的异常处理需要耗费很长的时间,如 D-Cache 缺失、TLB 缺失或者 Page Fault 等,这样会导致中断的响应时间过长而无法忍受。
10.4.4 Store 指令的处理
对于向存储器中保存数据的 store 指令来说,它在顺利离开流水线之前是不允许改变处理器状态的,只有等到它退休 (retire) 的时候,才允许将它携带的数据写到 D-Cache 中,在此之前,store 指令即使计算完毕,也会将结果暂存在一个缓存中,这个缓存就是之前讲过的 Store Buffer, 直到 store 指令退休的时候,才会将它在 Store Buffer 中对应的内容写到 D-Cache 中。使用 Store Buffer 这个缓存之后,所有的 load 指令不仅要访问 D-Cache, 也需要在这个缓存中进行查找,如果在这个缓存中发现有 store 指令携带的地址和它相等,并且在它之前进入到流水线,则这条 store 指令携带的数据直接送给 load 指令使用。一般来说, store 指令在退休的时候,只有将数据真正地写入到 D-Cache 中,才可以保证后面的 load 指令可以从 D-Cache 中得到正确的数据,此时 store 指令才算是执行完毕了,可以离开 ROB,这种方法虽然最安全,但是一旦 store 指令在写 D-Cache 的时候发生了缺失,则需要等待很长的时间才能够使它离开 ROB, 这样就造成了 ROB 的堵塞,即使 store 指令后面有很多指令已经执行完毕,处于 complete 的状态,但是它们由于 store 指令挡在前面而不能退休,造成了处理器性能的降低。
要解决上述的问题,最简单的方法就是在 Store Buffer 中增加一个状态位,用来标记一条 store 指令是否已经具备退休的条件,这样一条 store 指令在这个缓存中就有三个状态,即没有执行完毕 (un-complete)、已经执行完毕 (complete) 和顺利离开流水线 (retire)。当一条 store 指令在流水线的分发 (Dispatch) 阶段时,会按照程序中指定的顺序占据 Store Buffer 的空间,并标记为 un-complete 的状态;当这条 store 指令已经得到地址和数据,但是还没有变为流水线中最旧的指令时,就处于 complete 状态;当这条 store 指令成为流水线中最旧的指令并退休的时候,也将这个状态在 Store Buffer 中进行标记,此时这条 store 指令就可以离开 ROB, 这样就不会阻碍后面的指令继续离开流水线,而硬件会自动将 Store Buffer 中处于 retire 状态的 store 指令写到 D-Cache 中,需要注意的是,此时 Store Buffer 中那些处于 retire 状态的内容也成为了处理器状态 (Architecture state) 的一部分,这个过程如图 10.7 所示。

图 10.17 使用 Store Buffer 来存储所有退休的 store 指令
只有一条 store 指令真正完成了写 D-Cache 的过程,它才可以离开 Store Buffer, 对于大多数处理器来说,store 指令都是按照程序中指定的顺序来执行的,当然更需要按照这个顺序对处理器的状态进行更新,所以 Store Buffer 是按照 FIFO 的方式进行管理的。由于那些已经退休,但是还没有完成写 D-Cache 操作的 store 指令,仍旧会占据 Store Buffer 的空间,所以这些空间是没有办法被新的 store 指令使用的,一旦 Store Buffer 再也找不到可用的空间进行写入,此时就不能够接收新的 store 指令,分发 (Dispatch) 阶段之前的流水线就需要暂停。只有真正完成写 D-Cache 操作的 store 指令才可以离开 Store Buffer, 这样造成了它实际可用容量的降低,这一点和发射队列 (Issue Queue) 是很类似的,在发射队列中,很多被仲裁电路选中的指令不能马上离开,也就造成了发射队列实际可用容量的降低。这样的缺点限制了处理器性能的提高,但是相比于其他的方法,这种方法实现起来比较简单,因此综合看起来是一种可以接受的折中方法。
如果不想造成 Store Buffer 实际可用容量的降低,可以将那些已经退休的 store 指令存储在一个不同于 Store Buffer 的地方,这个地方可以称为 Write Back Buffer, 硬件会自动将 Write Back Buffer 中的 store 指令写到 D-Cache 中,如图 10.18 所示。

图 10.18 使用 Write Back Buffer 来保存所有退休的 store 指令
每条 store 指令一旦退休的时候,就将其从 Store Buffer 写到 Write Back Buffer 中,也就是说,此时这条 store 指令可以离开 ROB 和 Store Buffer 两个部件,硬件会根据 D-Cache 的端口使用情况将 Write Back Buffer 中的 store 指令写到其中。在这种方法中,Write Back Buffer 已经成为了处理器状态 (Architecture state) 的一部分,load 指令需要在 Store Buffer 和 Write Back Buffer 这两个缓存中都进行查找,这样增加了设计的复杂度。还需要注意的是,一旦 Write Back Buffer 没有空间了,那么就不能够再将 store 指令退休,而是要等到 Write Back Buffer 出现空闲的空间为止。
由于指令需要按照程序中指定的顺序进行退休,所以 Store 指令也是按照程序中指定的顺序进入 Write Back Buffer 的,不过,在进入的同时需要在其中查找有没有写到相同地址的 store 指令,如果存在,那么就需要将其置为无效,这样才能够保证后面的 load 指令在查找 Write Back Buffer 的时候,使用到最新的数据。
对于使用软件处理 TLB 缺失的处理器来说,如果在 store 指令要退休的时候,发现在 ROB 中记录了 TLB 缺失的异常,那么这条 store 指令是不能够进入 Write Back Buffer 的,而是应该进行异常的处理,这需要将流水线清空,进行处理器的状态恢复,然后跳转到对应的异常处理程序中,处理完之后会重新将这条 store 指令取到流水线中并执行,此时肯定就不会发生 TLB 缺失了。这种处理方式可以保证,所有进入 Write Back Buffer 中的 store 指令都不会产生 TLB 缺失,因此从这个缓存中向 D-Cache 写数据,只需要处理 D-Cache 缺失的情况就可以了,而 D-Cache 缺失一般使用硬件进行处理,不需要改变流水线中的指令流,如果再配合使用非阻塞 (non-blocking) 结构的 D-Cache, 则可以最大限度地提高处理器的性能。
10.4.5 指令离开流水线的限制
在 4-way 的超标量处理器中,如果 ROB 中最旧的四条指令 (由 head pointer 指定 ) 都已经处于 complete 状态,并且其中没有发现预测失败的分支指令,也没有指令产生异常,那么是不是这四条指令就都可以在同一个周期内离开流水线呢?
从理论上来说,这四条指令确实可以在一个周期内都退休而离开流水线,但是,这对于处理器中许多其他的部件提出了更多端口的要求。
(1) 每周期有四条 store 指令退休,意味着 D-Cache 或者 Write Back Buffer 需要支持四个写端口;
(2) 每周期有四条分支指令退休,意味着每周期需要将四条分支指令的信息写回到分支预测器中,这需要分支预测器中的所有部件 (例如 BTB、PHT 等 ) 都需要支持四个写端口,同时还需要能够将 Checkpoint 资源在每周期内释放四个;
(3) 如果在处理器中对 store/load 指令之间的相关性实现了预测,即预测一条 load 指令是否会和它之前的 store 指令存在相关性,在这种情况下,如果每周期有四条 load 指令退休,意味着每周期需要将四条 load 指令的信息写回到相关的预测器中,这也导致了四个写端口的需求。
上面列举的各种情况,出现的概率非常小,为这些不经常出现的情况而增加硬件设计的复杂度是得不偿失的,因此在超标量处理器中,可以对上述的这些特殊的指令进行限制。例如,每周期退休的指令中最多只能有一条分支指令,如果存在多于一条分支指令的情况,那么第二条分支指令及其之后的所有指令都不允许在本周期退休,这样处理器中其他的部件不需要那么多的端口了,这种思路其实在以前就使用过,例如,通过限制解码阶段的分支指令的个数,可以降低对分支指令进行编号分配的电路复杂度。这些设计方法在本质上都是遵循着同一个原则:不值得为不经常出现的情况而浪费硬件资源,在超标量处理器中,很多地方的设计都遵循着这个原则,例如将复杂指令进行拆解,而不是为这些复杂指令使用单独的硬件资源。进一步可以说,这个设计原则本质上也是 RISC 处理器的设计思想:只将那些常用的指令用硬件实现,其他的复杂功能的指令使用子程序来实现,这样通过简化的硬件设计来提高处理的速度,从而获得比较高的性能。
再回到本节要讲述的内容,按照前面的描述,在流水线的提交 (Commit) 阶段需要对分支指令,store 指令和 load 指令的个数都进行限制,如图 10.19 所示。

图 10.19 对一个周期内退休的指令进行检查的示意图
ROB 本质上是 FIFO, 在流水线的提交阶段会读取 ROB 中最旧的四条指令 (由 head pointer 指定 ),根据它们的 complete 信号来判断哪些指令有资格在本周期退休,然后使这四条指令通过如图 10.19 所示的三个并行的检查电路,如果它们存在多于一条的分支指令、 store 指令或 load 指令,就会分别通过 br_mask、st_mask 和 ld_mask 这三个信号将那些多余的指令进行屏蔽,最后将这三个信号进行与操作,就可以得到本周期最终允许哪些指令退休了。举例来说,如果 br_mask、st_mask 和 ld_mask 这三个信号的值分别为 1110、1111 和 1111, 就表示这 4 条指令中,并不存在多于一条的 store 和 load 指令,但是存在多于一条的分支指令,第四条指令就是第二个分支指令,因此它不能够在本周期退休,通过将上述三个信号相与,最后得到的信号是 1110, 表示只有前三条指令有资格在本周期退休。当然,仅仅是有资格而已,能不能真的退休而离开流水线,还需要一些其他的条件,例如分支指令是否预测正确,或者是否有指令发生了异常等。
在流水线的提交阶段需要对指令的异常进行处理,由于需要跳转到对应的异常处理程序中 (exception handler),所以每周期只能处理一条指令的异常,这需要对本周期内所有要退休的指令进行检查,使用如图 10.19 所示的方法仍旧可以完成这个功能,找到第一个产生异常的指令,并将这条指令后面的所有指令都进行屏蔽,不允许它们在本周期退休。在提交阶段的末尾,根据本周期最终允许退休的指令个数,对 ROB 中的读指针 (在图 10.19 中就是 head pointer) 进行更新。
本站所有文章转发 CSDN 将按侵权追究法律责任,其它情况随意。