NUMA 感知的内存分配策略与 Linux AutoNUMA 深度工程实战

在现代多核服务器中,访存延迟不再是均匀一致的。NUMA(Non-Uniform Memory Access)架构将 CPU 和内存划分为多个节点,本地访问与跨节点访问的延迟差距可达 2-3 倍。本文从硬件拓扑出发,深入剖析 Linux 内核的 NUMA 内存分配策略、AutoNUMA 平衡机制,并结合容器场景给出工程实战方案。

1. NUMA 硬件拓扑与内核抽象

典型的双路服务器包含两个 NUMA node,每个 node 绑定一组 CPU 核心和本地内存控制器。通过 numactl --hardware 可查看拓扑:

$ numactl --hardware
available: 2 nodes (0, 1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
node 0 size: 131016 MB
node 0 free: 98732 MB
node 1 cpus: 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
node 1 size: 131072 MB
node 1 free: 104521 MB
node distances:
node   0   1
  0:  10  20
  1:  20  10

其中 node distances 中的 10 和 20 是相对访问成本因子。本地 node 为 10,跨 node 为 20,意味着跨 socket 访存延迟大约是本地的 2 倍。

Linux 内核用 pg_data_t(PGDATA)结构抽象每个 NUMA node 的物理内存:

// include/linux/mmzone.h
typedef struct pglist_data {
    struct zone node_zones[MAX_NR_ZONES];      // DMA/DMA32/Normal/Movable 等 zone
    struct zonelist node_zonelists[MAX_ZONELISTS]; // 分配失败时的备选节点列表
    int node_id;                                // 节点 ID
    struct page *mem_map;                       // 该节点的 struct page 数组
    unsigned long node_start_pfn;              // 起始页帧号
    unsigned long node_present_pages;          // 实际存在的页数
    unsigned long node_spanned_pages;           // 跨度页数(含空洞)
    // ...
} pg_data_t;

每个 PGDATA 内部又按 zone 细分:ZONE_DMA(前 16MB)、ZONE_DMA32(4GB 以内)、ZONE_NORMAL(直接映射区)、ZONE_MOVABLE(可移动内存,用于热插拔)。分配器按zonelist顺序在节点间fallback。

2. 分配策略:从默认到精细控制

Linux 提供四种 NUMA 内存分配策略:

策略说明适用场景
MPOL_DEFAULT在进程运行的 CPU 所在节点分配(默认)通用负载
MPOL_BIND严格限定在指定节点集分配,失败则 OOM延迟敏感型应用
MPOL_PREFERRED优先指定节点,fallback 到其它节点希望尽量本地但允许远程
MPOL_INTERLEAVE轮询分配到多个节点,带宽最大化大内存流处理、数据库 shared buffer

策略可通过 set_mempolicy() 系统调用或 mbind() 针对特定地址区间设置。对于绑定型应用,推荐使用 numactl 启动:

# 绑定到 node0 运行,内存仅在 node0 分配
numactl --cpunodebind=0 --membind=0 ./myapp

# 优先 node1,允许 fallback
numactl --preferred=1 ./myapp

# 交错分配,均衡两个 node 带宽
numactl --interleave=all ./database

在代码中,可以通过 libnuma 自定义策略:

#include <numa.h>
#include <numaif.h>

int main() {
    // 设置进程默认策略为 PREFERRED node 1
    struct bitmask *nodes = numa_bitmask_alloc(numa_num_configured_nodes());
    numa_bitmask_setbit(nodes, 1);
    set_mempolicy(MPOL_PREFERRED, nodes->maskp, nodes->size);
    numa_bitmask_free(nodes);

    // 针对某块内存区间做 BIND(如 huge page 区域)
    void *ptr = mmap(NULL, 2 * 1024 * 1024,
                     PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
                     -1, 0);
    mbind(ptr, 2 * 1024 * 1024, MPOL_BIND,
          nodes->maskp, nodes->size, MF_MOVE);
}

3. Zone Reclaim 与水位线

当某个 node 的 low memory zone 耗尽时,内核需要回收或压缩内存。vm.min_free_kbytes 控制每个 zone 保留的最小空闲页,vm.watermark_boost_factor(5.16+)可动态加速回收。

在 NUMA 系统中,最危险的情况是"单 node 耗尽但整体仍有内存"——因为跨 node 分配会增加延迟,而本地节点已经 OOM。解决办法:

# 查看每个 node 的内存状态
$ cat /sys/devices/system/node/node*/meminfo | grep -E 'Node|MemFree|MemUsed|FilePages'
Node 0 MemUsed:   98234012 kB
Node 0 MemFree:   32812340 kB
Node 1 MemUsed:   62341098 kB
Node 1 MemFree:   68731972 kB  # node1 仍有大量空闲

若 node0 优先耗尽,可通过 vm.zone_reclaim_mode=1 让 node0 先做本地回收(kswapd/直接回收),而不是 fallback 到 node1。但在内存倾斜严重的场景下,开启 zone reclaim 可能加剧抖动,需权衡。

4. AutoNUMA:内核自动平衡

从 Linux 3.13 起引入的 AutoNUMA(Automatic NUMA Balancing)机制让内核自动追踪哪些页面被哪个 CPU 频繁访问,并在后台迁移页面使其靠近访问者。这是最接近"零配置 NUMA 优化"的方案。

4.1 扫描与采样

内核为每个 task 维护 numa_faults 计数。AutoNUMA 通过周期性地清空进程所有 PTE 的 Present 位(伪缺页),在下次访问时捕获访问 CPU 和页面位置:

// kernel/sched/fair.c 中 task_numa_work() 伪逻辑
- 每 scanning_delay(默认 1s 首次,后续按比例缩放)扫描一次进程地址空间
- 对大小为 numa_scan_size(默认 256MB)的窗口:
  1. 将所有 PTE 的 _PAGE_PRESENT 暂时清除
  2. 设置标志下次访问触发 minor fault
  3. 记录 minor fault 的 CPU node = 访问者
  4. 累计 fault 计数,判断是否需要迁移

4.2 迁移决策

当某个页面的"远程fault数"超过阈值(numa_threshold,通常为 256),内核会将该页面迁移到访问者所在的 node:

// mm/migrate.c: migrate_misplaced_page()
- 若 page 最近未被其他 CPU 访问 (last_hint_numa == 当前 node)
- 且 page_referenced 显示热度处于迁移窗口
- 调用 migrate_pages(),底层走 demote+promote 或直接拷贝
- 同时更新 PTE 的 node hint,加速下一次决策

控制参数位于 /proc/sys/kernel/numa_balancing:

# 全局开关(默认随 config 而定)
echo 1 > /proc/sys/kernel/numa_balancing

# 扫描延迟:首次扫描前等待毫秒数
echo 1000 > /proc/sys/kernel/numa_balancing_scan_delay_ms

# 单次最大扫描字节数(MB)
echo 256 > /proc/sys/kernel/numa_balancing_scan_size_mb

# 两次扫描之间的最小/最大间隔
echo 1000 > /proc/sys/kernel/numa_balancing_scan_period_min_ms
echo 30000 > /proc/sys/kernel/numa_balancing_scan_period_max_ms

5. 容器中的 NUMA 陷阱与最佳实践

在 Kubernetes/Docker 中运行工作负载时,NUMA 陷阱尤为明显:

5.1 cpuset 不感知 NUMA

设置 CPU Manager static policy(绑核)时,如果 cpuset 跨 node,进程可能在 node0 的 CPU 上运行但内存分配到了 node1,导致持续远程访存:

# 错误:涉及 node0 和 node1 的 CPU,内存可能跨 node
resources:
  requests:
    cpu: "4"
  limits:
    cpu: "4"
# cpuset: 0,1,16,17(前两个在 node0,后两个在 node1)

# 正确:确保 cpuset 全部在同一 node
annotations:
  cpu-manager-policy: static
# cpuset: 0,1,2,3(全部在 node0)

5.2 cgroup v2 memory.high 与 THP 的交互

当 cgroup 内存限制接近 NUMA node 的一半时,Transparent Huge Pages (THP) 可能因找不到连续 2MB 的物理页(分散在两个 node)而频繁拆分,拉低 TLB 命中率。建议:

# 对 NUMA 敏感但内存充足的 workload,关闭 THP
echo never > /sys/kernel/mm/transparent_hugepage/enabled
# 或仅禁用 defrag,保留大页但避免激进压缩
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
# 对大页友好型应用(JVM、Redis)使用 madvise 显式请求
madvise(ptr, len, MADV_HUGEPAGE);

5.3 监控 NUMA 命中与故障

通过 perf 和 numastat 查看实时 NUMA 行为:

// 查看进程级 NUMA 统计
$ numastat -p $(pidof myapp)
PID                 12345
                Node 0      Node 1
Numa_Hit        982341     234120
Numa_Miss        23412       1234    // = 从另一节点迁入的
Numa_Foreign     12000          0    // 本应分配到该节点的
Interleave_Hit    5234       5100

// 通过 perf 监控跨 node 内存访问的精确延迟
$ perf stat -e node-loads,node-load-misses,node-stores,node-store-misses \
    -p $(pidof myapp) -I 1000

若 Numa_Miss 或 Numa_Foreign 持续增长,说明 AutoNUMA 尚未收敛或 cpuset 与 node 不对齐。此时应检查 /proc/<pid>/numa_maps 查看页面在每个 node 的分布。

6. 实战案例分析:Redis 在双路 256G 服务器上的 NUMA 调优

某 Redis 实例部署于双路 EPYC 7763(2×64 核),内存占用 180GB。默认配置下延迟出现长尾抖动,P99 从 50μs 恶化到 400μs+。

诊断过程

// 1. 发现 cpuset 跨 node
$ cat /sys/fs/cgroup/cpuset.cpus
0-31,64-95,128-159,192-223  // 跨 node0 和 node1

// 2. numastat 确认不均衡
$ numastat -p $(pidof redis-server)
                Node 0      Node 0 (mapped)
Heap            1234 MB     62000 MB   // 大部分堆在 node0
                Node 1      Node 1 (mapped)
Heap             34 MB     28123 MB   // 少量在 node1

// 3. perf 统计显示跨 node 访问占 35%
$ perf stat -e node-load-misses,node-store-misses -p $(pid) sleep 3
node-load-misses:    1.2G   (34.2%)
node-store-misses:   432M   (28.1%)

解决方案

采用 node 绑定 + huge page 预绑定 + 禁用 AutoNUMA 组合策略:

# 1. 启动时绑定 cpuset 到 node0
numactl --cpunodebind=0 --membind=0 redis-server /etc/redis-server &

# 2. 为 Redis 预分配 2MB hugepage,锁定在 node0
echo 50000 > /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages
# redis.conf:
#   maxmemory 180gb
#   activedefrag yes

# 3. 关闭 AutoNUMA(进程绑定到固定 node 后无需内核再迁)
echo 0 > /proc/$(pidof redis-server)/numa_balancing

# 4. 绑定中断到 node0(NIC/RDMA 中断也在本地)
echo 0f > /proc/irq/123/smp_affinity  // node0 的 CPU mask

调优效果

// 调优后
                Before      After
P50 latency:    45 μs       42 μs
P99 latency:    420 μs      68 μs     ▼ 84%
P99.9 latency:  1.2 ms      95 μs     ▼ 92%
QPS:            620K        710K      ▲ 14.5%
Numa_Miss:      34%         0.8%

7. 总结:最佳决策树

场景推荐策略
延迟敏感型单进程(Redis/Nginx/LSM-Tree)numactl --cpunodebind=N --membind=N + 禁用 AutoNUMA
内存密集型共享缓存(数据库 buffer pool)--interleave=all + 开启 THP
Web 通用负载、内存 < node容量50%保持默认 AutoNUMA,内核自动平衡即可
K8s 绑核 workloadsCpuManager static + TopologyManager single-node
AI 训练/GPU 直通GPU 所在 node 绑定,禁用跨 node fallback

NUMA 调优本质上是"将数据推向计算"的过程。理解硬件拓扑、掌握分配策略、善用 AutoNUMA 与显式绑定的组合,是榨取多核服务器性能的关键一环。在实际工程中,永远先用 numastat 和 perf 验证假设,再用 cpuset/mempolicy 固化方案——切勿仅凭直觉调参。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部