NUMA 基础:从 CPU、内存到 Node 的整体理解
在看 Linux 性能优化、数据库调优或者多线程程序时,经常会看到 NUMA、NUMA Node、Local Memory、Remote Memory 这些概念。
如果只是普通 PC,这些东西平时很难直接感受到。但在双路服务器或者几十核、上百核的机器上,NUMA 对程序性能的影响会比较明显。
理解 NUMA 的关键,其实不是记住几个命令,而是先搞清楚一个问题:
为什么 CPU 访问不同位置的内存,速度会不一样?
1. NUMA 的由来
在早期的多处理器系统中,比较常见的是 SMP,也就是 Symmetric Multi-Processing,对称多处理架构。
可以简单理解为多个 CPU 共享一套内存:
text
CPU 0 ─┐
CPU 1 ─┼──── Memory
CPU 2 ─┤
CPU 3 ─┘
在这种结构下,不同 CPU 访问内存的方式基本相同,因此也可以认为内存访问是比较"均匀"的。
这种方式在 CPU 数量比较少时没有太大问题。
但随着 CPU Core 数量不断增加,所有 CPU 都通过共享总线或者共享内存控制器访问内存,问题就逐渐出现了。
比如现在有几十个 Core:
text
CPU CPU CPU CPU CPU CPU ...
|
|
Memory Controller
|
Memory
所有 CPU 的内存请求都集中到同一个地方,内存带宽和总线很容易成为瓶颈。
为了解决这个问题,现代服务器逐渐开始采用 NUMA 架构。
NUMA 的全称是:
text
Non-Uniform Memory Access
也就是非一致性内存访问。
它的核心思路比较直接:
不要让所有 CPU 都共享同一个内存控制器,而是把 CPU 和内存划分成多个区域。
每一组 CPU 都有离自己更近的内存。
例如:
text
Node 0
CPU 0 ~ CPU 15
|
Memory Controller
|
Memory 0
Node 1
CPU 16 ~ CPU 31
|
Memory Controller
|
Memory 1
这里的 Node 0、Node 1,就是所谓的 NUMA Node。
一个 NUMA Node 可以简单理解为:
text
一组 CPU Core
+
对应的 Memory Controller
+
离这些 CPU 最近的一部分内存
例如一台服务器可能是:
text
Node 0
CPU: 0-15
Memory: 64 GB
Node 1
CPU: 16-31
Memory: 64 GB
整台服务器一共有 128 GB 内存,但是从硬件拓扑上看,这些内存并不是完全一样的。
2. Local Memory 和 Remote Memory
NUMA 架构下最重要的一个变化,就是内存开始有"远近"之分。
假设一个线程现在运行在 Node 0 的 CPU 上。
如果它访问 Node 0 上的内存:
text
CPU(Node 0)
|
|
Memory(Node 0)
这种访问叫做 Local Memory Access,也就是本地内存访问。
由于内存直接连接在当前 Node 的 Memory Controller 上,因此访问路径比较短。
但如果 Node 0 上的 CPU 要访问 Node 1 的内存:
text
CPU(Node 0)
|
Interconnect
|
CPU(Node 1)
|
Memory(Node 1)
这就是 Remote Memory Access。
CPU 需要通过 Node 之间的互联链路去访问另外一个 NUMA Node 上的内存。
因此一般来说:
text
Local Memory
延迟更低
带宽更高
而:
text
Remote Memory
延迟更高
会占用 Node 之间的互联带宽
这也是 NUMA 中 Non-Uniform 的来源。
对于程序来说,内存仍然表现为统一的虚拟地址空间。
比如:
cpp
int *p = new int[1000000];
程序并不会看到:
text
这是 Node 0 的地址
这是 Node 1 的地址
这些区别主要由操作系统和硬件负责处理。
但在底层,数据最终落在哪个 NUMA Node,会影响 CPU 访问这些数据时的成本。
所以 NUMA 带来的问题通常不是:
text
程序能不能跑
而是:
text
程序跑得快不快
3. CPU、Socket、Core 和 NUMA Node
刚开始接触 NUMA 时,还有几个概念很容易混在一起:
text
Socket
Core
Thread
NUMA Node
可以先简单区分一下。
Socket
Socket 可以理解为主板上的物理 CPU 插槽。
一台双路服务器一般有两个 Socket:
text
Socket 0
Socket 1
每个 Socket 上放一颗物理 CPU。
Core
一颗 CPU 内部通常有多个物理 Core。
比如:
text
Socket 0
Core 0
Core 1
Core 2
Core 3
...
Thread
如果 CPU 支持 SMT 或者 Intel Hyper-Threading,那么一个 Core 还可以对应多个逻辑 CPU。
例如:
text
Core 0
├── CPU 0
└── CPU 16
Linux 中通过 lscpu 看到的 CPU 编号,通常就是逻辑 CPU。
NUMA Node
NUMA Node 和 Socket 并不完全等价。
在很多服务器中,可以近似认为:
text
1 Socket ≈ 1 NUMA Node
但这不是绝对的。
某些 CPU 内部本身也可能进一步划分多个 NUMA Domain,因此一个 Socket 也可能对应多个 NUMA Node。
所以实际机器还是应该通过:
bash
lscpu
或者:
bash
numactl --hardware
来查看。
例如:
text
NUMA node(s): 2
NUMA node0 CPU(s): 0-15
NUMA node1 CPU(s): 16-31
说明这台机器有两个 NUMA Node。
CPU 0-15 属于 Node 0,CPU 16-31 属于 Node 1。
4. NUMA 为什么会影响程序性能
假设现在有一个多线程程序。
程序启动以后,一个线程运行在 Node 0:
text
Thread
|
CPU 2
|
Node 0
然后程序申请了一大块内存。
如果这批内存最终主要分配在 Node 0,那么此时访问关系是:
text
CPU Node 0
|
Memory Node 0
访问的都是 Local Memory。
但 Linux 的线程调度并不是固定的。
后面调度器可能发现 Node 1 比较空,于是把线程迁移到了 Node 1:
text
Thread
|
CPU 20
|
Node 1
但之前分配的数据仍然在 Node 0。
于是变成:
text
CPU Node 1
|
Remote Access
|
Memory Node 0
程序逻辑完全没有问题,也不会崩溃。
但线程每次访问数据,都可能需要进行 Remote Memory Access。
如果程序本身是内存密集型程序,这种额外开销就可能比较明显。
例如:
text
数据库
缓存系统
搜索引擎
大规模并行程序
高性能计算
高并发服务
这些程序通常会频繁访问大量内存,因此相比普通应用,更容易受到 NUMA 的影响。
这里可以看到,NUMA 优化其实主要涉及两个问题:
text
线程在哪个 CPU 上运行
数据放在哪个 NUMA Node
比较理想的情况是:
text
Thread
|
Node 0 CPU
|
Node 0 Memory
也就是:
计算和数据尽量放在同一个 Node。
5. Linux 中的 First Touch
Linux 在 NUMA 内存分配中有一个比较重要的机制,叫做 First Touch。
比如:
cpp
int *a = new int[1024 * 1024];
这里只是申请了一段虚拟地址空间。
Linux 并不会在这一刻马上给整个数组分配对应的物理内存。
真正访问这些地址时,才会触发 Page Fault,然后分配物理页。
例如:
cpp
for (int i = 0; i < n; i++) {
a[i] = 0;
}
这个初始化过程,会真正触发物理内存分配。
如果当前线程运行在 Node 0,那么这些页面通常会优先分配到 Node 0。
因此:
谁第一次访问这块内存,会影响物理页最终落在哪个 NUMA Node。
这就是 First Touch。
来看一个典型情况。
程序先由一个线程完成整个数组初始化:
text
Node 0 CPU
|
初始化整个数组
|
大量页面分配到 Node 0
后面程序启动多个线程:
text
Node 0
Thread 0
Thread 1
Thread 2
Thread 3
Node 1
Thread 4
Thread 5
Thread 6
Thread 7
Node 1 上的线程也需要访问这些数据。
但数据大部分已经放到了 Node 0。
于是 Node 1 上的线程会产生大量 Remote Memory Access。
因此一些高性能并行程序会采用这样的方式:
text
Thread 0 初始化自己要处理的数据
Thread 1 初始化自己要处理的数据
Thread 2 初始化自己要处理的数据
这样可以让不同线程使用的数据尽量分布到对应的 NUMA Node。
6. CPU Affinity 和 NUMA
NUMA 经常会和 CPU Affinity 一起出现。
但这两个东西不是一回事。
CPU Affinity 控制的是:
text
线程在哪些 CPU 上运行
例如:
bash
taskset -c 0-7 ./server
表示让程序运行在 CPU 0-7 上。
而 NUMA Memory Policy 控制的是:
text
内存尽量分配在哪个 Node
所以只做 CPU Binding 并不能保证数据一定在本地内存。
反过来也一样。
即使内存放在 Node 0,如果线程经常被调度到 Node 1,一样会产生 Remote Memory Access。
因此很多 NUMA 优化实际上是:
text
CPU Binding
+
Memory Binding
一起做。
核心还是前面提到的:
text
计算在哪
数据就尽量在哪
7. Linux 下如何查看 NUMA
Linux 中最常用的一个命令就是:
bash
lscpu
例如:
text
Socket(s): 2
Core(s) per socket: 16
NUMA node(s): 2
NUMA node0 CPU(s): 0-15
NUMA node1 CPU(s): 16-31
通过这些信息,可以看到 Socket、Core 和 NUMA Node 之间的大致关系。
另外一个比较常用的命令是:
bash
numactl --hardware
可能会看到类似:
text
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 ...
node 0 size: 64000 MB
node 0 free: 32000 MB
node 1 cpus: 16 17 18 19 ...
node 1 size: 64000 MB
node 1 free: 28000 MB
这里不仅能看到 CPU 分布,还可以看到每个 Node 的内存容量。
另外还可以通过:
bash
numastat
查看 NUMA 相关的内存统计信息。
常见字段包括:
text
numa_hit
numa_miss
numa_foreign
local_node
other_node
其中最简单可以先关注:
text
local_node
other_node
如果程序出现大量 other_node 访问,通常就值得进一步检查是不是存在较多跨 Node 内存访问。
8. numactl 的基本使用
numactl 可以控制一个进程运行时的 NUMA 策略。
例如:
bash
numactl --cpunodebind=0 ./server
表示让程序尽量运行在 Node 0 对应的 CPU 上。
也可以指定内存:
bash
numactl --membind=0 ./server
表示内存尽量从 Node 0 分配。
两者也可以同时使用:
bash
numactl --cpunodebind=0 --membind=0 ./server
这样线程和内存都会尽量放在 Node 0。
除此之外,还有一种比较常见的策略:
bash
numactl --interleave=all ./server
表示把内存页交错分配到不同 NUMA Node。
大致类似:
text
Page 1 -> Node 0
Page 2 -> Node 1
Page 3 -> Node 0
Page 4 -> Node 1
这种方式可以一定程度上均衡不同 Node 的内存带宽。
不过需要注意的是,NUMA 并不是"绑得越死越好"。
例如服务器有:
text
Node 0: 64 GB
Node 1: 64 GB
如果强制:
text
--membind=0
那么程序就只能优先使用 Node 0 的内存。
当 Node 0 内存压力非常大时,反而可能带来新的问题。
所以 NUMA 策略还是需要根据程序的实际访问模式决定。
9. NUMA 优化到底在优化什么
NUMA 本质上并不是一个特别复杂的概念。
它的产生过程可以简单总结为:
text
CPU Core 数量增加
|
v
共享内存总线压力增加
|
v
CPU 和内存开始分组
|
v
形成多个 NUMA Node
|
v
每个 Node 有自己的 Local Memory
|
v
跨 Node 访问产生额外开销
所以在 NUMA 系统中,真正需要关注的其实就是三个问题:
text
线程运行在哪个 Node?
数据分配在哪个 Node?
线程是否频繁访问其他 Node 的内存?
如果线程和数据长期处于:
text
CPU Node 0
|
Memory Node 1
这种状态,那么就可能出现比较明显的 Remote Memory Access。
而如果能够尽量做到:
text
CPU Node 0
|
Memory Node 0
就可以减少跨 Node 的访问。
因此 NUMA 优化最核心的一句话就是:
让计算尽量靠近数据。
对于普通程序来说,NUMA 很多时候并不是首先需要考虑的问题。
但当程序运行在几十核甚至上百核服务器上,并且本身属于数据库、缓存、高性能网络或者大规模并行计算这类内存访问密集型程序时,NUMA 就很可能成为影响性能的一个重要因素。
理解了 CPU、Memory、NUMA Node、Local Access 和 Remote Access 之间的关系,后面再去看 CPU Affinity、绑核、内存绑定以及 NUMA 性能调优,就会比较容易理解了。