Linux 基础体系 · 第 38/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux NUMA 与硬件拓扑:节点、内存亲和、跨节点访问和诊断

NUMA(Non-Uniform Memory Access,非一致内存访问)描述的是这样一种机器:所有 CPU 都可以访问系统内存,但不同 CPU 访问不同物理内存区域的代价并不相同。

在单路、规模较小的系统中,CPU 到内存通常只有一条主要路径,访问延迟和带宽差异不明显。多路服务器、具有多个内存控制器的系统,以及部分高端非对称硬件,则会把 CPU、内存控制器和内存划分为多个区域。CPU 访问本地区域通常更快,访问其他区域则需要经过片间互连,延迟更高、可用带宽也可能受竞争影响。

NUMA 优化的核心不是“把线程绑到某个 CPU”这么简单,而是同时回答两个问题:

  1. 线程在哪里运行?
  2. 它访问的数据实际位于哪个 NUMA 节点?

只有 CPU 执行位置和数据所在位置同时合理,局部性才成立。


一、先区分几个容易混淆的概念

1. NUMA 节点不是 CPU,也不一定等于物理插槽

NUMA 节点(NUMA node)是 Linux 对一组处理器和一组可被其访问的内存资源的抽象。一个节点通常包含:

  • 一组逻辑 CPU;
  • 一部分物理内存;
  • 一个或多个内存控制器;
  • 到其他节点的距离或代价信息。

在很多双路服务器上,常见拓扑是:

NUMA node 0: socket 0 的 CPU + socket 0 连接的内存
NUMA node 1: socket 1 的 CPU + socket 1 连接的内存

但这不是规范保证。现代处理器可能因为:

  • 每个插槽包含多个内存控制器;
  • 一个插槽被拆成多个 NUMA cluster;
  • BIOS 的 SNC、NPS 等选项改变拓扑;
  • 虚拟机由 VMM 暴露出虚拟 NUMA 节点;

而出现“一个插槽对应多个 NUMA 节点”,也可能出现多个插槽共享一个 Linux NUMA 节点的配置。

因此,不能用 CPU 编号 < 某个值 推断节点,也不能仅凭物理插槽数量判断 NUMA 结构。

2. CPU affinity 与内存亲和是两种不同约束

CPU affinity(CPU 亲和性)限制任务可以在哪些逻辑 CPU 上运行。例如:

taskset -cp 4-7 1234

它只改变调度器选择 CPU 的范围,不会自动把进程已经分配的内存迁移到这些 CPU 所属的节点。

内存亲和性(memory affinity)则描述页面应该从哪些 NUMA 节点分配,或者优先从哪个节点分配。例如:

numactl --cpunodebind=1 --membind=1 ./server

这条命令同时表达:

  • 进程只能在节点 1 的 CPU 上运行;
  • 进程内存只从节点 1 分配。

两者可以独立设置:

numactl --cpunodebind=1 --membind=0 ./program

这会让线程运行在节点 1,却把数据放在节点 0,通常会产生持续的远程访问。它有时用于刻意构造实验,但不是一般意义上的局部性优化。

3. 逻辑 CPU、核心、线程和节点是不同层次

可以用以下层次理解硬件拓扑:

NUMA 节点
└── CPU 插槽或 NUMA cluster
    └── 物理核心
        └── SMT/超线程逻辑 CPU

Linux 的调度器最终选择的是逻辑 CPU;NUMA 策略通常以节点为单位;缓存亲和性则还受到核心、共享 LLC 等更细粒度结构影响。

因此,下面几种“亲和”不能混为一谈:

  • CPU affinity:任务可运行在哪些逻辑 CPU;
  • NUMA memory policy:页面从哪些内存节点分配;
  • cache locality:数据是否位于当前核心可快速访问的缓存层;
  • IRQ affinity:硬件中断在哪些 CPU 上处理;
  • cpuset:任务可使用的 CPU 和内存节点集合。

二、Linux 如何表示 NUMA 硬件拓扑

1. 用 lscpu 查看概览

lscpu

典型输出可能包含:

CPU(s):                128
On-line CPU(s) list:   0-127
NUMA node(s):          4
NUMA node0 CPU(s):     0-31
NUMA node1 CPU(s):     32-63
NUMA node2 CPU(s):     64-95
NUMA node3 CPU(s):     96-127

这些字段说明:

  • 系统有 128 个逻辑 CPU;
  • Linux 当前识别出 4 个 NUMA 节点;
  • 每个节点包含一组逻辑 CPU。

lscpu 的拓扑信息是概览,不足以说明内存容量、节点距离和在线状态。

2. 用 numactl --hardware 查看节点和距离

安装 numactl 后:

numactl --hardware

可能得到:

available: 4 nodes (0-3)
node 0 cpus: 0-31
node 0 size: 128000 MB
node 0 free: 96000 MB
node 1 cpus: 32-63
node 1 size: 128000 MB
node 1 free: 91000 MB
node 2 cpus: 64-95
node 2 size: 128000 MB
node 2 free: 100000 MB
node 3 cpus: 96-127
node 3 size: 128000 MB
node 3 free: 97000 MB
node distances:
node   0   1   2   3
  0:  10  20  30  30
  1:  20  10  30  30
  2:  30  30  10  20
  3:  30  30  20  10

这里的距离值是内核和硬件拓扑提供的相对代价,不应直接解释为纳秒。通常:

  • 对角线上的 10 表示本地访问;
  • 数值越大,通常表示访问路径代价越高;
  • 具体延迟、带宽和拥塞关系仍然需要基准测试验证。

node size 是节点可管理的内存容量,node free 是当前可用量。它们会随回收、缓存、迁移和其他进程变化,不能当作固定硬件属性。

3. 通过 sysfs 查看内核实际拓扑

for n in /sys/devices/system/node/node[0-9]*; do
    echo "[$n]"
    cat "$n/cpulist"
    grep -E '^(Node|MemTotal|MemFree|MemUsed)' "$n/meminfo" 2>/dev/null
    echo -n "distance: "
    cat "$n/distance"
done

常用路径包括:

/sys/devices/system/node/online
/sys/devices/system/node/possible
/sys/devices/system/node/nodeN/cpulist
/sys/devices/system/node/nodeN/meminfo
/sys/devices/system/node/nodeN/distance

online 表示当前在线的节点,possible 表示内核可能支持的节点范围。内存热插拔、CPU 热插拔和虚拟化环境可能导致两者不一致。

/sys 是内核导出的运行时状态,不是所有字段都适合被脚本永久假设。不同内核版本、架构和发行版配置可能有差异。


三、物理地址为什么会有“所属节点”

Linux 的虚拟内存系统把进程看到的虚拟地址映射到物理页。NUMA 的关键在于:物理页不仅有 PFN(Page Frame Number),还属于某个 NUMA 节点。

可以把一次数据访问简化为:

虚拟地址
  ↓ 页表转换
物理页 / PFN
  ↓ 根据物理地址和硬件映射确定内存控制器
NUMA 节点 j 的内存

运行线程的 CPU 位于节点 i,被访问的页面位于节点 j。当 i == j 时,通常称为本地访问;当 i != j 时,称为跨节点或远程访问。

“远程”并不表示该 CPU 无法访问页面。NUMA 系统仍然保持统一的物理地址空间和一致性协议,远程访问只是路径更长、资源竞争更多。

访问代价的形式化模型

设:

  • T_i 表示线程运行在 NUMA 节点 i
  • P_j 表示页面位于 NUMA 节点 j
  • L(i,j) 表示节点 i 的 CPU 访问节点 j 页面的一次访问代价;
  • a(i,j) 表示这类访问发生的次数或权重。

则一个简化的内存访问总成本可以写成:

C=ija(i,j)L(i,j)C = \sum_i \sum_j a(i,j) \cdot L(i,j)

当页面全部放在本地节点时:

Clocal=ia(i,i)L(i,i)C_{\text{local}} = \sum_i a(i,i) \cdot L(i,i)

如果一个线程运行在节点 0,却频繁访问节点 1 的页面,则对应的 a(0,1) 很大,成本由 L(0,1) 决定。

这个模型只是局部性分析的第一步。真实系统还受到以下因素影响:

  • CPU cache 是否命中;
  • 内存读写比例;
  • 访问是否可并行;
  • 内存控制器队列是否拥塞;
  • 远程链路带宽是否被其他 CPU 使用;
  • 页面是否正在迁移;
  • 多个线程是否共享并修改同一缓存行。

因此,NUMA 距离表可以解释拓扑关系,但不能单独预测应用的最终性能。


四、页面什么时候决定放在哪个节点

1. 虚拟地址分配不等于物理页分配

例如:

void *p = malloc(1024 * 1024 * 1024);

这通常只建立了用户空间分配器的虚拟地址范围或保留了地址空间。物理页往往在第一次真正访问时才分配,这一过程称为按需分配(demand allocation)。

典型路径是:

malloc / mmap
  ↓
得到虚拟地址,但未必有物理页
  ↓
第一次写入
  ↓
缺页异常
  ↓
内核根据 NUMA policy 选择节点
  ↓
分配物理页并建立页表映射

所以,“哪个线程第一次触碰页面”经常决定页面的初始位置。这就是 first-touch(首次触碰)策略在 Linux NUMA 系统中的重要性。

实际行为还会受到以下因素影响:

  • 匿名页还是文件映射页;
  • 页面是否来自共享内存;
  • 是否发生写时复制(COW);
  • 内核 NUMA 自动平衡是否启用;
  • 当前节点是否缺少可用内存;
  • 进程是否受 cpuset 或 cgroup 限制;
  • 分配请求是否要求高阶连续页;
  • 内核回收和迁移策略。

因此,“首次写入决定位置”是常见机制,不是对所有内存类型和所有时刻的绝对保证。

2. 一个 first-touch 反例

假设有两个节点:

node 0: CPU 0-31,内存 128 GiB
node 1: CPU 32-63,内存 128 GiB

程序在节点 0 上启动,分配一个数组,然后由主线程初始化:

double *a = malloc(n * sizeof(*a));

for (size_t i = 0; i < n; i++)
    a[i] = 0.0;

即使之后创建的工作线程全部运行在节点 1,这些页面也可能已经在初始化时分配到了节点 0。于是工作线程在节点 1 上运行,却持续远程读取或修改节点 0 的页面。

更合适的并行初始化方式是让“使用数据的线程”初始化对应分片:

线程组 A 在 node 0 运行并初始化数组前半段
线程组 B 在 node 1 运行并初始化数组后半段

这不是因为线程创建本身具有神奇的 NUMA 行为,而是因为每个线程在其所属节点上首次访问了自己负责的页面。

3. 反例:首次触碰也可能制造错误局部性

考虑一个生产者—消费者队列:

生产者固定运行在 node 0
消费者固定运行在 node 1
队列缓冲区由生产者首次初始化

如果消费者之后读取大量数据,那么所有数据都放在 node 0 可能并不理想。更好的布局可能是:

  • 生产者和消费者尽量放在同一节点;
  • 使用节点 0 和节点 1 的分片队列;
  • 让每个节点拥有自己的本地缓冲区;
  • 对只读数据复制副本,而不是所有线程共享一个远程副本。

NUMA 优化不是机械地执行 first-touch,而是让页面位置匹配主要访问者和数据流。


五、内存策略:Linux 如何决定从哪里分配页面

Linux 为进程、线程和虚拟内存区域提供了 NUMA memory policy。用户空间通常通过 numactllibnuma,或底层的 set_mempolicy(2)mbind(2) 进行设置。

1. 常见策略

默认策略:default

默认策略通常允许内核按照当前上下文和可用资源选择节点。具体分配还会受到 cpuset、节点状态和内核实现影响。

默认不等于“始终本地”。如果线程迁移了、节点内存不足,或者任务受 cpuset 约束,页面可能出现在其他节点。

本地策略:local

本地策略优先从当前运行 CPU 所属节点分配。

numactl --localalloc ./app

它适合线程和数据访问关系稳定的程序,但如果线程频繁跨节点迁移,所谓“当前节点”也会变化,页面布局可能因此变得不稳定。

绑定策略:bind

只允许从指定节点分配:

numactl --membind=0 ./app

这适合希望严格控制数据位置的场景,例如基准测试或节点内独占服务。风险是指定节点耗尽后,分配可能失败,或者受到 cpuset 等更高层资源约束;不要把它当作无条件的性能开关。

优先策略:preferred

优先从指定节点分配,无法满足时允许回退到其他节点:

numactl --preferred=1 ./app

它比严格绑定更有容错性,但当首选节点紧张时,页面会落到其他节点,应用可能出现性能波动。

交错策略:interleave

按照节点集合在页面粒度上轮流分配:

numactl --interleave=0,1 ./app

交错的主要目标通常是分散内存控制器压力,而不是让每个访问都本地。假设页面按顺序在节点 0、1、0、1 分配,而所有线程都在节点 0 上,那么约一半访问仍可能是远程的。

它更适合:

  • 多节点并行访问同一大块数据;
  • 单节点内存带宽成为瓶颈;
  • 应用访问模式足够均匀;
  • 需要避免某个节点成为容量或带宽热点。

2. 策略约束不是物理页永久标签

内存策略影响后续页面分配,但不会自动修复所有已有页面。

例如:

numactl --membind=1 ./app

只对该命令启动后的进程策略生效。它不能把文件系统缓存、父进程已经建立的页面,或其他进程共享的页面全部搬到节点 1。

对正在运行的进程设置策略还存在范围问题:

  • 策略可能作用于任务;
  • 也可能作用于某个虚拟内存区域;
  • 共享内存还有共享策略和 attach 时机;
  • 已经分配的页面未必立即迁移。

因此应把“设置分配策略”和“迁移现有页面”分开理解。

3. 继承、线程和作用域

进程通常通过 numactl 在启动时设定策略:

numactl --cpunodebind=0 --membind=0 ./app

新建线程通常继承进程的相关 NUMA 策略,但线程可以通过线程级接口或库函数进一步改变策略。具体作用域取决于调用的 API 和内核版本,不应仅凭“进程策略”推断每个线程和每个 VMA 的最终状态。

使用 libnuma 时,应检查返回值。例如伪代码中的以下调用不能忽略错误:

if (numa_available() < 0) {
    fprintf(stderr, "NUMA is not available\n");
    return 1;
}

if (numa_run_on_node(1) != 0) {
    perror("numa_run_on_node");
    return 1;
}

生产代码还应考虑:

  • 节点编号是否在目标机器上存在;
  • 目标节点是否 online;
  • 进程是否有权限改变自身或其他任务的策略;
  • cpuset 是否允许使用目标 CPU 和内存节点;
  • 绑定后内存不足时如何降级或退出。

六、CPU 绑定、内存绑定和 cpuset 的关系

NUMA 运行环境通常同时存在三层限制:

系统在线 CPU / 在线 NUMA 节点
  ↓
cgroup v2 cpuset 控制器
  ↓
进程或线程的 CPU affinity、memory policy

子 cgroup 的可用 CPU 和内存节点不能突破父 cgroup 的范围。即使执行:

numactl --cpunodebind=1 --membind=1 ./app

如果该服务所在 cgroup 只允许节点 0,实际可用集合仍会受到 cgroup 限制。

在 cgroup v2 中,相关文件常见于:

/sys/fs/cgroup/<group>/cpuset.cpus
/sys/fs/cgroup/<group>/cpuset.mems
/sys/fs/cgroup/<group>/memory.numa_stat

cpuset.cpus 限制 CPU,cpuset.mems 限制可使用的内存节点;memory.numa_stat 则用于观察该 cgroup 的内存按节点分布。不同发行版的 systemd 层级和挂载方式可能不同,不能假设路径一定完全相同。

一个重要边界:绑定策略不能突破可用节点集合

假设机器有节点 0 和 1,但服务被放入:

cpuset.cpus = 0-31
cpuset.mems  = 0

此时即使进程请求 --membind=1,也不能真正获得节点 1 的内存。最终行为可能表现为策略冲突、分配失败或受到内核允许的集合约束,具体取决于调用路径和策略类型。

诊断时应同时查看:

taskset -cp "$PID"
numactl --show --pid "$PID"
cat /proc/"$PID"/status | grep -E 'Cpus_allowed|Mems_allowed'

其中:

  • Cpus_allowed_list 表示任务允许运行的 CPU;
  • Mems_allowed_list 表示任务允许使用的内存节点;
  • numactl --show 显示 NUMA 相关策略和绑定信息。

七、跨节点访问到底发生了什么

当线程在节点 0 的 CPU 上执行:

value = array[i];

大致过程是:

sequenceDiagram
    participant C as 节点0的CPU
    participant T as 节点0缓存/一致性逻辑
    participant L as 节点间互连
    participant M as 节点1内存控制器
    participant P as 节点1物理页

    C->>T: 查找虚拟地址对应的缓存行
    T->>T: 页表转换并发现缓存未命中
    T->>L: 请求节点1物理页
    L->>M: 转发读取请求
    M->>P: 从DRAM读取缓存行
    P-->>M: 返回数据
    M-->>L: 返回数据
    L-->>T: 返回缓存行并维护一致性
    T-->>C: CPU继续执行

实际硬件可能使用不同的缓存一致性和互连协议,但因果关系相同:远程访问需要经过额外的互连和目标节点内存控制器。

远程访问的代价不只是一次加载多花一些时间:

  1. 远程链路占用带宽;
  2. 目标节点内存控制器处理更多请求;
  3. 请求可能排队;
  4. 多个源节点可能争用同一目标节点;
  5. 写入共享数据还会引起缓存行在核心之间转移;
  6. 页面迁移期间可能出现额外的复制和 TLB 失效成本。

因此,一个应用即使“远程访问比例不高”,也可能因为远程流量集中到某个节点而受到明显影响。

共享可写数据的特殊问题

两个节点上的线程共同修改同一数据结构时,页面放在哪个节点都可能不完美:

  • 放在节点 0:节点 1 的线程远程访问;
  • 放在节点 1:节点 0 的线程远程访问;
  • 交错放置:单个对象内部的访问代价不稳定;
  • 复制数据:减少远程读取,但增加一致性维护和内存容量;
  • 分片数据结构:减少跨节点共享,但改变编程模型。

NUMA 与缓存一致性是两个不同层次的问题。把页面迁移到线程所在节点,不能消除多个线程频繁写同一缓存行造成的 cache-line bouncing。


八、自动 NUMA 平衡:内核会不会自动搬页面

Linux 可以通过自动 NUMA 平衡(Automatic NUMA Balancing)观察任务和页面的访问关系,并尝试调整任务运行位置或页面位置。

相关配置通常是:

cat /proc/sys/kernel/numa_balancing

常见值:

  • 0:关闭;
  • 1:开启;
  • 某些内核版本还支持与扫描粒度、内存扫描相关的扩展模式。

内核实现通常通过对部分页表映射进行处理,使后续访问触发 NUMA hinting fault,从而采样“哪个 CPU 节点访问了哪个页面”。内核根据采样结果判断:

  • 是否将任务迁移到页面所在节点;
  • 是否将页面迁移到任务更常访问的节点;
  • 是否维持当前布局。

这一过程不是每次内存访问都检查 NUMA,也不是保证达到最优布局。它带来额外成本:

  • 页表操作和提示性缺页;
  • 页面迁移和复制;
  • TLB 失效;
  • 迁移期间的锁和带宽开销;
  • 在访问模式快速变化时发生反复迁移。

适合自动平衡的程序和适合显式绑定的程序不同:

  • 长时间运行、线程和数据关系较稳定的服务,自动平衡可能有帮助;
  • 延迟敏感、拓扑关系明确、工作集巨大且频繁共享的程序,显式划分通常更可控;
  • 短时间运行的程序可能还没积累足够采样,就已经结束;
  • 频繁改变线程位置的程序可能使自动平衡追赶变化,而不是产生稳定收益。

修改全局开关属于系统级变更:

sudo sysctl -w kernel.numa_balancing=0

生产环境不应只根据单个进程的观测就关闭全局功能。应先确认内核版本、业务范围、回滚方式和影响面。


九、使用 numastat 观察节点分布

1. 查看系统级统计

numastat

输出通常包含:

                           node0       node1
numa_hit                  123456      120001
numa_miss                   1024        2048
numa_foreign                2048        1024
interleave_hit            ...
local_node                ...
other_node                ...

这些计数是内核 NUMA 相关路径的统计,不应直接等同于应用所有的硬件远程 load/store。不同内核版本、架构和配置的统计含义可能不同。

常见理解是:

  • numa_hit:在期望或较合适节点上完成的分配;
  • numa_miss:尝试在某节点分配但实际落到其他节点;
  • local_node:任务在本地节点分配的相关计数;
  • other_node:任务在其他节点分配的相关计数。

具体字段应结合当前系统的 numastat 和内核文档确认,不能跨版本机械比较绝对数值。

2. 查看进程级统计

numastat -p 1234

它可以帮助判断某个进程的内存和 NUMA 事件是否集中在特定节点。执行时可能需要读取 /proc 中的进程信息;对其他用户的进程,权限和 hidepid 等挂载选项可能导致信息不完整。

需要注意:

  • 进程内存分布不等于实际访问分布;
  • 页面位于 node 0,不代表一定被 node 1 频繁访问;
  • 统计计数可能是累计值,不能直接作为当前瞬时状态;
  • 线程级差异可能被进程级汇总掩盖。

十、用 /proc/<pid>/numa_maps 查看页面级线索

sudo cat /proc/1234/numa_maps

可能出现类似内容:

7f2a10000000 default anon=262144 dirty=262144 N0=131072 N1=131072 kernelpagesize_kB=4
7f2a50000000 file=/data/index.bin mapped=65536 N1=65536
7f2a70000000 heap anon=8192 dirty=8192 N0=8192

常见字段含义:

  • 地址范围对应一个虚拟内存区域;
  • anon 表示匿名页数量;
  • file 表示文件映射;
  • dirty 表示脏页数量;
  • N0=...N1=... 表示页面在各 NUMA 节点的数量;
  • kernelpagesize_kB 表示内核页面大小。

这个文件适合回答“页面现在分布在哪里”,但不能直接回答“哪个 CPU 访问了它们”。要推断访问关系,仍需结合线程 CPU 位置、应用工作集和性能计数器。

例如:

N0=100000 N1=0

如果服务线程全部在 node 1 上运行,这说明存在潜在远程访问风险;但只有在这些页面确实被 node 1 线程高频访问时,才会转化为实际性能问题。

权限不足时,读取其他用户进程的 numa_maps 可能失败。对匿名映射、透明大页和共享映射,输出粒度和解释也需要结合映射属性。


十一、一个可重复的 first-touch 实验

下面的实验用于观察“初始化线程位置如何影响页面分布”。它需要:

  • 一台至少有两个 NUMA 节点的 Linux 主机;
  • 已安装 numactl
  • 编译器;
  • 足够的可用内存。

创建程序:

// touch.c
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <unistd.h>

int main(int argc, char **argv)
{
    size_t mb = argc > 1 ? strtoull(argv[1], NULL, 10) : 1024;
    size_t len = mb * 1024ULL * 1024ULL;
    size_t pagesize = (size_t)sysconf(_SC_PAGESIZE);

    unsigned char *p = malloc(len);
    if (!p) {
        perror("malloc");
        return 1;
    }

    for (size_t i = 0; i < len; i += pagesize)
        p[i] = 1;

    printf("pid=%ld allocated_and_touched=%zu MiB\n",
           (long)getpid(), mb);
    fflush(stdout);

    sleep(300);
    free(p);
    return 0;
}

编译:

cc -O2 -Wall -Wextra touch.c -o touch

在 node 0 上运行并首次触碰:

numactl --cpunodebind=0 --membind=0 ./touch 1024

程序打印 PID 后,另一个终端执行:

sudo cat /proc/<PID>/numa_maps | grep -E 'heap|anon='

预期会看到大量 N0= 页面。之后运行:

numactl --cpunodebind=1 --membind=1 ./touch 1024

对应进程的页面通常会大量落在 N1=

这个实验中每一步成立的原因是:

  1. malloc 得到虚拟地址;
  2. 循环按页步长写入,使每个页面产生实际分配;
  3. --membind 限制页面分配节点;
  4. sleep 保持进程存在,便于读取 /proc/<pid>/numa_maps
  5. 只写每页第一个字节,避免实验被大量计算操作主导。

要验证 first-touch 而不是 membind 的作用,可以改为:

numactl --cpunodebind=0 ./touch 1024
numactl --cpunodebind=1 ./touch 1024

默认策略下页面通常倾向于首次访问线程所在节点,但结果会受到当前内存压力、自动 NUMA 平衡、cpuset 和内核实现影响,所以这是观察实验,不是规范保证。

实验风险

如果将 1024 改成很大的值,可能导致:

  • 该进程被 OOM kill;
  • 系统回收文件缓存;
  • 绑定节点耗尽后分配失败;
  • 影响同机其他服务。

应在隔离主机或明确限制的 cgroup 中运行,避免直接在生产节点进行大规模测试。


十二、页迁移、内存回收和透明大页会改变观察结果

1. 页面位置不是永久不变的

页面可能因为以下原因改变节点:

  • 自动 NUMA 平衡;
  • 显式迁移工具或系统调用;
  • 内存压缩、回收和重新分配;
  • transparent huge page(THP)合并或拆分;
  • 内存热移除;
  • 虚拟机或宿主机层面的内存迁移。

因此,numa_maps 是某个时刻的快照。连续采样比单次读取更有意义:

while sleep 1; do
    date
    sudo grep -E 'heap|anon=' /proc/1234/numa_maps | head
done

2. 大页改变了分配和迁移粒度

普通页常见大小是 4 KiB,但透明大页可能以更大的粒度管理映射。大页可以减少页表层级和 TLB 压力,但也可能使:

  • 单次分配需要更大的连续物理资源;
  • 迁移成本更高;
  • 页面分布看起来不像普通页那样细粒度;
  • 拆分和合并产生额外开销。

NUMA 诊断中,应同时观察 /proc/<pid>/smapsAnonHugePages、系统 THP 配置以及应用的分配方式。不能只根据匿名页总数推断真实的物理分布。


十三、故障表现:NUMA 问题通常不会像程序错误那样直接报错

NUMA 配置错误的常见表现包括:

1. 延迟抖动

服务线程偶尔迁移到其他节点,或者数据页在多个节点间迁移,会使同一类请求的访问路径发生变化,表现为 P99、P999 延迟升高。

2. 吞吐下降但 CPU 利用率不高

线程可能在等待远程内存、互连或内存控制器,而不是进行计算。此时总 CPU 利用率不一定接近 100%。

3. 某个节点的内存或带宽成为热点

总内存还有很多空闲,但某个节点已经紧张。严格绑定的服务可能因此分配失败,默认策略的服务则可能开始远程分配。

4. 绑定后出现 OOM 或启动失败

例如:

numactl --membind=0 ./large-service

如果服务工作集超过 node 0 可提供的内存,程序可能在分配或缺页时失败。整机仍有 node 1 的空闲内存,并不意味着该进程可以使用它。

5. 自动平衡导致迁移震荡

如果线程在节点 0 和节点 1 之间频繁变化,而数据访问模式也在变化,内核可能不断尝试调整任务和页面位置,迁移成本反而高于收益。


十四、系统化诊断流程

NUMA 诊断应先确认拓扑,再确认约束,再确认页面分布,最后确认实际访问和性能影响。

第一步:确认节点和 CPU 拓扑

lscpu
numactl --hardware
cat /sys/devices/system/node/online

记录:

  • NUMA 节点数量;
  • 每个节点的 CPU;
  • 每个节点的容量和空闲内存;
  • 节点距离;
  • 是否存在离线节点;
  • BIOS 或虚拟化层是否改变过拓扑。

如果需要查看缓存和核心层次,可使用 hwloc

lstopo-no-graphics

hwloc 是额外工具,不是 Linux 内核接口;它对硬件拓扑的展示依赖系统固件、内核和虚拟化环境提供的信息。

第二步:确认任务允许运行和分配的范围

PID=1234

taskset -cp "$PID"
numactl --show --pid "$PID"
grep -E 'Cpus_allowed_list|Mems_allowed_list' /proc/"$PID"/status

同时检查服务所在 cgroup:

cat /proc/"$PID"/cgroup

然后在对应 cgroup 目录中查看 cpuset.cpuscpuset.memsmemory.numa_stat

第三步:确认页面实际分布

sudo cat /proc/"$PID"/numa_maps
numastat -p "$PID"

关注:

  • 大型匿名映射是否集中在单个节点;
  • 文件映射是否落在预期节点;
  • 页面是否随着运行时间迁移;
  • 页面分布与线程 CPU 位置是否匹配。

第四步:观察线程而不是只看进程

进程可能包含多个线程,而不同线程可能运行在不同节点。查看线程:

ps -L -p "$PID" -o pid,tid,psr,pcpu,comm

其中:

  • TID 是线程 ID;
  • PSR 是最近运行的逻辑 CPU;
  • PCPU 是 CPU 使用率。

PSR 是采样时或最近一次运行的 CPU,不代表线程永久绑定在那里。要判断稳定性,应持续采样,或查看实际 affinity。

第五步:观察 NUMA 事件和系统压力

numastat
grep -E 'numa|thp|pgmigrate' /proc/vmstat

/proc/vmstat 字段可能因内核配置和版本变化。不要写死某个字段一定存在;重点是观察 NUMA fault、页面迁移、透明大页和回收相关计数的变化趋势。

性能计数器可以使用:

perf stat -p "$PID" sleep 10

NUMA 相关硬件事件名称高度依赖 CPU 型号。应先查看本机支持的事件:

perf list | grep -i numa

不能把某一款处理器上的 remote_node 或类似事件名称直接复制到另一款处理器上。即使事件名称相同,计数定义也可能不同,应参考该 CPU 的 PMU 文档。


十五、用性能数据区分“页面远程”和“数据共享”

只看 /proc/<pid>/numa_maps 只能知道“页面在哪”。要进一步判断性能原因,需要结合访问路径:

页面分布
  + 线程运行节点
  + 实际负载访问模式
  + 内存带宽/互连计数器
  + 延迟和吞吐指标
  = NUMA 问题的可验证证据

例如有以下两种情况:

情况 A:明显的页面错位

线程:90% 在 node 1
工作集:80% 页面在 node 0

如果应用是读密集型,且 node 1 到 node 0 存在大量流量,那么 first-touch 或启动顺序很可能导致了远程访问。

情况 B:页面本地但仍然很慢

线程:在各自节点运行
页面:也在各自节点
性能:仍然下降

此时原因可能是:

  • 同一缓存行在多个核心间来回转移;
  • 单节点内存带宽已耗尽;
  • 锁竞争;
  • LLC 容量不足;
  • 分支预测或指令前端瓶颈;
  • 磁盘、网络或 GPU I/O 等其他路径。

NUMA 不是所有“多路机器变慢”问题的解释。


十六、工程中的数据布局选择

1. 节点私有分片

将工作集划分为节点私有部分:

node 0:
  worker 0..N
  data shard 0

node 1:
  worker N+1..M
  data shard 1

线程主要访问本节点数据,跨节点通信只处理必要的结果或边界信息。这通常比让所有线程访问一个全局大数组更容易获得稳定局部性。

2. 只读数据复制

只读索引、模型参数或查找表可以在多个节点复制:

node 0: read-only copy
node 1: read-only copy

代价是消耗额外内存,但可以减少远程读取。适用性取决于:

  • 数据是否真的只读;
  • 复制成本是否可接受;
  • 工作集是否会挤压其他热数据;
  • 更新时是否需要重新发布副本。

3. 交错放置

当每个节点的线程都会均匀访问整个大数组时,交错分配可能比集中在一个节点更好:

numactl --interleave=0,1 ./stream-like-workload

但如果访问具有明显分片结构,交错可能破坏本地性。策略应由访问模式决定,而不是由“多节点”三个字决定。

4. 内存分配器的影响

多线程程序的 malloc 可能使用多个 arena。分配器锁竞争和线程创建顺序会影响分配路径,但分配器的 arena 数量并不等于 NUMA 节点数量,也不能代替 NUMA 策略。

应用若需要确定性布局,通常应:

  • 明确线程与节点的映射;
  • 让负责消费数据的线程初始化数据;
  • 使用 libnumammap/页面策略接口;
  • 在变更后通过 numa_maps 和性能测量验证。

十七、容器、虚拟机和云主机中的限制

1. 容器看到的拓扑可能不完整

容器可能被限制到一组 CPU 和内存节点,也可能只能看到宿主机部分信息。容器内的:

lscpu
numactl --hardware

不一定反映完整物理机器。

容器的 CPU 绑定位于 cgroup 控制;内存节点限制通常由 cpuset 控制器决定。容器编排系统的 CPU manager、Topology Manager 和内存策略可能共同影响最终布局,具体行为依赖运行时和发行版版本。

2. 虚拟机中的 vNUMA 不等于宿主机物理 NUMA

虚拟机可以获得虚拟 NUMA 节点,但其虚拟节点与宿主机物理节点的映射由虚拟机管理器决定。常见风险包括:

  • 虚拟 CPU 被调度到与虚拟内存不匹配的宿主节点;
  • vNUMA 拓扑与宿主机拓扑不一致;
  • 内存 balloon、热迁移和超分配改变延迟;
  • 客户机看到的节点距离不能代表真实物理距离。

在虚拟化环境中,应同时检查客户机和宿主机视角,不能只凭客户机内的 numactl --hardware 下结论。


十八、常见误区

误区一:把 NUMA 节点当成物理插槽

NUMA 节点是内核和硬件共同暴露的内存访问域,不必与 socket 一一对应。应使用 numactl --hardware、sysfs 或 lstopo 确认。

误区二:只绑 CPU,不管内存

taskset -c 32-63 ./app

只能把任务放到某些 CPU。已有数据可能仍在其他节点,首次触碰也可能由不在目标节点的线程完成。

误区三:只绑内存,不绑 CPU

numactl --membind=0 ./app

可能导致线程在节点 1 执行、页面在节点 0,形成稳定远程访问。内存绑定必须结合线程运行位置和实际访问者分析。

误区四:看到节点有空闲内存,就认为绑定不会失败

严格内存绑定只允许使用指定节点或受限集合。其他节点的空闲内存不能自动解决目标节点耗尽问题。

误区五:页面分布均匀就一定性能好

均匀分布可能增加远程访问;页面本地也可能存在锁竞争和共享缓存行问题。页面数量分布只是诊断证据的一部分。

误区六:关闭自动 NUMA 平衡一定更快

关闭后可能减少迁移开销,但也可能让错误的 first-touch 布局永久保留。必须用业务延迟、吞吐、迁移计数和带宽数据验证。

误区七:NUMA 距离值就是纳秒

node distances 是相对距离或代价表示,不是通用的纳秒单位。真实延迟和带宽必须在目标机器、目标访问模式下测量。


十九、生产变更的风险与验证

NUMA 相关变更常见于启动参数、systemd 服务、容器 cgroup 或全局 sysctl。修改前应明确:

  • 目标节点是否在线;
  • 目标节点是否有足够容量;
  • 服务是否包含多个线程组;
  • 是否依赖文件缓存或共享内存;
  • 进程是否允许被迁移;
  • 失败时是否会导致启动失败或 OOM;
  • 回滚命令和原始配置是什么。

例如为 systemd 服务增加:

[Service]
ExecStart=/usr/bin/numactl --cpunodebind=0 --membind=0 /opt/app/server

验证时至少检查:

systemctl daemon-reload
systemctl restart app.service

PID=$(pidof server)
numactl --show --pid "$PID"
taskset -cp "$PID"
sudo grep -E 'heap|anon=' /proc/"$PID"/numa_maps
numastat -p "$PID"

应验证的是实际结果,而不是配置文本本身:

  1. 线程是否运行在预期 CPU;
  2. 进程允许使用的内存节点是否正确;
  3. 新分配页面是否落在预期节点;
  4. 服务是否出现节点内存耗尽;
  5. P99 延迟和吞吐是否改善;
  6. 是否出现页面迁移或远程访问增加。

如果验证失败,恢复原配置并重启服务通常比在线强行修改更可控。对共享内存、长时间运行进程和文件缓存,在线修改策略未必能立即改变已有页面位置。


二十、建立正确的 NUMA 推理顺序

面对一个 NUMA 性能问题,可以按以下因果顺序推理:

硬件拓扑
  ↓
任务允许的 CPU / 内存节点
  ↓
线程实际运行位置
  ↓
虚拟地址和页面当前所在节点
  ↓
应用线程对数据的访问模式
  ↓
本地/远程访问与带宽竞争
  ↓
延迟、吞吐、迁移和故障表现

每一层都需要证据:

  • lscpunumactl --hardware 和 sysfs 确认拓扑;
  • tasksetnumactl --show/proc/<pid>/status 确认约束;
  • 用线程级采样确认实际运行 CPU;
  • /proc/<pid>/numa_mapsnumastat -p 确认页面分布;
  • 用应用访问模式和 perf/平台 PMU 数据确认远程流量;
  • 用业务延迟和吞吐确认优化是否真正有效。

NUMA 的本质是“执行位置”和“数据位置”的匹配问题。节点、CPU affinity、内存策略、first-touch、页面迁移和硬件距离共同决定这两个位置;任何只观察其中一项的结论,都可能把相关性误认为因果关系。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。