NUMA:拓扑、内存策略与绑定

多插槽服务器上,一块内存离有的核近、离有的核远。 本文说明这种差别从哪来、代价多大、内核默认把页放在哪、怎样用命令和代码控制位置,以及怎样观测和排查。

适用范围:Linux x86-64,内核 5.x 起的行为为主线。拓扑的划分方式由 CPU 型号和 BIOS 设置决定,属于实现细节,以本机 numactl --hardware 的输出为准。

实测说明:本文代码、命令和输出样例均未实测。延迟和带宽数字只表示量级,随平台变化。

观测工具的更多用法见 performance.md 第九章;网卡收发包与 NUMA 的关系见 xdp-dpdk.md。


目录

# 章节 主题
一 UMA 与 NUMA 为什么从共享总线变成每个插槽自带内存
二 拓扑 节点、插槽、核;距离矩阵;单插槽多节点
三 远端访问的代价 延迟、带宽、跨插槽的缓存行传递
四 内存分配策略 首次触碰、策略一览、节点内存不足时的行为
五 首次触碰的陷阱 主线程初始化、std::vector、内存复用、页缓存
六 命令行控制 numactl、taskset、cpuset、systemd
七 程序内控制 线程亲和、libnuma、mbind、查询页所在节点
八 自动 NUMA 均衡 扫描、提示缺页、迁移;什么时候关
九 设备与中断 网卡、NVMe 所在节点;中断亲和;大页
十 典型应用的做法 数据库、JVM、DPDK、低延迟交易、容器、虚拟机
十一 观测与排查 numastat、numa_maps、perf mem、排查流程
十二 面试速答卡 高频问题浓缩答案

一、UMA 与 NUMA

UMA(Uniform Memory Access)          NUMA(Non-Uniform Memory Access)

 CPU0   CPU1   CPU2   CPU3            ┌─────────── 节点 0 ───────────┐
   └──────┴──┬───┴──────┘             │ CPU0 + 内存控制器 + 本地内存   │──┐
         共享总线                      └──────────────────────────────┘  │ 互联链路
        内存控制器                     ┌─────────── 节点 1 ───────────┐  │
           内存                        │ CPU1 + 内存控制器 + 本地内存   │──┘
                                      └──────────────────────────────┘
UMA NUMA
内存控制器 所有 CPU 共用一个 每个插槽自带
总内存带宽 固定,不随 CPU 数增长 每加一个插槽,多一组内存通道
访问延迟 所有核到所有内存相同 本地近,远端要经过互联链路
扩展性 CPU 越多,总线争用越严重 好

互联链路在 Intel 平台上是 UPI,在 AMD 平台上是 Infinity Fabric。

NUMA 不提供隔离:任何核都能访问任何节点的内存,差别只在快慢。


二、拓扑

三层关系

插槽(socket)       主板上的一个 CPU 插座
   └─ 节点(node)   一组核 + 一块本地内存;内核按节点管理内存
        └─ 核(core)
             └─ 逻辑 CPU(超线程)

常见情况是一个插槽对应一个节点。BIOS 设置可以改变这一点,见本节末尾。

查看拓扑

numactl --hardware

输出样例(双插槽,每插槽 16 核 32 线程):

available: 2 nodes (0-1)
node 0 cpus: 0 1 2 ... 15 32 33 ... 47
node 0 size: 128000 MB
node 0 free: 100000 MB
node 1 cpus: 16 17 ... 31 48 49 ... 63
node 1 size: 128000 MB
node 1 free: 110000 MB
node distances:
node   0   1
  0:  10  21
  1:  21  10
字段 含义
node N cpus 属于这个节点的逻辑 CPU 编号
node N size / free 这个节点的内存总量和空闲量
node distances 距离矩阵,来自固件提供的 ACPI SLIT 表

距离矩阵里的数字是相对值:本地固定为 10,21 表示远端的访问成本约为本地的 2.1 倍。它是固件声明的,不是测出来的延迟。

其他查看方式:

lscpu | grep -i numa                                    # 节点数和每个节点的 CPU 列表
cat /sys/devices/system/node/node0/cpulist              # 节点 0 的 CPU
cat /sys/devices/system/node/node0/meminfo              # 节点 0 的内存明细
cat /sys/devices/system/cpu/cpu0/topology/physical_package_id   # CPU 0 所在的插槽

逻辑 CPU 的编号顺序由固件决定。上面的样例里 015 和 3247 同属节点 0,其中 0 和 32 是同一个物理核的两个超线程。绑核之前先查本机的编号,不能假定相邻编号在同一节点。

单插槽也可能有多个节点

情况 原因
AMD EPYC BIOS 选项可把一个插槽划成 1、2 或 4 个节点,还可以把每个三级缓存域呈现为一个节点
Intel 至强的 Sub-NUMA Clustering BIOS 选项把一个插槽按内存控制器划成多个节点
CXL 内存扩展、持久内存 呈现为只有内存、没有 CPU 的节点
虚拟机 虚拟化层呈现的拓扑与物理机无关

只有一个节点的机器上,所有核到所有内存的距离相同,NUMA 绑定不产生差别。


三、远端访问的代价

延迟与带宽

指标 本地 远端 说明
一次访存的延迟 约 70~90 ns 约 120~150 ns 双插槽至强上的常见量级,未实测
内存带宽 本插槽全部内存通道 受互联链路限制,低于本地 顺序扫描大数组时差距明显

本机的数值用 Intel Memory Latency Checker 测量:

sudo mlc --latency_matrix      # 每对节点之间的访存延迟
sudo mlc --bandwidth_matrix    # 每对节点之间的带宽

只有未命中缓存的访问才付这个代价

访存 → 一级缓存命中?  ── 是 → 约 1 ns,与 NUMA 无关
        │ 否
        ▼
      二级、三级缓存命中? ── 是 → 几到几十 ns,与 NUMA 无关
        │ 否
        ▼
      从内存读 ── 本地约 80 ns / 远端约 140 ns
程序特征 受 NUMA 影响的程度
工作集放得进缓存 小
大数组顺序扫描 中:预取器能提前取数,但受远端带宽限制
大哈希表、大图、指针追逐 大:每次访问都是缓存未命中,延迟直接叠加

跨插槽的缓存行传递

两个线程读写同一个缓存行时,这一行要在两个核的缓存之间来回传递。两个核在不同插槽时,传递要经过互联链路,比同插槽内的传递慢。

场景 后果
锁、原子计数器被两个插槽上的线程争用 每次争用都是一次跨插槽传递
伪共享跨插槽 同上,且不容易从代码里看出来
队列的生产者和消费者在不同插槽 队列的头尾指针和数据都要跨插槽传递

互相通信频繁的线程放在同一个节点。


四、内存分配策略

首次触碰

malloc、mmap 返回时,内核只分配了虚拟地址,没有分配物理页。物理页在第一次写入时才分配,这次写入触发缺页异常,内核在当时运行这个线程的核所在的节点上取一页。这个默认行为叫首次触碰(first-touch)。

sequenceDiagram
    participant T as 线程(运行在节点 1 的核上)
    participant K as 内核
    T->>K: mmap 1 GB
    K-->>T: 返回虚拟地址,未分配物理页
    T->>K: 第一次写某一页,触发缺页异常
    Note over K: 查内存策略:默认是本地分配
    Note over K: 在节点 1 上分配一页
    K-->>T: 建立映射,写入继续

页一旦分配,位置就固定了。线程之后被调度到别的节点,页不会跟着走(自动 NUMA 均衡开启时除外,见第八节)。

策略一览

策略 行为 指定节点内存不足时
MPOL_DEFAULT 沿用上一级的策略;最上一级是本地分配 退到其他节点
MPOL_LOCAL 在当前核所在的节点分配 退到其他节点
MPOL_PREFERRED 优先在指定的一个节点分配 退到其他节点
MPOL_PREFERRED_MANY(5.15 起) 优先在指定的一组节点分配 退到其他节点
MPOL_BIND 只在指定的节点分配 回收或换出本节点的页;仍不够则触发 OOM
MPOL_INTERLEAVE 按页在指定的节点之间轮流分配 跳到下一个节点
MPOL_WEIGHTED_INTERLEAVE(6.9 起) 按各节点的权重轮流分配 跳到下一个节点

策略分三级,越具体的优先级越高:

系统默认  ←  线程策略(set_mempolicy、numactl)  ←  地址范围策略(mbind)

MPOL_BIND 的风险

节点 0:已用满                节点 1:空闲 100 GB
   │
   ▼
进程的策略是 bind 到节点 0,继续申请内存
   │
   ▼
内核在节点 0 上回收页缓存、换出匿名页 → 延迟飙升
   │
   ▼
仍不够 → OOM,尽管整机还有 100 GB 空闲

不确定内存用量时用 MPOL_PREFERRED:平时在指定节点分配,不够时退到其他节点。

节点内存不足时先回收还是先去别的节点

由 vm.zone_reclaim_mode 决定:

值 行为 适用
0(多数发行版的默认值) 本节点不够时直接去其他节点分配 通用
非 0 先在本节点回收页缓存,回收不出来再去其他节点 对本地性要求极高、能接受回收造成的停顿
sysctl vm.zone_reclaim_mode

五、首次触碰的陷阱

主线程初始化了全部数据

std::vector<double> data(n);          // 构造函数把 n 个元素置 0,全部页由主线程触碰
parallel_for(0, n, [&](size_t i) {    // 工作线程分布在各个节点
  data[i] = compute(i);
});

主线程在节点 0 上运行,data 的所有页都落在节点 0。节点 1 上的工作线程处理自己那一半数据时,全部是远端访问。

容易在主线程里触碰全部页的写法:

写法 原因
std::vector<T> v(n) 值初始化,逐个元素写 0
memset(p, 0, n) 直接写
new T[n]() 带括号,值初始化
读文件到缓冲区 read 把数据写进缓冲区

不会触碰的写法:

写法 原因
mmap 匿名映射 只分配虚拟地址
malloc 大块内存 glibc 对大块直接用 mmap
calloc 大块内存 glibc 知道 mmap 来的页本来就是 0,不再写一遍(实现相关)
std::make_unique_for_overwrite<T[]>(n)(C++20) 默认初始化,平凡类型不写入

做法:谁用谁初始化

// numa_first_touch.cpp
#include <pthread.h>
#include <sched.h>
#include <sys/mman.h>
#include <cstddef>
#include <cstring>
#include <thread>
#include <vector>

static void pin_to_cpu(int cpu) {
  cpu_set_t set;
  CPU_ZERO(&set);
  CPU_SET(cpu, &set);
  pthread_setaffinity_np(pthread_self(), sizeof(set), &set);
}

int main() {
  constexpr size_t kThreads   = 4;
  constexpr size_t kPerThread = 1ull << 30;               // 每个线程 1 GiB
  constexpr size_t kTotal     = kThreads * kPerThread;

  // 只分配虚拟地址,此时没有物理页
  char* buf = static_cast<char*>(mmap(nullptr, kTotal, PROT_READ | PROT_WRITE,
                                      MAP_PRIVATE | MAP_ANONYMOUS, -1, 0));
  if (buf == MAP_FAILED) return 1;

  // 按本机拓扑填写:这里假定 0、1 在节点 0,16、17 在节点 1
  const int cpus[kThreads] = {0, 1, 16, 17};

  std::vector<std::thread> workers;
  for (size_t t = 0; t < kThreads; ++t) {
    workers.emplace_back([=] {
      pin_to_cpu(cpus[t]);                                // 先绑核
      char* mine = buf + t * kPerThread;
      std::memset(mine, 0, kPerThread);                   // 再触碰:页落在本线程所在的节点
      // 之后这个线程只处理 mine 这一段
    });
  }
  for (auto& w : workers) w.join();

  munmap(buf, kTotal);
}
// g++ -O2 -std=c++17 -pthread numa_first_touch.cpp

顺序不能反:先绑核,再触碰。线程没绑核时可能在任意节点上运行,触碰的页落在哪个节点不确定。

内存复用

线程 A(节点 0):p = malloc(64),写入   → 物理页落在节点 0
线程 A:         free(p)               → 内存回到分配器,物理页仍在节点 0
线程 B(节点 1):q = malloc(64)        → 分配器可能把同一块内存交给 B
线程 B:         读写 q                → 远端访问

释放的内存回到分配器之后,物理页的位置不变。它再被分给另一个节点上的线程时,首次触碰早已发生过。

缓解办法 说明
每线程一个对象池 对象只在本线程内分配和释放
使用带线程缓存的分配器 glibc 的 per-thread arena、tcmalloc、jemalloc 都让线程优先复用自己释放的内存(实现相关)
跨线程传递的对象由接收方归还给发送方 避免内存在节点之间流动

页缓存占满一个节点

文件的页缓存也按首次触碰分配:哪个节点上的线程先读到这一页,页缓存就放在哪个节点。

节点 0 上的进程读了一个 100 GB 的文件 → 节点 0 的内存被页缓存占满
之后节点 0 上的线程申请匿名内存      → 本节点没有空闲页,退到节点 1 → 远端访问
做法 说明
启动关键进程之前清页缓存 sync; echo 1 > /proc/sys/vm/drop_caches
读大文件的进程用交错策略 numactl --interleave=all,页缓存均摊到各节点
关键进程预先分配并锁住内存 启动时触碰全部页,再 mlockall

六、命令行控制

numactl

命令 作用
numactl --hardware 查看拓扑
numactl --show 查看当前 shell 的策略
numactl --cpunodebind=0 --membind=0 ./app 线程只在节点 0 的核上运行,内存只从节点 0 分配
numactl --cpunodebind=0 --preferred=0 ./app 同上,但内存不够时可以去其他节点
numactl --physcpubind=2,3 --localalloc ./app 线程只在 2、3 号核上运行,内存在本地分配
numactl --interleave=all ./app 内存按页在所有节点之间轮流分配

--cpunodebind 和 --membind 要成对使用:

只指定 结果
--cpunodebind=0 线程在节点 0;内存按首次触碰也落在节点 0;节点 0 不够时退到节点 1
--membind=0 内存在节点 0;线程可能被调度到节点 1,变成远端访问
两者都指定 线程和内存都在节点 0

绑定还是交错

策略 适用 不适用
绑定到一个节点 进程的内存和线程都放得进一个节点 内存用量超过单节点容量
交错 一个大进程用掉大部分内存,线程分布在所有节点,访问模式随机(如数据库缓冲池) 线程与数据有明确的对应关系
按线程各自首次触碰 每个线程处理固定的一块数据 数据在线程之间流动

交错让每个线程的访问有 1/N 是本地、其余是远端,平均延迟高于全本地,但各节点的内存占用均衡,不会出现一个节点耗尽。

taskset

taskset 只控制线程能在哪些核上运行,不控制内存:

taskset -c 0-15 ./app          # 启动时限定
taskset -cp 0-15 <pid>         # 修改运行中的进程

cpuset

cgroup 的 cpuset 控制器同时限制核和内存节点,容器运行时用它实现绑定:

# cgroup v2
echo "0-15" > /sys/fs/cgroup/myapp/cpuset.cpus
echo "0"    > /sys/fs/cgroup/myapp/cpuset.mems

systemd

[Service]
CPUAffinity=0-15
NUMAPolicy=bind
NUMAMask=0

迁移已有的页

migratepages <pid> 1 0         # 把进程在节点 1 上的页迁到节点 0

迁移期间访问这些页的线程会被阻塞,不在延迟敏感的时段执行。


七、程序内控制

线程亲和

#include <pthread.h>
#include <sched.h>

void pin_to_cpu(int cpu) {
  cpu_set_t set;
  CPU_ZERO(&set);
  CPU_SET(cpu, &set);
  pthread_setaffinity_np(pthread_self(), sizeof(set), &set);
}

线程启动后第一件事就是绑核,之后再分配和触碰内存。

libnuma

// numa_alloc.cpp
#include <numa.h>
#include <cstddef>
#include <cstring>

int main() {
  if (numa_available() < 0) return 1;                 // 内核不支持 NUMA

  const int    node = 0;
  const size_t len  = 1ull << 30;

  numa_run_on_node(node);                             // 当前线程限制在节点 0 的核上
  void* p = numa_alloc_onnode(len, node);             // 这段地址的策略设为节点 0
  if (!p) return 1;

  std::memset(p, 0, len);                             // 触碰之后才有物理页
  numa_free(p, len);
}
// g++ -O2 numa_alloc.cpp -lnuma        需要 libnuma 的开发包
函数 作用
numa_available() 小于 0 表示不可用
numa_max_node() 最大的节点编号
numa_node_of_cpu(cpu) 某个核属于哪个节点
numa_run_on_node(node) 线程限制在某个节点的核上
numa_alloc_onnode(len, node) 分配内存并把策略设为指定节点
numa_alloc_interleaved(len) 分配内存并设为交错
numa_alloc_local(len) 分配内存并设为本地分配
numa_set_preferred(node) 设置线程的优先节点

numa_alloc_* 每次调用都走 mmap,按页取整,比 malloc 慢。它用于一次性分配大块内存,不用于频繁的小对象分配。

mbind:对已有的地址范围设策略

#include <numaif.h>
#include <cstddef>

// 把 [addr, addr + len) 绑定到 node;已经分配在别处的页一并迁过来
bool bind_range(void* addr, size_t len, int node) {
  unsigned long mask = 1UL << node;
  return mbind(addr, len, MPOL_BIND, &mask, sizeof(mask) * 8,
               MPOL_MF_MOVE | MPOL_MF_STRICT) == 0;
}
// g++ -O2 -c bind_range.cpp;链接时加 -lnuma
标志 作用
不带标志 只影响之后新分配的页
MPOL_MF_MOVE 把范围内已分配、且只被本进程映射的页迁到目标节点
MPOL_MF_STRICT 有页不符合策略且迁不动时返回错误

addr 要按页对齐。

查询一页在哪个节点

#include <numaif.h>
#include <unistd.h>
#include <cstdint>

// 返回 addr 所在页的节点编号;页还没分配时返回负值
int node_of(void* addr) {
  const uintptr_t page = static_cast<uintptr_t>(sysconf(_SC_PAGESIZE));
  void* pages[1] = { reinterpret_cast<void*>(reinterpret_cast<uintptr_t>(addr) & ~(page - 1)) };
  int status = -1;
  if (move_pages(0, 1, pages, nullptr, &status, 0) != 0) return -1;   // 目标节点传空指针:只查询,不迁移
  return status;
}
// g++ -O2 -c node_of.cpp;链接时加 -lnuma

这个函数可以放在单元测试里,验证关键数据结构确实落在预期的节点。

系统调用一览

系统调用 作用范围 用途
sched_setaffinity 线程 限定可运行的核
set_mempolicy 线程 设置之后新分配的页的策略
get_mempolicy 线程或地址 查询策略或页所在节点
mbind 地址范围 设置策略,可选迁移已有的页
move_pages 指定的页 迁移或查询
migrate_pages 整个进程 把一组节点上的页迁到另一组节点

八、自动 NUMA 均衡

内核自动把页迁到访问它的线程所在的节点,或把线程迁到它的内存所在的节点。程序不需要做任何绑定。

工作过程

flowchart TD
    A["内核周期性扫描进程的一部分地址空间"] --> B["把扫到的页表项改成不可访问"]
    B --> C["线程访问这些页,触发提示缺页"]
    C --> D["内核记录:哪个节点上的线程访问了哪个节点上的页"]
    D --> E{"页和访问它的线程在不同节点,且连续两次都是同一节点来访问?"}
    E -- 是 --> F["把页迁到访问者所在的节点"]
    E -- 否 --> G["恢复页表项,不迁移"]
    D --> H["调度器参考统计结果,把线程往它的内存所在的节点迁"]

代价

开销 来源
扫描 周期性遍历页表
提示缺页 每个被扫到的页下一次被访问时进一次内核
迁移 拷贝页内容、改页表、刷新 TLB
延迟抖动 以上三项都发生在业务线程的执行路径上,时间点不可控

开还是关

场景 建议 原因
通用服务、没有做过任何绑定 开 自动改善本地性,不用改程序
已经手工绑定线程和内存 关 位置已经正确,扫描和缺页是纯开销
低延迟程序 关 不能接受不可控的缺页和迁移
内存被多个节点上的线程共同访问(如数据库缓冲池) 关 页在节点之间来回迁移,没有稳定的归属
sysctl kernel.numa_balancing           # 查看;1 为开
sysctl -w kernel.numa_balancing=0      # 关闭

观察它做了多少事

grep -E 'numa_pte_updates|numa_hint_faults|numa_pages_migrated' /proc/vmstat
计数器 含义
numa_pte_updates 被改成不可访问的页表项数量
numa_hint_faults 提示缺页的次数
numa_hint_faults_local 其中访问者与页在同一节点的次数
numa_pages_migrated 迁移的页数

numa_hint_faults_local 占 numa_hint_faults 的比例接近 1 时,本地性已经很好。


九、设备与中断

设备所在的节点

PCIe 设备挂在某一个插槽的总线上,它的 DMA 缓冲区、中断处理放在同一个节点时路径最短。

cat /sys/class/net/eth0/device/numa_node         # 网卡所在节点;-1 表示未知或只有一个节点
cat /sys/class/net/eth0/device/local_cpulist     # 与网卡同节点的 CPU 列表
cat /sys/block/nvme0n1/device/numa_node          # NVMe 盘所在节点

收包路径上的三个位置

网卡(节点 0) → 中断和软中断(在哪个核上处理?) → 业务线程(在哪个核上运行?)
                        │                                │
                  包缓冲区在这里分配                 读包数据、写应用缓冲区
位置 放在网卡所在节点的办法
中断 /proc/irq/<n>/smp_affinity_list 写入同节点的核;停用 irqbalance 或为它配置排除规则
业务线程 numactl --cpunodebind 或程序内绑核
内存 --membind / --preferred,或靠首次触碰
grep eth0 /proc/interrupts                       # 找出网卡各队列的中断号
echo 2 > /proc/irq/128/smp_affinity_list         # 128 号中断固定到 2 号核

大页按节点预留

显式大页的数量是每个节点单独计数的。进程绑定在节点 0 时,只能用到节点 0 上预留的大页。

cat /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages
echo 4096 > /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages

通过 /proc/sys/vm/nr_hugepages 设置的总数会平均分到各节点。绑定到单节点的进程只能用到其中一部分,要按节点单独设置。


十、典型应用的做法

应用 特征 做法
MySQL InnoDB 缓冲池占整机大部分内存,线程分布在所有节点 innodb_numa_interleave=ON,缓冲池交错分配
Redis 单线程处理命令,内存通常放得进一个节点 numactl --cpunodebind=N --membind=N 绑到一个节点
JVM 堆很大,线程很多 -XX:+UseNUMA:每个节点一块分配区,线程在本节点的区里分配对象
DPDK 每个核处理一个网卡队列 内存池建在网卡所在节点(rte_eth_dev_socket_id),工作线程用同节点的核,--socket-mem 按节点预留大页
低延迟交易 线程少,延迟和抖动优先 全部放在网卡所在的节点,见下表
多实例部署 一台机器跑多个相同的进程 每个实例绑一个节点,实例之间互不干扰

数据库为什么用交错

默认策略下启动数据库,缓冲池 200 GB,每节点内存 128 GB:

节点 0:先被占满                 节点 1:还有大量空闲
   │
   ▼
节点 0 上其他的内存申请 → 内核在节点 0 上回收和换出 → 数据库的页被换到磁盘 → 查询变慢

交错分配让缓冲池均摊到两个节点,各占 100 GB,两个节点都留有余量。

低延迟交易系统

项目 做法
选节点 网卡所在的节点
核 在该节点上隔离若干个核(isolcpus、nohz_full、rcu_nocbs),关键线程一对一绑定
内存 --membind 到该节点;启动时触碰全部内存,mlockall 锁住
大页 在该节点上单独预留
中断 网卡中断固定到该节点上非关键线程的核
自动 NUMA 均衡 关闭
另一个节点 留给日志、监控、运维进程

容器

Kubernetes 的 kubelet 有三个相关组件:

组件 作用
CPU Manager(static 策略) 给申请整数个 CPU 的 Guaranteed 容器分配独占的核
Memory Manager 给容器的内存指定节点
Topology Manager 协调前两者和设备插件;single-numa-node 策略要求 CPU、内存、设备都在同一节点,否则拒绝调度到本机

虚拟机

问题 做法
虚拟机看到的拓扑与物理机不一致 配置 vNUMA,让虚拟节点对应物理节点
vCPU 在物理核之间漂移 把 vCPU 固定到物理核
虚拟机的内存跨了物理节点 把虚拟机的内存绑到对应的物理节点

十一、观测与排查

numastat

numastat -p <pid>          # 进程在各节点上占用的内存
numastat                   # 各节点的分配计数

不带参数时输出的六个计数器:

计数器 含义
numa_hit 想在本节点分配,并且分到了
numa_miss 页分在了本节点,但申请者原本想要的是别的节点
numa_foreign 申请者想要本节点,结果分到了别的节点
interleave_hit 交错策略按计划分到了本节点
local_node 分配时申请者正运行在本节点
other_node 分配时申请者运行在别的节点

numa_miss 和 numa_foreign 持续增长,说明有节点内存不足,分配在向其他节点溢出。

numa_maps

cat /proc/<pid>/numa_maps

输出样例:

7f3c00000000 default anon=262144 dirty=262144 N0=131072 N1=131072 kernelpagesize_kB=4
7f3d00000000 bind:0 anon=524288 dirty=524288 N0=524288 kernelpagesize_kB=4
字段 含义
第一列 这段映射的起始地址
default、bind:0、interleave:0-1 这段地址的内存策略
anon= 匿名页的数量
N0=、N1= 分别落在节点 0、节点 1 上的页数
kernelpagesize_kB= 页大小

第一行的映射有一半的页在节点 1。进程只在节点 0 上运行时,这一半就是远端访问。

硬件计数器与采样

perf stat -e node-loads,node-load-misses -p <pid> -- sleep 10

node-load-misses 是落到远端节点的访存次数。这两个事件是否可用取决于 CPU 型号。

perf mem record -- ./app
perf mem report

perf mem report 按数据来源分类,其中 Remote RAM 是远端内存访问,可以定位到具体的函数和数据地址。

其他工具:

工具 看什么
numatop 每个进程和线程的本地访问次数、远端访问次数及其比值
perf c2c 被多个核争用的缓存行,包括跨节点的争用
/proc/vmstat 里的 numa_* 自动 NUMA 均衡的活动量

排查流程

flowchart TD
    A["怀疑有 NUMA 问题:吞吐上不去,或加核之后反而变慢"] --> B["numactl --hardware:机器有几个节点?"]
    B -- "1 个" --> Z["与 NUMA 无关,查别的"]
    B -- "多个" --> C["numastat -p:进程的内存分布在哪些节点?"]
    C --> D["线程运行在哪些核上? ps -L -o pid,tid,psr,comm"]
    D --> E{"线程所在节点与内存所在节点一致?"}
    E -- 是 --> F["perf c2c:有没有跨节点争用的缓存行?"]
    E -- 否 --> G{"数据由谁初始化?"}
    G -- "主线程" --> H["改成由使用它的线程初始化"]
    G -- "使用它的线程" --> I["线程没绑核,触碰之后被迁走了 → 绑核"]
    C --> J{"某个节点的空闲内存接近 0?"}
    J -- 是 --> K["查页缓存和其他进程的占用;考虑交错或 preferred"]

十二、面试速答卡

NUMA 是什么 每个插槽自带内存控制器和本地内存,核访问本节点的内存快,访问其他节点的内存要经过互联链路,更慢。

为什么不用 UMA 所有 CPU 共用一条总线和一个内存控制器,CPU 越多争用越严重,总带宽不增长。NUMA 每加一个插槽多一组内存通道。

远端访问慢多少 延迟约为本地的 1.5 到 2 倍,带宽也更低。只有未命中缓存的访问才受影响,大哈希表和指针追逐这类程序最明显。

内核默认把页放在哪 首次触碰:页在第一次被写入时分配,落在当时运行这个线程的核所在的节点。

首次触碰最常见的问题 主线程初始化了全部数据,页都落在主线程所在的节点,其他节点上的工作线程全是远端访问。做法是先绑核,再由使用数据的线程自己初始化。

--cpunodebind 和 --membind 的区别 前者限制线程能在哪些核上运行,后者限制内存从哪些节点分配。只用后者时线程可能跑到别的节点,变成远端访问。

bind 和 preferred 的区别 指定节点内存不足时,bind 在本节点回收、换出,仍不够就 OOM;preferred 退到其他节点分配。

什么时候用交错 一个大进程占用大部分内存、线程分布在所有节点、访问模式随机时,例如数据库缓冲池。交错避免一个节点先耗尽。

自动 NUMA 均衡是什么,什么时候关 内核周期性把页表项改成不可访问,靠缺页统计谁在访问,再迁移页或线程。已手工绑定的程序和低延迟程序要关掉,扫描、缺页和迁移都会造成抖动。

整机还有空闲内存,为什么进程被换出或 OOM 进程的策略是 bind 到某个节点,而这个节点满了。或者一个节点被页缓存占满。

网卡和 NUMA 的关系 网卡挂在某一个插槽上。中断、业务线程、包缓冲区都放在网卡所在的节点,路径最短。节点号在 /sys/class/net/<dev>/device/numa_node。

怎么确认程序有 NUMA 问题 numastat -p 看内存分布,ps -L -o psr 看线程所在的核,两者不一致就有远端访问。perf mem 或 numatop 给出远端访问的占比。

单插槽的机器需要关心 NUMA 吗 只显示一个节点时不需要。AMD EPYC 和开启了 Sub-NUMA Clustering 的至强在单插槽上也会呈现多个节点,以 numactl --hardware 的输出为准。