如何更好更快地访问内存是 HPC 中最大的瓶颈之一,仅仅了解 SIMD 或并行编程接口是不足够的,本文将梳理计算机的内存层次结构、缓存友好编程、内存墙现象、NUMA 架构以及预取技术。
Understanding Memory Hierarchy
为了充分利用现代 CPU 的性能,我们必须理解数据是如何在不同层级的内存组件之间流动的。
Registers, Caches, and Main Memory
寄存器 (Registers): CPU 内置的、容量最小但速度最快的数据存储单元,用于存储正在被 CPU 活跃操作的数据。CPU 直接在寄存器上执行大部分计算。
缓存 (Cache): 位于 CPU 和主内存之间的小容量、高速存储区域。它们的目的是通过存储最可能被 CPU 再次访问的数据来减少对主内存的访问延迟。
- L1 缓存 (Level 1 Cache):最小、最快,通常分为数据缓存 (L1d) 和指令缓存 (L1i),每个 CPU 核心独有。其访问速度与 CPU 核心时钟周期相近。
- L2 缓存 (Level 2 Cache):比 L1 大且慢,每个 CPU 核心独有或由几个核心共享。
- L3 缓存 (Level 3 Cache):最大、最慢的缓存,通常由同一 CPU 插槽上的所有核心共享。
主内存 (Main Memory/RAM): 容量远大于缓存,但访问速度慢得多。当数据不在任何缓存中时,CPU 必须从主内存中获取。
TLB (Translation Lookaside Buffer): TLB 是一个专用的高性能缓存,用于存储虚拟地址到物理地址的转换映射。当 CPU 访问一个虚拟地址时,它首先检查 TLB。如果找到对应的物理地址(TLB 命中),则可以快速进行内存访问;如果未找到(TLB 未命中),则需要查询页表,这将导致显著的延迟。理解 TLB 对于优化内存页访问模式,尤其是在处理大型数据集时至关重要。
通过这种多级内存层次结构访问内存,我们需要尽可能满足局部性原理来提高效率:
- 时间局部性 (Temporal Locality):如果一个数据项最近被访问过,那么它很可能在不久的将来再次被访问。
- 空间局部性 (Spatial Locality):如果一个数据项被访问了,那么它附近的内存地址中的数据项也很可能在不久的将来被访问。
Cache-Friendly Programming
编写“缓存友好”的代码意味着组织数据和访问模式,最大化缓存命中率。
Cache Line and Performance Impact
- 缓存行 (Cache Line): 缓存和主内存之间数据传输的最小单元,通常为 64 字节。当 CPU 从主内存中请求一个字节时,整个缓存行都会被加载到缓存中。
- 这强调了空间局部性:如果你的程序按顺序访问内存,那么一次缓存加载可以为未来的访问提供多个数据项,从而提高效率。
- 伪共享 (False Sharing):如果两个或多个独立的变量不幸地位于同一个缓存行中,并且被不同的 CPU 核心修改,那么即使它们逻辑上不相关,也会因为缓存一致性协议导致大量的缓存行失效和重新加载,从而严重影响性能。
Cache Hit/Miss and Coherence
- 缓存命中 (Cache Hit):当 CPU 需要的数据已经在某个缓存级别中时,访问速度非常快。
- 缓存未命中 (Cache Miss):当 CPU 需要的数据不在任何缓存中时,必须从更慢的内存级别(最终是主内存)获取数据,这会引入延迟。未命中可分为:
- 强制性未命中 (Compulsory Miss/Cold Miss):首次访问数据。
- 容量性未命中 (Capacity Miss):缓存太小,无法容纳所有活跃数据。
- 冲突性未命中 (Conflict Miss):多个数据项映射到缓存中的同一个位置。
- 缓存一致性 (Cache Coherence): 在多核处理器系统中,不同的核心可能有同一份数据在各自的缓存副本中。为了确保所有核心看到的数据是一致的最新版本,需要缓存一致性协议,如 MESI (Modified, Exclusive, Shared, Invalid) 协议。理解这些协议有助于避免伪共享等问题。
SoA vs. AoS
选择正确的数据布局对缓存性能至关重要。这部分在 HPC 中的 C 和 C++ 中也有提及。
结构体数组 (AoS: Array of Structs):
struct Point { float x, y, z; } points[N];
- 这种布局下,一个
Point
结构体的所有成员在内存中是连续的。如果你的代码经常需要访问一个点的所有坐标,这种布局是高效的。
- 这种布局下,一个
数组结构体 (SoA: Struct of Arrays):
struct { float x[N], y[N], z[N]; } points_soa;
- 如果你需要对所有点的 $ x $ 坐标执行操作,那么可以高效地利用缓存行,因为内存访问是高度连续的。对于 SIMD 向量化操作来说,SoA 通常更优化。
选择 SoA 还是 AoS 取决于数据访问模式:如果经常需要访问一个对象的所有属性,AoS 可能更好(但要注意缓存行对齐和填充)。如果经常需要对多个对象的某个特定属性进行批处理操作,SoA 通常是更好的选择。
The Memory Wall
内存墙是指 CPU 的计算速度与主内存的访问速度之间日益扩大的差距。CPU 处理能力的增长远远快于内存延迟的改进速度,这意味着即使 CPU 理论上可以执行大量的指令,但如果它必须经常等待数据从主内存中加载,那么大部分时间都会处于空闲状态,从而限制了实际的应用程序性能。
解决方案:
- 优化算法,减少对内存的访问次数。
- 最大化缓存命中率,利用数据局部性。
- 采用预取技术来隐藏内存访问延迟。
NUMA Architectures
Non-Uniform Memory Access Challenges
NUMA (Non-Uniform Memory Access) ,即非一致性内存访问架构,在多处理器系统中变得越来越普遍。在 NUMA 系统中,每个 CPU (或 CPU 插槽) 都有一组直接连接的本地内存,访问本地内存比访问连接到另一个 CPU 的远端内存要快得多。不同的内存器件和 CPU 核心从属不同的 Node,每个 Node 都有自己的集成内存控制器(IMC,Integrated Memory Controller)。
如果一个线程在 CPU0 上运行,却频繁访问挂载在 CPU1 上的内存,性能会显著下降,因为数据必须通过处理器间互连(如 Intel 的 UPI 或 AMD 的 Infinity Fabric)传输,这会引入额外的延迟。
不当的内存放置策略可能导致严重的性能瓶颈,甚至超过内存墙的限制。
NUMA Optimization with numactl
为了在 NUMA 架构下获得最佳性能,我们必须确保计算尽可能地在靠近其所访问数据的 CPU 核心上进行。numactl
是一个强大的 Linux 命令行工具,它允许我们精确控制进程的 CPU 亲和性和内存分配策略。
- 查看 NUMA 节点布局:
numactl --hardware
命令可以显示系统中所有的 NUMA 节点、每个节点的 CPU 核心及其本地内存大小。
|
|
输出示例:
|
|
这表示系统有 2 个 NUMA 节点(0 和 1)。节点 0 拥有 CPU 核心 0-3,节点 1 拥有 CPU 核心 4-7。node distances
表示访问本地内存的成本为 10,访问远端内存的成本为 21,远端访问的开销约为本地的两倍。
- 重要
numactl
选项:--cpunodebind <nodes>
:将进程或线程绑定到指定 NUMA 节点上的 CPU 核心。例如,--cpunodebind=0
将进程限制在节点 0 上的 CPU。--membind <nodes>
:强制所有内存分配都来自指定 NUMA 节点。例如,--membind=1
将所有内存都从节点 1 分配。--localalloc
:在当前线程运行的 NUMA 节点上分配内存。这是最佳实践,因为它确保了数据存储在距离计算最近的位置。--physcpubind <cpus>
:将进程或线程绑定到特定的物理 CPU 核心。
NUMA Memory Access Test
可以通过一个简单的多线程数组求和程序来演示 numactl
对 NUMA 性能的影响。程序会分配一个非常大(足够超出 cache)的数组,然后使用 OpenMP 让多个线程并行计算数组元素的总和。
|
|
可以使用 GCC 编译这个程序:
|
|
(由于我的电脑只有一个 NUMA 核心,所以下面测试无法进行。。。)
- Baseline:
|
|
- 远端内存访问:CPU 在节点 1,内存绑定到节点 0。这时所有数据都是远端访问,理论上性能应该最差。
|
|
- 本地内存访问:CPU 在节点 0,内存绑定到节点 0。这是理性的 NUMA 配置,所有数据访问都是本地的。
|
|
- 真实多线程场景:CPU 绑定到节点 0 和 1,但内存仅分配到节点 0。跑在节点 1 上的线程将进行远端内存访问。
|
|
Prefetching
预取 (Prefetching) 是一种技术,它尝试在 CPU 实际需要数据之前,就将其从较慢的内存层级加载到较快的缓存中。这有助于隐藏内存访问延迟,使 CPU 能够专注于计算。
硬件预取器 (Hardware Prefetcher): 现代 CPU 内置的智能逻辑单元,它们会监控内存访问模式,并根据检测到的模式(如顺序访问)自动预测接下来可能需要哪些数据,将其提前加载到缓存中。
- 优点:全自动,无需程序员干预。
- 缺点:有时预测不准确,可能将无用数据加载到缓存中,挤出有用数据,甚至增加内存总线流量。
编译器预取 (Compiler Prefetching): 一些编译器能够根据代码中的循环和访问模式,在编译时插入预取指令。通过
-O3
等优化选项或特定的编译器提示,可以启用此功能。软件预取 (Software Prefetching): 程序员可以通过使用特殊的 CPU 指令(通常通过内联函数 Intrinsics 暴露)显式地告诉 CPU 预取哪些数据。 例如,在 x86 架构上:
|
|
- 当硬件预取器无法有效应对复杂的访问模式时,软件预取可以提供更精确的控制。
- 需要程序员手动插入,可能会增加代码复杂性,不当使用可能导致性能下降。
Summary
在 HPC 领域,仅依靠 CPU 的原始计算能力和并行编程模型是不够的。深入理解计算机内存,是编写高性能代码的基础。通过采用缓存友好的编程,如优化数据布局和分块算法,我们可以显著提高应用程序的性能,真正发挥现代 CPU 的潜力。