Linux 基础体系 · 第 38/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。
Linux NUMA 与硬件拓扑:节点、内存亲和、跨节点访问和诊断
NUMA(Non-Uniform Memory Access,非一致内存访问)描述的是这样一种机器:所有 CPU 都可以访问系统内存,但不同 CPU 访问不同物理内存区域的代价并不相同。
在单路、规模较小的系统中,CPU 到内存通常只有一条主要路径,访问延迟和带宽差异不明显。多路服务器、具有多个内存控制器的系统,以及部分高端非对称硬件,则会把 CPU、内存控制器和内存划分为多个区域。CPU 访问本地区域通常更快,访问其他区域则需要经过片间互连,延迟更高、可用带宽也可能受竞争影响。
NUMA 优化的核心不是“把线程绑到某个 CPU”这么简单,而是同时回答两个问题:
- 线程在哪里运行?
- 它访问的数据实际位于哪个 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)表示这类访问发生的次数或权重。
则一个简化的内存访问总成本可以写成:
当页面全部放在本地节点时:
如果一个线程运行在节点 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。用户空间通常通过 numactl、libnuma,或底层的 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继续执行
实际硬件可能使用不同的缓存一致性和互连协议,但因果关系相同:远程访问需要经过额外的互连和目标节点内存控制器。
远程访问的代价不只是一次加载多花一些时间:
- 远程链路占用带宽;
- 目标节点内存控制器处理更多请求;
- 请求可能排队;
- 多个源节点可能争用同一目标节点;
- 写入共享数据还会引起缓存行在核心之间转移;
- 页面迁移期间可能出现额外的复制和 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=。
这个实验中每一步成立的原因是:
malloc得到虚拟地址;- 循环按页步长写入,使每个页面产生实际分配;
--membind限制页面分配节点;sleep保持进程存在,便于读取/proc/<pid>/numa_maps;- 只写每页第一个字节,避免实验被大量计算操作主导。
要验证 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>/smaps、AnonHugePages、系统 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.cpus、cpuset.mems 和 memory.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 策略。
应用若需要确定性布局,通常应:
- 明确线程与节点的映射;
- 让负责消费数据的线程初始化数据;
- 使用
libnuma或mmap/页面策略接口; - 在变更后通过
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"
应验证的是实际结果,而不是配置文本本身:
- 线程是否运行在预期 CPU;
- 进程允许使用的内存节点是否正确;
- 新分配页面是否落在预期节点;
- 服务是否出现节点内存耗尽;
- P99 延迟和吞吐是否改善;
- 是否出现页面迁移或远程访问增加。
如果验证失败,恢复原配置并重启服务通常比在线强行修改更可控。对共享内存、长时间运行进程和文件缓存,在线修改策略未必能立即改变已有页面位置。
二十、建立正确的 NUMA 推理顺序
面对一个 NUMA 性能问题,可以按以下因果顺序推理:
硬件拓扑
↓
任务允许的 CPU / 内存节点
↓
线程实际运行位置
↓
虚拟地址和页面当前所在节点
↓
应用线程对数据的访问模式
↓
本地/远程访问与带宽竞争
↓
延迟、吞吐、迁移和故障表现
每一层都需要证据:
- 用
lscpu、numactl --hardware和 sysfs 确认拓扑; - 用
taskset、numactl --show、/proc/<pid>/status确认约束; - 用线程级采样确认实际运行 CPU;
- 用
/proc/<pid>/numa_maps和numastat -p确认页面分布; - 用应用访问模式和
perf/平台 PMU 数据确认远程流量; - 用业务延迟和吞吐确认优化是否真正有效。
NUMA 的本质是“执行位置”和“数据位置”的匹配问题。节点、CPU affinity、内存策略、first-touch、页面迁移和硬件距离共同决定这两个位置;任何只观察其中一项的结论,都可能把相关性误认为因果关系。
系列导航与关联阅读
- 系列入口:Linux 完整学习路线:从内核与文件系统到网络、性能和生产运维
- 上一篇:Linux Seccomp 与 Capabilities:系统调用过滤、最小权限和沙箱
- 下一篇:Linux PSI 资源压力:CPU、内存、I/O Stall、告警和容量判断
- 延伸:Linux 调度器深入:CFS、实时策略、优先级、Affinity 和 NUMA
- 延伸:Linux 虚拟内存:页表、缺页、Cache、Swap、OOM 与内存诊断
官方资料
本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论
0 条讨论