内存层次结构(Memory Hierarchy)

为了有效利用 CPU 中供应的所有硬件资源,机器需要在正确的时间获取正确的数据。未能做到这一点需要从主存中获取变量,这大约需要 100 ns。从 CPU 的角度来看,这是非常长的时间。理解内存层次结构对于发挥 CPU 的性能能力至关重要。大多数程序表现出局部性(locality)属性:它们不会均匀地访问所有代码或数据。CPU 内存层次结构建立在两个基本属性上:

  • 时间局部性(Temporal locality):当访问某个给定的内存位置时,同一位置很可能很快再次被访问。理想情况下,我们希望下次需要时该信息在缓存中。
  • 空间局部性(Spatial locality):当访问某个给定的内存位置时,附近的位置很可能很快也会被访问。这是指将相关数据放在彼此附近。当程序从内存读取单个字节时,通常会获取更大的内存块(一个缓存行),因为程序通常很快就会需要这些数据。

本节提供了现代 CPU 所支持的内存层次系统的关键属性摘要。

缓存层次结构(Cache Hierarchy)

缓存是 CPU 流水线发出的任何请求(代码或数据)的内存层次结构的第一级。理想情况下,流水线在拥有最小访问延迟的无限缓存时性能最佳。实际上,任何缓存的访问时间都是其大小的函数,会随大小增加。因此,缓存被组织为一个层次结构,由最靠近执行单元的小型、快速存储块组成,背后由更大、更慢的块提供支持。缓存层次结构的特定级别可以专用于代码(指令缓存,I-cache)或数据(数据缓存,D-cache),或在代码和数据之间共享(统一缓存)。此外,层次结构的某些级别可以是特定核心私有的,而其他级别可以在核心之间共享。

缓存被组织为具有定义大小的块,也称为缓存行(cache lines)。现代 CPU 中的典型缓存行大小为 64 字节。然而,这里值得注意的例外是 Apple 处理器(如 M1、M2 及更高版本)中的 L2 缓存,它以 128 字节的缓存行运行。最接近执行流水线的缓存通常大小从 32 KB 到 128 KB 不等。中级缓存通常有 1 MB 及以上。现代 CPU 中的末级缓存(last-level cache)可以达到数十甚至数百兆字节。

缓存中的数据放置

请求的地址用于访问缓存。在直接映射(direct-mapped)缓存中,给定块地址只能出现在缓存中的一个位置,由以下映射函数定义。直接映射缓存相对容易构建且访问时间快,但其缺失率(miss rate)较高。

缓存中的块数=缓存大小缓存块大小 \textrm{缓存中的块数} = \frac{\textrm{缓存大小}}{\textrm{缓存块大小}}

直接映射位置=(块地址)mod(缓存中的块数) \textrm{直接映射位置} = \textrm{(块地址)} \bmod \textrm{(缓存中的块数)}

全相联(fully associative)缓存中,给定块可以放置在缓存中的任何位置。这种方法涉及高硬件复杂性和较慢的访问时间,因此对于大多数用例来说被认为是不切实际的。

直接映射和全相联映射之间的中间选项是组相联(set-associative)映射。在这种缓存中,块被组织为组(set),通常每组包含 2、4、8 或 16 个块。给定地址首先被映射到一个组。在组内,该地址可以放置在该组的任何块中。每组有 m 个块的缓存被描述为 m 路组相联缓存。组相联缓存的公式为:

缓存中的组数=缓存中的块数每组块数(相联度) \textrm{缓存中的组数} = \frac{\textrm{缓存中的块数}}{\textrm{每组块数(相联度)}}

组(m 路)相联位置=(块地址)mod(缓存中的组数) \text{组(m 路)相联位置}=\text{(块地址)}\operatorname{mod}\text{(缓存中的组数)}

考虑一个 L1 缓存的例子,其大小为 32 KB,缓存行 64 字节,64 组,8 路。此缓存中的缓存行总数为 32 KB / 64 字节 = 512 行。新行只能插入其适当的组(64 组中的一个)。一旦确定了组,新行可以进入该组中的 8 路之一。同样,当你稍后搜索此缓存行时,首先确定组,然后只需检查该组中的最多 8 路。

这里是 Apple M1 处理器的缓存组织的另一个例子。每个性能核心内的 L1 数据缓存可以存储 128 KB,有 256 组,每组 8 路,以 64 字节行运行。性能核心形成一个集群并共享 L2 缓存,L2 缓存可以保存 12 MB,是 12 路组相联,以 128 字节行运行。[AppleOptimizationGuide]

在缓存中查找数据

m 路组相联缓存中的每个块都有一个与之关联的地址标签(tag)。此外,标签还包含状态位,例如指示数据是否有效的位。标签还可以包含附加位来指示访问信息、共享信息等。

用于缓存查找的地址组织。

用于缓存查找的地址组织。

图 CacheLookup 展示了如何使用流水线生成的地址来检查缓存。最低位地址位定义给定块内的偏移量;块偏移位(32 字节缓存行为 5 位,64 字节缓存行为 6 位)。使用上述公式中的索引位选择组。一旦选定组,标签位用于与该组中的所有标签进行比较。如果其中一个标签与传入请求的标签匹配且有效位被设置,则产生缓存命中(cache hit)。与该块条目关联的数据(与标签查找并行从缓存的数据阵列中读出)被提供给执行流水线。如果标签不匹配,则发生缓存未命中(cache miss)。

管理未命中

当发生缓存未命中时,缓存控制器必须选择缓存中要替换的块,以分配发生未命中的地址。对于直接映射缓存,由于新地址只能分配在单个位置,之前映射到该位置的条目被释放,新条目安装在其位置。在组相联缓存中,由于新缓存块可以放置在组的任何块中,因此需要替换算法。常用的替换算法是 LRU(最近最少使用,Least Recently Used)策略,其中最近最少访问的块被驱逐以为新数据腾出空间。另一种替代方案是随机选择其中一个块作为牺牲块。

管理写操作

对缓存的写访问比数据读取少见。在缓存中处理写操作比较困难,CPU 实现使用各种技术来处理这种复杂性。软件开发人员应特别注意硬件支持的各种写缓存流,以确保其代码的最佳性能。

CPU 设计使用两种基本机制来处理命中缓存的写操作:

  • 在直写(write-through)缓存中,命中的数据既写入缓存中的块,也写入层次结构的下一个更低级别。
  • 在写回(write-back)缓存中,命中的数据只写入缓存。随后,层次结构的较低级别包含陈旧数据。修改行的状态通过标签中的脏位(dirty bit)进行跟踪。当修改的缓存行最终从缓存中被驱逐时,写回操作强制将数据写回到下一个更低级别。

写操作的缓存未命中可以用两种方式处理:

  • 写分配(write-allocate)缓存中,未命中位置的数据从层次结构的较低级别加载到缓存中,然后写操作像写命中一样处理。
  • 如果缓存使用无写分配(no-write-allocate)策略,缓存未命中事务直接发送到层次结构的较低级别,块不加载到缓存中。

在这些选项中,大多数设计通常选择实现写回缓存加写分配策略,因为这两种技术都试图将后续写事务转换为缓存命中,而不会产生到层次结构较低级别的额外流量。直写缓存通常使用无写分配策略。

其他缓存优化技术

对于程序员来说,理解缓存层次结构的行为对于从任何应用程序中提取性能至关重要。从 CPU 流水线的角度来看,访问任何请求的延迟由以下公式给出,该公式可以递归地应用于缓存层次结构的所有级别,直至主存:

平均访问延迟=命中时间 + 缺失率 × 缺失惩罚 \textrm{平均访问延迟} = \textrm{命中时间 } + \textrm{ 缺失率 } \times \textrm{ 缺失惩罚}

硬件设计师通过许多新颖的微架构技术来应对减少命中时间和缺失惩罚的挑战。从根本上说,缓存未命中会停顿流水线并损害性能。任何缓存的缺失率高度依赖于缓存架构(块大小、相联度)和在机器上运行的软件。

硬件和软件预取(Hardware and Software Prefetching)

避免缓存未命中和随后停顿的一种方法是在流水线需要之前将数据预取到缓存中。假设是,如果预取请求在流水线中提前足够多地发出,则处理缺失惩罚的时间大多可以被隐藏。大多数 CPU 提供基于硬件的隐式预取,由程序员可以控制的显式软件预取补充。

硬件预取器观察正在运行的应用程序的行为,并针对缓存未命中的重复模式启动预取。硬件预取可以自动适应应用程序的动态行为,例如变化的数据集,不需要优化编译器的支持。此外,硬件预取不需要额外的地址生成和预取指令的开销。然而,硬件预取仅适用于有限的一组常用数据访问模式。

软件内存预取补充了硬件预取。开发人员可以通过专用硬件指令提前指定需要哪些内存位置(参见 [memPrefetch])。编译器还可以自动在代码中添加预取指令,以在需要数据之前请求数据。预取技术需要在需求和预取请求之间取得平衡,防止预取流量减慢需求流量。

主存(Main Memory)

主存是缓存下游的层次结构的下一级。加载和存储数据的请求由内存控制器单元(MCU,Memory Controller Unit)发起。过去,该电路位于主板上的北桥芯片中。但如今,大多数处理器已将该组件嵌入其中,因此 CPU 有一条专用的内存总线将其连接到主存。

主存使用 DRAM(动态随机存取存储器,Dynamic Random Access Memory)技术,支持以合理的成本提供大容量。比较 DRAM 模块时,人们通常关注内存密度和内存速度,当然还有价格。内存密度定义了以 GB 为单位的模块容量。显然,可用内存越多越好,因为它是操作系统和应用程序使用的宝贵资源。

主存的性能由延迟和带宽描述。内存延迟是从发出内存访问请求到 CPU 可以使用数据之间经过的时间。内存带宽定义了每单位时间内可以获取多少字节,通常以 GB/s 为单位。

DDR

DDR(双倍数据率,Double Data Rate)是大多数 CPU 支持的主流 DRAM 技术。从历史上看,DRAM 带宽每代都在改善,而 DRAM 延迟保持不变或有所增加。表 mem_rate 显示了最近三代 DDR 技术的最高数据速率、峰值带宽和相应的读取延迟。数据速率以每秒百万次传输(MT/s)为单位。此表中显示的延迟对应于 DRAM 设备本身的延迟。通常,从 CPU 流水线看到的延迟(加载到使用的缓存未命中)更高(在 50ns-150ns 范围内),这是由于缓存控制器、内存控制器和片上互连中产生的额外延迟和排队延迟。你可以在 [MemLatBw] 中看到测量观察到的内存延迟和带宽的示例。

DDR 代际 年份 最高数据速率 (MT/s) 峰值带宽 (GB/s) 设备内读取延迟 (ns)
DDR3 2007 2133 17.1 10.3
DDR4 2014 3200 25.6 12.5
DDR5 2020 6400 51.2 14

表:最近三代 DDR 技术的性能特性。

值得一提的是,DRAM 芯片需要定期刷新其存储单元。这是因为位值存储为微小电容器上的电荷存在,随着时间的推移可能会失去电荷。为防止这种情况,有专门的电路读取每个单元并将其写回,有效地恢复电容器的电荷。当 DRAM 芯片处于刷新过程中时,它不为内存访问请求服务。

DRAM 模块被组织为一组 DRAM 芯片。内存rank(内存组)是描述模块上存在多少组 DRAM 芯片的术语。例如,单 rank(1R)内存模块包含一组 DRAM 芯片。双 rank(2R)内存模块有两组 DRAM 芯片,因此容量是单 rank 模块的两倍。同样,还有四 rank(4R)和八 rank(8R)内存模块可供购买。

每个 rank 由多个 DRAM 芯片组成。内存宽度(width)定义每个 DRAM 芯片的总线有多宽。由于每个 rank 是 64 位宽(或 ECC RAM 为 72 位宽),它也定义了 rank 内存在的 DRAM 芯片数量。内存宽度可以是三个值之一:x4x8x16,定义了到每个芯片的总线宽度。作为示例,图 Dram_ranks 展示了一个总容量为 2GB 的 2Rx16 双 rank DRAM DDR4 模块的组织。每个 rank 有四个芯片,总线宽度为 16 位。四个芯片合并提供 64 位输出。两个 rank 通过 rank 选择信号一次选择一个。

总容量为 2GB 的 2Rx16 双 rank DRAM DDR4 模块的组织。

总容量为 2GB 的 2Rx16 双 rank DRAM DDR4 模块的组织。

关于单 rank 还是双 rank 性能更好,没有直接答案,这取决于应用类型。单 rank 模块通常产生较少热量,故障可能性较小。此外,多 rank 模块需要 rank 选择信号从一个 rank 切换到另一个,这需要额外的时钟周期,可能会增加访问延迟。另一方面,如果一个 rank 未被访问,它可以在其他 rank 忙碌时并行进行刷新周期。一旦前一个 rank 完成数据传输,下一个 rank 就可以立即开始传输。

进一步来说,我们可以在系统中安装多个 DRAM 模块,不仅增加内存容量,还增加内存带宽。使用多内存通道(multiple memory channels)的设置用于扩大内存控制器与 DRAM 之间的通信速度。

具有单个内存通道的系统在 DRAM 和内存控制器之间有 64 位宽的数据总线。多通道架构增加了内存总线的宽度,允许同时访问 DRAM 模块。例如,双通道架构将内存数据总线宽度从 64 位扩展到 128 位,使可用带宽翻倍,参见图 Dram_channels。注意,每个内存模块仍然是 64 位设备,但我们以不同方式连接它们。如今,服务器机器通常具有四个或八个内存通道。

双通道 DRAM 设置的组织。

双通道 DRAM 设置的组织。

或者,你也可能遇到具有重复内存控制器的设置。例如,处理器可能有两个集成内存控制器,每个控制器能够支持多个内存通道。两个控制器是独立的,只查看其自己的总物理内存地址空间的一部分。

我们可以使用下面的简单公式进行快速计算,以确定给定内存技术的最大内存带宽:

最大内存带宽=数据速率 × 每周期字节数 \textrm{最大内存带宽} = \textrm{数据速率 } \times \textrm{ 每周期字节数}

例如,对于数据速率为 2400 MT/s 且每次传输 64 位(8 字节)的单通道 DDR4 配置,最大带宽等于 2400 * 8 = 19.2 GB/s。双通道或双内存控制器设置将带宽翻倍至 38.4 GB/s。但请记住,这些数字是理论最大值,假设每个内存时钟周期都会发生数据传输,而实际上这从未发生。因此,在测量实际内存速度时,你总会看到低于理论最大传输带宽的值。

要启用多通道配置,你需要具有支持此类架构的 CPU 和主板,并在主板上的正确内存插槽中安装偶数数量的相同内存模块。在 Windows 上检查设置的最快方法是运行硬件识别实用程序,如 CPU-ZHwInfo;在 Linux 上,你可以使用 dmidecode 命令。或者,你可以运行内存带宽基准测试,如 Intel MLC 或 Stream。

为了利用系统中的多个内存通道,有一种称为交错(interleaving)的技术。它将页面内的相邻地址分散到多个内存设备上。图 Dram_channel_interleaving 展示了顺序内存访问的 2 路交错示例。如前所述,我们有一个双通道内存配置(通道 A 和 B),带有两个独立的内存控制器。现代处理器以每四条缓存行(256 字节)交错,即前四条相邻缓存行进入通道 A,然后下一组四条缓存行进入通道 B。

顺序内存访问的 2 路交错。

顺序内存访问的 2 路交错。

没有交错,连续的相邻访问将发送到同一内存控制器,不利用第二个可用控制器。相比之下,交错使硬件并行性能够更好地利用可用内存带宽。对于大多数工作负载,当所有通道都被填充时,性能最大化,因为这将单个内存区域分散到尽可能多的 DRAM 模块上。

虽然增加内存带宽通常是好事,但它并不总是转化为更好的系统性能,且高度依赖于应用程序。另一方面,重要的是要关注可用和已利用的内存带宽,因为一旦它成为主要瓶颈,应用程序就会停止扩展,即添加更多核心不会使其运行更快。

GDDR 和 HBM

除了多通道 DDR 之外,还有其他技术针对需要更高内存带宽以实现更高性能的工作负载。GDDR(图形 DDR,Graphics DDR)和 HBM(高带宽内存,High Bandwidth Memory)是最值得注意的。它们在高端图形、高性能计算(如气候建模、分子动力学和物理仿真)中找到了用途,还用于自动驾驶,当然还有 AI/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 通用服务器芯片现已上市。

results matching ""

    No results matching ""