3 CPU CPU 微架构
本章简要概述了对软件性能有直接影响的关键 CPU 微体系结构特性。本章的目的并不是要涵盖 CPU 架构的所有细节和权衡,文献[Hennessy & Patterson, 2017 Computer Architecture, Sixth Edition]、[Shen & Lipasti, 2013 Modern Processor Design: Fundamentals of Superscalar Processors]已经对此进行了大量论述。我将对现代处理器的特性进行回顾,以便读者为本书接下来的内容做好准备。
3.1 指令集架构
指令集架构(ISA instruction set architecture)是软件与硬件之间的契约,它定义了通信规则。英特尔 x86-64、Armv8-A 和 RISC-V 是当今广泛使用的 ISA 的例子。所有这些都是 64 位架构,即所有地址计算都使用 64 位。ISA 开发人员和 CPU 架构师通常会确保符合规范的软件或固件可以在任何使用该规范构建的处理器上执行。广泛部署的 ISA 通常还能确保向后兼容性,例如为 GenX 版本处理器编写的代码可以继续在 GenX+i 上执行。
大多数现代体系结构都可归类为基于寄存器的通用加载存储体系结构,如 RISC-V 和 ARM,在这些体系结构中,操作数是明确指定的,内存只能通过加载和存储指令访问。X86 ISA 是一种寄存器内存架构,可对寄存器和内存操作数进行操作。除了提供 ISA 的基本功能(如加载、存储、控制以及使用整数和浮点进行标量运算)外,广泛部署的体系结构还不断增强其 ISA,以支持新的计算模式。其中包括增强型向量处理指令(如英特尔 AVX2、AVX512、ARM SVE、RISC-V “V ”向量扩展)以及矩阵/张量指令(英特尔 AMX、ARM SME)。广泛使用这些高级指令的应用程序通常会大幅提高性能。
现代 CPU 支持 32 位和 64 位精度的浮点和整数算术运算。随着机器学习和人工智能领域的快速发展,业界对替代数字格式重新产生了兴趣,以推动性能的显著提高。研究表明,使用更少的比特来表示变量,机器学习模型的性能同样出色,从而节省了计算和内存带宽。因此,除了用于算术运算的传统 32 位和 64 位格式外,大多数主流 ISA 最近都增加了对较低精度数据类型的支持,如 8 位和 16 位整数和浮点类型(int8、fp8、fp16、bf16)。
3.2 流水线技术
流水线(Pipelining)技术是使 CPU 快速运行的一种基础技术,在这种技术中,多条指令在执行过程中相互重叠。流水线技术在 CPU 中的应用灵感来自汽车装配线。指令的处理分为若干阶段。各阶段并行运行,处理不同指令的不同部分。DLX 是一种相对简单的架构,由 John L. Hennessy 和 David A. Patterson 于 1994 年设计。根据[Hennessy & Patterson, 2017 Computer Architecture, Sixth Edition]中的定义,它有一个 5 级流水线,由以下部分组成:
-
- 指令取回 (IF Instruction fetch)
-
- 指令解码 (ID Instruction decode )
-
- 执行 (EXE Execute)
-
- 内存访问 (MEM Memory access)
-
- 回写 (WB Write back)
上图显示了 5 级流水线 CPU 的理想流水线视图。在周期 1 中,指令 x 进入流水线的 IF 阶段。在下一个周期,当指令 x 进入 ID 阶段时,程序中的下一条指令进入 IF 阶段,依此类推。一旦流水线满了,如上述第 5 周期,CPU 的所有流水线阶段都忙于处理不同的指令。如果没有流水线,指令 x+1 在指令 x 完成工作后才能开始执行。
现代高性能 CPU 有多个流水线级,通常为 10 到 20 级(有时更多),具体取决于体系结构和设计目标。这涉及到比前面介绍的简单 5 级流水线复杂得多的设计。例如,解码阶段可能会被分成几个新的阶段。我们还可以在执行级之前增加新的级来缓冲已解码的指令等。
流水线 CPU 的吞吐量定义为单位时间内完成和退出流水线的指令数。任何给定指令的延迟都是通过流水线所有阶段的总时间。由于流水线的所有阶段都是连接在一起的,因此每个阶段都必须准备就绪,以步调一致的方式进入下一条指令。将一条指令从一个阶段移动到下一个阶段所需的时间定义了 CPU 的基本机器周期或时钟。为特定流水线选择的时钟值由流水线中最慢的阶段决定。中央处理器硬件设计人员努力平衡每级可完成的工作量,因为这直接影响到中央处理器的运行频率。
在实际应用中,流水线引入了一些限制因素,这些因素限制了流畅执行。流水线危害会阻碍理想的流水线行为,导致停滞。危害分为结构危害、数据危害和控制危害三类。对程序员来说幸运的是,在现代 CPU 中,所有类型的危害都由硬件处理。
- 结构性冒险(资源冲突: Structural hazards)
由资源冲突引起,即有两条指令竞争同一资源。例如,两条 32 位加法指令准备在同一周期内执行,但该周期内只有一个执行单元可用。在这种情况下,我们需要选择执行两条指令中的哪一条,以及哪一条将在下一周期执行。在很大程度上,可以通过复制硬件资源来消除这些问题,例如使用多个执行单元、指令解码器、多端口寄存器文件等。不过,这可能会在硅片面积和功耗方面造成相当大的代价。
- 数据冒险(Data Hazards)
由程序中的数据依赖性引起,分为三种类型:
写入后读取(RAW read-after-write)危险要求在写入后执行依赖性读取。当指令 x+1 在前一条指令 x 向源写入之前读取源时,就会发生这种情况,导致读取错误的值。中央处理器会将数据从流水线的较后阶段转发到较早阶段(称为 “旁路”),以减轻与 RAW 危险相关的惩罚。其原理是,在指令 x 完全完成之前,指令 x 的结果可以转发到指令 x+1。请看下面的例子
R1 = R0 ADD 1
R2 = R1 ADD 2
寄存器 R1 存在 RAW 依赖关系。如果我们在加法 R0 ADD 1 完成后(从 EXE 流水线阶段)直接取值,就无需等到 WB 阶段结束(此时该值将写入寄存器文件)。旁路有助于节省几个周期。流水线越长,旁路就越有效。
读后写(WAR write-after-read)危险要求在读取后执行从属写操作。当一条指令在先前指令读取寄存器之前写入寄存器,导致读取错误的新值时,就会发生这种情况。WAR 危险不是真正的依赖关系,可以通过一种称为寄存器重命名的技术来消除。这是一种从物理寄存器中抽象出逻辑寄存器的技术。CPU 通过保留大量物理寄存器来支持寄存器重命名。逻辑(架构)寄存器,即 ISA 定义的寄存器,只是更广泛寄存器文件的别名。有了这种架构状态的解耦,解决 WAR 危险就很简单了:我们只需在写操作中使用不同的物理寄存器即可。例如
在原始汇编代码中,寄存器 R0 存在 WAR 依赖关系。对于左边的代码,我们不能重新安排指令的执行顺序,因为这可能会在 R1 中留下错误的值。不过,我们可以利用庞大的物理寄存器池来克服这一限制。为此,我们需要重命名从写操作(R0 = R2 ADD 2)开始的所有 R0 寄存器,并使用空闲寄存器。重命名后,我们给这些寄存器赋予与物理寄存器相对应的新名称,例如 R103。通过重新命名寄存器,我们消除了初始代码中的 WAR 危险,可以安全地以任何顺序执行这两个操作。
写入后写入(WAW write-after-write)危险要求在写入后执行从属写入。当一条指令写入一个寄存器后,另一条指令才写入同一寄存器,从而导致错误的值被存储。
寄存器重命名也能消除 WAW 危险,允许两个写入以任何顺序执行,同时保留正确的最终结果。下面是消除 WAW 危险的示例。
你会在许多生产程序中看到类似的代码。在我们的示例中,R1 保留 ADD 操作的临时结果。SUB 指令完成后,R1 立即重新用于存储 MUL 运算的结果。左侧的原始代码包含所有三种类型的数据危险。在 ADD 和 SUB 之间,R1 存在 RAW 依赖关系,并且必须在寄存器重命名后继续使用。此外,在 MUL 操作中,同一个 R1 寄存器还存在 WAW 和 WAR 危险。同样,我们需要对寄存器进行重命名,以消除这两个危险。请注意,寄存器重命名后,MUL 操作有了一个新的目标寄存器(R104)。现在,我们可以安全地将 MUL 与其他两个操作重新排序。
- 控制冒险(Control Hazards)
由程序流程的变化引起。它们产生于流水线分支和其他改变程序流程的指令。
决定分支方向(执行与不执行)的分支条件是在执行流水线阶段解决的。因此,除非控制危险被消除,否则下一条指令的取值无法流水线化。下一节介绍的动态分支预测和推测执行等技术可用于缓解控制冒险。
3.3 利用指令级并行性 (ILP Instruction Level Parallelism)
程序中的大多数指令都是独立的,因此适合流水线化并行执行。现代 CPU 采用了大量附加硬件功能来利用这种指令级并行性 (ILP),即单条指令流内的并行性。这些硬件特性与先进的编译器技术相结合,可显著提高性能。
3.3.1 乱序执行(OOO Out-Of-Order)
大多数现代 CPU 都支持无序执行 (OOO),在这种情况下,顺序指令可以按照任意顺序进入执行阶段,只是受到其依赖关系和资源可用性的限制。具有 OOO 执行功能的 CPU 仍必须提供与所有指令按程序顺序执行时相同的结果。
一条指令在最终执行后被称为 “退役”,其结果是正确的,并在架构状态中可见。为确保正确性,CPU 必须按程序顺序执行所有指令。OOO 执行主要用于避免 CPU 资源因依赖关系导致的停滞而未得到充分利用,尤其是在超标量引擎中,我们稍后将讨论这一点。
假设由于某些冲突,指令 x+1 无法在周期 4 和 5 中执行。按顺序执行的 CPU 会阻止所有后续指令进入 EXE 流水线阶段,因此指令 x+2 只能在周期 7 开始执行。在采用 OOO 执行方式的 CPU 中,只要不存在任何冲突(例如,指令的输入可用,执行单元未被占用等),指令就可以开始执行。如图所示,指令 x+2 在指令 x+1 之前开始执行。指令 x+3 无法在第 6 周期进入 EXE 阶段,因为它已被指令 x+1 占用。所有指令仍按顺序退出,即指令按程序顺序完成 WB 阶段。
与按顺序执行相比,OOO 执行通常能大幅提高性能。不过,它也会带来额外的复杂性和功耗。
对指令重新排序的过程通常称为指令调度。调度的目的是以最小化流水线危险和最大化 CPU 资源利用率的方式发出指令。指令调度可以在编译时进行(静态调度),也可以在运行时进行(动态调度)。让我们来解读这两种方案。
3.3.1.1 静态调度(Static scheduling)
Intel Itanium 就是静态调度的一个例子。在超标量、多执行单元机器的静态调度中调度, 使用一种称为 VLIW(超长指令字)的技术,将调度从硬件转移到编译器。其基本原理是通过要求编译器选择正确的指令组合来简化硬件,从而保持机器的充分利用。
编译器可以使用软件流水线和循环解卷等技术,在硬件结构无法合理支持的情况下,更进一步寻找合适的 ILP。
英特尔 Itanium 一直未能取得成功,原因有几个。其中一个原因是与 x86 代码缺乏兼容性。另一个原因是,由于负载延迟可变,编译器很难对指令进行调度,使 CPU 保持繁忙。x86 ISA 的 64 位扩展 (x86-64)由 AMD 在同一时间窗口推出,与 IA-32(x86 ISA 的 32 位版本)兼容,并最终成为其真正的继承者。Itanium 处理器最终于 2021 年停产。
3.3.1.2 动态调度
为了克服静态调度的问题,现代处理器采用了动态调度。动态调度最重要的两种算法是Scoreboarding 计分板和Tomasulo 托马苏洛算法。它的主要缺点是不仅保留了真实依赖关系(RAW),还保留了虚假依赖关系(WAW 和 WAR),因此它提供了次优的 ILP。虚假依赖性是由于架构寄存器数量较少造成的,现代 ISA 中的架构寄存器数量通常在 16 到 32 个之间。这就是所有现代处理器都采用 Tomasulo 算法进行动态调度的原因。Tomasulo 算法由 Robert Tomasulo 于 20 世纪 60 年代发明,并首次在 IBM360 91 型处理器中实现。
为了消除错误的依赖关系,Tomasulo 算法使用了寄存器重命名技术,我们在上一节已经讨论过这一技术。因此,与计分板相比,性能大大提高。然而,带有 RAW 依赖性的指令序列(也称为依赖链)对于 OOO 执行仍然存在问题,因为在寄存器重命名后,ILP 不会增加,因为所有 RAW 依赖性都得到了保留。依赖链通常出现在循环中(循环携带依赖),在这种情况下,当前循环迭代依赖于上一次迭代产生的结果。
实现动态调度的另外两个关键组件是重排序缓冲区(ROB Reorder Buffer)和预约站(RS Reservation Station)。重排缓冲区是一个循环缓冲区,用于跟踪每条指令的状态,在现代处理器中,重排缓冲区有几百个条目。通常情况下,ROB 的大小决定了硬件可以提前多久独立调度指令。指令按程序顺序插入 ROB,可以不按顺序执行,也可以按程序顺序退出。在将指令放入 ROB 时,会对寄存器进行重命名。
指令从 ROB 插入 RS,RS 中的条目要少得多。指令一旦进入 RS,就会等待其输入操作数可用。当输入操作数可用时,就可以向相应的执行单元发出指令。
因此,一旦指令的操作数可用,指令可按任何顺序执行,而不再受程序顺序的限制。现代处理器越来越宽(一个周期内可执行多条指令)、越来越深(更大的 ROB、RS 和其他缓冲区),这表明在生产应用中发现更多 ILP 的潜力巨大。
3.3.2 超标量引擎(Superscalar Engines)
大多数现代 CPU 都是超标量引擎,即在任何给定周期内都可以发出多条指令。发射宽度是指在同一周期内可发行指令的最大数量(参见第 4.5 节)。2024 年主流 CPU 的典型发行宽度介于 6 到 9 之间。为了确保适当的平衡,此类超标量引擎还具有多个执行单元和/或流水线执行单元。CPU 还将超标量能力与深度流水线和无序执行相结合,为特定软件提取最大的 ILP。
上图显示的是支持双通道的 CPU 的流水线图。请注意,每个周期的每个流水线阶段都可以处理两条指令。例如,指令 x 和 x+1 都在周期 3 开始执行。这可能是两条相同类型的指令(如两条加法指令),也可能是两条不同的指令(如一条加法指令和一条分支指令)。超标量处理器会复制执行资源,以保持流水线中指令的流畅性,避免出现结构性冲突。例如,要同时支持两条指令的解码,我们需要 2 个独立的解码器。
3.3.3 预测执行(Speculative Execution)
如上一节所述,如果指令在分支条件得到解决之前停滞不前,控制危险会导致流水线性能大幅下降。
避免这种性能损失的一种技术是硬件分支预测。利用这种技术,CPU 可以预测分支的可能目标,并从预测路径开始执行指令。
让我们来看看清单 3.1 中的一个例子。处理器要想知道下一步应该执行哪个函数,就必须知道条件 a < b 是假还是真。
如果不知道这一点,CPU 就会一直等待,直到确定分支指令的结果:
if (a < b)foo();
elsebar();
通过推测执行,CPU 猜测分支的结果,并从所选路径开始处理指令。假设处理器预测条件 a < b 将被评估为真。处理器不等待分支结果,直接调用函数 foo。在条件得到解决之前,机器的状态变化不能提交,以确保机器的架构状态绝不会受到推测执行指令的影响。
在上例中,分支指令比较两个标量值,速度很快。但实际上,分支指令可能取决于从内存加载的值,这可能需要数百个周期。
如果预测结果正确,就能节省大量周期。但有时预测并不正确,应该调用函数栏。在这种情况下,推测执行的结果必须被挤压并丢弃。
这就是我们将在第 4.8 节讨论的分支错误预测惩罚。投机执行的指令在 ROB 中被标记为投机指令。一旦它不再是推测性指令,就可以按程序顺序退出。这里是架构状态提交和架构寄存器更新的地方。由于推测指令的结果不会被提交,因此当发生错误预测时很容易回退。
3.3.4 分支预测
正如我们刚才所看到的,正确的预测可以大大提高执行效率,因为它可以让 CPU 在没有先前指令结果的情况下向前推进。
然而,错误的预测往往会带来代价高昂的性能损失。现代 CPU 采用了复杂的动态分支预测机制,可提供极高的准确性,并能适应分支行为的动态变化。有三类分支可以特殊方式处理:
-
无条件跳转和直接调用:这是最容易预测的分支,因为它们每次都会跳转,而且方向相同。
-
有条件分支:它们有两种可能的结果:采取或不采取。采取的分支可以向前或向后。前向条件分支通常是为 if-else 语句生成的,这些语句有很大几率不会被采用,因为它们经常代表错误检查代码。后向条件跳转经常出现在循环中,用于进入循环的下一次迭代;此类分支通常会被采用。
-
间接调用和跳转:它们有许多目标。间接跳转或间接调用可以由 switch 语句、函数指针或虚拟函数调用产生。函数的返回也值得关注,因为它也有许多潜在的目标。
大多数预测算法都是基于先前的分支结果。分支预测单元(BPU)的核心是分支目标缓冲区(BTB),它缓存了每个分支的目标地址。预测算法在每个周期都会查阅 BTB,以生成下一个可提取指令的地址。CPU 使用新地址获取下一个指令块。如果当前取指令块中没有发现分支,下一个取指令地址将是下一个顺序对齐的取指令块(fall through)。
无条件分支不需要预测;我们只需在 BTB 中查找目标地址。每个周期,BPU 都需要生成下一个取指令地址,以避免流水线停滞。我们本可以只从指令编码本身提取地址,但这样我们就必须等到解码阶段结束,这会在流水线中引入一个气泡,导致速度变慢。因此,必须在获取分支时确定下一个获取地址。
对于条件分支,我们首先需要预测分支是否会被执行。如果不采取,那么我们就会失败,无需查找目标地址。否则,我们将在 BTB 中查找目标地址。条件分支通常占总分支的最大部分,也是生产软件中错误预测惩罚的主要来源。对于间接分支,我们需要从可能的目标中选择一个,但预测算法可能与条件分支非常相似。
所有预测机制都试图利用两个重要原则,这两个原则与我们稍后讨论的缓存类似:
- 时间相关性:分支的解析方式可以很好地预测下一次执行时的解析方式。这也被称为局部相关性。
- 空间相关性:多个相邻分支可能以高度相关的方式(优先执行路径)解析。这也称为全局相关性。
通常利用局部和全局相关性可以达到最佳精度。因此,我们不仅要查看当前分支的结果历史,还要将其与相邻分支的结果相关联。
另一种常用技术称为混合预测。其原理是,某些分支的行为会产生偏差。例如,如果一个条件分支在 99.9% 的时间内都会朝一个方向发展,那么就没有必要使用复杂的预测器和污染其数据结构。相反,可以使用一种简单得多的机制。另一个例子是循环分支。如果分支具有循环行为,则可以使用专用的循环预测器对其进行预测,该预测器将记住循环通常执行的迭代次数。
目前,最先进的预测方法主要是类似 TAGE 的预测器 Seznec & Michaud, 2006。Championship冠军分支预测器每 1000 条指令的错误预测少于 3 次。现代 CPU 在大多数工作负载上的预测率通常都能达到 95%以上。
3.4 SIMD 多处理器
另一种促进并行处理的技术称为单指令多数据(SIMD Single Instruction Multiple Data),几乎所有高性能处理器都采用这种技术。顾名思义,在 SIMD 处理器中,一条指令在一个周期内使用多个独立的功能单元对多个数据元素进行操作。矢量和矩阵的运算非常适合 SIMD 架构,因为矢量或矩阵的每个元素都可以使用相同的指令进行处理。SIMD 体系结构能更有效地处理大量数据,最适合涉及矢量操作的数据并行应用。
double *a, *b, *c;
for (int i = 0; i < N; ++i) {c[i] = a[i] + b[i];
}
在传统的单指令单数据 (SISD) 模式(也称为标量模式)中,加法运算分别应用于数组 a 和 b 的每个元素。如果我们使用的 CPU 架构的执行单元能够对 256 位矢量进行运算,那么我们就可以用一条指令处理四个双精度元素。这样可以少发 4 倍指令,速度可能比 4 次标量计算快 4 倍。
对于常规整数 SISD 指令,处理器使用通用寄存器。同样,对于 SIMD 指令,CPU 有一组 SIMD 寄存器,以保存从内存中加载数据并存储计算的中间结果。在我们的示例中,将从内存中加载与数组 a 和 b 相对应的两个 256 位连续数据区域,并将其分别存储在两个矢量寄存器中。接下来,进行元素加法运算,并将结果存储到一个新的 256 位矢量寄存器中。注意,数据元素可以是整数或浮点数。
矢量执行单元在逻辑上被划分为多个通道。在 SIMD 的背景下,车道指的是 SIMD 执行单元内的一条独立数据通道,用于处理向量的一个元素。在我们的例子中,每个通道处理 64 位元素(双精度),因此 256 位寄存器中有 4 个通道。
大多数流行的 CPU 架构都具有向量指令,包括 x86、PowerPC、ARM 和 RISC-V。1996 年,英特尔发布了专为多媒体应用设计的 SIMD 指令集 MMX。继 MMX 之后,英特尔又推出了功能更强、矢量更大的新指令集: SSE、AVX、AVX2 和 AVX-512。ARM 在其不同版本的体系结构中可选择支持 128 位 NEON 指令集。在第 8 版(aarch64)中,这种支持成为强制性的,并增加了新的指令。
随着新指令集的问世,软件工程师开始着手将它们变为现实。利用 SIMD 指令所需的软件更改称为代码矢量化。最初,SIMD 指令是用汇编程序编写的。后来,引入了特殊的编译器本征(intrinsics),即提供 SIMD 指令一对一映射的小函数。如今,所有主要的编译器都支持流行处理器的自动矢量化,即可以直接从用 C/C++、Java、Rust 和其他语言编写的高级代码中生成 SIMD 指令。
为了使代码能在支持不同矢量长度的系统上运行,Arm 引入了 SVE 指令集。其显著特点是可扩展向量的概念:它们的长度在编译时是未知的。有了 SVE,就无需将软件移植到每一种可能的向量长度上。当新一代 CPU 中出现更宽的向量时,用户无需重新编译应用程序的源代码即可利用这些向量。可扩展向量的另一个例子是 RISC-V V 扩展(RVV),该扩展于 2021 年底获得批准。一些实现支持相当宽(2048 位)的矢量,最多可将 8 个矢量组合在一起,产生 16384 位矢量,从而大大减少了执行指令的数量。在每次循环迭代时,SVE 代码通常会执行 ptr += number_of_lane,其中 number_of_lanes 在编译时是未知的。ARM SVE 为这种依赖长度的操作提供了特殊指令,而 RVV 则使程序员能够查询/设置 number_of_lanees。
上例如果 N 等于 5,并且我们有一个 256 位的向量,我们就无法在一次迭代中处理所有元素。我们可以使用一条 SIMD 指令处理前四个元素,但第五个元素需要单独处理。这就是所谓的循环余量。循环余量是循环中必须处理的元素数量少于矢量宽度的部分,需要额外的标量代码来处理剩余的元素。可扩展矢量 ISA 扩展不存在这个问题,因为它们可以在一条指令中处理任意数量的元素。
解决循环剩余问题的另一种方法是使用掩码,它可以根据条件有选择地启用或禁用 SIMD 通道。
此外,CPU 也越来越多地加速机器学习中经常使用的矩阵乘法。英特尔的 AMX 扩展自 2023 年起在服务器处理器中得到支持,可对形状为 16x64 和 64x16 的 8 位矩阵进行乘法运算,累加为 32 位 16x16 矩阵。相比之下,苹果 CPU 中毫不相关但名称相同的 AMX 扩展以及 ARM 的 SME 扩展则分别计算存储在特殊 512 位寄存器或可扩展矢量中的行和列的外乘积。
SIMD 最初由多媒体应用和科学计算驱动,但后来在许多其他领域也得到了应用。随着时间的推移,SIMD 指令集所支持的操作集也在稳步增加。除了直接算术运算外,SIMD 的新用例还包括
- 字符串处理:查找字符、验证 UTF-8、解析 JSON 和 CSV;散列、随机生成、密码学(AES);
- 列式数据库(位打包、过滤、连接);
- 内置类型排序(VQSort、QuickSelect);
- 机器学习和人工智能(加速 PyTorch、TensorFlow)。
3.5 利用线程级并行性
前面介绍的技术依赖于程序中可用的并行性来加快执行速度。除此之外,CPU 还支持利用 CPU 上执行的进程和/或线程间并行性的技术。接下来,我们将讨论三种利用线程级并行性(TLP)的技术:多核系统、同步多线程和混合架构。这些技术可以最大限度地利用现有硬件资源,提高系统的吞吐量。
3.5.1 多核系统
随着处理器设计人员开始受到半导体设计和制造的实际限制,GHz 竞赛放缓,设计人员不得不将重点放在其他创新上,以提高 CPU 性能。多核设计是其中一个重要方向,它试图增加每一代处理器的内核数。其想法是在单个芯片上复制多个处理器内核,让它们同时为不同的程序服务。例如,其中一个内核可以同时运行网络浏览器,另一个内核可以渲染视频,还有一个内核可以播放音乐。对于服务器机器来说,来自不同客户的请求可以在不同的内核上处理,这可以大大提高系统的吞吐量。
第一款面向消费者的双核处理器是 2005 年发布的英特尔酷睿 2 双核处理器,同年晚些时候又发布了 AMD Athlon X2 架构。多核系统导致许多软件组件需要重新设计,并影响了我们编写代码的方式。如今,几乎所有面向消费者设备的处理器都是多核 CPU。在撰写本书时,高端笔记本电脑包含十多个物理内核,服务器处理器在一个插座上包含 100 多个内核。
这听起来似乎非常惊人,但我们不能无限增加内核。首先,每个内核在工作时都会产生热量,而如何安全地通过处理器封装将热量从内核中散发出去仍然是一个难题。这意味着当更多内核运行时,热量很快就会超过冷却能力。在这种情况下,多核处理器会降低时钟速度。这就是为什么拥有大量内核的服务器芯片的频率远远低于笔记本电脑和台式机处理器的原因之一。
多核系统中的内核相互连接,并与末级高速缓存和内存控制器等共享资源相连。这种通信通道称为互连,通常采用环形或网状拓扑结构。CPU 设计人员面临的另一个挑战是如何在内核数量增加时保持机器的平衡。当你复制内核时,一些资源仍然是共享的,例如内存总线和末级高速缓存。除非同时解决其他共享资源的吞吐量问题,如互连带宽、末级高速缓存大小和带宽以及内存带宽,否则随着内核的增加,性能回报会越来越低。共享资源经常成为多核系统性能问题的根源。
3.5.2 同步多线程
为提高多线程性能,一种更复杂的方法是同步多线程(SMT Simultaneous Multithreading)。人们经常使用 “超线程”(Hyperthreading)来描述同一事物。该技术的目标是充分利用 CPU 管线的可用宽度。SMT 允许多个软件线程使用共享资源在同一物理内核上同时运行。更确切地说,来自 50 个线程的指令可以在一个物理内核上同时运行。。这些线程不一定是同一进程的线程,也可以是碰巧安排在同一物理内核上的完全不同的程序。
在非 SMT 和双路 SMT(SMT2)处理器上的执行示例如图。在这两种情况下,处理器流水线的宽度都是 4,每个插槽都代表一次发布新指令的机会。机器 100% 利用率是指没有未使用的插槽,而这在实际工作负载中从未发生过。不难看出,在非 SMT 情况下,有很多未使用的插槽,因此可用资源没有得到很好的利用。出现这种情况可能有多种原因。例如,在周期 3 中,线程 1 无法向前推进,因为所有指令都在等待输入可用。非 SMT 处理器只会停滞不前,而支持 SMT 的处理器则会利用这个机会安排另一个线程的有用工作。这样做的目的是占用另一个线程未使用的插槽,以提高硬件利用率和多线程性能。
在 SMT2 实现中,每个物理内核由两个逻辑内核表示,操作系统可将其视为两个独立的处理器来处理工作。假设有 16 个软件线程可以运行,但只有 8 个物理内核。在非 SMT 系统中,只有 8 个线程同时运行,而使用 SMT2,我们可以同时执行所有 16 个线程。在另一种假设情况下,如果两个程序在一个支持 SMT 的内核上运行,并且每个程序只持续使用四个可用插槽中的两个,那么它们的运行速度很有可能与单独在该物理内核上运行时一样快。
虽然两个程序在同一个处理器内核上运行,但它们彼此完全分离。在支持 SMT 的处理器中,即使指令是混合的,它们也有不同的上下文,这有助于保持执行的正确性。
为支持 SMT,CPU 必须复制架构状态(程序计数器、寄存器)以保持线程上下文。其他 CPU 资源可以共享。在典型的实施中,缓存资源在硬件线程之间动态共享。跟踪 OOO 和推测执行的资源可以复制或分割。
在 SMT2 内核中,两个逻辑内核真正同时运行。在 CPU 前端,它们以交替顺序(每个周期或几个周期)获取指令。在后端,处理器在每个周期从所有线程中选择要执行的指令。指令的执行是混合的,因为处理器会在两个线程之间动态调度执行单元。
因此,SMT 是一种非常灵活的设置,可以恢复未使用的 CPU 问题槽。除了多线程的优势外,SMT 还能提供同等的单线程性能。选择支持 SMT 的现代 CPU 通常采用双向(SMT2)SMT,有时也采用四向(SMT4)SMT。
SMT 也有自己的缺点。由于逻辑内核之间共享某些资源,它们最终可能会竞争使用这些资源。L1 和 L2 高速缓存的竞争最有可能造成 SMT 损失。由于这些资源由两个逻辑内核共享,它们可能会因为缓存空间不足而强制驱逐将来会被其他线程使用的数据。
SMT 给软件开发人员带来了相当大的负担,因为它使得预测和衡量在 SMT 内核上运行的应用程序的性能变得更加困难。
想象一下,你正在 SMT 内核上运行性能关键型代码,而操作系统突然将另一个要求苛刻的任务放到了同级逻辑内核上。你的代码几乎耗尽了机器的资源,现在你需要与其他人共享资源。这个问题在云环境中尤为突出,因为你无法预测你的应用程序是否会有吵闹的邻居。
某些同步多线程实现也存在安全隐患。研究人员发现,一些早期的实现存在漏洞,一个应用程序可以通过这个漏洞从另一个运行在同时多线程上的应用程序中窃取关键信息(如加密密钥)。
通过监控缓存的使用情况,从运行在同一处理器的同级逻辑内核上的另一个应用程序中窃取关键信息(如加密密钥)。由于硬件安全不属于本书的讨论范围,我们将不再深入探讨这一问题。
3.5.3 混合架构
计算机架构师还开发了一种混合 CPU 设计,将两种(或多种)内核放在同一个处理器中。通常情况下,更强大的内核与相对较慢的内核相结合,以实现不同的目标。在这种系统中,大内核用于对延迟敏感的任务,而小内核则可降低功耗。此外,两种内核还可以同时使用,以提高多线程性能。所有内核都可以访问相同的内存,因此工作负载可以从大内核迁移到小内核,然后再返回。这样做的目的是为了创建一种能更好地适应动态计算需求、耗电更少的多核处理器。例如,视频游戏既有单线程突发执行的部分,也有可扩展到多个内核的部分。
第一个主流混合架构是 Arm 于 2011 年 10 月推出的 big.LITTLE。其他厂商也纷纷效仿。苹果公司在 2020 年推出了 M1 芯片,该芯片有四个高性能的 “Firestorm ”内核和四个高能效的“Icestorm ”内核。英特尔在 2021 年推出了 Alder Lake 混合架构,其顶级配置为 8 个 P 核和 8 个 E 核。
混合架构结合了两种内核类型的优点,但也带来了一系列挑战。首先,它要求内核完全兼容 ISA,即它们应能执行同一套指令。否则,调度就会受到限制。例如,如果一个大内核具有一些小内核无法使用的花哨指令,那么你只能分配大内核来运行使用这些指令的工作负载。这就是为什么供应商在为混合处理器选择 ISA 时通常使用 “最大公分母 ”方法。
即使是 ISA 兼容的内核,调度也变得非常具有挑战性。不同类型的工作负载需要特定的调度方案,如突发执行与稳定执行、低 IPC( Instructions Per Cycle 指令每周期) 与高 IPC、50 低重要性与高重要性等。这很快就会变得非常棘手。以下是优化调度的几个注意事项:
- 利用小内核节省电能。不要唤醒大内核进行后台工作。
- 识别候选任务(低重要性、低 IPC),将其卸载到较小的内核上。
- 分配新任务时,首先使用空闲的大核心。如果是 SMT,则在两个逻辑线程都空闲的情况下使用大内核。然后,使用空闲的小内核。然后,使用大核心的同级逻辑线程。
从程序员的角度来看,使用混合系统无需修改代码。这种方法在面向客户端的设备中非常流行,尤其是在智能手机中。
3.6 内存层次结构
为有效利用 CPU 中的所有硬件资源,需要在正确的时间向机器提供正确的数据。如果不能做到这一点,就需要从主存储器中获取变量,这需要大约 100 ns 的时间。从 CPU 的角度来看,这是一个非常长的时间。要发挥 CPU 的性能,了解内存层次结构至关重要。大多数程序都具有局部性:它们不会统一访问所有代码或数据。CPU 内存层次结构基于两个基本特性:
- 时间局部性:当访问给定内存位置时,同一位置很可能很快会再次被访问。理想情况下,我们希望下次需要时,缓存中就有这些信息。
- 空间位置性:当访问给定内存位置时,附近的位置可能很快也会被访问。这是指将相关数据放在彼此靠近的位置。当程序从内存中读取单个字节时,通常会获取较大的内存块(缓存行),因为通常情况下,程序很快就会需要该数据。
本节概述了现代 CPU 支持的内存分层系统的主要属性。
3.6.1 高速缓存层次结构(Cache Hierarchy)
高速缓存是内存层次结构的第一层,用于处理从 CPU 流水线发出的任何请求(代码或数据)。缓存是 CPU 流水线发出的任何请求(代码或数据)的第一级内存层次结构。理想情况下,流水线的最佳性能是拥有一个访问延迟最小的无限缓存。实际上,任何高速缓存的访问时间都会随其大小而增加。因此,高速缓存被组织成一个层次结构,由最靠近执行单元的小型快速存储块组成,并以较大、较慢的存储块为后盾。高速缓存层次结构中的某一层次可专门用于代码(指令高速缓存,I-cache)或数据(数据高速缓存,D-cache),或代码与数据共享(统一高速缓存 unified cache)。此外,层次结构中的某些层级可为特定内核专用,而其他层级则可在内核间共享。
高速缓存以块的形式组织,具有规定的大小,也称为高速缓存行。现代 CPU 的典型缓存行大小为 64 字节。不过,苹果处理器(如 M1、M2 及更高版本)的二级缓存是个明显的例外,它采用 128B 缓存行。最接近执行流水线的缓存大小通常在 32 KB 到 128 KB 之间。中级缓存的大小通常在 1MB 及以上。现代 CPU 的末级缓存可达几十甚至上百兆字节。
3.6.1.1 数据在缓存中的位置。
请求的地址用于访问缓存。在直接映射缓存中,给定的块地址只能出现在缓存中的一个位置,并由下图所示的映射函数定义。
直接映射高速缓存相对容易构建,访问速度快,但错失率高。
在完全关联缓存中,给定块可以放在缓存中的任何位置。这种方法涉及的硬件复杂度高,访问时间慢,因此被认为不适合大多数使用情况。
介于直接映射和完全关联映射之间的一种选择是集合关联映射。在这种高速缓存中,块被组织成集,通常每个集包含 2、4、8 或 16 个块。给定地址首先映射到一个集合。
在一个集合中,地址可以被放置在该集合中的任何区块中。每组包含 m 个块的高速缓存被称为 m 路集关联高速缓存。集合关联型高速缓存的公式为
以 L1 缓存为例,其大小为 32 KB,有 64 字节缓存行、64 个集和 8 条路。该缓存的缓存行总数为 32 KB / 64 字节 = 512 行。新行只能插入相应的集(64 个集之一)中。一旦集合确定,新行就可以进入该集合中的 8 种方式之一。同样,在以后搜索该缓存行时,首先要确定该组,然后只需检查该组中的 8 条路。
以下是 Apple M1 处理器缓存组织的另一个示例。每个性能核心内部的 L1 数据高速缓存可存储 128 KB,有 256 个集,每个集有 8 条路,以 64 字节行运行。性能内核组成一个集群,共享二级缓存,二级缓存可存储 12 MB,具有 12 路集合关联,并在 128 字节行上运行。苹果公司,2024
3.6.1.2 在缓存中查找数据。
m 路集合关联缓存中的每个块都有一个地址标签。此外,标签还包含状态位,如用于指示数据是否有效的位。标签还可以包含额外的位来指示访问信息、共享信息等。
最低阶地址位定义了给定块内的偏移;块偏移位(32 字节高速缓存行为 5 位,64 字节高速缓存行为 6 位)。根据上述公式,使用索引位选择数据集。一旦选择了数据集,标记位就会被用来与该数据集中的所有标记进行比较。如果其中一个标签与输入请求的标签相匹配,且有效位被置位,则会产生缓存命中。与该数据块条目相关的数据(与标签查找同时从高速缓存的数据数组中读出)将提供给执行流水线。如果标签不匹配,则会出现缓存缺失。
3.6.1.3 管理缺失(缓存缺失)
当缓存未命中时,缓存控制器必须在缓存中选择一个要替换的块,以分配导致未命中的地址。对于直接映射型高速缓存,由于新地址只能在单个位置上分配,因此要删除映射到该位置的前一个条目,并在其位置上安装新条目。而在集合关联型高速缓存中,由于新的高速缓存块可以放在集合中的任何一个块中,因此需要一种替换算法。
典型的替换算法是 LRU(最近最少使用)策略,即驱逐最近访问次数最少的数据块,为新数据腾出空间。
另一种方法是随机选择一个区块作为受害区块。
3.6.1.4 管理写入。
与数据读取相比,缓存的写入访问频率较低。处理缓存中的写操作比较困难,CPU 实现使用各种技术来处理这种复杂性。软件开发人员应特别注意硬件支持的各种写缓存流,以确保代码达到最佳性能。
CPU 设计使用两种基本机制来处理命中缓存的写操作:
- 在直通写缓存中,命中的数据被写入缓存中的块和下一级层次结构。
- 在回写缓存中,命中数据只写入缓存。随后,层次结构的下一级包含陈旧数据。修改行的状态通过标签中的脏位进行跟踪。当修改过的缓存行最终被从缓存中剔除时,回写操作会强制将数据写回到下一级。
写操作的缓存缺失可通过两种方式处理:
- 在写分配高速缓存中,遗漏位置的数据会从层次结构的下层加载到高速缓存中,随后写操作会像写命中一样被处理。
- 如果高速缓存采用的是不写入-分配策略,则高速缓存未命中事务会直接发送到层次结构的下层,而不会将数据块加载到高速缓存中。
在这些选项中,大多数设计通常会选择使用写分配策略来实现回写高速缓存,因为这两种技术都试图将后续的写事务转换为高速缓存命中,而不会给层次结构的下层带来额外的流量。直通写缓存通常使用无写分配策略。
3.6.1.5 其他缓存优化技术
对于程序员来说,了解缓存层次结构的行为对于从任何应用程序中获取性能都至关重要。从 CPU 流水线的角度来看,访问任何请求的延迟由以下公式给出,该公式可递归应用于缓存层次结构的所有层级,直至主内存:平均访问延迟 = 命中时间 + 未命中率 × 未命中惩罚
硬件设计人员通过许多新颖的微体系结构技术来减少命中时间和未命中惩罚。从根本上说,高速缓存的未命中会使流水线停滞,影响性能。任何缓存的未命中率都与缓存架构(块大小、关联性)和机器上运行的软件密切相关。
3.6.1.6 硬件和软件预取。
避免缓存未命中和后续停滞的一种方法是在流水线需要数据之前将数据预取到缓存中。其假设是,如果在流水线中足够提前地发出预取请求,处理未命中惩罚所需的时间基本可以隐藏起来。大多数 CPU 提供基于硬件的隐式预取,并辅以程序员可以控制的显式软件预取。
硬件预取器会观察运行应用程序的行为,并根据缓存缺失的重复模式启动预取。硬件预取可以自动适应应用程序的动态行为,如变化的数据集,而且不需要优化编译器的支持。此外,硬件预取无需额外的地址生成和预取指令。不过,硬件预取只适用于有限的常用数据访问模式。软件内存预取是对硬件预取的补充。开发人员可通过专用硬件提前指定所需的内存位置参见第 8.5 节)。编译器也可以在代码中自动添加预取指令,在需要数据之前提出请求。预取技术需要在需求和预取请求之间取得平衡,以防止预取流量拖慢需求流量。
3.6.2 主存储器
主存储器是层次结构的下一级,位于高速缓存的下游。加载和存储数据的请求由内存控制器单元 (MCU Memory Controller Unit) 发起。过去,该电路位于主板上的北桥芯片中。但现在,大多数处理器都嵌入了这一组件,因此 CPU 有一条专用内存总线连接到主存储器。
主存储器采用 DRAM(动态随机存取存储器)技术,可在合理的成本范围内支持大容量。在比较 DRAM 模块时,人们通常会关注内存密度和内存速度,当然还有价格。内存密度是指模块的容量,单位为 GB。显然,可用内存越多越好,因为它是操作系统和应用程序使用的宝贵资源。
主内存的性能由延迟和带宽来描述。内存延迟是指从发出内存访问请求到 CPU 可以使用数据之间所经过的时间。内存带宽是指在一定时间内可获取多少字节,通常以每秒千兆字节为单位。
3.6.2.1 DDR(双倍数据速率)
DDR(Double Data Rate)是大多数 CPU 支持的主要 DRAM 技术。从历史上看,每一代 DRAM 的带宽都在提高,而 DRAM 的延迟却保持不变或有所增加。下表显示了过去三代 DDR 技术的最高数据速率、峰值带宽和相应的读取延迟。数据传输率以每秒百万次传输(MT/s)为单位。表中显示的延迟与 DRAM 设备本身的延迟相对应。通常情况下,由于高速缓存控制器、内存控制器和片上互连中产生的额外延迟和排队延迟,从 CPU 流水线(负载使用时的高速缓存未命中)看到的延迟会更高(在 50ns-150ns 范围内)。您可以在第 4.10 节中看到测量观察到的内存延迟和带宽的示例。
值得一提的是,DRAM 芯片需要定期刷新内存单元。这是因为位值是以电荷的形式存储在一个微小的电容器上的,因此它会随着时间的推移而失去电荷。为了防止这种情况发生,有一种特殊的电路可以读取每个单元并将其写回,从而有效地恢复电容器的电荷。DRAM 芯片在刷新过程中,并不提供内存访问请求。
DRAM 模块由一组 DRAM 芯片组成。内存等级是一个术语,用于描述一个模块上有多少组 DRAM 芯片。例如,单级(1R)内存模块包含一组 DRAM 芯片。双通道(2R)内存模块有两组 DRAM 芯片,因此容量是单通道模块的两倍。同样,我们还可以购买四排(4R)和八排(8R)内存模块。
每个等级由多个 DRAM 芯片组成。内存宽度定义了每个 DRAM 芯片的总线宽度。由于每个阶宽为 64 位(ECC RAM 为 72 位),因此它也定义了阶内 DRAM 芯片的数量。内存宽度可以是三个值之一:x4、x8 或 x16,定义了每个芯片的总线宽度。例如,下图显示了总容量为 2GB 的 2Rx16 双排 DRAM DDR4 模块的组织结构。每级有四个芯片,总线宽度为 16 位。四个芯片的总输出为 64 位。通过等级选择信号一次选择一个等级。
单排还是双排的性能更好,并没有直接的答案,因为这取决于应用类型。单排模块通常发热较少,不易出现故障。此外,多级模块需要一个级选择信号来从一个级切换到另一个级,这需要额外的时钟周期,可能会增加访问延迟。另一方面,如果一个级没有被访问,它可以在其他级繁忙时并行刷新周期。一旦前一个等级完成数据传输,下一个等级就可以立即开始传输。
更进一步,我们可以在系统中安装多个 DRAM 模块,这样不仅可以增加内存容量,还能提高内存带宽。多内存通道的设置可提高内存控制器与 DRAM 之间的通信速度。
单内存通道系统的 DRAM 和内存控制器之间的数据总线宽为 64 位。多通道架构增加了内存总线的宽度,允许同时访问 DRAM 模块。例如,双通道架构将内存数据总线的宽度从 64 位扩展到 128 位,使可用带宽增加了一倍。请注意,每个内存模块仍然是 64 位设备,只是连接方式不同。如今,服务器机器通常有四个或八个内存通道。
此外,你还可能遇到重复内存控制器的设置。例如,处理器可能有两个集成内存控制器,每个都能支持多个内存通道。这两个控制器是独立的,只能查看各自的物理内存地址空间。我们可以使用下面的简单公式进行快速计算,以确定特定内存技术的最大内存带宽:最大内存带宽=数据速率×每周期字节数
例如,对于数据速率为 2400 MT/s、每次传输 64 位(8 字节)的单通道 DDR4 配置,最大带宽等于 2400 * 8 = 19.2 GB/s。双通道或双内存控制器设置可将带宽提高一倍,达到 38.4 GB/s。但请记住,这些数字都是理论上的最大值,假设每个内存时钟周期都会进行一次数据传输,而实际上这种情况从未发生过。因此,在测量实际内存速度时,你看到的数值总是低于最大理论传输带宽。
要启用多通道配置,需要有支持这种架构的 CPU 和主板,并在主板上正确的内存插槽中安装偶数个相同的内存模块。在 Windows 系统上,检查设置的最快方法是运行 CPU-Z 或 HwInfo 等硬件识别实用程序;在 Linux 系统上,可以使用 dmidecode 命令。此外,还可以运行 Intel MLC 或 Stream 等内存带宽基准。
要利用系统中的多个内存通道,有一种技术叫做交错。它将一个页面内的相邻地址分散到多个内存设备上。下图显示了一个用于顺序内存访问的双向交错实例。如前所述,我们有一个双通道内存配置(通道 A 和 B),有两个独立的内存控制器。现代处理器以每四条高速缓存线(256 字节)为单位进行交错,即前四条相邻的高速缓存线进入通道 A,然后下一组四条高速缓存线进入通道 B。
如果没有交错功能,连续的相邻访问将被发送到同一个内存控制器,无法利用第二个可用的控制器。
相比之下,交错可实现硬件并行,从而更好地利用可用的内存带宽。对于大多数工作负载而言,当所有通道都被填满时,性能将达到最大化,因为这样可以将单个内存区域尽可能地分散到更多的 DRAM 模块上。
虽然增加内存带宽通常是件好事,但并不总能带来更好的系统性能,这在很大程度上取决于应用程序。另一方面,注意可用和已用内存带宽也很重要,因为一旦内存带宽成为主要瓶颈,应用程序就会停止扩展,也就是说,增加更多内核并不能使其运行得更快。
3.6.2.2 GDDR 和 HBM
除多通道 DDR 外,还有其他技术可用于需要更高的内存带宽来实现更高性能的工作负载。最著名的技术是 GDDR(图形 DDR:Graphics DDR )和 HBM(高带宽内存:High Bandwidth Memory)。它们不仅可用于高端图形处理、高性能计算(如气候建模、分子动力学和物理模拟),还可用于自动驾驶,当然还有人工智能/ML。由于此类应用需要快速移动大量数据,因此它们自然而然地适用于这些领域。
GDDR 主要是为图形设计的,如今几乎所有高性能显卡都使用它。虽然 GDDR 与 DDR 有一些共同特点,但也有很大不同。DRAM DDR 是为更低的延迟而设计的,而 GDDR 则是为更高的带宽而设计的,因为它与处理器芯片本身位于同一封装内。与 DDR 相似,GDDR 接口每个时钟周期传输两个 32 位字(共 64 位)。最新的 GDDR6X 标准可实现高达 168 GB/s 的带宽,工作频率相对较低,为 656 MHz。
HBM 是一种新型 CPU/GPU 内存,垂直堆叠内存芯片,也称为 3D 堆叠。与 GDDR 相似,HBM 大幅缩短了数据到达处理器的距离。与 DDR 和 GDDR 的主要区别在于,HBM 内存总线非常宽: 每个 HBM 堆栈为 1024 位。这使得 HBM 能够实现超高带宽。最新的 HBM3 标准支持每个封装高达 665 GB/s 的带宽。它的工作频率低至 500 MHz,每个封装的内存密度高达 48 GB。
如果想最大限度地提高数据传输吞吐量,板载 HBM 的系统将是一个不错的选择。不过,在撰写本文时,这项技术的价格还相当昂贵。
由于 GDDR 主要用于显卡,HBM 可能是加速 CPU 上运行的某些工作负载的不错选择。事实上,第一款集成了 HBM 的 x86 通用服务器芯片现已面世。
3.7 虚拟内存
虚拟内存是与 CPU 上执行的所有进程共享物理内存的机制。虚拟内存提供一种保护机制,防止其他进程访问分配给特定进程的内存。虚拟内存还提供重定位功能,即在不改变程序地址的情况下,将程序加载到物理内存中的任意位置。在支持虚拟内存的 CPU 中,程序使用虚拟地址进行访问。但是,虽然用户代码在虚拟地址上运行,但从内存中检索数据却需要物理地址。此外,为了有效管理稀缺的物理内存,内存被划分为多个页。因此,应用程序在操作系统提供的一组页面上运行。
访问数据和代码(指令)都需要进行虚拟地址到物理地址的转换。页面大小为 4KB 的系统的转换机制如上图。虚拟地址分为两部分。虚拟页码(52 个最有效位)用于索引页表,以生成虚拟页码与相应物理页之间的映射。12 个最小有效位用于在 4KB 页面内进行偏移。这些位无需转换,可 “按原样 ”访问物理内存位置。
页表可以是单层的,也可以是嵌套的。上图显示了双层页表的一个示例。请注意地址是如何被分割成更多块的。首先要提到的是,16 个最重要位没有被使用。这看起来像是在浪费位数,但即使使用剩余的 48 位,我们也能寻址 256 TB 的总内存(248)。有些应用程序会使用这些未使用的位来保存元数据,也就是所谓的指针标记。
嵌套页表是一棵保留物理页地址和一些元数据的弧度树。要在 2 级页表中查找转换,我们首先使用第 32...47 位作为第 1 级页表(也称为页表目录)的索引。目录中的每个描述符都指向 216 个二级页表块中的一个。找到相应的 L2 块后,我们使用第 12...31 位查找物理页面地址。
将其与页面偏移量(第 0...11 位)连接,就得到了物理地址,可用来从 DRAM 中检索数据。
页表的具体格式由 CPU 决定,原因我们将在后面几段讨论。因此,页表组织的变化受到 CPU 支持的限制。现代 CPU 既支持 48 位指针的 4 级页表(总内存容量为 256 TB),也支持 57 位指针的 5 级页表(总内存容量为 128 PB)。
将页表分成多级并不会改变可寻址内存的总量。不过,嵌套方法不需要将整个页表存储为连续数组,也不会分配没有描述符的块。这样可以节省内存空间,但会增加遍历页表时的开销。
无法提供物理地址映射称为页面故障。如果请求的页面无效或当前不在主内存中,就会发生这种故障。两种最常见的原因是 1) 操作系统承诺分配一个页面,但尚未用一个物理页面来支持它,以及 2) 访问的页面已被交换到磁盘,当前未存储在 RAM 中。
3.7.1 转换后备缓冲器(TLB Translation Lookaside Buffer)
在分层页表中进行搜索的代价可能很高,因为需要遍历分层结构,可能要进行多次间接 访问。这种遍历称为走页。为了减少地址转换时间,CPU 支持一种称为转换查找缓冲区(TLB)的硬件结构,用于缓存最近使用的转换。与普通缓存类似,TLB 通常设计为 L1 ITLB(指令)和 L1 DTLB(数据)的层次结构,然后是共享(指令和数据)的 L2 STLB。
为了降低内存访问延迟,L1 高速缓存的查找可以与 DTLB 的查找部分重叠,这要归功于对高速缓存关联性和大小的限制,它允许在没有物理地址的情况下选择 L1 集。然而,更高层次的高速缓存(L2 和 L3)通常也是物理索引和物理标记(PIPT)高速缓存,但无法受益于这种优化,因此需要在高速缓存查找之前进行地址转换。
TLB 层次结构保留了相对较大内存空间的转换。尽管如此,TLB 的未命中代价仍然非常高昂。为了加快 TLB 未命中的处理速度,CPU 有一种称为硬件走页器的机制。硬件走页器可以直接在硬件中执行走页操作,发出所需的指令来遍历页表,而不会中断内核。这就是为什么页表的格式由 CPU 规定,操作系统必须遵守的原因。高端处理器拥有多个硬件走页器,可以同时处理多个 TLB 未命中。然而,即使现代 CPU 提供了所有加速功能,TLB 错失仍会对许多应用程序造成性能瓶颈。
3.7.2 超大页面 (Huge Pages)
较小的页面大小可以更有效地管理可用内存并减少碎片。但缺点是需要更多的页表项来覆盖相同的内存区域。考虑两种页面大小:
4KB(x86 默认值)和 2MB 的超大页面大小。对于操作 10MB 数据的应用程序来说,第一种情况下需要 2560 个条目,而如果将地址空间映射到超大页上,则只需要 5 个条目。这些页面在 Linux 中被命名为 “巨大页面”,在 FreeBSD 中被命名为 “超级页面”,在 Windows 中被命名为 “大型页面”,但它们的含义是一样的。在本书的其余部分,我们将把它们称为巨型页。
下图显示了一个指向巨页面中数据的地址示例。就像默认页面大小一样,使用超大页面时的确切地址格式是由硬件决定的,但幸运的是,作为程序员,我们通常不必担心这个问题。
由于需要的 TLB 条目较少,使用超大页面大大减轻了对 TLB 层次结构的压力。这大大增加了 TLB 命中的机会。我们将在第 8.4 节和第 11.8 节讨论如何使用超大页面来降低 TLB 错失的频率。使用超大页面的缺点是内存碎片,在某些情况下还会导致不确定的页面分配延迟,因为操作系统更难管理大内存块并确保有效利用可用内存。要在运行时满足 2MB 的超大页面分配请求,操作系统需要找到 2MB 的连续块。如果找不到,操作系统就需要重新组织页面,从而导致更长的分配延迟。
3.8 现代 CPU 设计
为了了解本章所讲的所有概念在实践中的应用,让我们来看看英特尔于 2021 年推出的第 12 代内核 Golden Cove 的实现。该内核在 Alder Lake 和 Sapphire Rapids 平台中用作 P 核心。下图显示了 Golden Cove 内核的框图。请注意,本节只介绍单个内核,而不是整个处理器。因此,我们将跳过有关频率、内核数、L3 高速缓存、内核互连、内存延迟和带宽的讨论。
该内核分为一个将 x86 指令获取并解码为 µops的顺序内前端和一个 6 宽超标量顺序外后端。Golden Cove 内核支持双向 SMT。它有一个 32KB 的一级指令高速缓存(L1 I-高速缓存)和一个 48KB 的一级数据高速缓存(L1 D-高速缓存)。L1 缓存由统一的 1.25MB(服务器芯片为 2MB)L2 缓存提供支持。L1 和 L2 高速缓存对每个内核都是私有的。在本节末尾,我们还将介绍 TLB 层次结构。
3.8.1 CPU 前端
CPU 前端由多个功能单元组成,负责从内存中获取和解码指令。它的主要目的是将准备好的指令送入负责实际执行指令的 CPU 后端。
从技术上讲,指令获取是执行指令的第一阶段。但一旦程序达到稳定状态,分支预测器单元(BPU)就会引导 CPU 前端的工作。箭头从分支预测单元(BPU)指向指令缓存,这表明BPU预测所有分支指令的目标,并根据这个预测来控制下一步的指令获取。
BPU 的核心是一个分支目标缓冲区 (BTB),它有 12K 个条目,包含有关分支及其目标的信息。预测算法使用这些信息。每个周期,BPU 都会生成下一个取回地址,并将其传递给 CPU 前端。
CPU 前端每个周期从 L1 I 缓存中获取 32 字节的 x86 指令。如果启用 SMT,则由两个线程共享,因此每个线程每隔一个周期获取 32 个字节。这些都是复杂、长度可变的 x86 指令。首先,预解码阶段通过检查块来确定和标记可变指令的边界。在 x86 中,指令长度范围为 1 至 15 字节。这一阶段还能识别分支指令。预解码阶段将最多 6 条指令(也称为宏指令)移至指令队列,该队列由两个线程分割。指令队列还支持宏操作融合单元,可检测两个宏指令何时可融合为一个微操作(µop)。这种优化可节省流水线其他部分的带宽。
之后,每个周期最多有六条预解码指令从指令队列发送到解码器单元。两个 SMT 线程在每个周期交替访问该接口。6 路解码器将复杂的宏操作步骤转换为固定长度的 µ 操作步骤。解码后的 µops 排入指令解码队列 (IDQ Instruction Decode Queue),图中标为 “µop 队列”。
前端的一个主要性能提升功能是 µop 缓存。此外,人们还经常称其为解码流缓冲区(DSB Decoded Stream Buffer)。其目的是将宏操作数到 µops 的转换缓存在一个独立的结构中,该结构与 L1 I 缓存并行工作。当 BPU 生成要获取的新地址时,也会检查 µop 缓存,查看 µops 转换是否可用。经常出现的宏操作将进入 µop Cache,流水线将避免重复昂贵的 32 字节捆绑预解码和解码操作。µop Cache 每个周期可提供 8 个 µ操作,最多可容纳 4K 条目。
某些非常复杂的指令所需的 µops 可能超过解码器的处理能力。此类指令的 µops 由微码序列器 (MSROM Microcode Sequencer) 提供。这类指令的例子包括字符串操作、加密、同步等硬件操作支持。此外,MSROM 还保留了微代码操作,以处理特殊情况,如分支错误预测(需要刷新流水线)、浮点辅助(例如,当指令使用去规范化浮点值操作时)等。MSROM 每个周期可向 IDQ 推送多达 4 µops 的指令。
指令解码队列(IDQ)提供了序内前端与序外后端之间的接口。IDQ 按顺序排列 µops,在单线程模式下,每个逻辑处理器可容纳 144 µops,在 SMT 激活时,每个线程可容纳 72 µops。此时,按顺序排列的 CPU 前端结束,按顺序排列的 CPU 后端开始。
3.8.2 CPU 后端
CPU 后端采用一个 OOO 引擎来执行指令和存储结果。我在下图中重复了描述 Golden Cove OOO 引擎的部分示意图。
OOO 引擎的核心是 512 条目重排序缓冲区(ROB)。它有几个作用。虽然只有 16 个通用整数寄存器和 32 个浮点/SIMD 结构寄存器,但物理寄存器的数量要多得多。整数寄存器和浮点/SIMD 寄存器有不同的 PRF。从架构可见寄存器到物理寄存器的映射关系保存在寄存器别名表(RAT)中。
其次,ROB 分配执行资源。当指令进入 ROB 时,会分配一个新的条目,并为其分配资源,主要是执行单元和目标物理寄存器。ROB 每个周期最多可分配 6 µops。
第三,ROB 跟踪推测执行。当一条指令执行完毕后,其状态会被更新,并一直保持到前一条指令执行完毕。这样做是因为指令必须按程序顺序退出。一旦指令退出,其 ROB 条目就会被重新分配,指令的执行结果也会变得可见。退行阶段比分配阶段更宽泛:ROB 每个周期可退行 8 条指令。
处理器会以特定方式处理某些操作,这些操作通常被称为惯用法,无需执行或执行成本较低。处理器能识别这种情况,并允许它们比常规指令运行得更快。下面是其中的一些情况:
- 清零:为了给寄存器赋零,编译器通常使用 XOR / PXOR / XORPS / XORPD 指令,例如 XOR EAX, EAX,编译器倾向于使用这些指令,而不是等价的 MOV EAX, 0x0 指令,因为 XOR 编码使用的编码字节数更少。这种归零惯用法不会像其他常规指令一样执行,而是在 CPU 前端解决,从而节省了执行资源。
之后,该指令照常退出。 - 移动消除:与前一条指令类似,寄存器到寄存器的 mov 操作(如 MOV EAX、EBX)的执行周期延迟为零。
- NOP 指令: NOP 通常用于填充或对齐目的。它只是被标记为已完成,而不分配给保留站。
- 其他旁路 CPU 架构师还优化了某些算术运算。例如,任何数字乘以 1,结果总是相同的。任何数字除以 1 也是如此。任何数字乘以零,结果总是零,等等。有些 CPU 可以在运行时识别这种情况,并以比普通乘法或除法更短的延迟执行它们。
调度器/预订站"(RS)是一种结构,用于跟踪给定 µop 的所有资源可用性,并在 µop 准备就绪后将其分派到执行端口。执行端口是连接调度器和执行单元的通道。每个执行端口可连接多个执行单元。当指令进入 RS 时,调度器开始跟踪其数据依赖关系。一旦所有源操作数都可用,RS 就会尝试将 µop 发送到空闲的执行端口。RS 的条目数55 比 ROB 少。每个周期最多可调度 6 个 µ操作。人们测得 RS 中的条目数约为 200 个,但实际条目数并未公布。
共有 12 个执行端口:
- 端口 0、1、5、6 和 10 提供整数 (INT) 运算,其中一些端口处理浮点和矢量 (FP/VEC) 运算。
- 端口 2、3 和 11 用于地址生成 (AGU) 和加载操作。
- 端口 4 和 9 用于存储操作 (STD)。
- 端口 7 和 8 用于地址生成。
需要内存操作的指令由加载-存储单元(端口 2、3、11、4、9、7 和 8)处理,我们将在下一节讨论。如果操作不涉及加载或存储数据,则会被分派到执行引擎(端口 0、1、5、6 和 10)。某些指令可能需要在不同的执行端口执行两个 µops,如加载和加法。
例如,整数移位操作只能被分派到端口 0 或 6,而浮点除法操作只能被分派到端口 0。 当调度程序必须分派两个需要相同执行端口的操作时,其中一个操作将被延迟。
FP/VEC 堆栈可执行浮点标量和所有打包(SIMD)操作。
例如,端口 0、1 和 5 可以处理以下类型的 ALU 操作:
打包整数、打包浮点和浮点标量。整数寄存器文件和矢量/浮点寄存器文件分开存放。将数值从 INT 堆栈移至 FP/VEC 或反向移至 FP/VEC 的操作(如转换、提取或插入)会产生额外的处罚。
3.8.3 加载-存储单元(Load-Store Unit)
加载-存储单元 (LSU) 负责内存操作。通过使用端口 2、3 和 11,Golden Cove 内核最多可发出三个负载(三个 256 位或两个 512 位)。AGU 是地址生成单元的缩写,用于访问 68 位内存位置。
3.8 现代 CPU 设计 它还可以通过端口 4、9、7 和 8 在每个周期内发出最多两个存储(两个 256 位或一个 512 位)。STD 表示存储数据。
请注意,加载和存储操作都需要 AGU 来执行动态地址计算。例如,在指令 vmovss DWORD PTR [rsi+0x4],xmm0 中,AGU 将负责计算 rsi+0x4,用于存储 xmm0 的数据。
一旦加载或存储离开调度程序,LSU 将负责访问数据。加载操作将获取的值保存在寄存器中。存储操作将寄存器中的值传输到内存中的某个位置。LSU 有一个加载缓冲区(又称加载队列)和一个存储缓冲区(又称存储队列);它们的大小未公开。
当出现内存加载请求时,LSU 会使用虚拟地址查询 L1 缓存,并在 TLB 中查找物理地址转换。这两个操作同时启动。L1 D 缓存的大小为 48KB。如果这两个操作都命中,则加载将数据传送到整数或浮点寄存器,并离开加载缓冲区。同样,存储会将数据写入数据高速缓存,并退出存储缓冲区。
在 L1 未命中的情况下,硬件会启动对(私有)L2 缓存标签的查询。
在查询 L2 缓存时,会分配一个 64 字节宽的填充缓冲区 (FB) 条目,一旦缓存行到达,该条目将保留缓存行。Golden Cove 内核有 16 个填充缓冲区。
为了降低延迟,在进行二级缓存查询的同时,会向三级缓存发送推测查询。此外,如果两个负载访问同一缓存行,它们将撞击同一个 FB。这两个负载将被 “粘合 ”在一起,只启动一个内存请求。
如果 L2 缺失得到确认,负载将继续等待 L3 缓存的结果,这将产生更高的延迟。从这时起,请求将离开内核,进入非内核(这是有时在剖析工具中看到的术语)。超级队列(Super Queue,图中未显示)会跟踪来自内核的未处理未命中请求,最多可跟踪 48 个非内核请求。在 L3 未命中的情况下,处理器开始设置内存访问。更多细节不在本章讨论范围之内。
当存储修改内存位置时,处理器需要加载完整的高速缓存行,修改后再写回内存。如果要写入的地址不在高速缓存中,则需要通过与加载非常相似的机制将数据引入。
在数据写入高速缓存层次结构之前,存储无法完成。
当然,存储操作也有一些优化。首先,如果我们处理的是一个或多个相邻的存储(也称为流存储)
首先,如果我们要处理的是一个或多个相邻的存储空间(也称为流存储空间),这些存储空间会修改整个缓存行,那么就没有必要先读取数据,因为所有的字节都会被删除。因此,处理器会尝试合并写入,以填满整个缓存行。如果成功,则无需进行内存读取操作。
其次,写入组合可以将多个存储组合在一起并进一步写入。
在缓存层次结构中作为一个单元输出。因此,如果多个存储修改了同一缓存行,则只需向内存子系统发出一次内存写入。所有这些优化都是在存储缓冲区内完成的。存储指令将从寄存器写入的数据复制到存储缓冲区。从那里,数据可能被写入 L1 缓存,也可能与其他存储一起写入同一缓存行。存储缓冲区的容量是有限的,因此它只能将部分写入高速缓存行的请求保留一段时间。不过,当数据在存储缓冲区等待写入时,其他加载指令可以直接从存储缓冲区读取数据(存储到加载转发)。此外,当有一个较旧的存储区包含所有加载字节,且存储区的数据已经生成并可在存储队列中使用时,LSU 也支持存储到加载转发。
最后,在某些情况下,我们可以通过使用所谓的非时态内存访问来提高高速缓存的利用率。如果我们执行部分存储(例如,覆盖缓存行中的 8 个字节),我们需要先读取缓存行。新的高速缓存行将取代高速缓存中的另一行。但是,如果我们知道我们不会再需要这些数据,那么最好不要在缓存中为该行分配空间。非时态内存访问是一种特殊的 CPU 指令,它不会将获取的行保留在高速缓存中,而是在使用后立即将其删除。
在一个典型的程序执行过程中,可能会有数十次内存访问。
在大多数高性能处理器中,加载和存储操作的顺序并不一定要求与程序顺序一致,这就是所谓的弱有序内存模型。出于优化目的,处理器可以对内存读写操作重新排序。考虑这样一种情况:当一次加载遇到缓存缺失时,必须等待数据从内存中读出。处理器允许后续负载在等待数据的负载之前进行。这样,后面的加载可以在前面的加载之前完成,而不会不必要地阻塞执行。这种加载/存储重新排序使内存单元能够并行处理多个内存访问,从而直接转化为更高的性能。
LSU 动态重新排列操作顺序,既支持绕过旧负载的负载,也支持绕过旧的非冲突存储的负载。不过,也有一些例外情况。
就像通过常规算术指令产生的依赖关系一样,通过加载和存储也会产生内存依赖关系。换句话说,加载可以依赖于较早的存储,反之亦然。首先,存储不能与较早的加载一起重新排序:
加载 R1、MEM_LOC_X 存储 MEM_LOC_X、0 如果我们允许存储先于加载,那么 R1 寄存器可能会从内存位置 MEM_LOC_X 读取错误的值。
另一种有趣的情况是,加载时会消耗先前存储的数据:
存储 MEM_LOC,0 加载 R1,MEM_LOC 如果加载消耗了尚未完成存储的数据,我们就不应该允许继续加载。但如果我们还不知道存储空间的地址呢?在这种情况下,处理器会预测加载和存储之间是否会有任何潜在的数据转发,以及重新排序是否安全。这就是所谓的内存设计歧义。当负载开始执行时,必须对照所有旧存储检查是否存在潜在的存储转发。有四种可能的情况:
- 预测: 不依赖;结果: 不依赖。这是一个成功的内存消歧案例,能产生最佳性能。
- 预测: 依赖;结果:不依赖。在这种情况下,处理器过于保守,没有让负载先于存储。这是一次错失的性能优化机会。
- 预测: 不依赖;结果:依赖。这是内存顺序违规。与分支预测错误的情况类似,处理器必须清空流水线,回滚执行,然后重新开始。代价非常高昂。
- 预测: 依赖;结果: 依赖。加载和存储之间存在内存依赖关系,处理器预测正确。不会错失良机。
值得一提的是,从存储到加载的转发在实际代码中经常出现。尤其是任何使用读修改写访问其数据结构的代码,都有可能引发此类问题。由于存在较大的失序窗口,CPU 很容易尝试同时处理多个读取-修改-写入序列,因此一个序列的读取可能会在前一个序列的写入完成之前发生。第 12.2 节将介绍一个这样的例子。
3.8.4 TLB 层次结构
回顾第 3.7.1 节,虚拟地址到物理地址的转换缓存在 TLB 中。Golden Cove 的 TLB 层次结构如下图所示。与普通数据缓存类似,它有两个层次,其中第 1 层有单独的指令实例(ITLB)和数据实例(DTLB)。L1 ITLB 有 256 个条目,用于常规 4K 页面,覆盖 1MB 内存,而 L1 DTLB 有 96 个条目,覆盖 384 KB 内存。
层次结构的第二级(STLB)缓存指令和数据的转换。它是一个更大的存储空间,用于处理在 L1 TLB 中错过的请求。L2 STLB 可容纳 2048 个最近的数据和指令页地址转换,总共覆盖 8MB 内存空间。2MB 巨大页面的可用条目较少: L1 ITLB 有 32 个条目,L1 DTLB 有 32 个条目,而 L2 STLB 只能使用 1024 个条目,这些条目也与普通 4KB 页面共享。
如果在 TLB 层次结构中找不到翻译,就必须通过 “走读 ”内核页表从 DRAM 中检索。回想一下,页表是以子表的弧度树形式构建的,子表的每个条目都包含指向下一级树的指针。
加速走页过程的关键因素是一组分页结构缓存(Paging-Structure Caches,AMD:Page Walk Caches) ,它缓存了页表结构中的热点条目。对于 4 级页表,我们使用最小有效的 12 位(11:0)来表示页偏移量(未翻译),47:12 位表示页码。TLB 中的每个条目都是一个单独的完整翻译,而分页结构缓存只覆盖上面 3 层(第 47:21 位)。这样做的目的是减少 TLB 未命中时需要执行的加载次数。例如,如果没有这种缓存,我们就必须执行 4 次加载,这会增加指令完成的延迟。但在分页结构缓存的帮助下,如果我们找到了地址第 1 层和第 2 层(第 47:30 位)的转换,我们只需执行剩余的 2 次加载。
Golden Cove 微体系结构有 4 个专用走页器,可同时处理 4 个走页。在 TLB 未命中的情况下,这些硬件单元将向内存子系统发出所需的加载,并用新条目填充 TLB 层次结构。走页器生成的页表加载可以进入 L1、L2 或 L3 高速缓存(细节未披露)。最后,走页器可以预测未来的 TLB 未命中,并在未命中实际发生之前进行推测性走页以更新 TLB 条目。
Golden Cove 规范未披露两个 SMT 线程如何共享资源。但一般来说,缓存、TLB 和执行单元是完全共享的,以提高这些资源的动态利用率。另一方面,用于在主要管道级之间分期执行指令的缓冲区要么是复制的,要么是分区的。
这些缓冲区包括 IDQ、ROB、RAT、RS、加载缓冲区和存储缓冲区。PRF 也是复制的。
参考资料
- 软件测试精品书籍文档下载持续更新 https://github.com/china-testing/python-testing-examples 请点赞,谢谢!
- 本文涉及的python测试开发库 谢谢点赞! https://github.com/china-testing/python_cn_resouce
- python精品书籍下载 https://github.com/china-testing/python_cn_resouce/blob/main/python_good_books.md
- Linux精品书籍下载 https://www.cnblogs.com/testing-/p/17438558.html
3.9 性能监控单元(Performance Monitoring Unit)
每个现代 CPU 都提供监控性能的功能,这些功能被整合到性能监控单元 (PMU) 中。该单元包含的功能可帮助开发人员分析应用程序的性能。下图是现代英特尔 CPU 中 PMU 的示例。大多数现代 PMU 都有一组性能监控计数器 (PMC),可用于收集程序执行过程中发生的各种性能事件。稍后在第 5.3 节中,我们将讨论如何利用 PMC 进行性能分析。此外,PMU 还有其他增强性能分析的功能,如 LBR、PEBS 和 PT,第 6 章将专门讨论这些主题。
随着 CPU 设计的更新换代,其 PMU 也在不断发展。在 Linux 系统中,可以使用 cpuid 命令确定 CPU 中 PMU 的版本。通过检查 dmesg 命令的输出,也可以从内核信息缓冲区中提取类似信息。各英特尔 PMU 版本以及与前一版本相比的变化可参见 Intel, 2023, Volume 3B, Chapter 20。
...Physical Address and Linear Address Size (0x80000008/eax):maximum physical address bits = 0x27 (39)maximum linear (virtual) address bits = 0x30 (48)maximum guest physical address bits = 0x0 (0)Extended Feature Extensions ID (0x80000008/ebx):CLZERO instruction = falseinstructions retired count support = falsealways save/restore error pointers = falseINVLPGB instruction = falseRDPRU instruction = falsememory bandwidth enforcement = falseMCOMMIT instruction = false
...
3.9.1 性能监控计数器
如果我们想象一个简化的处理器视图,它可能与下图相似。正如我们在本章前面所讨论的,现代 CPU 有缓存、分支预测器、执行流水线和其他单元。当连接到多个单元时,PMC 可以从中收集有趣的统计数据。例如,它可以计算时钟周期的时间、执行的指令数量、期间发生的缓存未命中或分支预测错误数量,以及其他性能事件。
PMC 通常为 48 位宽,这使得分析工具可以在不中断程序执行的情况下长时间运行。性能计数器是作为特定型号寄存器(MSR Model-Specific Register)实现的硬件寄存器,这意味着计数器的数量和宽度可能因型号而异,因此不能依赖 CPU 中相同的计数器数量。您应首先使用 cpuid 等工具进行查询。PMC 可通过 RDMSR 和 WRMSR 指令访问,这些计数器只能在内核空间执行。幸运的是,只有当你是性能分析工具(如 Linux perf 或 Intel VTune profiler)的开发人员时,才需要关心这个问题。这些工具能处理 PMC 编程的所有复杂问题。
工程师在分析应用程序时,通常会收集执行指令数和周期数。这就是某些 PMU 具有专用 PMC 来收集此类事件的原因。固定计数器总是测量 CPU 内核中的相同内容。对于可编程计数器,用户可以自行选择要测量的内容。
例如,在英特尔 Skylake 架构(PMU 版本 4,见清单 3.3)中,每个物理内核都有三个固定计数器和八个可编程计数器。三个固定计数器用于计算内核时钟、参考时钟和退役指令(有关这些指标的更多详情,请参阅第 4 章)。AMD Zen4 和 Arm Neoverse V1 内核支持每个处理器内核 6 个可编程性能监控计数器,没有固定计数器。
PMU 提供 100 多个可供监控的事件并不罕见。上上图显示的只是现代英特尔 CPU 上可用于监控的性能监控事件的一小部分。不难发现,可用 PMC 的数量远远少于性能事件的数量。要同时统计所有事件是不可能的,但分析工具可以通过在程序执行过程中复用各组性能事件来解决这个问题(参见第 5.3.1 节)。
- 对于英特尔 CPU,完整的性能事件列表可参见 Intel, 2023, Volume 3B, Chapter 20或 perfmon-events.intel.com 网站。
- AMD 没有公布每款 AMD 处理器的性能监控事件列表。好奇的读者可以在 Linux perf 源代码中找到一些信息。此外,您还可以使用 AMD uProf 命令行工具列出可供监控的性能事件。有关 AMD 性能计数器的一般信息,请参阅 AMD, 2023, 13.2 性能监控计数器。
- 对于 ARM 芯片,性能事件的定义并不明确。供应商核心采用 ARM 架构,但性能事件的含义和支持的事件各不相同。对于 Arm 自己设计的 Arm Neoverse V1 内核,性能事件列表可在 Arm, 2022b 中找到。对于 Arm Neoverse V2 和 V3 微体系结构,性能事件列表可在 Arm 网站上找到。
3.10 问题与练习
- 描述流水线、失序和投机执行。
- 寄存器重命名如何帮助加快执行速度?
- 描述空间和时间定位。
- 在大多数现代处理器中,高速缓存行的大小是多少?
- 说出构成 CPU 前端和后端的组件。
- 4 级页表的组织结构是怎样的?什么是页面故障?
- x86 和 ARM 体系结构的默认页面大小是多少?
- TLB(转换旁路缓冲区)起什么作用?
3.11 本章小结
- 指令集体系结构(ISA)是软件和硬件之间的基本契约。ISA 是计算机的一个抽象模型,它定义了一系列可用的操作和数据类型、一组寄存器、内存寻址等。你可以用多种不同的方式实现特定的 ISA。例如,你可以设计一个优先考虑节能的 “小 ”内核,也可以设计一个以高性能为目标的 “大 ”内核。
- CPU 的 “微体系结构 ”概括了实现的细节。长期以来,成千上万的计算机科学家一直在研究这一课题。多年来,许多聪明的想法被发明出来,并在大众市场的 CPU 中得以实现。其中最著名的有流水线、乱序执行、超标量引擎、预测执行和 SIMD 处理器。所有这些技术都有助于利用指令级并行性(ILP),提高单线程性能。
- 在提高单线程性能的同时,硬件设计人员也开始推动多线程性能的发展。绝大多数面向客户端的现代设备都配备了包含多个内核的处理器。在同时多线程技术(SMT)的帮助下,一些处理器将可观察到的 CPU 内核数量增加了一倍。SMT 可让多个软件线程利用共享资源在同一物理内核上同时运行。这一方向上的最新技术被称为 “混合 ”处理器,它将不同类型的内核整合在一个封装中,以更好地支持各种工作负载。
- 现代计算机的内存层次结构包括多个级别的高速缓存,它们反映了访问速度与大小的不同权衡。L1 高速缓存往往最靠近内核,速度快但体积小。L3/LLC 缓存速度较慢,但也较大。DDR 是大多数平台使用的主要 DRAM 技术。DRAM 模块的级数和内存宽度各不相同,可能会对系统性能产生轻微影响。处理器可能有多个内存通道,可同时访问多个 DRAM 模块。
- 虚拟内存是与 CPU 上运行的所有进程共享物理内存的机制。程序在访问时使用虚拟地址,然后将其转换为物理地址。内存空间被划分为多个页面。x86 默认页大小为 4KB,ARM 默认页大小为 16KB。只有页地址会被翻译,页内的偏移量则按原样使用。操作系统将翻译保存在页表中,页表以弧度树的形式实现。有一些硬件特性可以提高地址转换的性能:主要是转换后备缓冲器(TLB)和硬件走页器。此外,在某些情况下,开发人员还可以利用大页面来降低地址转换的成本(参见第 8.4 节)。
- 我们研究了英特尔最近推出的 Golden Cove 微体系结构的设计。从逻辑上讲,内核分为前端和后端。前端由分支预测单元 (BPU)、L1 I 缓存、指令获取和解码逻辑以及向 CPU 后端馈送指令的 IDQ 组成。后端由 OOO 引擎、执行单元、负载存储单元、L1 D 缓存和 TLB 层次结构组成。
- 现代处理器的性能监控功能封装在性能监控单元(PMU)中。该单元以性能监控计数器(PMC)的概念为基础,可以观察程序运行时发生的特定事件,如缓存未命中和分支预测错误。