NUMA 架构与内存亲和性:节点拓扑、访问延迟与 numactl 绑核实战

从 SMP 到 NUMA 的演进、ACPI SRAT/SLIT 描述的内存拓扑、本地与远程访问的延迟差距,到内核 struct pglist_data 与 zonelist 的调度策略,最后落地 numactl、numad 与自动 NUMA balancing 的调优实践与常见误区。

当 CPU 主频撞上物理极限后,厂商转向"堆核心"来提升吞吐。但核心一多,所有核心共享一条前端总线去访问同一块内存就变成了瓶颈:内存带宽被争抢,延迟随核心数线性上升。NUMA(Non-Uniform Memory Access,非统一内存访问)就是为了解决这个问题而生的架构——内存被切分给各个"节点",每个节点离一部分核心近、离另一部分核心远。这直接改变了性能模型:同一份代码,内存分配在哪、线程跑在哪个核,可能带来数倍的延迟差异。

理解 NUMA 需要把三件事串起来:硬件拓扑如何被固件描述、内核如何建模并调度内存、用户态如何观测与干预。本文从这三层展开,并与 https://plumephp.com/os-multiprocessor-cache/(多核缓存一致性)、https://plumephp.com/os-cpu-scheduling/(调度器如何选择 CPU)、https://plumephp.com/os-linux-memory/(物理内存管理)以及 https://plumephp.com/os-buddy-system-slab/(页框分配器)形成一条完整的知识链。


一、从 SMP 到 NUMA:为什么需要非统一内存

1.1 SMP 的天花板

SMP(Symmetric Multi-Processing)架构里,所有 CPU 通过一条共享总线访问同一组内存控制器。它的优点是"内存对谁都一样"——编程模型简单,任意核心访问任意地址的延迟相同(UMA,Uniform Memory Access)。

但共享总线的带宽是固定的,随核心数增长会出现两个问题:

  • 带宽争抢:N 个核心同时读写,每个核心实际可用的带宽约为总线带宽 / N。
  • 延迟恶化:总线仲裁冲突增加,访问延迟随负载上升。
SMP(UMA):
  ┌────┐ ┌────┐ ┌────┐ ┌────┐
  │CPU0│ │CPU1│ │CPU2│ │CPU3│
  └─┬──┘ └─┬──┘ └─┬──┘ └─┬──┘
    └──────┴──┬───┴──────┘
       共享总线(带宽固定)
              │
        ┌─────┴─────┐
        │  内存控制器 │
        └───────────┘

1.2 NUMA 的解法:把内存切给节点

NUMA 把"内存 + 内存控制器 + 一部分核心"打包成一个 node(节点),节点之间用高速互联(Intel 的 UPI、AMD 的 Infinity Fabric)连接:

NUMA:
  ┌─────── Node 0 ───────┐        ┌─────── Node 1 ───────┐
  │ CPU0 CPU1  ── 本地内存 │◄─UPI──►│ CPU2 CPU3  ── 本地内存 │
  └──────────────────────┘        └──────────────────────┘
       本地访问:~80ns                 远程访问:~140ns

于是内存访问分成了两类:

  • 本地访问(local):核心访问同节点内存,延迟低、带宽高。
  • 远程访问(remote):核心访问其他节点内存,要跨互联,延迟高、带宽受限。

1.3 关键数字

在现代双路服务器上,量级大致如下(具体随型号变化):

访问类型典型延迟相对开销
本地内存80~100 ns1.0x
跨节点(一跳)130~160 ns1.5~1.8x
跨节点(两跳)200 ns 以上2.5x 以上

看起来"1.5 倍"不算夸张,但内存密集型负载的吞吐与延迟直接相关,跨节点访问还会额外消耗互联带宽,在 8 路以上系统中差距会进一步放大。


二、拓扑描述:ACPI 的 SRAT 与 SLIT

2.1 固件如何告诉内核

NUMA 拓扑不是内核猜出来的,而是固件(BIOS/UEFI)通过 ACPI 表描述、内核解析得到的。两张表最关键:

  • SRAT(System Resource Affinity Table):声明"哪些 CPU、哪些内存区间属于哪个节点"。
  • SLIT(System Locality Information Table):一张 N×N 的距离矩阵,描述节点之间的相对距离。

SLIT 里的距离是相对值而非纳秒:本地固定为 10,跨一跳通常为 20~21,跨两跳为 30+。内核用这个数值决定"次优节点"的选择顺序。

2.2 从用户态看拓扑

内核把解析结果导出到 /sys:

# 系统里有几个 NUMA 节点
ls /sys/devices/system/node/
# node0  node1  ...

# 节点间的距离矩阵(对应 SLIT)
cat /sys/devices/system/node/node0/distance
# 10 21  (到 node0 距离 10,到 node1 距离 21)

# 每个节点包含哪些 CPU
cat /sys/devices/system/node/node0/cpulist
# 0-23,48-71

# 每个节点的内存信息
cat /sys/devices/system/node/node0/meminfo

也可以用 numactl --hardware 得到一份可读的汇总:

numactl --hardware
# available: 2 nodes (0-1)
# node 0 cpus: 0 1 2 ... 47
# node 0 size: 128000 MB
# node 0 free: 118000 MB
# node distances:
# node   0   1
#   0:  10  21
#   1:  21  10

2.3 为什么 SLIT 会被改坏

虚拟化环境里常见一个坑:hypervisor 为了让 guest 看到"平坦"的内存,把 SLIT 里所有距离都设成 10,或干脆不暴露 SRAT。结果是 guest 内核以为这是 UMA 机器,不做任何亲和性调度;而宿主机上这台 VM 的 vCPU 与内存其实是分散在多个物理节点上的。表现为:虚拟机里跑同样负载,性能波动极大却查不出原因。

排查方法就是进 guest 看 /sys/devices/system/node/ 是否为空、numactl --hardware 是否只报一个节点。若宿主有拓扑而 guest 看不到,需要在 libvirt 里开启 <cpu><numa> 拓扑透传。


三、内核如何建模 NUMA

3.1 核心数据结构

内核用 struct pglist_data(别名 pg_data_t)表示一个节点,全局数组 node_data[] 保存所有节点:

/* include/linux/mmzone.h */
typedef struct pglist_data {
    struct zone node_zones[MAX_NR_ZONES];   /* 节点内的各个 zone */
    struct zonelist node_zonelists[MAX_ZONELISTS];
    int nr_zones;
    struct page *node_mem_map;              /* 该节点的 struct page 数组 */
    unsigned long node_start_pfn;
    unsigned long node_present_pages;
    int node_id;
    ...
} pg_data_t;

extern pg_data_t *node_data[];

关键点:每个节点拥有自己独立的 node_zones(DMA、Normal、Movable 等)和 node_mem_map(页描述符数组)。物理内存被彻底切成了互不重叠的片段。

3.2 zonelist:分配时的候选顺序

分配内存时,内核需要一个"先试哪个 zone、不行再退到哪个 zone"的顺序表,这就是 zonelist。在 NUMA 系统上,zonelist 会优先包含本地节点的 zone,再按 SLIT 距离由近到远排列远程节点的 zone:

node 0 的 Normal zonelist(示意):
  node0/Normal → node1/Normal → node0/DMA32 → ...
     本地优先      次优(距离21)   兜底

这解释了 NUMA 上的"兜底"行为:当本地节点内存不足时,分配器不会立刻失败,而是"溢出"到远程节点——这就是远程内存最隐蔽的来源。

3.3 内存策略 mempolicy

每个进程或 VMA 可以绑定一种内存策略:

  • MPOL_DEFAULT:跟随当前 CPU 的节点(配合自动平衡)。
  • MPOL_BIND:只允许在指定节点集合分配。
  • MPOL_PREFERRED:优先指定节点,不足时回退。
  • MPOL_INTERLEAVE:在多个节点间轮流分配,用于最大化带宽。

numactl --membind / --interleave 就是设置这些策略的封装。策略会随 fork() 继承,也会被 set_mempolicy(2) 修改。

3.4 查看进程的 NUMA 内存分布

# 某个进程的内存分布在各节点的情况
cat /proc/<pid>/numa_maps

# 系统级:每个节点的活跃/非活跃页
grep -E 'numa_(hit|miss|foreign)' /proc/vmstat

numa_hit 是成功在期望节点分配的页数,numa_miss 是期望节点分配失败(溢出到别处)的页数,numa_foreign 则是"本来打算给别的节点、结果分配到了本地"的页数。numa_miss 持续增长,说明本地内存不够或策略过紧。


四、自动 NUMA Balancing:内核的自我修复

4.1 问题:内存和线程会漂移

即使启动时分配得当,运行中也可能失衡:进程初始化时在 node0 分配了大块内存,之后线程被调度到 node1 上跑——每次访问都跨节点。

4.2 机制

内核的 AutoNUMA(自动 NUMA 平衡,kernel.numa_balancing)通过"周期性取消映射 + 页错误采样"来发现"内存与访问它的线程不在同一节点":

  1. 内核周期性把一部分匿名页标记为不可访问(清除 PTE 的 present 位)。
  2. 下次访问触发 NUMA hinting fault,内核记录"哪个任务在哪个 CPU 上访问了这个页"。
  3. 若发现某页长期被远程节点上的任务访问,就把它迁移到那个节点(migrate_misplaced_page),或者把任务迁移到页所在节点(任务迁移由调度器完成)。
# 查看/开关
cat /proc/sys/kernel/numa_balancing
sysctl -w kernel.numa_balancing=1

# 统计信息
grep numa_pte_updates /proc/vmstat
grep -E 'numa_(migrate|hint_faults)' /proc/vmstat

4.3 什么时候该关掉它

自动平衡不是免费的:hinting fault 会带来页错误开销,页迁移会消耗 CPU 与带宽。以下场景建议显式关闭并手工绑定:

  • 延迟敏感:如交易系统、DPDK/SPDK 轮询线程,页迁移的抖动不可接受。
  • 大页 + 手工绑定:已经用 numactl 与 hugetlbfs 精确绑定的进程,自动平衡反而添乱。
  • 实时任务:迁移带来的 TLB shootdown 会引入不可控延迟。
# 只对某个进程关闭
echo 0 > /proc/<pid>/...   # 不支持;应全局关闭后由应用自行绑定
numactl --membind=0 --cpunodebind=0 ./latency_critical_app

五、numactl 与绑核实战

5.1 numactl 常用姿势

# 把进程绑到 node0 的 CPU 与内存
numactl --cpunodebind=0 --membind=0 ./app

# 内存交错分布(最大化带宽),CPU 绑到两个节点
numactl --interleave=all --cpunodebind=0,1 ./bandwidth_app

# 只绑内存不绑 CPU(谨慎:CPU 仍可能漂移)
numactl --membind=1 ./app

# 查看当前策略
numactl --show

5.2 CPU 亲和性:taskset 与 sched_setaffinity

numactl --cpunodebind 底层调用 sched_setaffinity(2)。也可以直接用 taskset:

# 把已运行进程绑定到 CPU 0-23
taskset -cp 0-23 <pid>

# 启动时绑定
taskset -c 0-23 ./app

C 代码里直接控制:

#define _GNU_SOURCE
#include <sched.h>
#include <numa.h>
#include <numaif.h>

int main(void) {
    /* 1) 绑定到 node0 的 CPU 集合 */
    struct bitmask *cpus = numa_allocate_cpumask();
    numa_node_to_cpus(0, cpus);
    numa_sched_setaffinity(0, cpus);

    /* 2) 绑定内存策略到 node0 */
    struct bitmask *mems = numa_allocate_nodemask();
    numa_bitmask_setbit(mems, 0);
    set_mempolicy(MPOL_BIND, mems->maskp, mems->size + 1);
    return 0;
}

编译:gcc -O2 app.c -lnuma。

5.3 first-touch 原则

Linux 的匿名内存遵循 first-touch:物理页在第一次写入时才分配,且分配在当时执行写入的 CPU 所在节点。这意味着:

/* 反例:初始化在主线程(node0),计算在 worker(node1) */
for (size_t i = 0; i < N; i++) big[i] = 0;   /* 全落在 node0 */
#pragma omp parallel for
for (size_t i = 0; i < N; i++) big[i] = compute(i);  /* node1 远程写 */

正确做法是让每个线程初始化自己将来要访问的那段内存:

#pragma omp parallel for
for (size_t i = 0; i < N; i++) big[i] = 0;   /* 各线程触碰各自区段 */
#pragma omp parallel for
for (size_t i = 0; i < N; i++) big[i] = compute(i);  /* 就近访问 */

numactl --interleave=all 也能规避 first-touch 失衡,代价是所有访问都变成"部分远程"。

5.4 numad:动态平衡守护进程

numad 是一个用户态守护进程,它周期性读取 /proc/<pid>/numa_maps 与调度信息,为进程计算最优节点并自动绑定。适合无法改代码的遗留应用,但对短生命周期进程和剧烈变化的负载反应滞后,生产上更推荐应用内显式绑定。


六、常见误区与观测清单

6.1 五个高频误区

  1. “绑了 CPU 就等于绑了内存”。taskset/--cpunodebind 只影响调度,不影响内存分配策略。线程在 node0 跑,内存仍可能在 node1——除非同时设置 mempolicy 或依赖自动平衡。

  2. “内存够用就不会远程访问”。本地节点内存耗尽是常见原因,但即使本地充裕,MPOL_DEFAULT 在 CPU 漂移时也会把新页分配到别处。

  3. “大页一定能救 NUMA”。透明大页(THP)减少 TLB miss,但不改变物理页落在哪个节点。numa_balancing 对 THP 的处理还有额外复杂度。

  4. “虚拟机和物理机一样”。如前所述,guest 看不到 SRAT/SLIT 时,所有 NUMA 优化都是徒劳。

  5. “跨节点延迟只差一点,无所谓”。在 QPS 型负载里,一次跨节点访问多 50 ns,乘以每请求数十万次访存,就是可观的尾延迟。

6.2 观测清单

# 1) 拓扑是否可见
numactl --hardware

# 2) 系统级失衡
grep -E 'numa_(hit|miss|foreign|interleave_hit)' /proc/vmstat

# 3) 进程级分布
cat /proc/<pid>/numa_maps | head

# 4) 自动平衡状态
cat /proc/sys/kernel/numa_balancing
grep -E 'numa_(pte_updates|hint_faults|pages_migrated)' /proc/vmstat

# 5) 用 perf 看本地/远程访存比例(需内存控制器 PMU)
perf stat -e node-loads,node-load-misses ./app

若 numa_miss 与 numa_foreign 持续上升、numactl --hardware 显示某节点 free 偏低,优先检查是否本地内存不足或策略过紧;若两节点 free 都充足但延迟仍高,检查线程是否被调度到了远端节点。


结语

NUMA 把"内存访问"从一个均质概念变成了有几何结构的概念。硬件层面它用 SRAT/SLIT 描述拓扑,内核层面用 pglist_data 与 zonelist 建模并优先本地分配,用户态则通过 numactl、set_mempolicy 与 sched_setaffinity 干预。真正决定性能的往往不是"有没有 NUMA 优化",而是三件小事:内存是否在正确的节点上分配(first-touch)、线程是否稳定地跑在正确的节点上(绑核)、以及本地内存是否够用(避免溢出)。

把这三件事做成清单,NUMA 调优就从"玄学"变成了可复现的工程动作。它与 https://plumephp.com/os-memory-allocation/ 讨论的分配器行为配合使用,覆盖了从分配到观测的完整闭环。


延伸阅读

  1. Linux 内核源码:mm/mempolicy.c(内存策略)、mm/page_alloc.c(zonelist 与分配)、mm/migrate.c(页迁移)
  2. Linux Kernel Documentation: Documentation/admin-guide/mm/numa_memory_policy.rst
  3. ACPI 规范第 5 章:SRAT 与 SLIT 表定义(uefi.org/acpi)
  4. numactl 与 libnuma 官方手册页:man 8 numactl、man 3 numa
  5. 《Computer Architecture: A Quantitative Approach》第 5 章存储器层次结构(Hennessy & Patterson)

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 内核调试:kgdb、kdump、crash 与动态追踪
  2. cgroups v2 与命名空间底层实现与资源隔离
  3. Linux 设备驱动模型:字符设备、块设备与 sysfs