NUMA 基础:从 CPU、内存到 Node 的整体理解

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 性能调优,就会比较容易理解了。

相关推荐
对空六课2 小时前
两个统计平台数据不同:用同一时间窗和事件样本做差异定位
大数据·前端·网络·数据库
User_芊芊君子2 小时前
【JavaEE初阶 - 网络原理】网络分几层?从自定义协议聊到 DNS
java·网络·java-ee
小白的码BUG之路9 小时前
linux -- ssh两台机器相连
运维·服务器·ssh
2603_969579289 小时前
备份最常用的三个软件是什么软件
网络
土豆.exe10 小时前
nf_tables 的 verdict 被读了两次:一个十年未闭合的位域错配
java·数据库·网络安全·缓存
缓慢更新11 小时前
表单引擎的存储建模:宽表与文档库的取舍
运维·服务器·网络·数据库·低代码·oracle·适配器模式
Data_Journal11 小时前
如何绕过 Cloudflare:网页抓取的最佳方法
网络·python
续写烂尾11 小时前
贴片 SD NAND、eMMC、TF 卡规格怎么定?硬件工程师选型清单
运维·服务器·网络
不昀11 小时前
网口Link Up后仍然掉线,怎样区分物理链路和数据通路问题?
linux·以太网