第 11 章 真实世界的例子:Alpha 21264 处理器
书内第 344–370 页;PDF 第 356–382 页。原书疑点见校注。
- 11.1 概述
- 11.2 取指令和分支预测
- 11.2.1 line/way 的预测
- 11.2.2 分支预测
- 11.3 寄存器重命名
- 11.4 发射
- 11.5 执行单元
- 11.5.1 整数的执行单元
- 11.5.2 浮点数的执行单元
- 11.6 存储器的访问
- 11.6.1 Speculative Disambiguation
- 11.6.2 Load hit/miss Prediction
- 11.7 退休
- 11.8 结论
11.1 概述
到目前为止,本书对超标量处理器的基本知识进行了讲述,本章将介绍现实世界中的一个真实的处理器:Alpha 21264 处理器。尽管它诞生于十几年之前,但是这款处理器在架构方面是超标量处理器的一个典范,值得深入研究。要讲述 Alpha 处理器,就不得不提到 DEC (Digital Equipment Corporation) 公司,DEC 公司是计算机业界最出名的公司之一,成立于 1957 年,其产品包括 PDP 系列计算机、VAX 系列计算机等,在 20 世纪的七八十年代都是热卖的产品,到了 90 年代,当 RISC 处理器开始流行的时候,DEC 也顺应潮流,着手开始研制 RISC 处理器,并于 1992 年的 2 月 25 日,在东京召开的一次会议上正式提出了 Alpha 架构,新架构采用完全 64 位的 RISC 设计,执行固定长度指令 (32 位 ), 有 32 个 64 位整数寄存器,操作 43 位的虚拟地址 (在后来能够扩充到 64 位 ),使用小端 (little-endian) 字节顺序,除此之外,还支持 32 个浮点的 64 位寄存器,采用随机存取,而不是在 Intel x87 协处理器上使用的堆栈存取方式,Alpha 架构是纯粹的 RISC 架构 [45]。
第一款 Alpha 系列的处理器被称为 21064(21 意为 Alpha 是一款面向 21 世纪的新架构,0 代表处理器的版本,64 代表具备 64 位的计算能力 ),处理器的工作电压为 3.3V, 核心频率为 150~200MHz, 采用三层金属、$0.75\mu\mathrm{m}$ 的 CMOS 工艺制造,处理器内部共有 168 万个晶体管,芯片面积为 $233\mathrm{mm}^2$。
经过了中间许多次小改款之后,真正意义上的第二款 Alpha 处理器于 1994 年推出,命名为 21164, 在新处理器中,每周期可以从 I-Cache 中取出四条指令送到流水线中,并且进行整数运算和浮点运算的流水线数量均增加了一倍,这样算术运算可以交给一个整数流水线处理,同时逻辑运算可以交给另一个整数流水线进行处理,这样就可以提高处理器执行程序的并行度 (在计算机的术语中称为 IPC),但是在 21164 处理器内部仍旧采用了顺序执行 (in-order) 的方式,这类似于 ARM 的 Cortex A8 处理器 [46],不过 Cortex A8 每周期只能将两条指令送到流水线中 (2-way),而 21164 处理器则每周期可以将四条指令送入流水线 (4-way)。 21164 处理器采用四层金属、$0.5\mu\mathrm{m}$ 的 CMOS 工艺制造,工作电压为 3.3V, 共有 930 万个晶体管,其中缓存部分占用了 718 万个晶体管,芯片面积为 $299\mathrm{mm}^2$,处理器的核心频率为 266~333MHz。
第三款 Alpha 处理器命名为 21264, 在 1996 年 10 月举行的微处理器论坛上面被首次披露,那时候的 DEC 已经是债务缠身,直到 1998 年 2 月芯片的整体研制完全结束时,DEC 公司已经在进行资产清算了,并且于当年的 5 月 18 日被康柏 (Compaq) 公司完全收购。可以说,21264 处理器诞生在一个动荡的时代,但是这仍然掩盖不了它的光芒,很多新的理念被应用到它上面。21264 处理器是一个 4-way、乱序执行的超标量处理器,它的性能相比于它的前辈 21164 处理器有了很大的提高,采用了六层金属、$0.35\mu\mathrm{m}$ 的 CMOS 工艺制造,由 1520 万个晶体管组成,其中有 900 万个晶体管用于 I-Cache、D-Cache 和分支预测器,芯片面积为 $314\mathrm{mm}^2$,工作电压是 2.2V,核心频率为 466~667MHz。
Alpha 21264 处理器在推出的时候,可以称得上是当时最快的处理器,它在工作频率是 667MHz 的时候,benchmark 的值是 SPECint95=40、SPECfp95=83.6, 同时代的其他处理器的 benchmark 值如表 11.1 所示。
表 11.1 Alpha 21264 和其他同时代的处理器的对比
| 处理器 | 工作频率 | SPECint95 | SPECfp95 |
|---|---|---|---|
| Alpha 21264 | 667MHz | 40 | 83.6 |
| AMD K7 | 750MHz | 32.9 | 29.4 |
| UltraSparc Ⅱ | 360MHz | 16 | 23.5 |
| IBM G4 | 332MHz | 14.4 | 12.6 |
| MIPS R10000 | 200MHz | 9 | 19 |
| Intel PⅡ | 450MHz | 17.2 | 12.9 |
从表 11.1 可以看出,21264 处理器在它的那个时代,可以称得上是佼佼者,它的很多特性即使到现在仍旧被其他的处理器使用,足见其影响的深远。作为一款超标量处理器, 21264 处理器每周期可以向流水线中送入四条指令,并且可以乱序执行,只要指令的所有操作数都准备好了,这条指令就可以被执行,处理器使用硬件对流水线中所有的指令进行分析,找出可以提前执行的指令来提高并行性,因此流水线中可以容纳的指令个数决定了处理器乱序执行能力的高低。不同处理器之间,流水线所能容纳的指令个数也不尽相同,21264 处理器能够支持 80 条指令,同时期的其他处理器在流水线中支持的指令个数分别如下:Intel 的 P6 架构支持 40 条指令,HP 的 PA-8x00 处理器支持 56 条指令,MIPS R12000 处理器支持 48 条指令,IBM 的 Power3 处理器支持 32 条指令,而 PowerPC G4 处理器只能够支持 5 条指令,Sun 的 UltraSPARC Ⅱ 处理器还不支持乱序执行的功能。同时,21264 处理器为流水线中的每条指令都使用了一个 Checkpoint, 也就是说,处理器内部支持 80 个 Checkpoint, 因此对于分支预测失败 (mis-prediction) 和异常 (exception) 进行处理时,可以快速地对处理器进行状态恢复。
21264 处理器使用了复杂的分支预测器,同时实现了基于局部历史 (Local prediction ) 和基于全局历史 (Global prediction) 两种分支预测的方式,并且根据程序执行的情况,动态地选择预测率最高的方式,这就像是两种分支预测方式在进行竞争一样。通过这种方式,并配合数量众多的 FU, 使 21264 处理器在运行大多数程序的时候,相比于 21164 处理器,都会获得 50%~200% 的性能提高,即使它们都可以每周期向流水线送入四条指令,但是顺序执行 (in-order) 和乱序执行 (out-of-order) 的差别导致它们在性能方面有着本质区别。
对于访问存储器的指令,也就是 load/store 指令,Alpha 21264 处理器也采用了一些在当时看起来比较 “激进”的方法,它采用了 Speculative Memory Disambiguation 和 Load hit/miss Prediction 两种预测方式来处理 load/store 指令。前文说过,在乱序执行的超标量处理器中,load/store 指令的执行效率始终是性能的瓶颈,通过对这些指令进行加速,可以使整个处理器的性能得到提高,这也是 21264 处理器性能出众的原因之一。
21264 处理器使用了 7 级流水线,比较短小的流水线可以使分支预测失败时的惩罚 (mis-prediction penalty) 得到降低,从而提高处理器的执行效率,图 11.1 表示了 21264 处理器的流水线 [47]。

图 11.1 Alpha 21264 处理器的流水线
在 21264 处理器中使用的很多设计理念是领先于那个时代的,例如竞争的分支预测、 store/load 指令之间相关性的预测、数量丰富的 Checkpoint 等特性,即使放到现在来看,依然是先进的。在单核的性能方面,21264 处理器已经非常优秀,虽然频繁的商业并购和很多人为的因素使它并没有被大面积地使用,但是这丝毫不影响它在处理器领域的经典地位,它的很多设计理念被用到了 AMD 和 Intel 的处理器中,直接或者间接地影响了现代的计算机产业。
11.2 取指令和分支预测
Alpha 21264 处理器在流水线的取指令阶段,每周期可以从 I-Cache 中取出四条指令送到流水线中,I-Cache 使用了 64KB 两路组相连结构 (2-way set-associative) 的配置,并且使用了两级流水线来访问 I-Cache( 称为 fetch0 和 fetch1)。在第一级流水线 (fetch0) 中访问I-Cache, 但是在这个周期只能够得到 I-Cache 中的指令和 Tag, 没有时间再做其他事情了;在第二级流水线 (fetch1) 中,进行 Tag 比较的同时,还会将指令送到解码器和寄存器重命名相关的部件中,在 21264 处理器的芯片中,这个传输需要跨越大半个芯片,所以是比较浪费时间的,这也是 21264 处理器将 I-Cache 进行流水线访问的主要原因。
为了能够连续地从 I-Cache 中取出指令,每周期都需要向 I-Cache 送入地址,而程序中存在的分支类型的指令会改变指令串行的顺序,需要能够尽早地察觉到程序中的分支指令并得到分支地址,21264 处理器通过两种方法来提高取指令的准确度。
(1) line/way 的预测;
(2) 分支预测。
这两种方法从本质上可以理解为一个简单的分支预测器和一个复杂的分支预测器,简单的分支预测器速度比较快,可以在 fetch0 阶段就给出结果,但是预测准确度比较低,而复杂的分支预测器需要占用两个周期,即 fetch1 阶段才能给出结果,但是它的预测准确度比较高,如果发现之前简单的分支预测器得到的结果和自己的不一样,那么就抛弃 fetch0 阶段的指令,使用新的预测地址来取指令,这会在流水线中引入一个周期的气泡 (bubble)。
11.2.1 line/way 的预测
Alpha 21264 处理器在取指令阶段,为了能够尽快地从 I-Cache 中取出指令,对 I-Cache 中取指令的位置也进行了预测,这就是 line/way 的预测,这种方法在本质上就是将之前讲过的 BTB 放到了 I-Cache 中,由于 21264 处理器每周期取出的四条指令必须是位于四字对齐的边界之内,所以只需要对 Cache line 中每一组四字对齐的四条指令使用一个 line/way 预测就可以了,如图 11.2 所示。

图 11.2 line/way prediction
对于一个大小为 64 字节的 Cache line, 需要包括 4 个 line/way 的预测信息,每个预测信息包括下面三个内容。
(1) successor way: 下个周期需要取出的一组指令 (fetch group) 在 I-Cache 中所在的 way, 例如在一个四路组相连 (4-way set-associative) 结构的 I-Cache 中,这个部分就需要两位来表示。
(2) successor index: 下个周期需要取出的一组指令 (fetch group) 中,第一条指令在 Cache line 中的位置,例如对于一个容量为 64KB 两路组相连、Cache line 的大小为 64 字节的 I-Cache 来说,使用 PC[14:6] 可以找到一个 Cache line, 使用 PC[5:2] 可以找到 Cache line 中的某条指令,因此在这个部分只需要存储 PC[14:2] 就可以了。
(3) branch position: 本周期取出的这组指令中 (fetch group), 如果存在预测跳转的分支指令,则将这条分支指令的下一条指令的位置信息记录在这个部分。这里之所以不存储分支指令本身的位置信息,而是存储分支指令下一条指令的位置信息,是因为本周期取出的这组指令中,如果存在预测结果是跳转的分支指令,则它后面的所有指令都不会进入流水线,而分支指令本身是会进入流水线的,因此记录分支指令下一条指令的位置信息更容易找到本周期取出的这组指令中,哪些指令不应该进入流水线。而且,当本周期取出的这组指令中不存在分支指令,或者存在预测结果为不跳转的分支指令时,只需要将这个部分置为 00,就可以表示出 “本周期取出的这组指令需要全部进入流水线” 这个信息了。如果预测跳转的分支指令位于这组指令的最后一个,那么这组指令实际上也都会进入流水线,因此也只需要将这个部分置为 00 就可以了,也就是说,这部分的值为 00 即表示本周期取出的这组指令都会进入到流水线中。
在 21264 处理器中,当一个 Cache line 从 L2 Cache 被放到 I-Cache 中时,这个 Cache line 包含的所有预测信息都会被初始化为 “没有需要跳转的分支指令”, 此时 line/way 的预测信息会指向下一个顺序的 PC 值,即 “$\mathrm{PC}+\operatorname{sizeof}(\text{fetch group})$”, 一旦在后面的过程中发现这个预测信息是错误的,就会将 Cache line 中的预测信息进行修改。可以按照之前讲过的两位饱和计数器的方式来管理 line/way 的预测值,也就是说,只有连续两次的预测失败才会使这个预测值发生改变。
在 21264 处理器中,每周期从 I-Cache 中取出的四条指令不能跨越四字对齐的边界,这样就可以在 Cache line 中为每四字对齐的四条指令使用一个 line/way 的预测器。同时,为了保证取指令的连续性,必须在一个周期内 (也就是流水线的 fetch0 阶段 ) 读取到 line/way 的预测信息,这样下个周期就可以继续从 I-Cache 中读取指令了,这样保证了取指令的连续性。
为了便于理解 line/way 的预测过程,如图 11.3 所示给出了一个例子。

图 11.3 line/way prediction 的一个例子
在这个例子中,指令 A0~A5 是一个基本块 (basic block),位于一个 Cache line 中;指令 B0~B4 也是一个基本块,位于另一个 Cache line 中。这个 Cache 的容量是 64KB, 两路组相连结构,每个 Cache line 的大小是 32 字节,因此使用 PC[14:2] 就可以从I-Cache 中找到需要的指令。仍旧为每组四字对齐的四条指令使用一个 line/way 的预测信息,因此每个 Cache line 都包括两个 line/way 的预测信息,使用这个预测信息,就可以知道下个周期从I-Cache 的什么地方取指令了。
在第一个周期的时候,利用之前的预测信息从 I-Cache 中取指令,由于取出的指令需要四字对齐,根据取指令的地址,只能取出三条指令 (A0~A2),通过查看这组指令的 line/way 预测信息,发现 “branch position=00”, 也就是说,这三条指令全部可以进入流水线,同时从预测信息中得知下个周期寻址 Cache 的地址是 0x0360,位于 way0 中,将这个信息送入到 I-Cache 中进行取指令。
在第二个周期中,根据取指令的地址,是可以取出四条指令的,但是通过查看 line/way 的预测信息,发现 “branch position=11”, 也就是说,这组指令中的最后一条指令是不可以进入流水线的,因此只有三条指令 (A3~A5) 可以送入流水线中,同时从预测信息中得知下周期寻址 Cache 的地址是 0x0A28,位于 way1 中,将这个信息送入到 Cache 中进行取指令。
在第三个周期中,根据取指令的地址,只能取出两条指令 (B0~B1),通过查看这组指令的 line/way 预测信息,发现“branch position=00”, 也就是说,这两条指令全部可以进入流水线,同时从预测信息中得知下个周期寻址 Cache 的地址是 0x0A30,位于 way1 中,将这个信息送入到 I-Cache 中进行取指令。
在第四个周期中,根据取指令的地址,是可以取出四条指令的,但是通过查看 line/way 的预测信息,发现 “branch position=11”, 也就是说,这组指令中的最后一条指令是不可以进入流水线的,因此只有三条指令 (B2~B4) 可以送入流水线中,同时从预测信息中可以得知下周期寻址 Cache 的地址信息,从而继续取指令的过程。
上面使用的这种预测方法相当于将前文讲过的 BTB 放到了 I-Cache 中,相比于独立的 BTB, 这种方法存在下面三个缺点。
(1) 每个 Cache line 都要使用固定个数的预测信息,例如上面的例子中,每个 Cache line 都包括两个 line/way 的预测信息,但是很多 Cache line 中其实没有分支指令,因此它们之中的预测信息其实并没有起到作用;而同时很多 Cache line 中却包括很多条的分支指令,只对其使用两个预测值会导致预测准确度的降低。而独立的 BTB 则不会出现这种问题,因为它只会将那些预测结果是跳转的分支指令放到 BTB 中,这样就避免了上述的问题。
(2) 随着 Cache line 容量的增大,需要的 line/way 预测值的个数也随之增多,举例来说,如果使用 64 字节的 Cache line, 则每个 Cache line 需要 4 个 line/way 的预测值,这会导致 Cache 占用更多的硅片资源,而独立的 BTB 则不会有这种问题。
(3) 每次当一个 Cache line 被替换时,它包含的 line/way 预测信息也会消失了,被取代的是一个默认值,即指向下一次顺序的 PC 值,而独立 BTB 中的内容和 Cache line 是否被替换是无关的。
在 Alpha 21264 处理器中,由于 line/way 的信息都是预测的,需要验证这个预测是否正确,这个过程是在流水线的 fetch1 阶段完成的。根据被找到的 Cache line 的 tag 值 ( 即 PC[47:15]) 再加上 successor index( 即 PC[14:2]),就可以组成 line/way 的预测方法中使用的 PC 值。而在流水线的 fetch1 阶段,21264 处理器所使用的分支预测器也会得到一个 PC 值,如果它和 line/way 的预测方法中使用的 PC 值不一样,那么就认为 line/way 的预测产生了错误,需要使用分支预测器产生的 PC 值来重新从 I-Cache 中取指令,这需要将此时 fetch0 阶段的指令抹掉,因此会引入一个周期的流水线气泡 (bubble),这就是 line/way 的预测在失败时所产生的惩罚 (penalty)。 当然,21264 处理器使用的是 virtually-indexed, virtually-tagged 结构的 I-Cache, 这会使一部分工作得到简化,如果使用了 virtually-indexed,physically-tagged 结构的 I-Cache, 则此时使用 line/way 的预测值从 I-Cache 中读取到的 Tag 值是物理地址的一部分,而 fetch1 阶段的分支预测器得到的 PC 值是虚拟地址, 需要将其经过 TLB 的转换,得到对应的物理地址后,才可以验证 line/way 的预测值是否正确,这样会增加一些的工作量,因此使用 line/way 的预测方法时,配合使用 virtually-indexed,virtually-tagged 这种结构的 Cache 是比较合理的,21264 处理器中使用的 line/way 的预测方法可以用图 11.4 来总结 [47]。

图 11.4 Alpha 21264 处理器中使用的 line/way prediction
11.2.2 分支预测
在超标量处理器中,随着流水线的加深以及乱序执行的原因,分支预测失败时产生的惩罚 (mis-prediction penalty) 变得越来越大,因此分支预测的准确度直接影响了处理器的性能,在 21264 处理器中最多允许 80 条指令存在于流水线中,相比于它的前辈 21164 处理器的 20 条指令已经有了巨大的进步,更多的指令存在于流水中可以使处理器能够最大限度地寻找并行性,从而使用乱序执行的特性来获得更高的性能。 为了获得准确的分支预测, 21264 处理器使用的分支预测器是比较复杂的,因此无法在流水线的 fetch0 阶段完成,需要使用两个周期,在流水线的 fetch1 阶段才可以得到结果,这个结果会和之前 line/way 的预测结果进行比较,如果发现不一致,就以分支预测的结果为准,因为分支预测的复杂度更高, 因此它的结果的可信度也是比较高的。 这就是在之前讲过的预测方法,即使用一个准确度稍低但是实现起来比较简单的分支预测方法,在一个周期内得到结果,同时使用一个准确度较高但是比较复杂的分支预测方法,可以占用多个周期,如果发现两个分值预测的结果不一致,那么以准确度高的那个分支预测的结果为准。
21264 处理器使用了比较复杂的分支预测方法,称为竞争的分支预测 (tourament branch prediction),在之前讲过,有些分支指令使用基于全局历史的预测算法会取得比较高的准确度,同时有些分支指令更适合使用基于局部历史的预测算法,因此在 21264 处理器中对这两种分支预测算法都进行了实现:一种基于全局历史,一种基于局部历史。 对于每条分支指令来说,处理器会根据两种分支预测方法的准确度,动态地为每条分支指令选择合适的预测方法,这就相当于这两种分支预测算法在进行竞争,因此就称为竞争的分支预测了。
图 11.5 表示了在 21264 处理器中使用的分支预测 [47]。

图 11.5 Alpha 21264 处理器中使用的分支预测
图 11.5 的左边为基于局部历史的分支预测器,使用了两级的结构,第一级的 local history table 的大小是 $1024\times10\,\mathrm{bit}$, 也就是使用了 10 位的分支历史寄存器,可以记录一条分支指令过去 10 次的分支结果,整个表格可以记录 1024 条分支指令的历史信息。10 位的分支历史寄存器需要 PHT 支持 $2^{10}=1024$ 个饱和计数器,21264 处理器在这个 PHT 中使用了三位的饱和计数器,因此这个基于局部历史的分支预测器的第二级需要占用的存储空间大小是 $1024\times3\,\mathrm{bit}$。
图 11.15 的右边部分表示了基于全局历史的分支预测器,它使用了一个 12 位的全局历史寄存器 (GHR),可以记录过去 12 条分支指令的结果,相应的,这个预测器使用的 PHT 应该包括 $2^{12}=4096$ 个饱和计数器,21264 处理器在这个 PHT 中使用了两位的饱和计数器, 因此这个基于全局历史的分支预测器需要占用的存储空间是 $4096\times2\,\mathrm{bit}$。
对于某条分支指令来说,为了能够从这两种分支预测算法中选出准确度较高的那一个, 还需要对这个选择进行预测,这就是图 11.5 中的 choice prediction。 它同样是一个表格,使用全局历史寄存器 (GHR) 进行寻址,因此有 $2^{12}=4096$ 个表项,同时使用两位的饱和计数器来追踪两种分支预测的正确度,例如 00 表示基于局部历史的分支预测算法有比较高的准确度,而 11 表示基于全局历史的分支算法会有比较高的准确度,因此 Choice Prediction 占用的存储空间的大小是 $4096\times2\,\mathrm{bit}$, 关于它的内容在前文已经进行了介绍,此处不再赘述。
对基于局部历史的分支预测方法,使用了非推测 (non-speculative) 的方式进行更新,也就是说,只有当分支指令退休 (retire) 的时候,才会更新分支预测器中对应的内容,例如分支历史寄存器、饱和计数器等;而对基于全局历史的分支预测方法来说,使用了推测 (speculative) 的方式进行更新,一旦分支指令得到了预测结果,就会将这个信息更新到全局历史寄存器 (GHR) 中,也就是说,此时 GHR 的状态是有可能不正确的。 为了便于在分支预测失败时对 GHR 进行恢复,需要在更新 GHR 之前,将它的值进行保存 ( 这可以理解成之前讲述的 Checkpoint 方法 ),这样当发现分支预测失败时,可以利用之前保存的内容对 GHR 进行恢复,图 11.6 表示了一个对 GHR 进行恢复的原理图。

图 11.6 GHR 的状态恢复
每条分支指令得到预测的结果后,在流水线的解码阶段,将 GHR 左移一位,同时将分支指令预测的结果取反,插入到 GHR 的最右边,然后将新的 GHR 放入到一个缓存中,由于在解码阶段,指令仍旧遵循程序中指定的顺序 (in-order), 所以这个缓存采用了 FIFO 的结构。 当这条分支指令在后面的流水段被验证为预测错误时,使用这个缓存中对应的值就可以对 GHR 进行恢复了。
对于 PHT 中的饱和计数器一般都是在分支指令得到确定的结果之后才进行更新,具体的原因在之前已经阐述,此处不再赘述。
11.3 寄存器重命名
Alpha 21264 处理器使用了统一的 PRF 进行寄存器重命名,在整个处理器中只有一个物理寄存器堆,一个寄存器的值在它的生命周期内只会存在于一个地方,不会发生位置的变化;当一条指令退休 (retire) 的时候,它对应的物理寄存器才会变为 Architecture 状态,从处理器外部可以访问到这个值。 寄存器重命名消除了不必要的先写后写 (WAW) 和先写后读 (WAR) 的相关性,为乱序执行创造了条件,图 11.7 表示了 21264 处理器中使用的寄存器重命名 [47]。

图 11.7 Alpha 21264 处理器中的寄存器重命名
在 Alpha 架构中定义了 31 个整数寄存器和 31 个浮点寄存器,还有 8 个整数类型的影子寄存器,它们都是指令集定义的,在本书中称为逻辑寄存器,为了进行寄存器重命名, 21264 处理器内部又增加了 41 个整数寄存器和 41 个浮点寄存器,也就是说,21264 处理器内部使用了 80 个整数的物理寄存器和 72 个浮点的物理寄存器,使用重命名映射表 (RAT) 来记录每个逻辑寄存器对应的物理寄存器;21264 处理器使用了 CAM 的方式来实现这个映射表,这种方式决定了 21264 处理器可以实现数量丰富的 Checkpoint 一一可以为流水线中的全部 80 条指令提供 Checkpoint, 这样处理器中任意一条指令发生异常 (exception) 时, 都可以快速地对 RAT 进行恢复,更不用提分支预测失败时的状态恢复了。
在 Alpha 指令集中有一个特殊的指令 CMOV (conditional-move),这条指令的格式如下。
CMOV Ra, Rb ? Rc
上面的指令表达的意思可以用下面的代码来表示。
if (Ra == 0)
Rc = Rb;
else
Rc = Rc;
也就是说,只有寄存器 Ra 的值为 0 时,才将寄存器 Rb 的值赋给寄存器 Rc, 否则寄存器 Rc 的值保持不变。 这条指令在寄存器重命名阶段,需要读取三个源寄存器 Ra、Rb 和 Rc, 之所以要读取 Rc 寄存器的值,是因为 Rc 只是一个指令集中定义的逻辑寄存器,在进行寄存器重命名时,作为目的寄存器的 Rc 会被指派为一个新的物理寄存器,而旧的 Rc 寄存器值则存储在另外的物理寄存器中,因此需要将其读取出来。 而对于 21264 处理器中的其他指令,都只需要在寄存器重命名阶段读取两个源寄存器就可以了,因此不值得为 CMOV 指令增加 RAT 的读端口,而是需要按照之前讲述的思路,对 CMOV 指令进行拆解,将其拆分成下述的两条指令。
CMOV1 Ra, oldRc → newRc1
CMOV2 newRc1, Rb → newRc2
第一条 CMOV1 指令将检查寄存器 Ra 的值是否为 0, 将这个结果用一位的值来表示, 并将它和原来的寄存器 Rc 放在一起,组成一个新的 65 位的值,将它放到一个内部的物理寄存器 newRC1 中,也就是说,newRC1 寄存器是一个 65 位的寄存器,低 64 位来自于寄存器 Rc 的值 ( 要注意,21264 处理器是 64 位的,所有内部寄存器都是 64 位 ),第 65 位用来记录寄存器 Ra 是否为 0。
第二条 CMOV2 指令将根据 newRc1 寄存器的第 65 位,决定寄存器 newRc2 的值是来自于 newRc1 寄存器的低 64 位,还是来自于寄存器 Rb。 这样通过将 CMOV 指令拆解成 CMOV1 和 CMOV2 两条指令,使寄存器重命名的过程可以按照一般的指令对其进行处理, 只不过需要占用两个内部的物理寄存器。
由于 21264 处理器是一个 4-way 的超标量处理器,每周期可以对四条指令进行寄存器重命名,但是如果在进行重命名的四条指令中存在 CMOV 指令,则会造成一个周期内实际上有多于四条指令在进行重命名,这样又违背了一般性的原则,处理器不可能因为这种不常见的情况而增加重命名部分的带宽。 因此在 21264 处理器中,采用了一种比较简单的方法来处理这个问题,即如果在重命名阶段遇到 CMOV 指令时,那么只有 CMOV1 指令及其之前的指令可以在本周期进行重命名。CMOV2 及其之后的指令需要等到下个周期才可以进行重命名。 这样就可以保证,不管 CMOV 指令处于四条指令的哪个位置,最后进行重命名的指令总是不大于四条的,图 11.8 给出了一个例子。

图 11.8 对 CMOV 指令进行拆解
在图 11.8 所示的例子中,当在寄存器重命名阶段遇到第一条 CMOVx 指令时,本周期取出的这组指令会到此截断,因此在第一个周期 (cycle1) 只有 ADD 指令和 CMOVx1 指令通过寄存器重命名阶段;在下个周期 (cycle2),CMOVx2 指令会占用原来 CMOVx 指令的位置,继续进行重命名,同时在这个周期又遇到了一个新的 CMOV 指令 CMOVy, 因此在这个周期只有 CMOVx2、SUB 和 CMOVy1 指令进行重命名;在不考虑后续指令的情况下,在第三个周期 (cycle3), 只有 CMOVy2 指令进行重命名。 总结起来就是,如果在寄存器重命名阶段包括了 $n$ 条 CMOV 指令,那么最后需要 $n+1$ 个周期才可以完成重命名的过程,这样的做法肯定使处理器的执行效率有所降低,但是考虑到 CMOV 指令使用的频率并不高,而且这种方法易于实现,所以是一种可以接受的折中方法。
11.4 发射
Alpha 21264 处理器中包括两个发射队列 (Issue Queue), 即整数发射队列和浮点发射队列,分别用来存储整数类型的指令和浮点类型的指令,整数发射队列可以存储 20 条指令, 被四个整数执行单元 (也就是 FU) 共享,每周期可以从这个发射队列中选出四条整数指令来执行;浮点发射队列可以存储 15 条指令,被两个浮点执行单元共享,每周期可以选出两条浮点指令来执行。
21264 处理器采用了 Cluster 结构,将整数执行部分分成了 Cluster0 和 Cluster1 两部分,每个 Cluster 都使用一份完整的整数寄存器堆,因此在处理器中共有两份一模一样的整数寄存器堆,每个 Cluster 内部又被分成了两个 Subcluster, 分别被称为 upper(U) 和 lower(L),这样整数部分的四个执行单元就分布在四个地方,即 Cluster0 中的 U0、L0 以及 Cluster1 中的 U1、L1, 如图 11.9 所示。
这种将寄存器堆进行复制的方法是 Cluster 结构中使用的方法之一,由于可以减少每个寄存器堆的读端口个数,所以访问寄存器堆的速度会提高,而且总的面积并不会显著地增多。

图 11.9 Alpha 21264 处理器采用的 Cluster 结构
从宏观上来看,每周期可以从整数发射队列的 20 条指令中选出 4 条指令,送到四个执行引擎中,但是 21264 处理器并没有直接实现 4-of-20 的仲裁电路,而是采用如下的方式。
(1) Cluster0 中的 U0 和 Cluster1 中的 U1 共用一个仲裁电路,在图 11.9 中称为 select0;
(2) Cluster0 中的 L0 和 Cluster1 中的 L1 共用一个仲裁电路,在图 11.9 中称为 select1。
这样,每个仲裁电路只需要实现 2-of-20 的功能就可以了,当经过重命名之后的四条指令在写入到发射队列的时候,会根据指令的类型,将这些指令指派给对应的仲裁电路。 很多指令没有太多的选择余地,只能对应某一个固定的仲裁电路,例如,load/store 指令只能在 L0 和 L1 中执行,因此只能对应到仲裁电路 1(select1); 移位类型 (shift) 的指令只能在 U0 和 U1 中执行,因此也只能对应到仲裁电路 0(select0); 对于这种归属地比较专一的指令, 只需要将它们指派给对应的仲裁电路就可以了,而对于加减法和逻辑运算指令来说,它在 U0、L0、U1 和 L1 中都可以执行,则这种类型的指令指派给任意一个仲裁电路都没有问题, 可以采用一些比较简单的分配方法,例如前两条指令中存在的这种指令会被分配给仲裁电路 0(select0),后两条指令中存在的这种指令会被分配给仲裁电路 1(select1)。
将发射队列中的指令分配给不同的仲裁电路的这个过程,实现起来也是比较容易的,只需要使发射队列中的每个表项 (entry) 都有两个请求信号,分别送到两个仲裁电路中,根据这个表项中的指令类型,将对应的请求信号置为有效就可以了,这样发射队列中的每个表项都会根据自身当中指令的类型,向对应的仲裁电路发出请求信号。
仲裁电路根据指令的年龄信息,从发射队列中所有处于 ready 状态并且向自己发出申请的指令中,选出年龄最旧的两条指令,这就是 2-of-20 的仲裁过程。 被仲裁电路选出的两条指令如何分配到对应的两个 Subcluster 中执行,是一个动态的过程,它遵循的原则可以总结为下面的三条。
(1) 对仲裁电路 0(select0) 对应的 U0 和 U1 来说,如果一条指令在 U0 和 U1 中都可以执行,并且在当前周期,没有更旧的指令要求使用 U0, 那么就将这条指令放到 U0 中执行, 否则就放到 U1 中执行;
(2) 对于仲裁电路 1(select1) 对应的 L0 和 L1 来说,如果一条指令在 L0 和 L1 中都可以执行,并且在当前周期,没有更旧的指令要求使用 L0, 那么就将这条指令放到 L0 中执行, 否则就放到 L1 中执行;
(3) 如果其中一条指令只能在一个特定的 Subcluster 中执行,例如乘法指令只能在 U0 执行,那么另一条指令就只能放在另外一个 Subcluster 中执行,这对于仲裁电路 0(select0 ) 和仲裁电路 1(select1) 都是适用的。
下面通过例子来说明这种分配原则。
例 1: 仲裁电路 0(select0) 选择出了 ADD 和 BNE 两条指令,因为 U0 和 U1 都可以执行它们,则 ADD 指令会放到 U0 中执行,BNE 指令会放到 U1 中执行。
例 2: 仲裁电路 0(select0) 选择出了 ADD 和 MUL 两条指令,因为 MUL 指令只能在 U0 中执行,则 MUL 指令会放到 U0 中执行,ADD 指令会放到 U1 中执行。
为了便于仲裁电路按照指令的年龄信息对其进行选择,21264 处理器中的发射队列采用了压缩 (compressing) 的方式,新的指令只能从发射队列的顶部写入,而每当有指令离开发射队列时,这条指令之上的所有的指令都会下移,将刚才指令离开时所产生的 “空隙”填充上,所有的指令都会被 “压缩”在一起,这样发射队列中的所有指令其实都是按照 old→new 的顺序排列的,每个仲裁电路只需要从发射队列的底部开始查找,找到属于这个仲裁电路的两条 ready 状态的指令就可以了。 对于整数发射队列来说,它会根据两个仲裁电路选择指令的情况进行压缩,经过压缩之后的发射队列会重新将每条指令和两个仲裁电路对应起来。 这种压缩方式的发射队列是比较复杂的,因为每条指令都可以写到它后面的位置中,但是这种设计方式可以简化仲裁电路的设计,使仲裁电路很容易实现 oldest-first 的功能,如果使用非压缩方式的发射队列实现这样的功能,则需要比较复杂的电路了。
这种压缩方式的发射队列还有一个最大的缺点,就是功耗很大,在发射队列中,一条指令的离开会导致它之前的所有的指令都需要移动,再加上最旧的指令总是位于发射队列的底部,因此被仲裁电路选中而离开的指令一般都是位于底部范围,这样会导致大量的指令需要移动,导致功耗的增大,如果是面向移动领域的处理器,是很难采用这种设计方式的。
在 Alpha 21264 处理器中,除了 load 类型的指令,其他类型指令的执行周期都是确定的。 而对于 load 指令来说,都是假设 D-Cache 是命中的,然后按照这个时间对发射队列中的相关寄存器进行唤醒,如果发生 D-Cache 缺失,则会导致执行周期变长,此时所有被 load 指令唤醒的指令就不能够通过旁路网络 (bypassing network) 得到需要的操作数了,这些指令都应该从流水线中抹掉,重新放回发射队列中,等待再次被唤醒。21264 处理器将设计进行了简化:当发现 load 指令产生 D-Cache 缺失时,在它的 SW(Speculative Window) 窗口内的所有指令都会被抹掉,这些指令会重新在发射队列中等待被唤醒,然后参与仲裁的过程。 对于那些本来和 load 指令无关的指令,会很快再次被仲裁电路选中,而那些和 load 指令相关的指令则需要等待 D-Cache 缺失被解决。 这种方式虽然降低了一些性能,但是实现起来比较简单,因为不需要识别 SW 窗口中哪些指令和 load 指令存在相关性,是一种可以接受的折中方法。
为了配合这个功能,被仲裁电路选中的指令不能够马上离开发射队列,而是需要确认这条指令没有被 D-Cache 缺失的 load 指令所影响,才可以离开发射队列,这要求一条指令被仲裁电路选中之后,在发射队列中需要等待两个周期,才可以从发射队列中删除。 举例来说,如果一条指令在第 $n$ 个周期 ($\mathrm{cycle}n$) 被仲裁电路选中,那么在第 $n+1$ 个周期 ($\mathrm{cycle}{n+1}$), 这条指令仍旧会在发射队列中,但是这条指令不会继续向仲裁电路发出申请信号了,等到第 $n+2$ 个周期 ($\mathrm{cycle}{n+2}$),这条指令其实就已经到了流水线的执行阶段,可以从旁路网络 (bypassing network) 中获取操作数了,如果此时发现操作数来自于 load 指令,但是 load 指令产生了 D-Cache 缺失,那么这条指令就无法再继续执行,需要重新 “回到”发射队列中等待 (事实上这条指令还没有离开发射队列,因此只需要将相应的状态位改变就可以了 )。 相反,如果发现这条指令的操作数都可以获得 (不管是来自于旁路网络还是来自于寄存器堆 ), 那么表示这条指令可以被正常执行,此时这条指令就可以从发射队列中删除了。 总结起来就是,一条指令在 $\mathrm{cycle}_n$ 被仲裁电路选中时,只有到了 $\mathrm{cycle}{n+2}$ 才可以离开发射队列。
还需要注意的是,浮点数的 load/store 指令也是在整数的 Cluster 部分被执行的,只是对于一个 128 位的浮点数来说,需要两次的存储器操作才可以完成。
11.5 执行单元
11.5.1 整数的执行单元
Alpha 21264 处理器有六个执行单元 (在本书中称为 FU),每周期可以同时执行六条指令,也就是说,21264 处理器是一个 “machine width = 4, issue width = 6” 的超标量处理器,其中整数执行部分 (也称为 Ebox) 包括四个执行单元,每周期可以执行四条指令;浮点执行部分 (也称为 Fbox) 包括两个执行单元,每周期可以执行两条指令。21264 处理器采用了非数据捕捉 (non-data-capture) 的结构,在指令被仲裁电路选中之后才读取物理寄存器堆,这样对于整数部分的寄存器堆来说,需要同时支持四条指令的读取,也就是需要支持八个读端口,导致了比较慢的速度。 为了解决这个问题,21264 处理器采用了 Cluster 的结构,将整数执行部分分成了 Cluster0 和 Cluster1,每个 Cluster 都使用一份完整的整数寄存器堆,每个 Cluster 内部又被分成了两个 Subcluster, 分别被称为 upper(U) 和 lower(L),如图 11.10 所示为 21264 处理器的整数执行单元的示意图。
从图 11.10 可以看出,每个 Cluster 可以同时执行两条指令,在 Cluster0 中包括两个执行单元 U0 和 L0, 在 Cluster1 中包括两个执行单元 U1 和 L1, 每个 Cluster 中使用了一份完整的寄存器堆,由于只需要支持两条指令的读取,所以每个 Cluster 中的寄存器堆只需要支持四个读端口,这样简化了每个寄存器堆的设计,使它的速度变得更快。
Cluster 的设计还会影响到旁路网络 (bypassing network),在之前讲过,对于普通的处理器来说,如果一条指令在执行阶段需要一个周期 (也就是 latency = 1) 就可以得到结果,则它和相邻的相关指令可以获得背靠背地执行,如图 11.11 所示 ( 以 21264 处理器的流水线为例 )。

图 11.10 Alpha 21264 处理器中的整数执行单元

图 11.11 在同一个 Cluster 内的相关指令可以背靠背地执行
在 21264 处理器中,如果两条相邻的相关指令是在同一个 Cluster 内执行,那么可以像图 11.11 那样背靠背地执行,但是当这两条指令位于不同的 Cluster 时,要将一条指令的结果通过旁路网络送给另一条指令,就需要跨越 Cluster, 由于需要经过更长的连线,因此需要为这个阶段单独使用一个周期,这会使相邻的相关指令不能背靠背地执行,而是会间隔一个周期, 如图 11.12 所示。

图 11.12 属于不同 Cluster 的相关指令不能够背靠背地执行
不过,由于 21264 处理器是乱序执行的超标量处理器,所以硬件可以找到一条不相关的指令来插入到图 11.12 中的间隔周期中,这样就不会降低处理器的性能了。
在 21264 处理器中的整数执行单元包括了如下的资源。
① 四个 64 位的加法器,位于 U0、U1、L0 和 L1 中,用来完成整数的加减法,位于 L0 和 L1 中的加法器还用来计算 load/store 指令的虚拟地址;
② 四个逻辑运算的部件,位于 U0、U1、L0 和 L1 中;
③ 两个桶形移位器,位于 U0 和 U1 中;
④ 两个处理条件分支的部件,位于 U0 和 U1 中;
⑤ 两个完整的 80-entry 物理寄存器堆;
⑥ 一个流水线的乘法器,位于 U0 中,用来完成所有的整数乘法,它需要 7 个周期才可以得到结果;
⑦ 一个用来执行 Alpha 指令集中的所有特殊指令的部件,位于 U1 中。
每个 Cluster 中都有一个 80-entry 的寄存器堆,除了用来保存 Alpha 指令集定义的 31 个整数寄存器和 8 个影子 (shadow) 寄存器,剩下的 41 个寄存器就是扩展的寄存器,用来保存没有退休 (retire) 的所有整数指令的结果。
从理论上来说,整数执行单元中的两份寄存器堆包含的内容是一样的,但是考虑到跨越 Cluster 进行数据传输会引入一个周期的延迟,当一个 Cluster 在产生结果时,可以在当前周期将结果写到它所属的寄存器堆中,但是只能在下个周期才将结果写到另一个 Cluster 的寄存器堆中,所以实际上从某个时间点来看,两份寄存器堆包含的内容是不一样的,不过这并不会影响处理器的正确执行。
每个 Cluster 中的寄存器堆都包括四个读端口和六个写端口,由于每个 Cluster 可以同时执行两条指令,这需要寄存器堆支持四个读端口以提供源操作数,而六个写端口的使用情况如下。
① 两个写端口,当前 Cluster 的两条指令会通过这两个写端口,写入它们的结果;
② 两个写端口,另一个 Cluster 的两条指令会通过这两个写端口,写入它们的结果;
③ 两个写端口,专门供两个处理 load 指令的部件使用,由于 load 指令的延迟比较长, 而且一般处于相关性的顶端,为 load 指令设置专门的写端口可以提高执行效率,这样 load 指令就不需要和其他的指令争夺寄存器堆的写端口了。
11.5.2 浮点数的执行单元
在 Alpha 21264 处理器中,浮点数运算部分 ( 称为 Fbox) 每周期可以执行两条浮点指令,对应的有两个浮点数的执行单元,它的结构示意图如图 11.13 所示。

图 11.13 Alpha 21264 处理器中的浮点数执行单元
21264 处理器中的浮点执行单元包括如下的资源。
① 流水线的乘法器,四个周期的延迟,用来完成浮点数的乘法;
② 72-entry 的物理寄存器堆;
③ 流水线的加法器,四个周期的延迟;
④ 非流水线的除法器和开方电路 (SQRT)。
浮点数运算部分使用的 72-entry 的寄存器堆,除了用来保存 Alpha 指令集定义的 31 个浮点数寄存器,剩下的 41 个寄存器就是扩展的寄存器,用来保存流水线中没有退休 (retire) 的所有浮点数指令的结果。
浮点数运算部分使用的寄存器堆包括六个读端口和四个写端口,六个读端口的使用情况如下。
① 浮点数的加法 /除法 / 开方和乘法指令读取源操作数,需要使用四个读端口;
② 浮点数的 store 指令,要得到指令中所携带的数据,需要使用两个读端口。
而四个写端口的使用情况如下。
① 浮点数的加法 /除法 / 开方和乘法指令产生的结果需要使用两个写端口;
② 浮点数的 load 指令产生的结果需要使用两个写端口。
图 11.14 表示了 21264 处理器的版图中,整数执行单元和浮点数执行单元的位置,可以看出,为了尽量减少连线的延迟,每个 Cluster 中的执行单元都和自己的寄存器堆紧挨在一起,这样可以最大限度地缩短 Cluster 之内的旁路网络 (bypassing network) 经过的连线,保证了同一个 Cluster 内的相关指令可以背靠背地执行。

图 11.14 Alpha 21264 处理器的版图布局
如表 11.2 所示为 Alpha 21264 处理器中,不同指令在执行阶段需要耗费的周期数,这就是前文所说的 latency, 这些值都是通过使用旁路网络 (bypassing network) 而获得的,而且都是位于同一个 Cluster 之内,如果跨越了 Cluster, 则还需要增加一个周期。
表 11.2 Alpha 21264 处理器中,不同指令的 latency
| instruction class | latency(cycles) |
|---|---|
| simple integer operations | 1 |
| special instruction | 3 |
| integer multiply | 7 |
| integer load | 3 (假设 D-Cache hit) |
| floating-point add | 4 |
| floating-point load | 4 (假设 D-Cache hit) |
| floating-point multiply | 4 |
| floating-point divide | 12 s-p, 15 d-p * |
| floating-point square-root | 15 s-p, 30 d-p |
续表内容已并入上表。
- s-p 表示单精度 (single-precision) 的浮点数,d-p 表示双精度 (double-precision) 的浮点数。
11.6 存储器的访问
Alpha 21264 处理器中,访问存储器的部件被称为 Mbox, 它主要用来控制对 D-Cache 的访问,保证 load 指令和 store 指令的执行符合指令集定义的规范。 相比于同时代的其他处理器,21264 处理器的 Mbox 有两个重要的特性,一是能对 store/load 指令之间存在的先写后读 (RAW) 相关性进行预测,从而规划某些 load 指令进入流水线的时间,防止它们提前进入流水线而做无用功,这称为 Speculative Disambiguation。 二是能够对 load 指令访问 D-Cache 时是否命中进行预测,从而避免不必要的唤醒操作,这称为 Load hit/miss Prediction。 这两种方法本质上都是通过预测的方式来提高处理器的执行效率,这些特性在现代的处理器中依然被使用。
11.6.1 Speculative Disambiguation
对于访问存储器的 load/store 指令来说,它们之间存在的相关性在寄存器重命名阶段是无法解决的,只有到了流水线的执行阶段,将指令中携带的地址计算出来了,才可以判断它们之间的相关性,这个过程就是 Memory Disambiguation。 在前文讲过,保持 load 和 store 指令的串行执行,会便于它们之间相关性的检查,但是会严重降低处理器的性能;对 load 和 store 指令采取部分的乱序执行可以提高一些性能,但是造成相关性检查电路复杂度的增加;对 load 和 store 指令采取完全的乱序执行可以最大限度地提高处理器的性能, 但是需要更复杂的电路来处理它们之间的相关性,而且一旦发现它们之间存在相关性,还需要将流水线中的部分内容抹掉,以获得正确的执行。Alpha 21264 处理器即采用了将 load/store 指令完全的乱序执行的方式,同时为了最大限度地减少对流水线的负面影响,21264 处理器还对 load 指令是否和它之前的 store 指令存在相关性进行了预测,如果预测一条 load 指令和流水线中没有退休 (retire) 的 store 指令之间不存在先写后读 (RAW) 的相关性, 那么这条 load 指令就是自由状态的,不需要等待它之前的 store 指令将地址计算出来,直接就可以按照乱序的方式进入执行单元,这样提高了性能。
图 11.15 表示了 21264 处理器中如何实现 Speculative Disambiguation 这个功能。
在 Alpha 21264 处理器中,D-Cache 的访问使用了两级流水线,第一级流水线访问 D-Cache, 但是在这个周期无法得到结果,第二级流水线会得到数据和 Tag 的值,并进行 Tag 比较,以判断是否命中。 两级流水线的 D-Cache 增大了 load 指令的执行周期,使 load 指令到与之相关的指令之间间隔的周期数增多,这对于顺序执行的处理器是很致命的,会导致很多的流水线气泡 (bubble),但是对于乱序执行的超标量处理器来说,硬件可以调度其他不相关的指令在这些周期执行,因此不会对性能造成太大的负面影响,而且,将 D-Cache 的访问进行流水线处理,可以提高处理器的执行频率,现代的处理器基本上都使用流水线的方式访问 Cache 了。

图 11.15 Speculative Disambiguation
在 21264 处理器访问 D-Cache 的第一级流水线中 ( 即图 11.15 中的 D-Cache1),由于 store 指令和 load 指令所携带的地址都已经计算出来,所以会对它们之间是否存在相关性进行检查,这就是 Disambiguation, 通过下面的部件可以完成这个功能。
(1) Load/Store Queue: 它其实是整数发射队列的一部分,用来对 load 指令和 store 指令按照乱序的方式送到执行单元中。
(2) Load Queue: 这个部件按照程序中的原始顺序存储着所有 load 指令的地址,load 指令在流水线的寄存器重命名阶段就被写入到这个部件,这样就保证了程序中指定的顺序。 当 load 指令退休 (retire) 的时候,它在这个部件中所占据的空间也就释放了,为了便于进行地址比较,这个部件使用了 32-entry 的 CAM 来实现。
(3) Store Queue: 这个部件按照程序中指定的顺序存储着所有 store 指令的地址和数据,store 指令在流水线的寄存器重命名阶段就被写入到这个部件中,这样就保证了程序中指定的顺序,当 store 指令退休 (retire) 的时候,它在这个部件中所占据空间也就释放了,为了便于进行地址比较,这个部件也使用 32-entry 的 CAM 来实现。
(4) Wait Table: 这是一个 $1024\times1\,\mathrm{bit}$ 的表格,使用 PC 值进行寻址,用来存储 load 指令的相关性信息,这个表格中的每个位被称为 Wait Bit, 初始状态时都为 0, 表示所有的 load 指令和它之前的 store 指令都不存在相关性,可以乱序执行。 当发现了 store/load 指令之间的违例,也就是说,一条 load 指令在流水线中发现了位于它之前并且地址相等的 store 指令,此时就将这条 load 指令在表格中的 Wait Bit 置为 1。 在流水线的解码阶段,每当解码出一条 load 指令时,就需要读取这个表格,将这条 load 指令对应的 Wait Bit 和 load 指令一起写到发射队列中,如果一条 load 指令对应的 Wait Bit 为 1, 那么这条 load 指令必须等到它之前的所有 store 指令都计算出了地址,才允许向仲裁电路发出申请。 这个表格每经过 16384 个周期就需要全部清零一次,否则经过一段时间的运行,这个表格就变得全是 1 了, 这就相当于所有的 load 指令都不允许提前进行执行,降低了处理器的性能。 而且,当程序进入不同的执行阶段时,以前存在相关性的 store/load 指令可能就不再有相关性了,因此周期性地将这个表格进行清零可以适应这种变化,图 11.6 表示了这个表格的使用过程。

图 11.16 Wait Table 的使用过程
load 和 store 指令会首先进入发射队列中等待,一旦用来计算地址的源操作数准备好了,就可以向仲裁电路发出申请,对于 store 指令来说,可以先不用理会数据是否准备好了, 因为相关性的检查只需要地址就可以。 如果 load 指令对应的 Wait Bit 是 1,那么只有它前面所有的 store 指令都已经被仲裁电路选中了,这条 load 指令才可以向仲裁电路发出申请。
从之前的流水线示意图可以看出,在流水线的发射 (Issue) 阶段之后的下个周期,需要从寄存器堆中读取操作数,然后再到下个周期就可以计算指令所携带的地址了,将地址计算出来之后,就可以进入 D-Cache1 这个流水段。 在这个周期,会对 load 指令和 store 指令之间是否存在相关性进行检查,因此这个周期也称为 Disambiguation 阶段,它是 21264 处理器中比较复杂的部分,下面分别介绍 load 和 store 指令如何通过这个阶段。
1. Load Disambiguation
在这个周期,load 指令会将计算出的地址写到 Load Queue 对应的位置中,同时,load 指令还会完成下面两件事情。
(1) load 指令会查找 Store Queue 这个部件,如果发现存在地址相等并且指令顺序位于它之前的 store 指令,那么表示这条 load 指令不需要访问 D-Cache 了,直接通过 Store Queue 就可以得到结果。
(2) load 指令还需要查找 Load Queue 这个部件,在 21264 处理器中,要求访问相同地址的 load 指令必须保持程序中指定的顺序 (in-order)。 如果不满足这个规则,则在多核环境下可能会发生错误:某一个 CPU 中访问相同地址的两条 load 指令乱序执行了,但是在这两条 load 指令执行之间的某个时间点,这个地址上的值被另外一个 CPU 改变了,这样就会导致在前面的 load 指令得到了最新的值,而在后面的 load 指令得到了旧的值,这和程序的本意已经不一样了,如图 11.17 所示。

图 11.17 Load/Load 指令违例时会引起的问题
因此 load 指令在地址计算出来之后,还需要查找 Load Queue 这个部件,如果发现存在地址相等并且指令顺序位于它之后的 load 指令,那么就出现了 load-load 违例,在 21264 处理器中定义了各种访问存储器的操作应该保持的执行顺序,如表 11.3 所示 [47]。
表 11.3 Alpha 21264 处理器中,访问存储器的操作应该保持的顺序
| First Instruction In Pair | Second Instruction In Pair | Reference order |
|---|---|---|
| load memory to address X | load memory to address X | maintained |
| load memory to address X | load memory to address Y | not maintained |
| store memory to address X | store memory to address X | maintained |
| store memory to address X | store memory to address Y | maintained |
| load memory to address X | store memory to address X | maintained |
| load memory to address X | store memory to address Y | not maintained |
| store memory to address X | load memory to address X | maintained |
| store memory to address X | load memory to address Y | not maintained |
每两条访问存储器的指令构成了一组,表 11.3 中规定的访问存储器的顺序,除了第一条之外,其他的都可以归纳为下面的两种情况。
情况 1: store 指令必须按照程序中指定的顺序 (in-order) 更新存储器;
情况 2: store 指令和 load 指令如果地址相同,则必须按照程序中指定的顺序 (in-order) 执行。
在 21264 处理器中,由于 load 和 store 指令是乱序执行的,如果不加以处理,就不能够保证表 11.3 中的要求得到满足,需要在流水线的 Disambiguation 阶段使用硬件来解决。
需要注意的是,load 指令查找 Store Queue 时,如果发现了位于它之前的地址相同的 store 指令,这并不是违反表 11.3 中规定的访问顺序,只是表示 load 指令可以从 Store Queue 中直接获得结果而已,因此也就不需要抹掉流水线中的指令。 但是,这只是一般的情况,还有一种意外的情况需要特殊考虑,那就是 load 指令需要的数据宽度大于 store 指令存储数据的宽度,如图 11.18 所示。

图 11.18 load 指令在 Store Queue 中发现了部分需要的数据
// Program order: R2 == R3
SB R1, #8[R2] // 将寄存器 R1 的最低字节写到存储器中
LW R4, #8[R3] // 读取一个字,写到寄存器 R4 中
此时 load 指令需要的数据,有一部分存在于 Store Queue 中,还有一部分存在于 D-Cache 中,load 指令无法在同一个周期内得到它需要的数据,此时需要将 load 指令,以及它之后的所有指令都从流水线中抹掉,并将它们重新从 I-Cache 中取到流水线中,这个过程在 21264 处理器中称为 replay trap, 在下面会进行详细的介绍。
2. Store Disambiguation
在流水线的 Disambiguation 阶段,store 指令会将计算出的地址写到 Store Queue 对应的位置中,同时在这个周期,store 指令会查找 Load Queue 这个部件,如果存在地址相等且指令顺序位于它之后的 load 指令,则表示这条提前执行的 load 指令没有使用到正确的结果,这就产生了 Store/Load 指令的违例,这个过程可以用图 11.19 来表示。

图 11.19 产生 Store/Load 指令违例的一个例子
从图 11.19 中可以看到,当发现 Store/Load 违例时,Load 指令以及之后所有和它相关的指令都使用了错误的操作数,需要将它们重新执行,有很多种方法都可以实现这个过程, 它们实现的难易程度也是不相同的,最理想的方法是只将违例的 Load 指令以及所有和它相关的指令从流水线中抹掉,重新使这些指令参与发射 (issue) 的过程,其他不相关的指令则不受影响,但是要实现这样的功能需要识别出哪些指令和 load 指令存在相关性,还需要将这些指令放回发射队列中 (这些指令可能早就离开了发射队列 ),这样增加了设计的难度。 Alpha 21264 处理器采用了相对比较简单的处理方法,称之为 replay trap, 当已经离开发射队列的 Load/Store 指令由于某些原因不能再继续执行时,这条 Load/Store 指令以及它之后的所有指令都会从流水线中抹掉,并对处理器进行状态恢复 (21264 处理器中数量丰富的 Checkpoint 此时就派上了用场 ), 然后重新从 I-Cache 中将这些指令取出来送到流水线中, 也就是说,这些指令会重新从流水线的 fetch 阶段开始执行。 对于图 11.19 中的 store/load 违例,21264 处理器会将违例的 load 指令及其之后的全部指令都从流水线中抹掉,重新将这条违例的 load 以及之后的指令从 I-Cache 中取到流水线中,上面讲述的 load/load 违例也会使用这种 replay trap 的方式来解决。
这种 replay trap 的解决方式虽然简单,但是它降低了处理器的性能,如果经常发生 store/load 违例或者 load/load 违例,那么就需要经常抹掉流水线中的部分指令,并重新执行它们,这样造成了执行效率的降低。 正因为如此,在 21264 处理器中才使用 Wait Table 对 store 和 load 指令之间的相关性进行预测,这样可以避免大部分的 store/load 违例,使处理器不至于经常进行 replay trap 操作。
一旦在流水线的 Disambiguation 阶段发现了 store/load 违例,除了进行 replay trap 这种操作,还需要对 Wait Table 进行更新,这样,当这条违例的 load 指令再被遇到时,就需要等到它之前所有的 store 指令都被仲裁电路选中之后,才允许这条 load 指令向仲裁电路发出申请,这条 load 指令有可能会从 Store Queue 中得到对应的结果,这个过程如图 11.20 所示。

图 11.20 Load 指令可能会从 Store Queue 中得到结果
Alpha 21264 处理器之所以要从流水线的 fetch 阶段重新开始执行,而不是将违例的 load 指令及其之后的指令重新放回发射队列中开始执行,是因为此时的发射队列中可能已经没有空间存储这些指令了,因此这些指令需要等待。 这个等待时间可能会很长,处理器中需要使用一个部件来暂存这些指令,不仅如此,这些需要重新执行的指令由于不能够进入发射队列,也就不能够唤醒发射队列中的其他指令,而此时在发射队列中的指令可能正等待被它们唤醒,则发射队列中的这些指令也不能离开,这样就造成发射队列中永远没有空余的空间,使处理器死锁在了这个地方,无法继续执行,图 11.21 给出了一个例子。

图 11.21 可能产生死锁的一个例子
在图 11.21 中,当 store 指令在流水线的 Disambiguation 阶段发现了 store/load 违例时,第一组的指令已经都被仲裁电路选中并离开了发射队列,而第二组的指令都还没有离开发射队列,甚至有些还没有进入到发射队列中,此时需要将第一组中的指令都重新放入发射队列中。 但是,如果此时的发射队列已经被第二组中的指令占满了,而第二组中的很多指令都要使用第一组指令的结果,那么就会造成这样的情形:第二组中的指令在发射队列中等待被唤醒,它们将发射队列占满了,因此第一组中的指令无法重新进入到发射队列中,也就无法重新被发射 (issue) 而唤醒第二组中的指令,这样就造成了死锁,使处理器无法继续执行下去。 因此在 21264 处理器中,将第一组和第二组中的全部指令都从流水线中抹掉了,并使用 Checkpoint 对处理器的状态进行恢复,然后将它们从I-Cache 中取出来,重新送到流水线中,此时就可以使处理器恢复到正确的状态了。
总的看起来,21264 处理器采用的这种 replay 的方法是基于 I-Cache 进行的,它会降低处理器的执行效率,如果想要从发射队列中进行 replay, 有两种方式可以采用。
(1) 让指令被仲裁电路选中之后,不离开发射队列,只有当指令确定不会有问题的时候才允许离开,那么,什么时候可以确定一条指令肯定没问题呢?即使一条指令在进入到执行阶段之前获得了操作数,也不能够保证这个数据是正确的,只有等到一条指令退休 (retire)
的时候,才能够保证它肯定正确执行了,因此需要等到一条指令退休,才允许它离开发射队列。 这样当发现 store/load 违例时,所有需要 replay 的指令可以重新从发射队列中向仲裁电路发出申请,但是这种方式给发射队列带来了压力,因为发射队列中总是保存着过期的指令,降低了它实际可用的容量,也就降低了寻找指令并行执行的机会。
(2) 使用 Replay IQ, 用来保存所有已经被仲裁电路选中,但是还没有退休的指令,一条指令被仲裁电路选中之后就会离开发射队列,进入到这个 Replay IQ, 当发现了 store/load 违例时,所有的指令只需要从 Replay IQ 中重新向仲裁电路发出申请就可以了,Intel Pentium 4 处理器就使用了这种方法。
11.6.2 Load hit/miss Prediction
在 Alpha 21264 处理器中,load 指令的执行周期数是不确定的,很多因素都会影响这个周期数,例如是否 D-Cache 命中、D-Cache 中是否有 bank 冲突、是否和其他部件产生了 D-Cache 读端口的冲突等,这些因素造成了 load 指令的执行周期数是可变的。 但是,如果等到 load 指令得到结果之后才对发射队列中相关的指令进行唤醒,会导致间隔周期过长,考虑到大部分情况下,load 指令都能够顺利地从 D-Cache 中得到结果 (否则 D-Cache 就没有存在的意义了 ),因此可以假设 load 指令总是产生 D-Cache 命中,从而按照这种假设来对发射队列中的相关指令进行唤醒,这就是前文中讲过的推测唤醒 (speculative wake-up ), 图 11.22 表示了整数的 load 指令在流水线中的执行情况 [48]:

图 11.22 整数 load 指令的执行情况
从图 11.22 可以看出,从整数 load 指令被仲裁电路选中,直到和它相关的指令 (图中的指令 A) 被仲裁电路选中,中间间隔了两个周期。 由于假设 load 指令会产生 D-Cache 命中, 在周期 4 和周期 5, 和 load 指令相关的指令就可以被仲裁电路选中了,这两个周期也称为 SW 窗口 (Speculative Window)。 在这两个周期被仲裁电路选中的任何指令都不能离开发射队列,不管它们是否真的和 load 指令存在相关性,当发现 load 指令真的是 D-Cache 命中时,这两条指令可以从发射队列中离开,但是,如果不幸发现了 D-Cache 缺失,那么这两条指令会从流水线中抹掉,并在发射队列中等待被唤醒,然后重新向仲裁电路发出申请。
Alpha 21264 处理器并没有区分 SW 窗口中哪些指令和 load 指令相关,而是认为全部都和 load 指令相关,在发现 D-Cache 缺失时,将 SW 窗口中的指令全部从流水线中抹掉,并重新在发射队列中等待被唤醒,此时会假设 L2 Cache 是命中的,在图 11.22 中的周期 6, 这些指令可以再次被仲裁电路选中,这导致了四个周期的延迟,如图 11.23 所示。
如果 L2 Cache 命中,那么这些指令就可以从发射队列中离开了,但是,如果 L2 Cache 缺失,那么这些指令就需要在发射队列中继续等待。

图 11.23 当 D-Cache 缺失时,假设 L2 Cache 会命中
在 load 指令发生 D-Cache 缺失时,需要将 SW 窗口中的所有指令都从流水线中抹掉,这样就浪费了效率,因为这些周期本可以选中其他的指令来执行。 为了尽量避免这种情况的发生,在 21264 处理器中,对 load 指令是否会产生 D-Cache 命中也进行了预测,只有那些预测命中的 load 指令才会按照 latency = 2 的情形对相关的指令进行唤醒,否则就需要按照 latency = 4 的情况来执行。21264 处理器使用了一个四位的饱和计数器来预测 load 指令是否会产生 D-Cache 命中,每次当 D-Cache 命中时,计数器会增 1, 每当 D-Cache 缺失时,计数器会减 2, 使用计数器的最高位来作为 load 指令的预测值,最高位是 1 表示预测结果是命中,最高位是 0 表示预测结果是缺失。 从 load 指令被仲裁电路选中,直到和它相关的指令被仲裁电路选中,中间间隔的这些周期内 (这段时间也被称为 load latency),处理器可以选择其他不相关的指令来执行,所以即使预测 load 指令会发生 D-Cache 缺失,load latency 的四个周期由于可以执行其他不相关的指令,因此也不会对处理器的性能产生太大的负面影响。
还需要注意的是,不同于 store/load 违例,在这种方法中,处于 load 指令的 SW 窗口中的指令,被仲裁电路选中后是不会马上离开发射队列的,因此当 load 指令发生 D-Cache 缺失时,可以从发射队列中进行 replay。 而当处理器对 store 指令进行发射 (issue) 后,有可能发现 store/load 违例,此时发生违例的 load 指令,以及它之后的所有相关指令,可能早就离开了发射队列,甚至都已经处于 complete 状态了,所以在这种情况下,是没有办法基于发射队列进行 replay 的,只能将它们全部从流水线中抹掉,并重新将它们取到流水中,相比于这种基于 I-Cache 进行 replay 的方法,基于发射队列进行 replay 的方法显然效率要更高一些,因此也被称为 mini replay。
对于浮点数的 load 指令来说,由于需要 128 位的数据,需要两个周期才可以从 D-Cache 中取得完整的结果 (21264 是一个 64 位的处理器 ), 因此浮点数 load 指令的 latency 要增加一个周期,也就是三个周期,如图 11.24 所示。

图 11.24 浮点 load 指令的执行情况
此时这条 load 指令的 SW 窗口只有一个周期,在这个周期就可以知道 D-Cache 是否命中,如果命中,那么 SW 窗口中的指令就可以从发射队列中离开;如果缺失,那么这条指令就要在发射队列中等待被重新唤醒。D-Cache 缺失时,需要从 L2 Cache 中取数据,此时浮点数 load 指令的 latency 仍旧比整数 load 指令要多一个周期,即五个周期,也就是在图 11.24 中,在第七个周期 (cycle 7),指令 A 可以再次进行发射 (issue)。
对于浮点数的 load 指令来说,由于它的 SW 窗口只有一个周期,而且浮点数指令的执行周期一般都比较长,所以当 load 指令发生 D-Cache 缺失时,有足够的时间进行处理,因此对浮点数的 load 指令并不进行 D-Cache 是否命中的预测。
11.7 退休
在 Alpha 21264 处理器中,虽然指令按照乱序的方式来执行,但是从处理器外部看起来,仍旧是按照串行的方式执行的。 每条指令经过流水线的寄存器重命名阶段之后,都会按照程序中指定的顺序占据 ROB 的空间,一条指令在 FU 中执行完后,不会马上离开 ROB, 而是等到在它之前进入流水线的所有指令都已经退休 (retire) 时,这条指令才允许离开 ROB 而退休,它的结果可以更新处理器的状态 (Architecture State),并对外界可见。 这种顺序离开 ROB 的方式可以保证 21264 处理器支持精确的异常,对于处理器中的一条指令来说,如果它之前的某条指令产生了异常,那么它的异常是不会被外界看到的。
如果一条指令在即将退休的时候,发现了异常,那么在流水线中的所有指令都应该被抹掉,这些指令所占据的物理寄存器也应该被释放,同时,重命名映射表 (RAT) 也应该恢复到产生异常的指令发生之前的状态,这可以通过 Checkpoint 的方式来实现。 在 21264 处理器中,ROB 中的每一条指令都有一个 Checkpoint 与之对应,因为 21264 处理器的 ROB 最多可以容纳 80 条指令,也就是支持 80 个 Checkpoint, 这样可以最大限度地寻找指令的并行度,同时可以快速地对处理器的状态进行恢复。 而且,由于 21264 处理器使用了 CAM 的方式来实现重命名映射表 (RAT),因此 Checkpoint 所占据的硬件资源也是很小的。
在 21264 处理器中,指令在退休的时候,还会将这条指令的目的寄存器对应的旧的物理寄存器进行释放,因为这个物理寄存器再也不会被当前流水线中的指令使用了,因此这个物理寄存器会被放回到空闲寄存器的列表中,等待被指派给新的指令。
21264 处理器每周期最多可以从流水线中退休 8 条指令。
11.8 结论
Alpha 21264 处理器在它所处的那个时代是佼佼者,在架构方面做出了很多有益的尝试,为了达到很高的时钟频率,21264 处理器在很多地方都加入了额外的流水线,例如 Cache 的访问,这样就增加了分支预测失败时的惩罚 (mis-prediction penalty), 并增加了 load latency。 当然,21264 处理器使用乱序执行的方式可以缓解 load latency 增大所引起的问题,同时使用了复杂的分支预测算法来准确地预测分支指令;为了加快对分支预测失败和异常这两种情况的处理过程,21264 处理器为 ROB 中的每条指令都使用了 Checkpoint, 这样加快了处理器状态恢复的速度;21264 处理器使用 Cluster 的结构来解决多端口寄存器堆所产生的问题;使用 Load hit/miss Prediction 和 Speculative Disambiguation 这两种预测算法来加速 load/store 指令的执行等。 更深的流水线加上更准确的各种预测算法,使21264 处理器运行在更快的时钟频率的同时,保持了较高的执行效率,不至于造成“高频低能”的后果。 Alpha 21264 处理器可以用图 11.25 所示的流水线来总结 [49]。

图 11.25 Alpha 21264 的流水线
对于普通的指令来说,21264 处理器使用了 7 级的流水线;对于 load/store 类型的指令,由于对 D-Cache 的访问使用了流水线,因此需要一个 9 级的流水线;对于浮点类型的指令,使用了 10 级的流水线。 事实上,并不能够从流水线级数的多少来判断一个处理器性能的高低,而是需要考虑流水线执行的效率,这需要更准确的各种预测算法来支持,这样才可以使流水线保持高效率的运行。
当然,由于商业方面的种种原因,21264 处理器并没有能够真正在市场上获得成功,后来随着其他处理器性能的提高,21264 处理器逐渐消失在历史的潮流之中,让人不禁慨叹英雄迟暮。 但是 21264 处理器对于其他的处理器,不管是 Intel 还是 AMD,都产生了直接或者间接的影响,在计算机领域是一款经典的处理器,值得纪念。
本站所有文章转发 CSDN 将按侵权追究法律责任,其它情况随意。