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 绑核 workloads | CpuManager static + TopologyManager single-node |
| AI 训练/GPU 直通 | GPU 所在 node 绑定,禁用跨 node fallback |
NUMA 调优本质上是"将数据推向计算"的过程。理解硬件拓扑、掌握分配策略、善用 AutoNUMA 与显式绑定的组合,是榨取多核服务器性能的关键一环。在实际工程中,永远先用 numastat 和 perf 验证假设,再用 cpuset/mempolicy 固化方案——切勿仅凭直觉调参。

发表评论 取消回复