Linux内核分析--Intel CPU体系结构

2023-02-20 00:00:00 数据 执行 缓存 指令 流水线

看了《计算机系统结构》、《深入理解计算机系统》、《大话处理器》等经典书籍,也在google上搜了一大堆资料,前前后后、断断续续的折腾了一个多月,终于想通了,现在把自己的思想心得记录下来,希望对有这方面困惑的朋友有些帮助。

本文主要关注以下几个问题。

  • 什么是CPU的流水线?为什么需要流水线?
  • 为什么需要内存屏障?在只有单个Core的CPU中是否还需要内存屏障?
  • 什么是乱序执行?分为几种?
  • MOB和ROB是干什么的?
  • load buffer和store buffer的功能是什么?
  • x86和arm、power中的memory model有什么区别?
  • MESI主要是做什么的?
  • meldown漏洞的原理是什么?

一、CPU指令的执行过程

几乎所有的冯·诺伊曼型计算机的 CPU,其工作都可以分为 5 个阶段:取指令、指令译码、执行指令、访存取数、结果写回。

图1 CPU指令的执行阶段

1.取指令阶段

取指令(Instruction Fetch,IF)阶段是将一条指令从主存中取到指令寄存器的过程。 程序计数器 PC 中的数值,用来指示当前指令在主存中的位置。当一条指令被取出后,PC 中的数值将根据指令字长度而自动递增:若为单字长指令,则(PC)+1->PC;若为双字长指令,则(PC)+2->PC,依此类推。

2.指令译码阶段

取出指令后,计算机立即进入指令译码(Instruction Decode,ID)阶段。 在指令译码阶段,指令译码器按照预定的指令格式,对取回的指令进行拆分和解释,识别区分出不同的指令类别以及各种获取操作数的方法。在组合逻辑控制的计算机中,指令译码器对不同的指令操作码产生不同的控制电位,以形成不同的微操作序列;在微程序控制的计算机中,指令译码器用指令操作码来找到执行该指令的微程序的入口,并从此入口开始执行。 在传统的设计里,CPU中负责指令译码的部分是无法改变的。不过,在众多运用微程序控制技术的新型 CPU 中,微程序有时是可重写的。

3.执行指令阶段

在取指令和指令译码阶段之后,接着进入执行指令(Execute,EX)阶段。 此阶段的任务是完成指令所规定的各种操作,具体实现指令的功能。为此,CPU 的不同部分被连接起来,以执行所需的操作。 例如,如果要求完成一个加法运算,算术逻辑单元 ALU 将被连接到一组输入和一组输出,输入端提供需要相加的数值,输出端将含有后的运算结果。

4.访存取数阶段

根据指令需要,有可能要访问主存,读取操作数,这样就进入了访存取数(Memory,MEM)阶段。 此阶段的任务是:根据指令地址码,得到操作数在主存中的地址,并从主存中读取该操作数用于运算。

5.结果写回阶段

作为后一个阶段,结果写回(Writeback,WB)阶段把执行指令阶段的运行结果数据“写回”到某种存储形式:结果数据经常被写到 CPU 的内部寄存器中,以便被后续的指令快速地存取;在有些情况下, 结果数据也可被写入相对较慢、但较廉价且容量较大的主存。许多指令还会改变程序状态字寄存器中标志位 的状态,这些标志位标识着不同的操作结果,可被用来影响程序的动作。

在指令执行完毕、结果数据写回之后,若无意外事件(如结果溢出等)发生,计算机就接着从程序计数器 PC 中取得下一条指令地址,开始新一轮的循环,下一个指令周期将顺序取出下一条指令。许多新型 CPU 可以同时取出、译码和执行多条指令,体现并行处理的特性。

二、CPU指令流水线

在任一条指令的执行过程中,各个功能部件都会随着指令执行的进程而呈现出时忙时闲的现象。要加快计算机的工作速度,就应使各个功能部件并行工作,即以各自可能的高速度同时、不停地工作,使得各部件的操作在时间上重叠进行,实现流水式作业。 从原理上说,计算机的流水线(Pipeline)工作方式就是将一个计算任务细分成若干个子任务每个子任务都由专门的功能部件进行处理,一个计算任务的各个子任务由流水线上各个功能部件轮流进行处理 (即各子任务在流水线的各个功能阶段并发执行),终完成工作。这样,不必等到上一个计算任务完成, 就可以开始下一个计算任务的执行。 流水线的硬件基本结构如图2所示。流水线由一系列串联的功能部件(Si)组成,各个功能部件之间设有高速缓冲寄存器(L),以暂时保存上一功能部件对子任务处理的结果,同时又能够接受新的处理任务。在一个统一的时钟(C)控制下,计算任务从功能部件的一个功能段流向下一个功能段。在流水线中, 所有功能段同时对不同的数据进行不同的处理,各个处理步骤并行地操作。

图2 流水线的硬件基本结构

当任务连续不断地输入流水线时,在流水线的输出端便连续不断地输出执行结果,流水线达到不间断流水的稳定状态,从而实现了子任务级的并行

当指令流不能顺序执行时,流水过程会中断(即断流)。为了保证流水过程的工作效率,流水过程不应经常断流。在一个流水过程中,实现各个子过程的各个功能段所需要的时间应该尽可能保持相等,以避免产生瓶颈,导致流水线断流。

流水线技术本质上是将一个重复的时序过程分解成若干个子过程,而每一个子过程都可有效地在其专用功能段上与其他子过程同时执行。采用流水线技术通过硬件实现并行操作后,就某一条指令而言,其执行速度并没有加快,但就程序执行过程的整体而言,程序执行速度大大加快。

流水线技术适合于大量的重复性的处理。

前面我提到过CPU 中一个指令周期的任务分解。假设指令周期包含取指令(IF)、指令译码(ID)、 指令执行(EX)、访存取数(MEM)、结果写回(WB)5 个子过程(过程段),流水线由这 5个串联的过程段 组成,各个过程段之间设有高速缓冲寄存器,以暂时保存上一过程段子任务处理的结果,在统一的时钟信号控制下,数据从一个过程段流向相邻的过程段。

非流水计算机的时空图如下:

图3 非流水计算机时空图

对于非流水计算机而言,上一条指令的 5 个子过程全部执行完毕后才能开始下一条指令,每隔 5 个时 钟周期才有一个输出结果。因此,图3中用了 15 个时钟周期才完成 3 条指令,每条指令平均用时 5 个时钟周期。 非流水线工作方式的控制比较简单,但部件的利用率较低,系统工作速度较慢。

  • 标量流水计算机工作方式

标量(Scalar)流水计算机是只有一条指令流水线的计算机。图 4表示标量流水计算机的时空图。

图4 标量流水计算机时空图

对标量流水计算机而言,上一条指令与下一条指令的 5 个子过程在时间上可以重叠执行,当流水线满 载时,每一个时钟周期就可以输出一个结果。因此,图4中仅用了 9 个时钟周期就完成了 5 条指令,每条指令平均用时 1.8 个时钟周期

采用标量流水线工作方式,虽然每条指令的执行时间并未缩短,但 CPU 运行指令的总体速度却能成倍 提高。当然,作为速度提高的代价,需要增加部分硬件才能实现标量流水。

  • 超标量流水计算机工作方式

一般的流水计算机因只有一条指令流水线,所以称为标量流水计算机。所谓超标量(Superscalar)流 水计算机,是指它具有两条以上的指令流水线。图 5表示超标量流水计算机的时空图。

图5 超标量流水计算机时空图

当流水线满载时,每一个时钟周期可以执行 2 条以上的指令。因此,图5中仅用了 9 个时钟周期就完成了 10 条指令,每条指令平均用时 0.9 个时钟周期。 超标量流水计算机是时间并行技术和空间并行技术的综合应用。

三、指令的相关性

指令流水线的一个特点是流水线中的各条指令之间存在一些相关性,使得指令的执行受到影响。要使流水线发挥高效率,就要使流水线连续不断地流动,尽量不出现断流情况。然而,由于流水过程中存在的相关性冲突,断流现象是不可避免的

1.数据相关

在流水计算机中,指令的处理是重叠进行的,前一条指令还没有结束,第二、三条指令就陆续开始工 作。由于多条指令的重叠处理,当后继指令所需的操作数刚好是前一指令的运算结果时,便发生数据相关冲突。由于这两条指令的执行顺序直接影响到操作数读取的内容,必须等前一条指令执行完毕后才能执行后一条指令。在这种情况下,这两条指令就是数据相关的。因此,数据相关是由于指令之间存在数据依赖性而引起的。根据指令间对同一寄存器读和写操作的先后次序关系,可将数据相关性分为写后读(Read-AfterWrite,RAW)相关、读后写(Write-After-Read,WAR)相关、写后写(Write-After-Write,WAW)相关三种类型。

解决数据相关冲突的办法如下:

⑴采用编译的方法 编译程序通过在两条相关指令之间插入其他不相关的指令(或空操作指令)而推迟指令的执行,使数据相关消失,从而产生没有相关性的程序代码。这种方式简单,但降低了运行效率。

⑵由硬件监测相关性的存在,采用数据旁路技术设法解决数据相关 当前一条指令要写入寄存器而下一条指令要读取同一个寄存器时,在前一条指令执行完毕、结果数据还未写入寄存器前,由内部数据通路把该结果数据直接传递给下一条指令,也就是说,下一条指令所需的 操作数不再通过读取寄存器获得,而是直接获取。这种方式效率较高,但控制较为复杂。

2.资源相关

所谓资源相关,是指多条指令进入流水线后在同一机器周期内争用同一个功能部件所发生的冲突。 例如,在图 4所示的标量流水计算机中,在第 4 个时钟周期时,第 1 条指令处于访存取数(MEM) 阶段,而第 4 条指令处于取指令(IF)阶段。如果数据和指令存放在同一存储器中且存储器只有一个端口,这样便会发生这两条指令争用存储器的资源相关冲突。 因为每一条指令都可能需要 2 次访问存储器(读指令和读写数据),在指令流水过程中,可能会有 2 条指令同时需要访问存储器,导致资源相关冲突解决资源相关冲突的一般办法是增加资源,例如增设一个存储器,将指令和数据分别放在两个存储器中。

3.控制相关

控制相关冲突是由转移指令引起的。当执行转移指令时,依据转移条件的产生结果,可能顺序取下一 条指令,也可能转移到新的目标地址取指令。若转移到新的目标地址取指令,则指令流水线将被排空,并等待转移指令形成下一条指令的地址,以便读取新的指令,这就使得流水线发生断流。 为了减小转移指令对流水线性能的影响,通常采用以下两种转移处理技术:

⑴延迟转移法编译程序重排指令序列来实现。其基本思想是“先执行再转移”,即发生转移时并不排空指令流水线,而是继续完成下几条指令。如果这些后继指令是与该转移指令结果无关的有用指令,那么延迟损失时间片正好得到了有效的利用。

⑵转移预测法 用硬件方法来实现。依据指令过去的行为来预测将来的行为,即选择出现概率较高的分支进行预取。通过使用转移取和顺序取两路指令预取队列以及目标指令 Cache,可将转移预测提前到取指令阶段进行,以获得良好的效果。

四、指令的动态执行技术

1.指令调度

为了减少指令相关性对执行速度的影响,可以在保证程序正确性的前提下,调整指令的顺序,即进行指令调度。 指令调度可以由编译程序进行也可以由硬件在执行的时候进行,分别称为静态指令调度动态指令调度静态指令调度是指编译程序通过调整指令的顺序来减少流水线的停顿,提高程序的执行速度;动态 指令调度用硬件方法调度指令的执行以减少流水线停顿

流水线中一直采用的有序(in-order)指令启动是限制流水线性能的主要因素之一。如果有一条指令在流水线中停顿了,则其后的指令就都不能向前流动了,这样,如果相邻的两条指令存在相关性,流水线就将发生停顿,如果有多个功能部件,这些部件就有可能被闲置。消除这种限制流水线性能的因素从而提高指令执行速度,其基本思想就是允许指令的执行是无序的(out-of-order,也称乱序),也就是说,在保持指令间、数据间的依赖关系的前提下,允许不相关的指令的执行顺序与程序的原有顺序有所不同,这一思想是实行动态指令调度的前提。

2.乱序执行技术

乱序执行(Out-of-order Execution)是以乱序方式执行指令,即 CPU 允许将多条指令不按程序规定的顺序而分开发送给各相应电路单元进行处理。这样,根据各个电路单元的状态和各指令能否提前执行的具体情况分析,将能够提前执行的指令立即发送给相应电路单元予以执行,在这期间不按规定顺序执行指令;然后由重新排列单元将各执行单元结果按指令顺序重新排列。乱序执行的目的,就是为了使 CPU 内部电路满负荷运转,并相应提高 CPU 运行程序的速度

实现乱序执行的关键在于取消传统的“取指”和“执行”两个阶段之间指令需要线性排列的限制,而使用一个指令缓冲池来开辟一个较长的指令窗口,允许执行单元在一个较大的范围内调遣和执行已译码的程序指令流

3.分支预测

分支预测(Branch Prediction)是对程序的流程进行预测,然后读取其中一个分支的指令。采用分支预测的主要目的是为了提高 CPU的运算速度。 分支预测的方法有静态预测和动态预测两类:静态预测方法比较简单,如预测永远不转移、预测永远转移、预测后向转移等等,它并不根据执行时的条件和历史信息来进行预测,因此预测的准确性不可能很高;动态预测方法则根据同一条转移指令过去的转移情况来预测未来的转移情况。 由于程序中的条件分支是根据程序指令在流水线处理后的结果来执行的,所以当 CPU 等待指令结果时, 流水线的前级电路也处于等待分支指令的空闲状态,这样必然出现时钟周期的浪费。如果 CPU 能在前条指令结果出来之前就预测到分支是否转移,那么就可以提前执行相应的指令,这样就避免了流水线的空闲等待,也就相应提高了 CPU 的运算速度。但另一方面,一旦前条指令结果出来后证明分支预测是错误的,那么就必须将已经装入流水线执行的指令和结果全部清除,然后再装入正确的指令重新处理,这样就比不进行分支预测而是等待结果再执行新指令还要慢了

因此,分支预测的错误并不会导致结果的错误,而只是导致流水线的停顿,如果能够保持较高的预测 准确率,分支预测就能提高流水线的性能。

五、实例分析

前面的知识只是一个理论基础铺垫,下面我们就结合一款真实的CPU架构进行对应分析,图6和图7分别是x86和ARM体系结构的内核架构图(都是具有OoOE特性的CPU架构),可以看到他们基本的组成都是一样的(虽然x86是CISC而ARM是RISC,但是现代x86内部也是先把CISC翻译成RISC的),因此我在这里就只分析x86结构。

图6 intel Nehalem内核架构图

图7 ARM Cortex-A57内核架构图

1.取指令阶段(IF)

处理器在执行指令之前,必须先装载指令。指令会先保存在 L1 缓存的 I-cache (Instruction-cache)指令缓存当中,Nehalem 的指令拾取单元使用 128bit 带宽的通道从 I-cache 中读取指令。这个 I-cache 的大小为 32KB,采用了 4 路组相连,在后面的存取单元介绍中我们可以得知这种比 Core 更少的集合关联数量是为了降低延迟。

为了适应超线程技术,RIP(Relative Instruction Point,相对指令指针)的数量也从一个增加到了两个,每个线程单独使用一个。

指令拾取单元包含了分支预测器(Branch Predictor),分支预测是在 Pentium Pro 处理器开始加入的功能,预测如 if then 这样的语句的将来走向提前读取相关的指令并执行的技术,可以明显地提升性能。指令拾取单元也包含了 Hardware Prefetcher根据历史操作预先加载以后会用到的指令来提高性能,这会在后面得到详细的介绍。

当分支预测器决定了走向一个分支之后,它使用 BTB(Branch Target Buffer,分支目标缓冲区)来保存预测指令的地址。Nehalem 从以前的一级 BTB 升级到了两个级别,这是为了适应很大体积的程序(数据库以及 ERP 等应用,跳转分支将会跨过很大的区域并具有很多的分支)。Intel 并没有提及 BTB 详细的结构。与BTB 相对的 RSB(Return Stack Buffer,返回堆栈缓冲区)也得到了提升,RSB 用来保存一个函数或功能调用结束之后的返回地址,通过重命名的 RSB 来避免多次推测路径导致的入口/出口破坏。RSB 每个线程都有一个,一个核心就拥有两个,以适应超线程技术的存在

指令拾取单元使用预测指令的地址来拾取指令,它通过访问 L1 ITLB 里的索引来继续访问 L1 ICache,128 条目的小页面 L1 ITLB 按照两个线程静态分区,每个线程可以获得 64 个条目,这个数目比 Core 2 的少。当关闭超线程时,单独的线程将可以获得全部的 TLB 资 源。除了小页面 TLB 之外,Nehalem 还每个线程拥有 7 个条目的全关联(Full Associativity) 大页面 ITLB,这些 TLB 用于访问 2M/4M 的大容量页面,每个线程独立,因此关闭超线程不会让你得到 14 个大页面 ITLB 条目。

指令拾取单元通过 128bit 的总线将指令从 L1 ICache 拾取到一个 16Bytes(刚好就是 128bit)的预解码拾取缓冲区。128 位的带宽让人有些迷惑不解,Opteron 一早就已经使用 了 256bit 的指令拾取带宽。重要的是,L1D 和 L1I 都是通过 256bit 的带宽连接到 L2 Cache 的。

由于一般的CISC x86指令都小于4Bytes(32位x86指令;x86指令的特点就是不等长), 因此一次可以拾取 4 条以上的指令,而预解码拾取缓冲区的输出带宽是 6 指令每时钟周期, 因此可以看出指令拾取带宽确实有些不协调,特别是考虑到 64 位应用下指令会长一些的情 况下(解码器的输入输出能力是 4 指令每时钟周期,因此 32 位下问题不大)。

指令拾取结束后会送到 18 个条目的指令队列,在 Core 架构,送到的是 LSD 循环流缓冲区,在后面可以看到,Nehalem 通过将 LSD 移动后更靠后的位置来提高性能。

2.指令译码阶段(ID)

在将指令充填到可容纳 18 条目的指令队列之后,就可以进行解码工作了。解码是类 RISC (精简指令集或简单指令集)处理器导致的一项设计,从 Pentium Pro 开始在 IA 架构出现。 处理器接受的是 x86 指令(CISC 指令,复杂指令集),而在执行引擎内部执行的却不是x86 指令,而是一条一条的类 RISC 指令,Intel 称之为 Micro Operation——micro-op,或者写 为 μ-op,一般用比较方便的写法来替代掉希腊字母:u-op 或者 uop。相对地,一条一条的 x86 指令就称之为 Macro Operation或 macro-op。

RISC 架构的特点就是指令长度相等,执行时间恒定(通常为一个时钟周期),因此处理器设计起来就很简单,可以通过深长的流水线达到很高的频率,IBM 的 Power6 就可以轻松地达到 4.7GHz 的起步频率。和 RISC 相反,CISC 指令的长度不固定,执行时间也不固定,因此 Intel 的 RISC/CISC 混合处理器架构就要通过解码器 将 x86 指令翻译为 uop,从而获得 RISC 架构的长处,提升内部执行效率

和 Core 一样,Nehalem 的解码器也是 4 个(3 个简单解码器加 1 个复杂解码器)。简单解码器可以将一条 x86 指令(包括大部分 SSE 指令在内)翻译为一条 uop,而复杂解码器则将一些特别的(单条)x86 指令翻译为 1~4 条 uops——在极少数的情况下,某些指令需要通过 额外的可编程 microcode 解码器解码为更多的 uops(有些时候甚至可以达到几百个,因为 一些 IA 指令很复杂,并且可以带有很多的前缀/修改量,当然这种情况很少见),下图 Complex Decoder 左方的 ucode 方块就是这个解码器,这个解码器可以通过一些途径进行升级或者扩展,实际上就是通过主板 Firmware 里面的 Microcode ROM 部分。

之所以具有两种解码器,是因为仍然是关于 RISC/CISC 的一个事实: 大部分情况下(90%) 的时间内处理器都在运行少数的指令,其余的时间则运行各式各样的复杂指令(不幸的是, 复杂就意味着较长的运行时间),RISC 就是将这些复杂的指令剔除掉,只留下经常运行的指令(所谓的精简指令集),然而被剔除掉的那些指令虽然实现起来比较麻烦,却在某些领域确实有其价值,RISC 的做法就是将这些麻烦都交给软件,CISC 的做法则是像现在这样: 由硬件设计完成。因此 RISC 指令集对编译器要求很高,而 CISC 则很简单。对编程人员的要求也类似。


3、循环流检测

在解码为 uop 之后 Nehalem 会将它们都存放在一个叫做 uop LSD Buffer 的缓存区。在Core 2 上,这个 LSD Buffer 是出现在解码器前方的,Nehalem 将其移动到解码器后方,并相对加大了缓冲区的条目。Core 2 的 LSD 缓存区可以保存 18 个 x86 指令而 Nehalem 可以保 存 28 个 uop,从前文可以知道,大部分 x86 指令都可以解码为一个 uop,少部分可以解码 为 1~4 个 uop,因此 Nehalem 的 LSD 缓冲区基本上可以相当于保存 21~23 条x86 指令,比 Core 2 要大上一些。

LSD 循环流监测器也算包含在解码部分,它的作用是: 假如程序使用的循环段(如 for..do/do..while 等)少于 28 个 uops,那么 Nehalem 就可以将这个循环保存起来,不再需要重新通过取指单元、分支预测操作,以及解码器,Core 2 的 LSD 放在解码器前方,因此无法省下解码的工作。

Nehalem LSD 的工作比较像 NetBurst 架构的 Trace Cache,其也是保存 uops,作用也是部分地去掉一些严重的循环,不过由于 Trace Cache 还同时担当着类似于 Core/Nehalem 架构的 Reorder Buffer 乱序缓冲区的作用,容量比较大(可以保存 12k uops,准确的大小 是 20KB),因此在 cache miss 的时候后果严重(特别是在 SMT 同步多线程之后,miss 率加 倍的情况下),LSD 的小数目设计显然会好得多。不过笔者认为 28 个 uop 条目有些少,特 别是考虑到 SMT 技术带来的两条线程都同时使用这个 LSD 的时候。

在 LSD 之后,Nehalem 将会进行 Micro-ops Fusion,这也是前端(The Front-End)的后一个功能,在这些工作都做完之后,uops 就可以准备进入执行引擎了。

4.乱序执行指令阶段(OoOE)

OoOE— Out-of-Order Execution 乱序执行也是在 Pentium Pro 开始引入的,它有些类似于多线程的概念。乱序执行是为了直接提升 ILP(Instruction Level Parallelism)指令级并行化的设计,在多个执行单元的超标量设计当中,一系列的执行单元可以同时运行一些没有数据关联性的若干指令只有需要等待其他指令运算结果的数据会按照顺序执行,从而总体提升了运行效率。乱序执行引擎是一个很重要的部分,需要进行复杂的调度管理。

首先,在乱序执行架构中,不同的指令可能都会需要用到相同的通用寄存器(GPR,General Purpose Registers),特别是在指令需要改写该通用寄存器的情况下——为了让这些指令们能并行工作,处理器需要准备解决方法。一般的 RISC 架构准备了大量的GPR, 而x86 架构天生就缺乏 GPR(x86具有8个GPR,x86-64 具有 16 个,一般 RISC 具有 32 个,IA64 则具有 128 个),为此 Intel 开始引入重命名寄存器(Rename Register),不同的指令可以通过具有名字相同但实际不同的寄存器来解决。

此外,为了 SMT 同步多线程,这些寄存器还要准备双份,每个线程具有独立的一份。

乱序执行从Allocator定位器开始,Allocator 管理着RAT(Register Alias Table,寄存器别名表)ROB(Re-Order Buffer,重排序缓冲区)RRF(Retirement Register File,退回寄存器文件)。在 Allocator 之前,流水线都是顺序执行的,在 Allocator 之后,就可以进入乱序执行阶段了。在每一个线程方面,Nehalem 和 Core 2 架构相似,RAT 将重命名的、虚拟的寄存器(称为 Architectural Register 或 Logical Register)指向ROB 或者RRFRAT 是一式两份,每个线程独立每个 RAT 包含了 128 个重命名寄存器RAT 指向在 ROB 里面的近的执行寄存器状态,或者指向RRF保存的终的提交状态。

ROB(Re-Order Buffer,重排序缓冲区)是一个非常重要的部件,它是将乱序执行完毕的指令们按照程序编程的原始顺序重新排序的一个队列,以保证所有的指令都能够逻辑上实现正确的因果关系打乱了次序的指令们(分支预测、硬件预取)依次插入这个队列,当一条指令通过 RAT 发往下一个阶段确实执行的时候这条指令(包括寄存器状态在内)将被加入 ROB 队列的一端,执行完毕的指令(包括寄存器状态)将从 ROB 队列的另一端移除(期间这些指令的数据可以被一些中间计算结果刷新),因为调度器是 In-Order 顺序的,这个队列(ROB)也就是顺序的从 ROB 中移出一条指令就意味着指令执行完毕了,这个阶段叫做 Retire 回退,相应地 ROB 往往也叫做 Retirement Unit(回退单元),并将其画为流水线的后一部分

在一些超标量设计中,Retire 阶段会将 ROB 的数据写入 L1D 缓存(这是将MOB集成到ROB的情况),而在另一些设计里, 写入 L1D 缓存由另外的队列完成。例如,Core/Nehalem 的这个操作就由 MOB(Memory Order Buffer,内存重排序缓冲区)来完成。

ROB 是乱序执行引擎架构中都存在的一个缓冲区,重新排序指令的目的是将指令们的寄存器状态依次提交到RRF退回寄存器文件当中以确保具有因果关系的指令们在乱序执行中可以得到正确的数据从执行单元返回的数据会将先前由调度器加入ROB 的指令刷新数据部分标志为结束(Finished),再经过其他检查通过后才能标志为完毕(Complete)一旦标志为完毕,它就可以提交数据并删除重命名项目并退出ROB 。提交状态的工作由 Retirement Unit(回退单元)完成,它将确实完毕的指令包含的数据写入RRF(“确实” 的意思是,非猜测执性、具备正确因果关系,程序可以见到的终的寄存器状态)。和 RAT 一样,RRF 也同时具有两个,每个线程独立。Core/Nehalem 的 Retirement Unit 回退单元每时钟周期可以执行 4 个 uops 的寄存器文件写入,和 RAT 每时钟 4 个 uops 的重命名一致。

由于 ROB 里面保存的指令数目是如此之大(128 条目),因此一些人认为它的作用是用来从中挑选出不相关的指令来进入执行单元,这多少是受到一些文档中的 Out-of-Order Window 乱序窗口这个词的影响(后面会看到ROB 会和 MOB 一起被计入乱序窗口资源中)。

ROB 确实具有 RS 的一部分相似的作用,不过,ROB 里面的指令是调度器(dispacher)通过 RAT发往 RS 的同时发往ROB的(里面包含着正常顺序的指令和猜测执行的指令,但是乱序执行并不是从ROB中乱序挑选的),也就是说,在“乱序”之前,ROB 的指令就已经确定了指令并不是在 ROB 当中乱序挑选的(这是在RS当中进行),ROB 担当的是流水线的终阶段: 一个指令的 Retire回退单元;以及担当中间计算结果的缓冲区。
RS(Reservation Station,中继站): 等待源数据到来以进行OoOE乱序执行(没有数据的指令将在 RS 等待), ROB(ReOrder Buffer,重排序缓冲区): 等待结果到达以进行 Retire 指令回退 (没有结果的指令将在 ROB等待)。

Nehalem 的 128 条目的 ROB 担当中间计算结果的缓冲区它保存着猜测执行的指令及其数据猜测执行允许预先执行方向未定的分支指令。在大部分情况下,猜测执行工作良好——分支猜对了,因此其在 ROB 里产生的结果被标志为已结束,可以立即地被后继指令使用而不需要进行 L1 Data Cache 的 Load 操作(这也是 ROB 的另一个重要用处,典型的 x86 应用中 Load 操作是如此频繁,达到了几乎占 1/3 的地步,因此 ROB 可以避免大量的Cache Load 操作,作用巨大)。在剩下的不幸的情况下,分支未能按照如期的情况进行,这时猜测的分支指令段将被清除,相应指令们的流水线阶段清空,对应的寄存器状态也就全都了,这种的寄存器状态不会也不能出现在 RRF 里面。

重命名技术并不是没有代价的,在获得前面所说的众多的优点之后,它令指令在发射的时候需要扫描额外的地方来寻找到正确的寄存器状态,不过总体来说这种代价是非常值得的。RAT可以在每一个时钟周期重命名 4 个 uops 的寄存器,经过重命名的指令在读取到正确的操作数并发射到统一的RS(Reservation Station,中继站,Intel 文档翻译为保留站点) 上。RS 中继站保存了所有等待执行的指令。

和 Core 2 相比,Nehalem 的 ROB 大小和 RS 大小都得到了提升,ROB 重排序缓冲区从 96 条目提升到 128 条目(鼻祖 Pentium Pro 具有 40 条),RS 中继站从 32 提升到 36(Pentium Pro 为 20),它们都在两个线程(超线程中的线程)内共享,不过采用了不同的策略:ROB 是采用了静态的分区方法,而 RS 则采用了动态共享,因为有时候会有一条线程内的指令因 等待数据而停滞,这时另一个线程就可以获得更多的 RS 资源。停滞的指令不会发往 RS,但是仍然会占用 ROB 条目。由于 ROB 是静态分区,因此在开启 HTT 的情况下,每一个线程只能 分到 64 条,不算多,在一些极少数的应用上,我们应该可以观察到一些应用开启 HTT 后会 速度降低,尽管可能非常微小。

5、执行单元

在为 SMT 做好准备工作并打乱指令的执行顺序之后(指的是分支预测、硬件预取),uops 通过每时钟周期 4 条的速度进入 Reservation Station 中继站(保留站),总共 36 条目的中继站 uops 就开始等待超标量(Superscaler)执行引擎乱序执行了。自从 Pentium 开始,Intel 就开始在处理器里面采用了超标量设计(Pentium 是两路超标量处理器),超标量的意思就是多个执行单元,它可以同时执行多条没有相互依赖性的指令,从而达到提升 ILP 指令级并行化的目的。Nehalem 具备 6 个执行端口,每个执行端口具有多个不同的单元以执行不同的任务,然而同一时间只能有一条指令(uop)进入执行端口,因此也可以认为 Nehalem 有 6 个“执行单元”,在每个时钟周期内可以执行多 6 个操作(或者说,6 条指令),和 Core 一样。

36 条目的中继站指令在分发器的管理下,挑选出尽量多的可以同时执行的指令(也就是乱序执行的意思)——多 6 条——发送到执行端口。 这些执行端口并不都是用于计算,实际上,有三个执行端口是专门用来执行内存相关的操作的,只有剩下的三个是计算端口,因此,在这一点上 Nehalem 实际上是跟 Core 架构一 样的,这也可以解释为什么有些情况下,Nehalem 和 Core 相比没有什么性能提升。

计算操作分为两种: 使用 ALU(Arithmetic Logic Unit,算术逻辑单元)的整数(Integer) 运算和使用 FPU(Floating Point Unit,浮点运算单元)的浮点(Floating Point)运算。SSE 指令(包括 SSE1 到 SSE4)是一种特例,它虽然有整数也有浮点,然而它们使用的都是 128bit 浮点寄存器,使用的也大部分是 FPU 电路。在 Nehalem 中,三个计算端口都可以做整数运算(包括 MMX)或者SSE 运算(浮点运算不太一样,只有两个端口可以进行浮点 ADD 和 MUL/DIV 运算,因此每时钟周期多进行 2 个浮点计算,这也是目前 Intel 处理器浮点性能不如整数性能突出的原因),不过每一个执行端口都不是完全一致:只有端口 0 有浮点乘和除功能,只有端口 5 有分支能力(这个执行单元将会与分支预测单元连接),其他 FP/SSE 能力也不尽相同,这些不对称之处都由统一的分发器来理解,并进行指令的调度管理。没有采用完全对称的设计可能是基于统计学上的考虑。和 Core 一样,Nehalem 的也没有采用 Pentium 4 那样的 2 倍频的 ALU 设计(在 Pentium 4,ALU 的运算频率是 CPU 主频的两倍, 因此整数性能明显要比浮点性能突出)。

不幸的是,虽然可以同时执行的指令很多,然而在流水线架构当中运行速度并不是由 “宽”的单元来决定的,而是由“窄”的单元来决定的。这就是木桶原理,Opteron的解码器后端只能每时钟周期输出 3 条 uops,而 Nehalem/Core2 则能输出 4 条,因此它们的实际大每时钟运行指令数是 3/4,而不是 6。同样地,多少路超标量在这些乱序架构处理器中也不再按照运算单元来划分,Core Duo 及之前(到 Pentium Pro 为止)均为三路超标量处理器,Core 2/Nehalem 则为四路超标量处理器。可见在微架构上,Nehalem/Core 显然是 要比其他处理器快一些。顺便说一下,这也是 Intel 在超线程示意图中,使用 4 个宽度的方 块来表示而不是 6 个方块的原因。

6、存取单元

运算需要用到数据,也会生成数据,这些数据存取操作就是存取单元所做的事情,实际 上,Nehalem 和 Core 的存取单元没什么变化,仍然是 3 个。

三个存取单元中,一个用于所有的 Load 操作(地址和数据),一个用于 Store 地址,一个用于 Store 数据,前两个数据相关的单元带有 AGU(Address Generation Unit,地址生成单元)功能(NetBurst架构使用快速 ALU 来进行地址生成)。

在乱序架构中,存取操作也可以打乱进行类似于指令预取一样Load/Store 操作也可以提前进行以降低延迟的影响,提高性能。然而,由于Store操作会修改数据影响后继的Load 操作,而指令却不会有这种问题(寄存器依赖性问题通过ROB解决),因此数据的乱序操作更为复杂。

如上图所示,条 ALU 指令的运算结果要 Store 在地址 Y(第二条指令),而第九条 指令是从地址 Y Load 数据,显然在第二条指令执行完毕之前,无法移动第九条指令,否则将会产生错误的结果。同样,如果CPU也不知道第五条指令会使用什么地址,所以它也无法确定是否可以把第九条指令移动到第五条指令附近。

内存数据相依性预测功能(Memory Disambiguation)可以预测哪些指令是具有依赖性的或者使用相关的地址(地址混淆,Alias)从而决定哪些 Load/Store 指令是可以提前的, 哪些是不可以提前的。可以提前的指令在其后继指令需要数据之前就开始执行、读取数据到ROB当中这样后继指令就可以直接从中使用数据,从而避免访问了无法提前 Load/Store 时访问 L1 缓存带来的延迟(3~4 个时钟周期)。

不过,为了要判断一个 Load 指令所操作的地址没有问题,缓存系统需要检查处于 in-flight 状态(处理器流水线中所有未执行的指令)的 Store 操作,这是一个颇耗费资源的过程。在 NetBurst 微架构中,通过把一条 Store 指令分解为两个 uops——一个用于计算地址、一个用于真正的存储数据,这种方式可以提前预知 Store 指令所操作的地址,初步的解决了数据相依性问题。在 NetBurst 微架构中,Load/Store 乱序操作的算法遵循以下几条 原则:

  • 如果一个对于未知地址进行操作的 Store 指令处于 in-flight 状态,那么所有的 Load 指令都要被延迟
  • 在操作相同地址的 Store 指令之前 Load 指令不能继续执行
  • 一个 Store 指令不能移动到另外一个 Store 指令之前(指的是在RS中不能先挑选执行后面的一条store指令,注意这只是说某一种架构不允许重排store,其实还是有很多架构如Alpha等是松散内存模型,允许不相关的store重排序的,这一块就牵扯到memory models相关知识了,建议参考这里)

这种原则下的问题也很明显,比如条原则会在一条处于等待状态的 Store 指令所操作的地址未确定之前,就延迟所有的 Load 操作,显然过于保守了。实际上,地址冲突问题是极少发生的。根据某些机构的研究,在一个Alpha EV6 处理器中多可以允许 512 条指令处于 in-flight 状态,但是其中的 97%以上的 Load 和 Store 指令都不会存在地址冲突问题。
基于这种理念,Core 微架构采用了大胆的做法它令 Load 指令总是提前进行,除非新加入的动态混淆预测器(Dynamic Alias Predictor)预测到了该 Load 指令不能被移动到 Store 指令附近。这个预测是根据历史行为来进行的,据说准确率超过 90%。
在执行了预 Load 之后,一个冲突监测器会扫描 MOB 的 Store 队列,检查该是否有Store操作与该 Load 冲突。在很不幸的情况下(1%~2%),发现了冲突,那么该 Load 操作作废流水线清除并重新进行 Load 操作。这样大约会损失 20 个时钟周期的时间,然而从整体上看, Core 微架构的激进 Load/Store 乱序策略确实很有效地提升了性能,因为Load 操作占据了通常程序的 1/3 左右,并且 Load 操作可能会导致巨大的延迟(在命中的情况下,Core 的 L1D Cache 延迟为 3 个时钟周期,Nehalem 则为 4 个。L1 未命中时则会访问 L2 缓存,一般为 10~12 个时钟周期。访问 L3 通常需要 30~40 个时钟周期,访问主内存则可以达到多约 100 个时钟周期)。Store 操作并不重要,什么时候写入到 L1 乃至主内存并不会影响到执行性能。

如上图所示,我们需要载入地址 X 的数据,加 1 之后保存结果;载入地址 Y 的数据,加1 之后保存结果;载入地址 Z 的数据,加 1 之后保存结果。如果根据 Netburst 的基本准则, 在第三条指令未决定要存储在什么地址之前,处理器是不能移动第四条指令和第七条指令的。实际上,它们之间并没有依赖性。因此,Core 微架构中则“大胆”的将第四条指令和第七条指令分别移动到第二和第三指令的并行位置,这种行为是基于一定的猜测的基础上的“投机”行为,如果猜测的对的话(几率在 90%以上),完成所有的运算只要5个周期,相比之前的9个周期几乎快了一倍。

和为了顺序提交到寄存器而需要 ROB 重排序缓冲区的存在一样,在乱序架构中,多个打乱了顺序的 Load 操作和Store操作也需要按顺序提交到内存MOB(Memory Reorder Buffer, 内存重排序缓冲区)就是起到这样一个作用的重排序缓冲区(介于 Load/Store 单元 与 L1D Cache 之间的部件,有时候也称之为LSQ),MOB 通过一个 128bit 位宽的 Load 通道与一个 128bit 位宽的 Store 通道与双口 L1D Cache 通信。和 ROB 一样,MOB的内容按照 Load/Store 指令实际的顺序加入队列的一端按照提交到 L1 DCache 的顺序从队列的另一端移除。ROB 和 MOB 一起实际上形成了一个分布式的 Order Buffer 结构,有些处理器上只存在 ROB,兼备了 MOB 的功能(把MOB看做ROB的一部分可能更好理解)。

和ROB 一样,Load/Store 单元的乱序存取操作会在 MOB 中按照原始程序顺序排列,以提供正确的数据,内存数据依赖性检测功能也在里面实现(内存数据依赖性的检测比指令寄存器间的依赖性检测要复杂的多)MOB 的 Load/Store 操作结果也会直接反映到 ROB当中(中间结果)

MOB还附带了数据预取(Data Prefetch)功能,它会猜测未来指令会使用到的数据,并预先从L1D Cache 缓存 Load入MOB 中(Data Prefetcher 也会对 L2 至系统内存的数据进行这样的操作), 这样 MOB 当中的数据有些在 ROB 中是不存在的(这有些像 ROB 当中的 Speculative Execution 猜测执行,MOB 当中也存在着“Speculative Load Execution 猜测载入”,只不过失败的猜测执行会导致管线停顿,而失败的猜测载入仅仅会影响到性能,然而前端时间发生的Meltdown漏洞却造成了严重的安全问题)。MOB包括了Load Buffers和Store Buffers。

乱序执行中我们可以看到很多缓冲区性质的东西: RAT 寄存器别名表、ROB 重排序缓冲 区、RS 中继站、MOB 内存重排序缓冲区(包括 load buffer 载入缓冲和 store buffer 存储缓冲)。在超线程的作 用下,RAT是一式两份,包含了 128 个重命名寄存器; 128 条目的 ROB、48 条目的 LB 和 32 条目的 SB 都 每个线程 64 个 ROB、24 个 LB 和 16 个 SB; RS 则是在两个线程中动态共享。可见,虽然整体数量增加了,然而就单个线程而言,获得的资源并没有 提升。这会影响到 HTT 下单线程下的性能。

六、缓存(cache)

通常缓存具有两种设计:非独占和独占,Nehalem 处理器的 L3 采用了非独占高速缓存 设计(或者说“包含式”,L3 包含了 L1/L2 的内容),这种方式在 Cache Miss 的时候比独 占式具有更好的性能,而在缓存命中的时候需要检查不同的核心的缓存一致性。Nehalem 并 采用了“内核有效”数据位的额外设计,降低了这种检查带来的性能影响。随着核心数目的 逐渐增多(多线程的加入也会增加 Cache Miss 率),对缓存的压力也会继续增大,因此这 种方式会比较符合未来的趋势。在后面可以看到,这种设计也是考虑到了多处理器协作的情况(此时 Miss 率会很容易地增加)。这可以看作是 Nehalem 与以往架构的基础不同:之前的架构都是来源于移动处理设计,而 Nehalem 则同时为企业、桌面和移动考虑而设计。

在 L3 缓存命中的时候(单处理器上是通常的情况,多处理器下则不然),处理器检查内核有效位看看是否其他内核也有请求的缓存页面内容,决定是否需要对内核进行侦听。

在NUMA架构中,多个处理器中的同一个缓存页面必定在其中一个处理器中属于 F 状态(可以修改的状态),这个页面在这个处理器中没有理由不可以多核心共享(可以多核心共享就意味着这个能进入修改状态的页面的多个有效位被设置为一)。MESIF协议应该是工作在核心(L1+L2)层面而不是处理器(L3)层面,这样同一处理器里多个核心共享的页面,只有其中一个是出于 F 状态(可以修改的状态)。见后面对 NUMA 和 MESIF 的解析。(L1/L2/L3 的同步应该是不需要 MESIF 的同步机制)

在 L3 缓存未命中的时候(多处理器下会频繁发生),处理器决定进行内存存取,按照 页面的物理位置,它分为近端内存存取(本地内存空间)和远端内存存取(地址在其他处理 器的内存的空间):

七、缓存Cache架构原理

Cache的容量很小,它保存的内容只是主存内容的一个子集,且Cache与主存的数据交换是以块为单位的。为了把信息放到Cache中,必须应用某种函数把主存地址定位到Cache中,这称为地址映射。在信息按这种映射关系装入Cache后,CPU执行程序时,会将程序中的主存地址变换成Cache地址,这个变换过程叫做地址变换。

Cache的地址映射方式有直接映射、全相联映射和组相联映射。假设某台计算机主存容量为l MB,被分为2048块,每块512B;Cache容量为8KB,被分为16块,每块也是512B。下面以此为例介绍三种基本的地址映射方法。

直接映射

直接映射的Cache组织如图3-14所示。主存中的一个块只能映射到Cache的某一特定块中去。例如,主存的第0块、第16块、……、第2032块,只能映射到Cache的第0块;而主存的第1块、第17块、……、第2033块,只能映射到Cache的第1块……。

直接映射是简单的地址映射方式,它的硬件简单,成本低,地址变换速度快,而且不涉及替换算法问题。但是这种方式不够灵活,Cache的存储空间得不到充分利用,每个主存块只有一个固定位置可存放,容易产生冲突,使Cache效率下降,因此只适合大容量Cache采用。例如,如果一个程序需要重复引用主存中第0块与第16块,好将主存第0块与第16块同时复制到Cache中,但由于它们都只能复制到Cache的第0块中去,即使Cache中别的存储空间空着也不能占用,因此这两个块会不断地交替装入Cache中,导致命中率降低。

全相联映射

图3-15 是全相联映射的Cache组织,主存中任何一块都可以映射到Cache中的任何一块位置上。

全相联映射方式比较灵活,主存的各块可以映射到Cache的任一块中,Cache的利用率高,块冲突概率低,只要淘汰Cache中的某一块,即可调入主存的任一块。但是,由于Cache比较电路的设计和实现比较困难,这种方式只适合于小容量Cache采用。

组相联映射

组相联映射实际上是直接映射和全相联映射的折中方案,其组织结构如图3-16所示。主存和Cache都分组,主存中一个组内的块数与Cache中的分组数相同,组间采用直接映射,组内采用全相联映射。也就是说,将Cache分成u组,每组v块,主存块存放到哪个组是固定的,至于存到该组哪一块则是灵活的。例如,主存分为256组,每组8块,Cache分为8组,每组2块。

主存中的各块与Cache的组号之间有固定的映射关系,但可自由映射到对应Cache组中的任何一块。例如,主存中的第0块、第8块……均映射于Cache的第0组,但可映射到Cache第0组中的第0块或第1块;主存的第1块、第9块……均映射于Cache的第1组,但可映射到Cache第1组中的第2块或第3块。

常采用的组相联结构Cache,每组内有2、4、8、16块,称为2路、4路、8路、16路组相联Cache。组相联结构Cache是前两种方法的折中方案,适度兼顾二者的优点,尽量避免二者的缺点,因而得到普遍采用。

一次内存访问示意图

注意事项

  • TLB采用组相联
  • 页表采用两级页表
  • cache采用组相联
  • cache仅考虑L1 d-cache,不考虑L1 i-cache、L2 cache和L3 cache
  • 未考虑页表缺页
  • 简化了cache未命中情况

实际例子

下面展示了现代Intel处理器的CPU cache是如何组织的。有关cache的讨论往往缺乏具体的实例,使得一些简单的概念变得扑朔迷离。也许是我可爱的小脑瓜有点迟钝吧,但不管怎样,至少下面讲述了故事的前一半,即Core 2的 L1 cache是如何被访问的:

L1 cache – 32KB,8路组相联,64字节缓存线

1. 由索引拣选缓存组(行)

在cache中的数据是以缓存线(line)为单位组织的,一条缓存线对应于内存中一个连续的字节块。这个cache使用了64字节的缓存线。这些线被保存在cache bank中,也叫(way)。每一路都有一个专门的目录(directory)用来保存一些登记信息。你可以把每一路连同它的目录想象成电子表格中的一列,而表的一行构成了cache的一(set)。列中的每一个单元(cell)都含有一条缓存线,由与之对应的目录单元跟踪管理。图中的cache有64 组、每组8路,因此有512个含有缓存线的单元,合计32KB的存储空间。

在cache眼中,物理内存被分割成了许多4KB大小的物理内存页(page)。每一页都含有4kb/64/bytes== 64条缓存线。在一个4KB的页中,第0到63字节是条缓存线,第64到127字节是第二条缓存线,以此类推。每一页都重复着这种划分,所以第0页第3条缓存线与第1页第3条缓存线是不同的。

全相联缓存(fully associative cache)中,内存中的任意一条缓存线都可以被存储到任意的缓存单元中。这种存储方式十分灵活,但也使得要访问它们时,检索缓存单元的工作变得复杂、昂贵。由于L1和L2 cache工作在很强的约束之下,包括功耗,芯片物理空间,存取速度等,所以在多数情况下,使用全相联缓存并不是一个很好的折中。

取而代之的是图中的组相联缓存(set associative cache)。意思是,内存中一条给定的缓存线只能被保存在一个特定的组(或行)中。所以,任意物理内存页的第0条缓存线(页内第0到63字节)必须存储到第0组,第1条缓存线存储到第1组,以此类推。每一组有8个单元可用于存储它所关联的缓存线,从而形成一个8路关联的组(8-way associative set)。当访问一个内存地址时,地址的第6到11位(译注:组索引)指出了在4KB内存页中缓存线的编号,从而决定了即将使用的缓存组。举例来说,物理地址0x800010a0的组索引是000010,所以此地址的内容一定是在第2组中缓存的。

但是还有一个问题,就是要找出一组中哪个单元包含了想要的信息,如果有的话。这就到了缓存目录登场的时刻。每一个缓存线都被其对应的目录单元做了标记(tag);这个标记就是一个简单的内存页编号,指出缓存线来自于哪一页。由于处理器可以寻址64GB的物理RAM,所以总共有64GB/4KB == 224个内存页,需要24位来保存标记。前例中的物理地址0x800010a0对应的页号为524289。下面是故事的后一半:

2、在组中搜索匹配标记

由于我们只需要去查看某一组中的8路,所以查找匹配标记是非常迅速的;事实上,从电学角度讲,所有的标记是同时进行比对的,我用箭头来表示这一点。如果此时正好有一条具有匹配标签的有效缓存线,我们就获得一次缓存命中(cache hit)。否则,这个请求就会被转发的L2 cache,如果还没匹配上就再转发给主系统内存。通过应用各种调节尺寸和容量的技术,Intel给CPU配置了较大的L2 cache,但其基本的设计都是相同的。比如,你可以将原先的缓存增加8路而获得一个64KB的缓存;再将组数增加到4096,每路可以存储256kb。经过这两次修改,就得到了一个4MB的L2 cache。在此情况下,需要18位来保存标记,12位保存组索引;缓存所使用的物理内存页的大小与其一路的大小相等。(译注:有4096组,就需要lg(4096)==12位的组索引,缓存线依然是64字节,所以一路有4096*64B==256KB字节;在L2 cache眼中,内存被分割为许多256KB的块,所以需要lg(64GB/256KB)==18位来保存标记。)

如果有一组已经被放满了,那么在另一条缓存线被存储进来之前,已有的某一条则必须被腾空(evict)。为了避免这种情况,对运算速度要求较高的程序就要尝试仔细组织它的数据,使得内存访问均匀的分布在已有的缓存线上。举例来说,假设程序中有一个数组,元素的大小是512字节,其中一些对象在内存中相距4KB。这些对象的各个字段都落在同一缓存线上,并竞争同一缓存组。如果程序频繁的访问一个给定的字段(比如,通过虚函数表vtable调用虚函数),那么这个组看起来就好像一直是被填满的,缓存开始变得毫无意义,因为缓存线一直在重复着腾空与重新载入的步骤。在我们的例子中,由于组数的限制,L1 cache仅能保存8个这类对象的虚函数表。这就是组相联策略的折中所付出的代价:即使在整体缓存的使用率并不高的情况下,由于组冲突,我们还是会遇到缓存缺失的情况。然而,鉴于计算机中各个存储层次的相对速度,不管怎么说,大部分的应用程序并不必为此而担心。

一个内存访问经常由一个线性(或虚拟)地址发起,所以L1 cache需要依赖分页单元(paging unit)来求出物理内存页的地址,以便用于缓存标记。与此相反,组索引来自于线性地址的低位,所以不需要转换就可以使用了(在我们的例子中为第6到11位)。因此L1 cache是物理标记但虚拟索引的(physically tagged but virtually indexed),从而帮助CPU进行并行的查找操作。因为L1 cache的一路绝不会比MMU的一页还大,所以可以保证一个给定的物理地址位置总是关联到同一组,即使组索引是虚拟的。在另一方面L2 cache必须是物理标记和物理索引的,因为它的一路比MMU的一页要大。但是,当一个请求到达L2 cache时,物理地址已经被L1 cache准备(resolved)完毕了,所以L2 cache会工作得很好。

后,目录单元还存储了对应缓存线的状态(state)。在L1代码缓存中的一条缓存线要么是的(invalid)要么是共享的(shared,意思是有效的,真的J)。在L1数据缓存和L2缓存中,一条缓存线可以为4个MESI状态之一:被修改的(modified),独占的(exclusive),共享的(shared),的(invalid)。Intel缓存是包容式的(inclusive):L1缓存的内容会被复制到L2缓存中。

总结

1.内存层次结构的意义在于利用引用的空间局部性和时间局部性原理,将经常被访问的数据放到快速的存储器中,而将不经常访问的数据留在较慢的存储器中。

2.一般情况下,除了寄存器和L1缓存可以操作指定字长的数据,下层的内存子系统就不会再使用这么小的单位了,而是直接移动数据块,比如以缓存线为单位访问数据。

3.对于组冲突,可以这么理解:与上文相似,假设一个缓存,由512条缓存线组成,每条线64字节,容量32KB。

a) 假如它是直接映射缓存,由于它往往使用地址的低位直接映射缓存线编号,所以所有的32K倍数的地址(32K,64K,96K等)都会映射到同一条线上(即第0线)。假如程序的内存组织不当,交替的去访问布置在这些地址的数据,则会导致冲突。从外表看来就好像缓存只有1条线了,尽管其他缓存线一直是空闲着的。

b) 如果是全相联缓存,那么每条缓存线都是独立的,可以对应于内存中的任意缓存线。只有当所有的512条缓存线都被占满后才会出现冲突。

c) 组相联是前两者的折中,每一路中的缓存线采用直接映射方式,而在路与路之间,缓存控制器使用全相联映射算法,决定选择一组中的哪一条线。

d) 如果是2路组相联缓存,那么这512条缓存线就被分为了2路,每路256条线,一路16KB。此时所有为16K整数倍的地址(16K,32K,48K等)都会映射到第0线,但由于2路是关联的,所以可以同时有2个这种地址的内容被缓存,不会发生冲突。当然了,如果要访问第三个这种地址,还是要先腾空已有的一条才行。所以极端情况下,从外表看来就好像缓存只有2条线了,尽管其他缓存线一直是空闲着的。

e) 如果是8路组相联缓存(与文中示例相同),那么这512条缓存线就被分为了8路,每路64条线,一路4KB。所以如果数组中元素地址是4K对齐的,并且程序交替的访问这些元素,就会出现组冲突。从外表看来就好像缓存只有8条线了,尽管其他缓存线一直是空闲着的。

根据内存计算cacde结构:

page是os的概念,而cache是cpu的概念。虚拟地址和物理地址以page为单位进行操作的,由两部分组成:page地址和page内地址(偏移),所以,os的page和cpu的cache是没任何必然关系的。

备注:转载CSDN:ivanx_cc大哥的文章一起来学习,向原创致敬:

CPU体系结构 (转载) 向原创致敬net/ivanx_cc/article/details/89007691

相关文章