Skip to the content.

第 2 章 Cache

全书目录 · 本章学习辅助

书内第 17–47 页;PDF 第 29–59 页。原书疑点见校注。

2.1 Cache 的一般设计

在超标量处理器中,有两个部分直接影响着性能,它们是分支预测和 Cache,在处理器的设计中需要仔细规划这两个部件,纵观 Intel 每次处理器升级,都会改进这两个部件的设计,使分支预测的精度更高,Cache 的命中率更高,速度也更快。关于分支预测的内容将在后文进行详细的介绍,本章重点关注超标量处理器中的 Cache。

Cache 之所以存在,是因为在计算机的世界中,存在如下的两个现象。

(1)时间相关性(temporal locality):如果一个数据现在被访问了,那么在以后很有可能还会被访问。

(2)空间相关性(spatial locality):如果一个数据现在被访问了,那么它周围的数据在以后有可能也会被访问。

Cache 的出现可以说是一种无奈的妥协,如果存储器技术的发展速度能够比处理器技术的发展速度快,如果一个大容量存储器的访问速度能够和处理器的速度不相上下,那么 Cache 也就不复存在了,本章讲述的内容也可以被无视,但是在未来的一段时间,在当今的硅工艺不发生变化的情况下,这是很难实现的一件事情,因此使用 Cache 是必需的,尤其是在超标量处理器中,考虑到每周期需要从 Cache 中同时读取多条指令,同时每周期也可能有多条 load/store 指令会访问 Cache,因此需要实现多端口的 Cache,这给芯片的面积和速度带来了不小的挑战,需要进行特殊的处理,这些内容都将在本章进行介绍。

Cache 是一个很广义的概念,现代处理器的工作环境中存在着很多的 Cache,如图 2.1 表示了一个很典型的情况,DRAM 可以看做是硬盘(disk)或闪存(flash)的 Cache,而 L2 Cache 可以看做是 DRAM 的 Cache,L1 Cache 又可以看做是 L2 Cache 的 Cache。处理器使用硅工艺来制造,在摩尔定律的驱使下,处理器的速度越来越快,但是相比之下,存储器技术的发展速度就慢了很多,不管是曾经的硬盘还是现在逐渐流行的固态硬盘(本质上是闪存),它们的速度在当今的处理器面前都是慢了一个或几个数量级的,于是就使用了速度相对更快一些的 DRAM 来作为硬盘或固态硬盘的 Cache,但是 DRAM 的速度仍然无法赶上当今的处理器。设想一个主频为 2GHz 的 4-way 超标量处理器访问一个 100ns 的 DRAM 的结果是什么:在处理器访问一次 DRAM 的时间内,处理器内部可以执行 800 条指令!很显然这样的“代沟”是无法接受的,于是就有了使用和处理器一样的硅工艺制造的 L2 Cache 和 L1 Cache,这两部分是和处理器联系最紧密的,尤其是 L1 Cache,它紧密地耦合在处理器的流水线中,是影响处理器性能的一个关键因素。如果是加入了虚拟存储器(virtual memory)的处理器,Cache 的设计就更是变化多端,本节暂且不考虑虚拟存储器,而只是关注 Cache 本身的一些特性,关于加入虚拟存储器之后 Cache 的变化,将在后文进行详细的展开介绍。图 2.1 是处理器中的各种 Cache。

图 2.1 处理器中的各种 Cache

图 2.1 处理器中的各种 Cache

现代的超标量处理器都是哈佛结构,为了增加流水线的执行效率,L1 Cache 一般都包括两个物理的存在,指令 Cache(I-Cache)和数据 Cache(D-Cache),本质上来说,它们的原理都是一样的,但是 D-Cache 不仅需要读取,还需要考虑写入的问题,而 I-Cache 只会被读取,并不会被写入,因此 D-Cache 要更复杂一些,在本节中如果没有特殊说明,都是针对 D-Cache 进行讲述的。L1 Cache 最靠近处理器,它是流水线的一部分,需要和处理器保持近似相等的速度,这注定了它的容量不能够很大,L1 Cache 一般都是使用 SRAM 来实现的,容量大的 SRAM 需要更长的时间来找到一个指定地址的内容,但是一旦不能和处理器保持速度上的相近,L1 Cache 也就失去了意义,因此,对于 L1 Cache 来说,“快”就是硬道理。

如果说 L1 Cache 分为 I-Cache 和 D-Cache 是为了求“快”,那么 L2 Cache 则是为了求“全”。一般情况下,L2 Cache 都是指令和数据共享,它和处理器的速度不必保持同样的步调,可以容忍更慢一些,它的主要功能是为了尽量保存更多的内容,在现代的处理器中,L2 Cache 都是以 MB 为单位的。而且,在多核的环境中,L2 Cache 有可能是多核之间共享的,这时候情况就更复杂一些(不过,现在的处理器一般都是共享 L3 Cache),但是 L1 Cache 仍旧是每个核“私有”的。

在超标量处理器中,对于 Cache 有着更特殊的需求,对于 I-Cache 来说,需要能够每周期读取多条指令,不过,它的延迟时间即使很大,例如有好几个周期,在一般的情况下也不会造成处理器性能的下降,仍旧可以实现每周期都可以读取指令的效果,除非遇到预测跳转的分支指令时,这些延迟才会对性能造成影响。而对于 D-Cache 来说,它需要支持在每周期内有多条 load/store 指令的访问,也就是需要多端口的设计,虽然在超标量处理器中,多端口的部件有很多,例如发射队列(Issue Queue)、Store Buffer、寄存器堆(Register File)和重排序缓存(ROB)等(这些内容将在后文逐步介绍),但是这些部件的容量本身就很小,所以即使采用多端口的设计,也不会占用很大的空间,而 D-Cache 的容量本身就很大,如果采用多端口的设计,则占用的硅片面积就很难接受了,也会导致过大的延迟,这个延迟会直接暴露给流水线中后续的指令,考虑到 load 指令一般处于相关性的顶端,所以这会对处理器的性能造成负面的影响。因此对于 I-Cache 和 D-Cache 需要采用的设计思路是不一样的,而对于 L2 Cache 来说,由于它被访问的频率不是很高(L1 Cache 的命中率是比较高的),所以它并不需要多端口的设计,它的延迟也不是特别的重要,因为只有在发生 L1 Cache 缺失(miss)的时候才会访问它,但是 L2 Cache 需要有比较高的命中率,因为在它发生缺失的时候会去访问物理内存(一般是 DRAM),这个访问时间会很长,因此要尽可能地提高 L2 Cache 的命中率,这是设计时需要考虑的问题。

Cache 主要由两部分组成,Tag 部分和 Data 部分。前面讲过,Cache 是利用了程序中的相关性,一个被访问的数据,它本身和它周围的数据在最近都有可能被访问,因此 Data 部分就是用来保存一片连续地址的数据,而 Tag 部分则是存储着这片连续数据的公共地址,一个 Tag 和它对应的所有数据组成的一行称为一个 Cache line,而 Cache line 中的数据部分称为数据块(Cache data block,也称做 Cache block 或 Data block,在下文中不进行区分),如果一个数据可以存储在 Cache 中的多个地方,这些被同一个地址找到的多个 Cache Line 称为 Cache Set,这些术语在本书中会经常被使用,它们所表达的含义如图 2.2 所示。

图 2.2 Cache 的结构

图 2.2 Cache 的结构

当然,图 2.2 只表示了一种可能的实现方式,在实际当中,Cache 有三种主要的实现方式,直接映射(direct-mapped)Cache、组相连(set-associative)Cache 和全相连(fully-associative)Cache,这三种实现方式的原理如图 2.3 所示。对于物理内存(physical memory)中的一个数据来说,如果在 Cache 中只有一个地方可以容纳它,它就是直接映射的 Cache;如果 Cache 中有多个地方都可以放置这个数据,它就是组相连的 Cache;如果 Cache 中任何的地方都可以放置这个数据,那么它就是全相连的 Cache。可以看出,直接映射和全相连这两种结构的 Cache 实际上是组相连 Cache 的两种特殊情况,现代处理器中的 Cache 一般属于上述三种方式中的某一个,例如 TLB 和 Victim Cache 多采用全相连结构,而普通的 I-Cache 和 D-Cache 则采用组相连结构等。

图 2.3 Cache 的三种组成方式

图 2.3 Cache 的三种组成方式

Cache 只能保存最近被处理器使用过的内容,由于它的容量有限,很多情况下,要找的指令或者数据并不在 Cache 中,这称为 Cache 的缺失(Cache miss),它发生的频率直接影响着处理器的性能,在计算机领域,影响 Cache 缺失的情况可以概括如下。

(1)Compulsory,由于 Cache 只是缓存以前访问过的内容,因此,第一次被访问的指令或数据肯定就不会在 Cache 中,这样看起来,这个缺失是肯定会发生的,当然,可以采用预取(prefetching)的方法来尽量降低这种缺失发生的频率,这部分内容将在本章进行介绍。

(2)Capacity,Cache 容量越大,就可以缓存更多的内容,因此容量是影响 Cache 缺失发生频率的一个关键因素,当然,考虑到实际硅片面积的限制,Cache 的容量是没有办法做得很大的。例如,当程序频繁使用的 5 个数据属于不同的 Cache set,而 Cache 的容量只有 4 个 Cache set 时,那么就会经常发生缺失了。

(3)Conflict,为了解决多个数据映射到 Cache 中同一个位置的情况,一般使用组相连结构的 Cache,当然考虑到实际硅片面积的限制,相连度不可能很高,例如,在一个有着两路结构(2-way)的 Cache 中,如果程序频繁使用的三个数据属于同一个 Cache set,那么就肯定会经常发生缺失了,此时可以使用 Victim Cache 来缓解这个问题,这部分内容会在本章进行介绍。

上面列出的影响 Cache 缺失的三个条件,称为 3C 定理,尽管在超标量处理器中,可以采用预取(prefetching)和 Victim Cache 这两种方法,但是仍然无法从根本上消除 Cache 缺失,只能够尽量减少它发生的频率而已。

2.1.1 Cache 的组成方式

1. 直接映射

直接映射(direct-mapped)结构的 Cache 是最容易实现的一种方式,处理器访问存储器的地址会被分为三部分,Tag、Index 和 Block Offset,如图 2.4 所示,使用 Index 来从 Cache 中找到一个对应的 Cache line,但是所有 Index 相同的地址都会寻址到这个 Cache line,因此在 Cache line 中还有 Tag 部分,用来和地址中的 Tag 进行比较,只有它们是相等的,才表明这个 Cache line 就是想要的那个。在一个 Cache line 中有很多个数据,通过存储器地址中的 Block Offset 部分可以找到真正想要的数据,它可以定位到每个字节。在 Cache line 中还有一个有效位(valid),用来标记这个 Cache line 是否保存着有效的数据,只有在之前被访问过的存储器地址,它的数据才会存在于对应的 Cache line 中,相应的有效位也会被置为 1 了。

图 2.4 Direct-mapped Cache

图 2.4 Direct-mapped Cache

通过上面的描述可以看出,对于所有 Index 相同的存储器地址,都会寻址到同一个 Cache line,这就产生了冲突,这也是直接映射结构 Cache 的一大缺点,如果两个 Index 部分相同的存储器地址交互地访问 Cache,就会一直导致 Cache 缺失,严重地降低了处理器的执行效率。

下面通过一个实际的例子来说明存储器地址的划分对于 Cache 的影响,如图 2.5 所示为一个 32 位的存储器地址,因为 Block Offset 有 5 位,所以 Data block 的大小是 32 字节;Index 是 6 位,表示 Cache 中共有 $2^6=64$ 个 Cache line(在直接映射的 Cache 中,Cache line 和 Cache set 是同义的),存储器地址中剩余的 $32-5-6=21$ 位作为 Tag 值,因此,这个 Cache 中可以存储的数据大小是 $64\times32$ 字节 $=2048$ 字节,即 2KB;而 Tag 部分的大小是 $64\times21$ 位 $=1344$ 位 $\approx1.3\mathrm{Kb}$;有效位(valid)占用的大小是 $64\times1$ 位 $=64$ 位,一般情况下,都是以数据部分的大小来表示 Cache 的大小,因此这个 Cache 被称做是一个 2KB 直接映射结构的 Cache,而实际上它还额外占用了多于 1.3Kb 的存储空间。

图 2.5 存储器的地址划分

图 2.5 存储器的地址划分

直接映射结构的 Cache 在实现上是最简单的,它都不需要替换算法,但是它的执行效率也是最低的,现代的处理器很少会使用这种方式了。

2. 组相连

组相连(set-associative)的方式是为了解决直接映射结构 Cache 的不足而提出的,存储器中的一个数据不单单只能放在一个 Cache line 中,而是可以放在多个 Cache line 中,对于一个组相连结构的 Cache 来说,如果一个数据可以放在 $n$ 个位置,则称这个 Cache 是 $n$ 路组相连的 Cache($n$-way set-associative Cache),如图 2.6 表示了一个两路组相连 Cache 的原理图。

图 2.6 2-way set-associative Cache

图 2.6 2-way set-associative Cache

这种结构仍旧使用存储器地址的 Index 部分对 Cache 进行寻址,此时可以得到两个 Cache line,这两个 Cache line 称为一个 Cache set,究竟哪个 Cache line 才是最终需要的,是根据 Tag 比较的结果来确定的,当然,如果两个 Cache line 的 Tag 比较结果都不相等,那么就说明这个存储器地址对应的数据不在 Cache 中,也就是发生了 Cache 缺失。

从图 2.6 中可以看出,因为需要从多个 Cache line 中选择一个匹配的结果,这种 Cache 的实现方式较之直接映射结构的 Cache,延迟会更大,有时候甚至需要将其进行流水线,以便减少对处理器周期时间(cycle time)的影响,这样会导致 load 指令的延迟增大,一定程度上影响了处理器的执行效率,但是这种方式的优点很突出,它可以显著地减少 Cache 缺失发生的频率,因此在现代的处理器中得到了广泛的应用。

因为在现实的处理器中应用最广泛,这种 Cache 也是本书重点关注的,在实际的实现当中,上面提到的 Tag 和 Data 部分都是分开放置的,称为 Tag SRAM 和 Data SRAM,可以同时访问这两个部分,如图 2.6 所示的那样,这种方式称为并行访问;相反,如果先访问 Tag SRAM 部分,根据 Tag 比较的结果再去访问 Data SRAM 部分,这种方式就称为串行访问,这两种方式各有优缺点,下面简要进行说明。

对于并行访问的结构,当 Tag 部分的某个地址被读取的同时,这个地址在 Data 部分对应的所有数据也会被读取出来,并送到一个多路选择器上,这个多路选择器受到 Tag 比较结果的控制,选出对应的 Data block,然后根据存储器地址中 Block Offset 的值,选择出合适的字节,一般将选择字节的这个过程称为数据对齐(Data Alignment),如图 2.7 所示给出了并行访问方法的示意图。

图 2.7 并行访问 Cache 中的 Tag 和 Data 部分

图 2.7 并行访问 Cache 中的 Tag 和 Data 部分

Cache 的访问一般都是处理器中的关键路径(critical path),如图 2.7 所示的方式在一个周期内完成了访问过程,这在现实当中会占据很大的延迟,要想使处理器运行在比较高的频率下,Cache 的访问就需要使用流水线,前面说过,对于指令 Cache 来说,流水线的结构不会有太大的影响,仍旧可以实现每周期读取指令的效果;而对于数据 Cache 来说,使用流水线则会增大 load 指令的延迟,从而对处理器的性能造成负面影响,如图 2.8 给出了在并行访问的结构中使用流水线的示意图,流水线的地址计算(Address Calculation)阶段可以计算得出存储器的地址,接下来的 Disambiguation 阶段对 load/store 指令之间存在的相关性进行检查,然后在下个流水线阶段(Cache Access)就可以直接并行地访问 Tag SRAM 和 Data SRAM,并使用 Tag 比较的结果对输出的数据进行选择,然后在最后一个流水线阶段(Result Drive),使用存储器地址中的 block offset 值,从数据部分给出的 data block 中选出最终需要的数据(字节或者字)。从图 2.8 可以看到,通过这种流水线的实现方式,可以将整个 Cache 的访问放到几个周期内完成,这样可以降低处理器的周期时间。

图 2.8 并行访问的流水线

图 2.8 并行访问的流水线

而对于串行访问方法来说,首先对 Tag SRAM 进行访问,根据 Tag 比较的结果,就可以知道数据部分中,哪一路的数据是需要被访问的,此时可以直接访问这一路的数据,这样就不再需要图 2.8 中的多路选择器,而且,只需要访问数据部分指定的那个 SRAM,其他的 SRAM 由于都不需要被访问,可以将它们的使能信号置为无效,这样就可以节省很多的功耗,这种实现方式如图 2.9 所示。

图 2.9 串行访问 Cache 中的 Tag 和 Data 部分

图 2.9 串行访问 Cache 中的 Tag 和 Data 部分

当然,图 2.9 所示的这种方式完全串行了 Tag SRAM 和 Data SRAM 这两部分的访问,它的延迟会更大,仍旧需要使用流水线的方式来降低对处理器的周期时间的影响,用流水线的方式实现这种 Cache 的访问如图 2.10 所示,和上面并行访问的流水线进行对比,可以很明显地看到,这种方式降低了访问 Tag SRAM 和 Data RAM 的延迟,因为此时已经不再需要多路选择器,这对降低处理器的周期时间是有好处的,但是这样的设计也有一个明显缺点,那就是 Cache 的访问增加了一个周期,这也就是增大了 load 指令的延迟,因为 load 指令处于相关性的顶端,这样会对处理器的执行效率造成一定的负面影响。

图 2.10 串行访问的流水线

图 2.10 串行访问的流水线

对于 Cache 来说,不管是并行访问还是串行访问,这两种设计方式都很难说谁优谁劣,并行访问的方式会有较低的时钟频率和较大的功耗,但是访问 Cache 的时间缩短了一个周期,但是考虑到在乱序执行的超标量处理器中,可以将访问 Cache 的这段时间通过填充其他的指令而掩盖起来,所以对于超标量处理器来说,当 Cache 的访问处于处理器的关键路径上时,可以使用串行访问的方式来提高时钟频率,同时并不会由于访问 Cache 的时间增加了一个周期而引起性能的明显降低;相反,对于普通的顺序执行的处理器来说,由于无法对指令进行调度,访问 Cache 如果增加一个周期,就很可能会引起处理器性能的降低,因此在这种处理器中使用并行访问的方式就是一种比较合适的选择了。

3. 全相连

在全相连(fully-associative)的方式中,对于一个存储器地址来说,它的数据可以放在任意一个 Cache line 中,如图 2.11 所示,存储器地址中将不再有 Index 部分,而是直接在整个的 Cache 中进行 Tag 比较,找到比较结果相等的那个 Cache line;这种方式相当于直接使用存储器的内容来寻址,从存储器中找到匹配的项,这其实就是内容寻址的存储器(Content Address Memory, CAM),实际当中的处理器在使用全相连结构的 Cache 时,都是使用 CAM 来存储 Tag 值,使用普通的 SRAM 来存储数据的。当 CAM 中的某一行被寻址到时,SRAM 中对应的行(一般称为 word line)也将会被找到,从而 SRAM 可以直接输出对应的数据。全相连结构的 Cache 有着最大的灵活度,因此它的缺失率是最低的,但是很明显可以看出,由于有大量的内容需要进行比较,它的延迟也是最大的,因此一般这种结构的 Cache 都不会有很大的容量,例如 TLB 就会使用这种全相连的方式来实现,关于 TLB 在后面的章节会详细介绍。

图 2.11 全相连

图 2.11 全相连

2.1.2 Cache 的写入

由于 L1 Cache 紧密地耦合在处理器的流水线中,因此是本书重点关注的内容,它分为指令 Cache(I-Cache)和数据 Cache(D-Cache),在一般的 RISC 处理器中,I-Cache 都是不会被直接写入内容的,即使有自修改(self-modifying)的情况发生,也并不是直接写 I-Cache,而是要借助于 D-Cache 来实现,将要改写的指令作为数据写到 D-Cache 中,然后将 D-Cache 中的内容写到下级存储器中(例如 L2 Cache,这个存储器一定是被指令和数据共享的,这个过程称为 clean),并将 I-Cache 中的所有内容置为无效,这样处理器再次执行时,就会使用到那些被修改的指令了。本节只针对 D-Cache 进行讲述,对于 D-Cache 来说,它的写操作和读操作有所不同,当执行一条 store 指令时,如果只是向 D-Cache 中写入数据,而并不改变它的下级存储器中的数据,这样就会导致 D-Cache 和下级存储器中,对于这一个地址有着不同的数据,这称做不一致(non-consistent)。要想保持它们的一致性,最简单的方式就是当数据在写到 D-Cache 的同时,也写到它的下级存储器中,这种写入方式称为写通(Write Through),由于 D-Cache 的下级存储器需要的访问时间相对是比较长的,而 store 指令在程序中出现的频率又比较高,如果每次执行 store 指令时,都向这样的慢速存储器中写入数据,处理器的执行效率肯定不会很高了。

如果在执行 store 指令时,数据被写到 D-Cache 后,只是将被写入的 Cache line 做一个记号,并不将这个数据写到更下级的存储器中,只有当 Cache 中这个被标记的 line 要被替换时,才将它写到下级存储器中,这种方式就称为写回(Write Back),被标记的记号在计算机术语中称为脏(dirty) 状态,很显然,这种方式可以减少写慢速存储器的频率,从而获得比较好的性能。当然,这种方式也是有缺点的,它会造成 D-Cache 和下级存储器中有很多地址中的数据是不一致的,这会给存储器的一致性管理带来一定的负担。

上面所讲述的情况都是假设在写 D-Cache 时,要写入的地址总是在 D-Cache 中存在的,而实际当中,有可能发现这个地址并不在 D-Cache 中,这就发生了写缺失(write miss),此时最简单的处理方法就是将数据直接写到下级存储器中,而并不写到 D-Cache 中,这种方式称为 Non-Write Allocate。与之对应的方法就是 Write Allocate, 在这种方法中,如果写 Cache 时发生了缺失,会首先从下级存储器中将这个发生缺失的地址对应的整个数据块(data block) 取出来,将要写入到 D-Cache 中的数据合并到这个数据块中,然后将这个被修改过的数据块写到 D-Cache 中。如果为了保持存储器的一致性,将这个数据块也写到下级存储器中,这种方法就是前面说过的写通(Write Through)。如果只是将 D-Cache 中对应的 line 标记为脏(dirty) 的状态,只有等到这个 line 要被替换时才将其写回到下级存储器中,则这种方法就是前面提到的写回(Write Back)。

在这里可能会有一个疑问,写 D-Cache 而发生缺失时,为什么不直接从 D-Cache 中找到一个 line, 将要写入的信息直接写到这个 line 中,同时也将它写到下级存储器中呢?为什么还要先从下级存储器中将对应的数据块读取出来并写到 D-Cache 中呢?

这是因为在处理器中,对于写 D-Cache 来说,最多也就是写入一个字,例如 MIPS 中的 SW 指令,如果按照上面的方式,直接从 D-Cache 中找到一个 line 来存储这个需要写入的数据,并将这个 line 标记为脏(dirty) 的状态,那么就会导致这个 line 中,数据块中的其他部分和下级存储器中对应地址的数据不一致,而且此时 D-Cache 中的这些数据是无效的,如果这个 Cache line 由于被替换而写回到下级存储器中时,就会使下级存储器中的正确数据被篡改,这个过程如图 2.12 所示。

如图 2.12 所示的这种情况显然是不希望看到的,所以必须先从下级存储器中 (图 2.12 中以物理内存为例 ) 将 store 指令的地址对应的数据块读取出来,并将 store 指令携带的数据合并到其中,然后将这个修改后的数据块写到 D-Cache 内的某个 line 中 (究竟选择哪个 line 由替换算法决定 ),此时在这个 Cache line 中保存着最新的数据,应该将这个 line 标记为脏(dirty) 的状态,这符合正常 Cache 的功能。因此,对于 Write Allocate 的方法来说,就需要在发生写缺失时,首先将缺失的地址对应的数据块从下级存储器中读取出来,这个过程是必不可少的。

通过上面的描述可以看出,对于 D-Cache 来说,一般情况下,写通(Write Through) 的方法总是配合 Non-Write Allocate 一起使用的,它们都是直接将数据更新到下级存储器中,这两种方法配合的工作流程如图 2.13 所示5。

图 2.12 直接写 D-Cache 会引起的问题

图 2.12 直接写 D-Cache 会引起的问题

图 2.13 Write Through 和 Non-Write Allocate 两种方法配合工作的流程图

图 2.13 Write Through 和 Non-Write Allocate 两种方法配合工作的流程图

很容易想到,在 D-Cache 中,写回(Write back) 的方法和 Write Allocate 也是配合在一起使用的,它们的工作流程如图 2.14 所示。

由图 2.14 可以看出,在 D-Cache 中采用写回(Write back) 的方法时,不管是读取还是写入时发生缺失,都需要从 D-Cache 中找到一个 line 来存放新的数据,这个被替换的 line 如果此时是脏(dirty) 的状态,那么首先需要将其中的数据写回到下级存储器中,然后才能够使用这个 line 存放新的数据。也就是说,当 D-Cache 中被替换的 line 是脏的状态时,需要对下级存储器进行两次访问:首先需要将这个 line 中的数据写回到下级存储器,然后需要从下级存储器中读取缺失的地址对应的数据块,并将其写到刚才找到的 Cache line 中。

当然,对于写 D-Cache 的操作来说,还需要将写入的数据也放到这个 line 中,并将其标记为脏的状态。从图 2.13 和图 2.14 可以看出,采用写回(Write back) 和 Write Allocate 配合工作的方法,其设计复杂度要高于写通(Write Through) 和 Non-Write Allocate 配合工作的方法,但是它可以减少写下级存储器的频率,从而使处理器获得比较好的性能。

图 2.14 Write back 和 Write Allocate 配合工作的流程图

图 2.14 Write back 和 Write Allocate 配合工作的流程图

2.1.3 Cache 的替换策略

在前文讲过,不管是读取还是写入 D-Cache 时发生了缺失,都需要从对应的 Cache Set 中找到一个 line, 来存放从下级存储器中读出的数据,如果此时这个 Cache Set 内的所有 line 都已经被占用了,那么就需要替换掉其中一个,如何从这些有效的 Cache line 找到一个并替换之,这就是替换(Cache replacement) 策略,有很多方法都可以使用,本节介绍几种最常用的替换算法。

1. 近期最少使用法

顾名思义,近期最少使用法(Least Recently Used, LRU) 会选择最近被使用次数最少的 Cache line, 因此这个算法需要追踪每个 Cache line 的使用情况,这需要为每个 Cache line 都设置一个年龄(age) 部分,每次当一个 Cache line 被访问时,它对应的年龄部分就会增加,或者减少其他 Cache line 的年龄值,这样当进行替换时,年龄值最小的那个 Cache line 就是被使用次数最少的了,会选择它进行替换。举例来说,在一个两路组相连结构的 Cache(2-way set-associative)中,每个 way 的年龄部分只需要使用一位即可,当某个 way 被使用时,这个 way 的年龄部分被置 1,另一个 way 的年龄部分被清零,这样当进行替换时,年龄部分为 0 的那个 way 就可以被替换了。但是,随着 Cache 相关度的增加(也就是 way 的个数的增加),要精确地实现这种 LRU 的方式就非常昂贵了,因此在实际当中对于这种相关度很高的 Cache,都是使用“伪 LRU”的方法,将所有的 way 进行分组,每一组使用一个 1 位的年龄部分。举例来说,如图 2.15 所示为一个八路组相连结构的 Cache(8-way set-associative)中实现“伪 LRU”的示意图。

图 2.15 伪 LRU 算法的工作流程

图 2.15 伪 LRU 算法的工作流程

在这个 Cache 内共有 8 个 way,图 2.15 中对所有 way 进行了分组,共有三部分,分别介绍如下。

(1)首先将所有的 way 分为两组,每组有 4 个 way,使用一位的年龄部分来表示,称其为第一级年龄位,当这个年龄位是 0 时,表示 way0~way3 最近没有被访问过,也就是说,way4~way7 最近被访问过;同理,当这个年龄位是 1 时,表示 way4~way7 最近没有被访问,而 way0~way3 最近被访问过。

(2)然后将每组中的 way 再分为两组,也就是每组有两个 way,每两组使用 1 位的年龄部分来表示,此时需要两个年龄位,称其为第二级年龄位。在图 2.15 中,对于位于上部的第二级年龄位来说,它为 0 时,表示 way0~way1 最近没有被使用,也就是 way2~way3 最近被访问过;它为 1 时,表示 way2~way3 最近没有被使用,也就是 way0~way1 最近被使用过。而图 2.15 中位于下部的那个第二级年龄位则记录了 way4~way5 和 way6~way7 的使用情况,它为 0 时表示 way4~way5 最近没有被使用过,它为 1 时表示 way6~way7 最近没有被使用过。

(3)继续进行分组,此时每组只有一个 way 了,仍旧为每两组使用一个 1 位的年龄部分来表示,此时共需要四个年龄位,称其为第三级年龄位,在图 2.15 中,对于最上部的那个第三级年龄位来说,它为 0 时,表示 way0 最近没有被使用,也就是 way1 最近被使用过;它为 1 时,表示 way1 最近没有被使用,也就是 way0 最近被使用过;而对于图 2.15 中最下部的那个第三级年龄位来说,它为 0 时,表示 way6 最近没有被使用过,它为 1 时,则表示 way7 最近没有被使用过,其他的两个第三级年龄位也是同理。

这样对于共有 8 个 way 的 Cache 来说,共需要三级的年龄位 (4→2→1),对于每一个年龄位来说,为 0 时表示编号较小的那些 way 最近没有被使用过,为 1 时表示编号较大的那些 way 最近没有被使用过。图 2.15 给出了依次访问 way1 中的数据和 way5 中的数据这两种情况,随着时间的推移,会有越来越多的 way 被访问到,此时顺着年龄位部分的箭头,就可以找到哪个 way 是最近没有被使用过的,可以将它进行替换,当然,顺着这些箭头的另一个方向还可以找到哪个 way 最近被使用过。

2. 随机替换

在处理器中,Cache 的替换算法一般都是使用硬件来实现的,因此如果做得很复杂,会影响处理器的周期时间,于是就有了随机替换 (Random Replacement) 的实现方法,这种方法不再需要记录每个 way 的年龄信息,而是随机地选择一个 way 进行替换,相比于 LRU 替换方法来说,这种方法发生缺失的频率会更高一些,但是随着 Cache 容量的增大,这个差距是越来越小的。当然,在实际的设计中很难实现严格的随机,一般采用一种称为时钟算法 (clock algorithm) 的方法来实现近似的随机,它的工作原理本质上就是一个计数器,这个计数器一直在运转,例如每周期加 1, 计数器的宽度由 Cache 的相关度,也就是 way 的个数来决定,例如一个八路组相连结构的 Cache(8-way set-associative), 则计数器的宽度需要三位,每次当 Cache 中的某个 line 需要被替换时,就会访问这个计数器,使用计数器当前的值,从被选定的 Cache set 中找到要替换的 line, 这样就近似地实现了一种随机的替换,这种方法从理论上来说,可能并不能获得最优化的结果,但是它的硬件复杂度比较低,也不会损失过多的性能,因此综合看起来是一种不错的折中方法。

2.2 提高 Cache 的性能

2.1 节介绍了 Cache 的基本原理,在真实世界的处理器中,会采用更复杂的方法来提高 Cache 的性能,这些方法包括写缓存 (write buffer)、流水线 (pipelined Cache)、 多级结构 (multilevel Cache)、Victim Cache 和预取 (prefetching) 等方法,不管是顺序执行还是乱序执行的处理器,都可以从这些方法中受益,这些方法将在本节进行介绍。除此之外,对于乱序执行的超标量处理器来说,根据它的特点,还有一些其他的方法来提高 Cache 的性能,例如非阻塞 (non-blocking)Cache、 关键字优先 (critical word first) 和提前开始 (early restart) 等方法,这些内容将在以后的章节逐步进行介绍。

2.2.1 写缓存

在处理器中,不管是执行 load 指令还是 store 指令,当 D-Cache 发生缺失时,需要从下一级存储器中读取数据,并写到一个选定的 Cache line 中,如果这个 line 是脏的状态,那么首先需要将它写到下级存储器中,考虑一般的下级存储器,例如 L2 Cache 或是物理内存,一般只有一个读写端口,这就要求上面的过程是串行完成的,也就是说,先要将脏状态的 Cache line 中的数据写回到下级存储器中,然后才能够读取下级存储器而得到缺失的数据,由于下级存储器的访问时间都比较长,这种串行的过程导致 D-Cache 发生缺失的处理时间变得很长,此时就可以采用写缓存 (write buffer) 来解决这个问题,脏状态的 Cache line 会首先放到写缓存中,等到下级存储器有空闲的时候,才会将写缓存中的数据写到下级存储器中,这个过程如图 2.16 所示。

图 2.16 Write buffer 的位置

图 2.16 Write buffer 的位置

使用写缓存之后,可以将 D-Cache 中的数据写到下级存储器的时间进行隐藏,对于写回 (write back) 类型的 D-Cache 来说,当一个脏状态的 Cache line 被替换的时候,这个 line 中的数据会首先放到写缓存中,然后就可以从下级存储器中 (例如 L2 Cache) 读数据了,而写缓存中的数据会择机写到下级存储器中,例如当下级存储器的接口空闲的时候。对于写通 (write through) 类型的 D-Cache 来说,采用写缓存之后,每次当数据写到 D-Cache 的同时,并不会同时也写到下级存储器中,而是将其放到写缓存中,这样就减少了写通类型的 D-Cache 在写操作时需要的时间,从而提高了处理器的性能,而写通类型的 Cache 由于便于进行存储器一致性 (coherence) 的管理,所以在多核的处理器中,L1 Cache 会经常采用这种结构。

当然,加入写缓存之后,会增加系统设计的复杂度,举例来说,当读取 D-Cache 发生缺失时,不仅需要从下级存储器中查找这个数据,还需要在写缓存中也进行查找,这需要在写缓存中加入地址比较的 CAM 电路,很显然,写缓存中存储的数据是最新的,如果在其中发现了缺失的数据,那么就需要使用它,而抛弃从下级存储器中读取的数据。

总结来看,写缓存就相当于是 L1 Cache 到下级存储器之间的一个缓冲,通过它,向下级存储器中写数据的动作会被隐藏,从而可以提高处理器执行的效率,尤其是对于写通 (write through) 类型的 D-Cache 来说,写缓存尤为必要。

2.2.2 流水线

对于读取 D-Cache 来说,由于 Tag SRAM 和 Data SRAM 可以在同时进行读取,所以当处理器的周期时间要求不是很严格时,可以在一个周期内完成读取的操作;而对于写 D-Cache 来说,情况就比较特殊了,读取 Tag SRAM 和写 Data SRAM 的操作也只能串行地完成。只有通过 Tag 比较,确认要写的地址在 Cache 中之后,才可以写 Data SRAM,在主频比较高的处理器中,这些操作很难在一个周期内完成,这就需要对 D-Cache 的写操作采用流水线的结构,流水线的划分有很多种方式,比较典型的方式是将 Tag SRAM 的读取和比较放在一个周期,写 Data SRAM 放在下一个周期,这样对于一条 store 指令来说,即使在 D-Cache 命中的时候,最快也需要两个周期才可以完成写操作,但是整体来看,如果连续地执行 store 指令,那么仍可以获得每周期执行一条 store 指令的效果,将 D-Cache 的写操作进行流水线的示意图如图 2.17。

图 2.17 对 Cache 的写操作使用流水线

图 2.17 对 Cache 的写操作使用流水线

在图 2.17 的实现方式中,load 指令在 D-Cache 命中的情况下,可以在一个周期内完成,但是对 store 指令需要进行流水线的处理,第一个周期读取 Tag SRAM 并进行比较,根据比较的结果,在第二个周期选择是否将数据写到 Data SRAM 中。还需要注意的是,当执行 load 指令时,它想要的数据可能正好在 store 指令的流水线寄存器中 ( 即图 2.17 中的 Delayed Store Data 寄存器 ),而不是来自于 Data SRAM, 因此需要一种机制,能够检测到这种情况,这需要将 load 指令所携带的地址和 store 指令的流水线寄存器 ( 即图 2.17 中的 Delayed Store Addr 寄存器 ) 进行比较,如果相等,那么就将 store 指令的数据作为 load 指令的结果,由此可以看出,对写 D-Cache 使用流水线之后,不仅增加了流水线本身的硬件,也带来了其他一些额外的硬件开销。其实,不仅在 Cache 中有这种现象,在处理器的其他部分增加流水线的级数,也会伴随着其他方面的硬件需求,因此过深的流水线带来的硬件复杂度是非常高的,就像 Intel 的 Pentium 4 处理器,并不会从过深的流水线中得到预想的好处。

2.2.3 多级结构

现代的处理器很渴望有一种容量大,同时速度又很快的存储器,但是在当今的硅工艺条件下,对存储器来说,容量和速度是一对相互制约的因素,容量大必然速度慢,速度快只能容量小,因此这种既大又快的存储器似乎只是一个愿景,当然,这并不是说它不可能,或许有一天,人们真的可以发现一种新材料来替代当今的硅工艺,进而摆脱当前的种种限制,真要有那么一天的话,那么本章所讲述的内容可以就被忽略了。

为了能够使处理器 “看起来”使用了一个容量大同时速度快的存储器,可以使用多级结构的 Cache,如图 2.18 所示。

图 2.18 多级结构 Cache 的示意图

图 2.18 多级结构 Cache 的示意图

一般情况下,L1 Cache 的容量很小,能够和处理器内核保持在同样的速度等级,L2 Cache 的访问通常需要消耗处理器的几个时钟周期,但是容量要更大一些。在现代的处理器中,L1 Cache 和 L2 Cache 都会和处理器放在同一个芯片内,一些比较高阶的处理器,例如面向服务器领域的处理器,还会有片上的 L3 Cache,表 2.1 给出了三款处理器(Intel 的 Itanium-2 和 Core i7 以及 IBM 的 Power7)的 Cache 设计。

表 2.1 三款处理器的 Cache 设计

  Intel Itanium-26 IBM Power77 Intel Core i78
L1 I-Cache 16KB, 4-way, 1-cycle 32KB/core, 4-way, 3-cycle 32KB/core, 4-way, 4-cycle
L1 D-Cache 16KB, 4-way, 1-cycle 32KB/core, 8-way, 3-cycle 32KB/core, 8-way, 4-cycle
L2 Cache 256KB, 8-way, 5-cycle 256KB/core, 8-way, 8-cycle 256KB/core, 8-way, 10-cycle
L3 Cache 3MB, 12-way, 12-cycle 32MB, 8-way, 25-cycle 8MB, 16-way, 35-cycle

表 2.1 原始扫描

需要注意的是,IBM 的 Power7 处理器是一款 8 核的多核处理器,每个核的 L1 Cache 和 L2 Cache 都是私有的,8 个核共享 L3 Cache,所以它的 L3 Cache 的容量是比较大的。Intel 的 Core i7 处理器也是一款 4 核的处理器,每个核的 L1 Cache 和 L2 Cache 都是私有的,4 个核共享 L3 Cache。而 Intel 的 Itanium-2 处理器是一款单核处理器(这款处理器诞生于 2002 年,那时候多核处理器还不是主流),再加上它的主频相对比较低,所以它的访问延迟(latency)也会比较少一些。

一般在处理器中,L2 Cache 会使用写回(Write Back)的方式,但是对于 L1 Cache 来说,写通(Write Through)的实现方式也是可以接受的,这样可以简化流水线的设计,尤其是便于在多核的环境下,管理存储器之间的一致性(coherence),因此一些多核处理器对 L1 Cache 都采用了写通的方式。

对于多级结构的 Cache(multilevel Cache),还需要了解两个概念 Inclusive 和 Exclusive,以 L1 Cache 和 L2 Cache 为例,这两个概念如下。

Inclusive:如果 L2 Cache 包括了 L1 Cache 中的所有内容,则称 L2 Cache 是 Inclusive 的。

Exclusive:如果 L2 Cache 和 L1 Cache 的内容互不相同,则称 L2 Cache 是 Exclusive 的。

它们的示意图如图 2.19 所示。

图 2.19 Inclusive 和 Exclusive

图 2.19 Inclusive 和 Exclusive

由图 2.19 可以看出,Inclusive 类型的 Cache 是比较浪费硬件资源的,因为它将一份数据保存在了两个地方,但是这样也带来了明显的好处,首先可以将数据直接写到 L1 Cache 中,虽然此时会将 Cache line 中原来的数据覆盖了,但是在 L2 Cache 中存有这个数据的备份,所以这样的覆盖不会引起任何问题(当然,被覆盖的 line 不能是脏状态的);Inclusive 类型的 Cache 也简化了一致性(coherence)的管理,例如在多核的处理器中,当其中一个处理器改变了存储器中一个地址的数据时 (例如执行了 store 指令 ), 如果在其他处理器的私有 Cache 中 (一般在多核处理器中,L1 Cache 和 L2 Cache 都是私有的 ) 也保存了这个地址的数据,那么需要将它们置为无效,以避免这些处理器使用到错误的数据,如果是 Inclusive 类型的 Cache, 那么只需要检查最低一级的 Cache 即可,在本例中就是 L2 Cache, 如果在其中没有发现这个地址,那么在 L1 Cache 中也必然不存在这个地址,这样就可以不必打扰 L1 Cache, 避免了对处理器流水线的影响;如果采用Exclusive 类型的 Cache, 很显然要检查所有的 Cache, 而检查 L1 Cache 也就意味着干扰了处理器的流水线,这样会影响处理器的执行效率,同时,如果处理器要读取的数据不在 L1 Cache 中,而是在 L2 Cache 中,那么在将数据从 L2 Cache 放到 L1 Cache 的同时,也需要将 L1 Cache 中被覆盖的 line 写到 L2 Cache 中,这种交换数据的过程很显然会降低处理器的效率,但是 Exclusive 类型的 Cache 避免了硬件的浪费,尤其是在当代的处理器中,Cache 容量的大小会直接影响处理器的性能,在同样的硅片面积下,Exclusive 类型的 Cache 可以获得更多可用的容量,在一定程度上提高了处理器的性能,不过就目前来看,现代的大多数处理器都采用了 Inclusive 类型的 Cache。

2.2.4 Victim Cache

有时候,Cache 中被 “踢出”的数据可能马上又要被使用,这是很有可能的,毕竟在 Cache 中存储的是经常被使用的数据,举例来说,对于一个两路组相连 (2-way set-associative) 结构的 D-Cache 来说,如果一个程序频繁地使用 3 个数据恰好都位于同一个 Cache set 中,那么就会导致一个 way 中的数据经常被 “踢出”Cache, 然后又经常被写回 Cache, 如图 2.20 所示。

图 2.20 在两路组相连的 Cache 中,频繁使用的三个数据位于同一个 Cache set 中

图 2.20 在两路组相连的 Cache 中,频繁使用的三个数据位于同一个 Cache set 中

在图 2.20 中,如果下次要访问数据 C 时,就会将数据 A 从 Cache 中踢出,将 C 写入;再访问数据 A 时,又会将数据 B 踢出,将 A 写入;然后再访问数据 B 时,又会将数据 C 踢出,将 B 写入 ……,这样会导致 Cache 始终无法命中需要的数据,显然降低了处理器的执行效率,如果为此而增加 Cache 中 way 的个数,又会浪费掉大量的空间,因为其他的 Cache set 未必有这样的特征,Victim Cache(VC) 正是要解决这样的问题,它可以保存最近被踢出 Cache 的数据,因此所有的 Cache set 都可以利用它来提高 way 的个数,通常 Victim Cache 采用全相连 (fully-associative) 的方式,容量都比较小 (一般可以存储 4~16 个数据 ), 它在处理器的位置如图 2.21 所示。

图 2.21 Victim Cache 所处的位置

图 2.21 Victim Cache 所处的位置

Victim Cache 本质上相当于增加了 Cache 中 way 的个数,能够避免多个数据竞争 Cache 中有限的位置,从而降低 Cache 的缺失率。一般情况下,Cache 和 Victim Cache 存在互斥 (exclusive) 的关系,也就是它们不会包含同样的数据,处理器内核可以同时读取它们,如果在 Cache 中没有发现想要的数据,而在 Victim Cache 中找到了,那么只需要使用 Victim Cache 的数据就可以了,这样和 Cache 命中时的效果是一样的,同时 Victim Cache 的数据会被写到 Cache 中,而 Cache 中被替换的数据会写到 Victim Cache 中,这相当于它们互相交换了数据,这个过程其实和 Exclusive 类型的 Cache 是一样的。总体来看,使用 Victim Cache, 可以减少 Cache 的缺失率,从而提高处理器的性能,现代的大多数处理器中都采用了 Victim Cache。

还有一种和 Victim Cache 类似的设计思路,称为 Filter Cache, 只不过它使用在 Cache“之前”,而 Victim Cache 使用在 Cache“之后”。当一个数据第一次被使用时,它并不会马上放到 Cache 中,而是首先会被放到 Filter Cache 中,等到这个数据再次被使用时,它才会被搬移到 Cache 中,这样做可以防止那些偶然被使用的数据占据 Cache, 因为这样的数据在以后的时间并不会继续被使用,因此它最好不要放在 Cache 中,使用 Filter Cache 可以过滤掉这些偶然使用的数据,从而提高 Cache 的利用效率,这种设计思路如图 2.22 所示。

图 2.22 Filter Cache

图 2.22 Filter Cache

2.2.5 预取

在前文已经讲过影响 Cache 缺失率的 3C 定理,其中有一项是 Compulsory, 当处理器第一次访问一条指令或者一个数据时,这个指令或数据肯定不会在 Cache 中,这样看起来,这种情况引起的缺失似乎是不可避免的,但是实际上,使用预取 (prefetching) 可以缓解这个问题,所谓预取,本质上也是一种预测技术,它猜测处理器在以后可能使用什么指令或数据,然后提前将其放到 Cache 中,这个过程可以使用硬件完成,也可以使用软件完成,它们最终的目的都是一样的,下面分别进行介绍。

1. 硬件预取

对于指令来说,猜测后续会执行什么指令相对是比较容易的,因为程序本身就是串行执行的,因此只需要在访问 I-Cache 中的一个数据块 (data block) 的时候,将它后面的数据块也取出来放到 I-Cache 中就可以了,当然,由于程序中存在分支指令,所以这种猜测有时候也会出错,导致不会被使用的指令进入了 I-Cache, 这一方面降低了 I-Cache 实际可用的容量,一方面又占用了本来可能有用的指令,这称为 “Cache 污染”, 不仅浪费了时间,还会影响处理器的执行效率,为了避免这种情况的发生,可以将预取的指令放到一个单独的缓存中,

如图 2.23 所示。

图 2.23 对指令采用硬件预取

图 2.23 对指令采用硬件预取

图 2.23 所示的方法实际上是在 Alpha 21064 处理器中对指令使用的预取方法,这种方法称为硬件预取 (Hardware Prefetching), 当 I-Cache 发生缺失时,除了将需要的数据块 (data block) 从下级存储器 (在图 2.23 中即为 L2 Cache) 取出来并放到 I-Cache 中,还会将下一个数据块也读取出来,只不过它不会放到 I-Cache 中,而是放到一个称为 Stream Buffer 的地方,在后续执行时,如果在I-Cache 中发生了缺失,但是在 Stream Buffer 中找到了想要的指令,那么除了使用 Stream Buffer 中读取的指令之外,还会将其中对应的数据块搬移到 I-Cache 中,同时继续从 L2 Cache 中读取下一个数据块 (在图 2.23 中,即为 $\mathrm{block}_{i+2}$) 放到 Stream Buffer 中,当程序中没有遇到分支指令时,这种方法会一直正确地工作,从而使 I-Cache 的缺失率得到降低,当然,分支指令会导致 Stream Buffer 中的指令变得无效,此时的预取相当于做了无用功,浪费了总线带宽和功耗,事实上,使用预取的方法是一把双刃剑,它可能会减少 Cache 的缺失率,也可能由于错误的预取而浪费功耗和性能,这种情况对于数据的预取尤为明显,需要在设计的时候仔细进行权衡。

不同于指令的预取,对于数据的预取来说,它的规律更难以进行捕捉,一般情况下,当访问 D-Cache 发生缺失时,除了将所需要的数据块从下级存储器中取出来之外,还会将下一个数据块也读取出来,这种方法被广泛地应用于现实当中的处理器中,例如 Intel 的 Core i7 处理器就采用了这种预取下一个数据块的方法,但是,这种方法并不总是很有效,因为程序以后要访问的数据有可能并不在下一个数据块中,这时候的预取就会浪费带宽和功耗,因此有些处理器采用了更激进的方法来实现数据的预取,例如在 Intel Pentium 4 和 IBM Power5 处理器中,采用了一种称为 Strided Prefetching 的方法,它能够使用硬件来观测程序中使用数据的规律,例如,某些数据结构的访问,第一个数据位于地址 a, 第二个数据位于地址 $a+128$,第三个数据位于地址 $a+256$,那么负责预取的硬件就会预测下一个数据位于地址 $a+384$,并将这个地址的数据预取出来,甚至会将地址 $a+512$、$a+640$、$a+768$ 等数据都进行预取,例如 IBM Power5 处理器就可以根据规律,提前预取 12 个数据块,这种预取的方法是比较激进的,对于一些程序,它有可能大幅提高执行速度,但是也可能对一些其他的程序带来负面的影响,毕竟这种方法只是一种猜测,而且这些预取来的数据需要替换掉 D-Cache 中一些可能在以后使用的数据,因此这种预取的方法是否真的需要使用,是需要根据实际的情况进行权衡的。

2. 软件预取

使用硬件进行数据的预取,很难得到满意的结果,其实,在程序的编译阶段,编译器 (compiler) 就可以对程序进行分析,进而知道哪些数据是需要进行预取的,如果在指令集中设有预取指令 (prefetching instruction),那么编译器就可以直接控制程序进行预取,此时的预取就是比较有针对性的了,例如下面的一段程序。

for (i = 0; i < N; i++)
{
    Prefetch(&a[i + P]);
    Prefetch(&b[i + P]);
    SUM = SUM + a[i] + b[i];
}

这个程序要计算两个数组之和,即 a[i]+b[i],在进行计算之前,先将需要使用的数据使用软件预取出来,这样就可以保证在进行计算的时候,直接从 D-Cache 中就可以找到需要的数据,这就是软件预取(Software Prefetching),但是,这种软件预取的方法有一个前提,那就是预取的时机。如果预取数据的时间太晚,那么当真正需要使用数据时,有可能还没有被预取出来,这样的预取就失去了意义;如果预取的时间太早,那么就有可能踢掉 D-Cache 中一些本来有用的数据,造成 Cache 的“污染”。要选择一个合适的时机进行预取,也就是要决定程序中 P 的取值,不过,这并没有一个准确的答案,需要根据实际的设计情况来决定。

还需要注意的是,使用软件预取的方法,当执行预取指令的时候,处理器需要能够继续执行,也就是继续能够从 D-Cache 中读取数据,而不能够让预取指令阻碍了后面指令的执行,这就要求 D-Cache 是非阻塞(non-blocking)结构的,关于非阻塞结构的详细内容,将在后文进行介绍。

在实现了虚拟存储器(Virtual Memory)的系统中,预取指令有可能会引起一些异常(exception),例如发生 Page Fault、虚拟地址错误(Virtual Address Fault)或者保护违例(Protection Violation)等。此时有两种选择,如果对这些异常进行处理,就称这种预取指令为处理错误的预取指令(Faulting Prefetch Instruction),反之,如果不对这些异常进行处理,并抛弃掉这条预取指令,就称这种预取指令为不处理错误的预取指令(Nonfaulting Prefetch Instruction),此时发生异常的预取指令就会变成一条空指令,这种方法符合预取指令的定位,使预取指令“悄无声息”的执行,对于程序员来说是不可见的,现代的很多处理器都采用了这种方式。

2.3 多端口 Cache

在超标量处理器中,为了提高性能,处理器需要能够在每周期同时执行多条 load/store 指令,这需要一个多端口的 D-Cache,以便能够支持多条 load/store 指令的同时访问,因此多端口的 D-Cache 是在超标量处理器中必须要面对的问题,其实在超标量处理器中,有很多部件都是多端口结构的,例如寄存器堆(Register File)、发射队列(Issue Queue)和重排序缓存(ROB)等,但是由于这些部件本身的容量并不是很大,所以即使采用多端口结构,也不会对芯片的面积和速度产生太大的负面影响,但是 D-Cache 不同,它的容量本身就很大,如果再采用多端口的设计,会对芯片的面积和速度带来很大的负面影响,因此需要采用一些方法来解决这个问题,有很多方法都可以实现多端口的 D-Cache,本节重点介绍三种方法,True Multi-port、Multiple Cache Copies 和 Multi-banking。

2.3.1 True Multi-port

虽然在现实当中,不可能对 Cache 直接采用多端口的设计,但是本节还是看一下这种最原始的方法究竟有何缺点,这种方法真的使用一个多端口的 SRAM 来实现多端口的 Cache,以一个双端口的 Cache 为例,在这种设计中,所有在 Cache 中的控制通路和数据通路都需要进行复制,这就表示,它有两套地址解码器(Address Decoder),使两个端口可以同时寻址 Tag SRAM 和 Data SRAM;有两个多路选择器(Way Mux),用来同时读取两个端口的数据;比较器的数量也需要增加一倍,用来判断两个端口的命中情况;同时还需要有两个对齐器(Aligner,用来完成字节或半字的读取)等。Tag SRAM 和 Data SRAM 本身并不需要复制一份,但是它们当中的每个 Cell 都需要同时支持两个并行的读取操作(对于一个 SRAM Cell 来说,不需要两个写端口,因为无法对一个 SRAM Cell 既写 0 又写 1),如图 2.24 所示为一个 SRAM Cell 同时支持两个读端口和一个写端口的示意图。

图 2.24 多端口 SRAM 中每个 Cell 的示意图

图 2.24 多端口 SRAM 中每个 Cell 的示意图

虽然图 2.24 的这种方法可以精确地提供双端口 Cache 的功能,但是这种方法需要将很多电路进行复制,因此增大了面积。而且图 2.24 中的这种多端口的 SRAM Cell 需要驱动多个读端口,因此需要更长的访问时间,功耗也会随之增大,对于处理器周期时间的负面影响是很大的,在实际的处理器当中,一般不会直接采用这种方式来设计多端口 Cache。

2.3.2 Multiple Cache Copies

这种设计方法将 Tag SRAM 和 Data SRAM 进行了复制,它的实现方式如图 2.25 所示。

图 2.25 Multiple Cache Copies

图 2.25 Multiple Cache Copies

这种实现方式本质上和 2.3.1 节的实现方法是一样的,只不过通过将 Cache 进行复制,SRAM 将不再需要使用多端口的结构,这样可以基本上消除对处理器周期时间的影响。但是,这种方法浪费了很多的面积,而且需要保持两个 Cache 间的同步,例如 store 指令需要同时写到两个 Cache 中,当一个 Cache line 发生替换(replacement)等操作时,也需要对另一个 Cache 进行同样的操作,也就是要保持两个 Cache 之间的完全一样,这样的设计显然非常麻烦,不是一个很优化的方法,在现代的处理器中也很少被使用。

2.3.3 Multi-banking

这种结构是在现实当中的处理器中被广泛使用的方法,它将 Cache 分为很多小的bank, 每个 bank 都只有一个端口,如果在一个周期之内,Cache 的多个端口上的访问地址位于不同的 bank 之中,那么这样不会引起任何问题,只有当两个或者多个端口的地址位于同一个 bank 之中时,才会引起冲突,称之为 bank 冲突 (bank conflict),这种方法就是 Multi-banking。使用这种方法,一个双端口的 Cache 仍旧需要两个地址解码器 (Address Decoder) 两个多路选择器 (Way Mux) 两套比较器和两个对齐器 (Aligner), 而 Data SRAM 此时不需要实现多端口结构了,这样就提高了速度,并在一定程度上减少了面积。但是由于需要判断 Cache 的每个端口是不是命中,所以对于 Tag SRAM 来说,仍旧需要提供多个端口同时读取的功能,也就是需要采用多端口 SRAM 来实现,或者采用将单端口 SRAM 进行复制的方法。如图 2.26 表示了一个使用 Multi-banking 方式实现的双端口 Cache 的原理图,整个 Cache line 被分为两个 bank,在一个周期内,当 Cache 的两个端口的访问地址落在不同的 bank 时,这两个端口就可以同时进行访问,例如图 2.26 中的端口 0 访问 bank0,端口 1 访问 bank1( 或者端口 0 访问 bank1,端口 1 访问 bank0),这样就不会产生冲突,实现了双端口 Cache 的功能。只有 Cache 的两个端口都访问同一个 bank 时,例如端口 0 和端口 1 都访问 bank1,这样才会产生冲突,在当前周期 Cache 只能对一个端口进行响应。

图 2.26 Multi-banking 结构的 Cache

图 2.26 Multi-banking 结构的 Cache

影响这种多端口 Cache 性能的一个关键因素就是 bank 冲突,可以采用更多的 bank 来缓解这个问题,使 bank 冲突发生的概率尽可能降低,并且还可以提高 bank 的利用效率,避免有用的数据都集中在一个 bank 的情况发生 ( 这个功能或许还需要编译器的配合才可以实现 ), 同时,由于每个端口都会访问所有的 bank, 这需要更多的布线资源,有可能对版图的设计造成一定的影响,在实际的设计中需要加以注意。不管怎么说,使用 Multi-banking 的方式来实现多端口的 Cache, 相比于前面的两种实现方式,确实可以使 Cache 的总面积降低,而且不会对处理器的周期时间产生太严重的负面影响,因此在现代的处理器中得到了广泛的应用9,2.3.4 节将对 AMD Opteron 处理器的多端口 D-Cache 进行介绍。

2.3.4 真实的例子:AMD Opteron 的多端口 Cache9

AMD 的 Opteron(皓龙) 系列处理器是 64 位的处理器,也就是说,这个处理器内部的数据都是 64 位的,但是考虑到现实的需求,处理器的地址并没有使用 64 位,而是进行了简化:它的虚拟地址 (Virtual Address,VA) 是 48 位,物理地址 (Physical Address,PA) 是 40 位。其实当前的所有 64 位处理器,例如 Intel 的 Core i7,都采用了这种方式来简化地址,从而减少硅片面积。Opteron 处理器的 D-Cache 是双端口的,每个端口都是 64 位的位宽,可以直接读写 64 位的数据,双端口也就意味着这个 Cache 能够在一个周期内支持两条 load/store 指令同时进行访问,它使用了 Multi-banking 的机制来实现这个多端口的功能,其实现原理如图 2.27 所示。

图 2.27 AMD Opteron 处理器中的多端口 Cache

图 2.27 AMD Opteron 处理器中的多端口 Cache

在 AMD Opteron 处理器的这个 Cache 中,数据块 (data block) 的大小是 64 字节,需要 6 位地址才可以对其进行寻址,找到其中的某个字节;每个数据块被分为 8 个独立的 bank,每个 bank 都是 64 位的单端口 SRAM, 如果在一个周期内,Cache 的两个端口访问不同的 bank,那么就完全没有问题,只有这两个端口恰巧访问同一个 bank 时,才会产生冲突,此时 Cache 就无法支持两个端口同时操作了。

整个 Cache 的大小是 64KB, 采用了两路组相连 (2-way set-associative) 的结构,因此每一路的大小是 32KB; 使用了 “Virtually-indexed, Physically-tagged” 的实现方式,因此可以直接使用虚拟地址 (VA) 来寻址 Cache, 关于这种 Cache 的原理,本书的后文会进行详细的介绍。每一路由于是 32KB 的大小,因此需要 VA[14:0] 才可以寻址,又因为每个数据块的大小是 64 字节,因此寻址其中的每个字节需要使用 VA[5:0], 剩下的 VA[14:6] 用来找到每个 Cache set, 因此,用来寻址 Cache 的 VA[14:0] 可以进行如图 2.28 所示的划分。

图 2.28 寻址 Cache 的地址

图 2.28 寻址 Cache 的地址

由于每个 Cache line 中的数据块被划分为 8 个 bank, 每个 bank 是一个 64 位宽的 SRAM, 所以很自然地使用 VA[5:3] 来找到某个 bank, 剩下的 VA[2:0] 用来从 64 位的数据中找到某个字节,这种方式将两个连续的 64 位数据放到两个相邻的不同 bank 中,利用 Cache 当中的空间相关性原理,使得对这两个 64 位数据的访问落在两个不同的 bank 中。

由 Cache 的原理还可以看出,对于这个大小为 64KB、两路组相连结构的 Cache 来说,每一路都有 8 个大小为 4KB 的 bank,整个 Cache 共有 16 个 4KB 大小的 bank,由于 Cache 的每个端口在访问的时候,都会同时访问两个 way 中的数据,然后根据 Tag 的比较结果来从两个 way 中选择命中的那个,所以 Cache 的一个端口在访问的时候,会同时访问到两个 bank,每个 way 各一个。只要 Cache 的两个端口在访问的时候没有指向同一个 bank, 就不会产生冲突,也就是可以实现双端口 Cache 的功能。

每一个 way 的 Cache line 中都有一个 Tag 部分,它其中保存了物理地址的 PFN,也就是 PA[39:12], 当虚拟地址 (VA) 经过 TLB 转化为物理地址 (PA) 之后,使用 PA[39:12] 和两个 way 中对应的 Tag 部分进行比较,用来判断哪一个 way 是命中的。在 Opteron 处理器的这个 Cache 中,为每个端口都配备了一个 TLB, 因此处理器中有两个单端口的 TLB; 每个端口都需要同时读取两个 way 的 Tag 进行比较,理论上来讲,每一个 way 的 Tag SRAM 都需要支持两个读端口,这样可能会对处理器的周期时间产生一定的负面影响,因此这个 Cache 采用了将 Tag SRAM 进行复制的方法来实现这个功能。

在支持虚拟存储器的处理器中,最常见的页面 (page) 大小是 4KB, 这需要使用虚拟地址的 [11:0] 来寻址每个页面内部,因此对于 48 位的虚拟地址来说,剩下的 [47:12] 就作为 VPN 来寻址 TLB, 得到对应物理地址中的 PFN[39:12], 它用来和 Cache 中对应的 Tag 部分进行比较,以判断是否命中,这就是 “Virtually-indexed, Physically-tagged” 类型 Cache 的工作过程,后文会进行详细的介绍。

还可以看出,对于一个两路组相连结构的 Cache 来说,相比于单端口的实现方式,两个端口的实现方式所需要的控制逻辑电路基本上扩大了一倍,需要两个 TLB,两个 Tag 比较电路,还需要两倍的 Tag 存储器(Opteron 处理器采用了将 Tag SRAM 复制一份的方法来实现双端口的 SRAM,当然也可以采用真实的双端口 SRAM 来实现这个功能,面积也不会小多少,速度还会变慢)。除了 Cache 中存储数据的 Data SRAM 没有被复制之外,其他的电路基本上都被复制了一份,因此采用 Multi-banking 方式来实现的双端口的 Cache,面积会增大很多,但是它的好处是速度比较快,对处理器的周期时间有比较小的负面影响,因此在现实世界的处理器中得到了广泛的应用。

总体来看,在 AMD Opteron 处理器的双端口 D-Cache 中,由于每个 bank 都是单端口的 SRAM,这样的设计可以保持面积和速度的优化,而 bank 数量的增多也大大减少了产生冲突的概率,设想一下,如果每个数据块都采用两个 bank 的设计,每个 bank 仍旧是单端口的 SRAM,那么这时候 Cache 的两个端口可能会经常落到同一个 bank 中,产生 bank 冲突的概率就高很多了。

2.4 超标量处理器的取指令

如果一个超标量处理器在每周期可以同时解码四条指令,这个处理器就称为 4-way 的超标量处理器,这个处理器每周期应该能够从 I-Cache 中至少取得四条指令,这样才能够“喂饱”后续的流水线,如果少于这个值,则会造成后面流水线的浪费。一般来说,对于一个 $n$-way 的超标量处理器来说,它给出一个取指令的地址后,I-Cache 应该能够至少送出 $n$ 条指令,称这 $n$ 条指令为一组(fetch group)。I-Cache 如何才能够支持这个功能?可能最简单的方法就是使数据块(data block)的大小为 $n$ 个字,每周期将其全部进行输出,如图 2.29 所示。

图 2.29 将 Data block 中的所有指令都进行输出

图 2.29 将 Data block 中的所有指令都进行输出

在图 2.29 中,如果处理器送出的取指令地址是 $n$ 字对齐的,那么此时就可以实现每周期从 I-Cache 中读取 $n$ 条指令的功能,在数据块(data block)部分需要 $n$ 个 32 位的 SRAM,当 I-Cache 命中时,这些 SRAM 会同时进行输出。当然,这是一种最理想的情况,实际当中,由于存在跳转指令,处理器送出的取指令地址不可能总是 $n$ 字对齐的,此时的情况如图 2.30 所示(假设 $n$ 为 4)1。

如图 2.30 所示,当取指令的地址不再是四字对齐时,一个组(fetch group)中的指令就可能落在两个 Cache line 中,但是对于 Cache 来说,每周期只能够访问一个 Cache line,这会导致在一个周期内无法取出四条指令,例如在图 2.30 中只能取出三条指令,这种情况会导致后续的流水线无法得到充足的指令,使部分资源 “空置”。可以简单地计算出这种类型的 Cache 每周期平均可以取出多少条指令,假设在取指令的组 (fetch group) 中,第一条指令的位置是随机的,当这条指令位于偏移量为 00 的位置时,可以在一个周期内取出四条指令;当它位于偏移量为 01 的位置时,可以在一个周期内取出三条指令 ……,以此类推,那么实际上每周期能够取出的指令个数是 $\frac14\times4+\frac14\times3+\frac14\times2+\frac14\times1=2.5$。

图 2.30 一个周期内要取出的指令位于两个 Cache line 中

图 2.30 一个周期内要取出的指令位于两个 Cache line 中

也就是说,图 2.30 所示的这个 Cache 在每周期内平均可以取出 2.5 条指令,很显然,对于一个 4-way 的超标量处理器来说,这个数量是不够的。但是从另一个方面来说,如果对一个 2-way 的超标量处理器采用这样的 Cache, 就没有问题了,其实很多处理器正是采用了这种方法,使处理器每周期取出的指令个数多于它能够解码的指令个数,通过一个缓存来将多余的指令缓存起来,这样就可以使后续的流水线得到充足的指令,避免了硬件资源的浪费。例如 MIPS 74Kf 处理器就采用了这种方法10,它是一个 2-way 的超标量处理器,当取指令的地址是四字对齐时,每周期可以从 I-Cache 中取出四条指令,这些指令会存储到一个称为指令缓存 (Instruction Buffer,IB) 的地方,后续的指令解码器会从指令缓存中取指令,这样即使有些时候不能够从 I-Cache 中取出两条指令,仍能够保证后续的流水线从指令缓存中可以取到充足的指令。

其实,上面的分析是比较悲观的,图 2.30 所示的这种 Cache 在实际的应用中,每周期平均可以取出的指令个数会多于 2.5 条,因为即使当前周期送出的取指令地址不是四字对齐的,下个周期取指令的地址也会变成四字对齐,此时就可以在一周期内取出四条指令了。因此综合看起来,指令组 (fetch group) 中的第一条指令位于数据块中第一个字的概率是要大于在其他位置的概率,这样就使每周期平均可以取出的指令个数大于上面计算出的 2.5 个,这个过程如图 2.31 所示。

从图 2.31 可以看出,只有在取指令的地址不是四字对齐的那一个周期,才可能出现无法取出四条指令的情况,在以后的周期中,只要不出现引起指令的执行顺序改变的情况,例如分支指令和异常 (exception) 等情况,取指令的地址会一直按照四字对齐的方式进行增加。当然,如果分支指令或者异常等情况出现得比较频繁,取指令的地址就不能够保证总是四字对齐的了。其实在现实世界的程序中,分支指令出现的频率还是比较高的,这一方面会导致取指令的地址无法四字对齐,另一方面还会在分支指令执行的时候导致过多的无用指令进入流水线中,因此需要对分支指令进行预测,这些内容将在后文进行详细的介绍。不管怎么说,对于 2-way 的超标量处理器,使用图 2.31 所示的每周期可以取出四条指令的I-Cache, 是可以满足性能要求的。

图 2.31 很多情况都能取出四条指令

图 2.31 很多情况都能取出四条指令

其实,上述的 Cache 仍然可以进行改进,即使取指令的地址不是四字对齐的,仍旧可以在一个周期内读取四条指令,如何进行实现呢?最简单的方法就是使数据块 (data block) 变大,例如使其包含八个字,只要取指令的地址不是落在数据块的最后三个字上,就可以在每周期内读取四条指令,这个过程如图 2.32 所示。

图 2.32 从八个字的 Cache line 中,每周期取出四条指令的情况

图 2.32 从八个字的 Cache line 中,每周期取出四条指令的情况

图中计算式转写:

\[\frac58\times4+\frac18\times3+\frac18\times2+\frac18\times1=3.25\text{ instructions}\]

由于每周期只能访问一个 Cache line, 所以当取指令的地址落在这个 line 的最后三个字时,不能够在当前周期从下个 line 中继续取指令 ( 在当前周期只能判断一个 Cache line 是否命中 ),此时就不能够取出四条指令了,从图 2.32 可以看出,采用八个字的数据块,可以使每个周期平均取出 3.25 条指令,这已经好于上面的 2.5 条指令的平均值了,当然,这样做的前提是增大了数据块的容量,如果在 Cache 的总容量一定的情况下,这也就意味着 Cache set 的个数会减少,这样也可能增大 Cache 缺失的概率。综合来看,虽然这种方法增大了每周期可以取出的指令个数,但是可能引起更高的缺失率,因此究竟最后对于处理器的性能有无提升,是需要根据实际情况进行分析才可以知道的。

而且,如果 Cache 的每个数据块的大小是八个字,那么是不是也就需要使用八个 32 位的 SRAM 来实现这个 Cache 呢?当然这样做没问题,但是考虑到在实际的版图设计时,每块 SRAM 周围都需要摆放一圈保护电路,如果 SRAM 的个数过多,会导致这些保护电路也占用过多的面积,而且,要从八个 SRAM 的输出中选出四个字也是一件很浪费电路的事情,需要大量的布线资源和多路选择器电路,因此在实际当中,仍旧会使用四个 SRAM 来实现一个大小为八个字的数据块,如图 2.33 所示。

图 2.33 使用四块 SRAM 组成的 Cache

图 2.33 使用四块 SRAM 组成的 Cache

在图 2.33 所示的这种 Cache 中,一个 Cache line 中包含的八个字实际上占据了 SRAM 的两行,因此共使用了四个 32 位的 SRAM,当然,一个 Cache line 仍旧只包含一个 Tag 值,这样每次 Cache 命中时,每个 SRAM 的两行数据都是有效的,可以从这八个字中按照图 2.33 的方式选择四条指令,只是此时,有些 SRAM 需要读取第一行,有些 SRAM 需要读取第二行,因此需要一个电路来控制每个 SRAM 的读地址,同时,这四个 SRAM 的输出并不是按照指令原始的顺序进行排列的,例如从图 2.33 可以看出,第一个 SRAM ( 即 SRAM0) 输出的指令在指令组 (fetch group) 中是最靠后的,因此需要一段重排序的逻辑电路,对四个 SRAM 输出的四条指令进行重排序,使它们满足最原始的指令顺序,这样才能够使后续的流水线得到正确的指令。

从上面的描述可以看出,上面的这个 Cache 需要两个额外的控制电路,一个控制电路用来产生每个 SRAM 的读地址,例如在图 2.33 中,当寻址 Cache line 的地址是 001 时,SRAM1、SRAM2 和 SRAM3 需要读取第一行的内容,而 SRAM0 需要读取第二行的内容,当然,真正要生成读取 SRAM 的地址,还需要 Index 部分的配合才可以实现,Index 部分用来寻址整个 Cache, 从而找到对应的 Cache Set; 第二个控制电路用来将四个 SRAM 输出的内容进行重排序,使其按照程序中规定的原始顺序进行排列,从图 2.33 可以看出,四个 SRAM 输出的指令并不是按照程序中指定的顺序进行排列的,Cache line 中第一个被寻址到的指令是最老的,而最后一个被取出的指令是最新的,这就需要按照取指令的地址,将每个 SRAM 的输出送到规定的地方,这需要四个多路选择器,每个都是四选一,使用取指令的地址进行控制,对于图 2.33 所示的 Cache 来说,重排序逻辑电路如图 2.34 所示。

图 2.34 重排序逻辑电路

图 2.34 重排序逻辑电路

在图 2.34 中,假设 SRAM0~SRAM3 的输出分别是 out0~out3,当前取指令地址所指向的 SRAM 输出的指令是最旧的,应该排在指令组 (fetch group) 的第一个,其他 SRAM 输出的指令按照顺序依次向后排列,需要注意的是,当取指令的地址指向了 Cache line 中最后三条指令的某一个时,此时在本周期并不能输出四条指令,因此在重排序逻辑电路中还需要加入指示每条指令是否有效的标志信号,这样才能够将有效的指令写入到后续的指令缓存(Instruction Buffer)中。

如果在处理器中实现了分支预测的功能,那么上述取指令的过程还要更复杂一些,为什么要实现分支预测呢?这是因为在超标量处理器中,并行度很高,流水线也比较深,一条分支指令从进入流水线直到结果被计算出来,中间会经历很多流水线的阶段,如果使用最简单的静态预测方法 (例如预测所有的分支指令都是不执行的 ), 只有当分支指令的结果被计算出来之后,才根据实际结果进行修复,那么一旦发现这条分支指令是需要执行的,就需要将流水线中,在分支指令之后进入流水线的所有指令都抹掉,也就是这段时间做了无用功,这是一件非常影响处理器性能的事情,如果能够提前知道在本周期取出的指令中,哪条指令是分支指令,并且可以 “预知”这条分支指令的结果,那么就可以减少流水线被打断的次数,这就是为什么要在超标量处理器中实现分支预测的原因,关于这部分内容的详细介绍将在后文展开。

如果处理器实现了分支预测,那么对于取指令阶段有什么影响呢?实现分支预测之后,从 I-Cache 中取指令的同时,就已经可以知道当前指令组 (fetch group) 中哪条指令是分支指令,如果它被预测执行,那么指令组中位于它之后的指令就不应该进入到后续的流水线,

如图 2.35 所示。

当然,超标量处理器的取指令阶段所做的事情远不止如此,在实现了虚拟存储器(Virtual Memory) 的处理器中,处理器送出的取指令地址是虚拟地址 (virtual address),需要使用一定的方法将其转换为物理地址 (physical address),然后才能够从存储器中取指令,在这个转换的过程中可能发生很多事情,它们都可以打断流水线的正常执行,这部分内容将在第 3 章进行详细的介绍。

图 2.35 加入分支预测之后的取指令

图 2.35 加入分支预测之后的取指令

本站所有文章转发 CSDN 将按侵权追究法律责任,其它情况随意。