引言:为什么NUMA是性能绕不开的话题
现代多核服务器普遍采用NUMA(Non-Uniform Memory Access)架构——每个CPU节点拥有本地内存控制器和本地DRAM Bank,跨节点访问需要经过片间互联(如Intel UPI或AMD Infinity Fabric)。这意味着:同一块内存,被本地CPU访问时延迟可能是70ns,被远端CPU访问时可能飙到140ns。在高频交易、AI训练、大数据分析场景下,这种差异可以直接带来30%-50%的性能差距。
Linux内核为NUMA提供了一套完整的内存管理框架:从拓扑感知的节点描述符、Zone分配器、到页分配策略、再到运行时的自动均衡。但大多数开发者只是被动地接受内核默认配置,本文的目标是让你真正理解每一层抽象,从而能主动地为你的工作负载调优。
1. UMA 与 NUMA:内存架构的演进
1.1 UMA — 理想化的均匀访问
UMA (Uniform Memory Access) 拓扑:
+--------+ +--------+
| CPU 0 | | CPU 1 |
+---+----+ +----+----+
| |
+------+-------+
|
+------+------+
| Memory |
| Controller |
+------+------+
|
+------+------+
| DRAM Bank |
+-------------+
特点:所有CPU访问同一内存总线的延迟完全一致
局限:内存总线带宽成为瓶颈(4+核心争抢)
年代:1990s Pentium Pro/PII 时期
1.2 NUMA — 分而治之的工程选择
NUMA (Non-Uniform Memory Access) 拓扑 — 以双路服务器为例:
Node 0 Node 1
+--------+--------+ +--------+--------+
| CPU 0 | CPU 1 | | CPU 2 | CPU 3 |
+---+----+----+--+ +--+----+----+----+
| | | |
+----+----+ +----+----+
| |
+----+----+ +----+----+
| IMC 0 | QPI/UPI/ | IMC 1 |
| DRAM 0 |:::Infinity Fabric::::| DRAM 1 | ← 远端访问
+---------+ (跨节点带宽受限) +---------+
本地访问 ~70ns 本地访问 ~70ns
远端访问 ~130-140ns (约 2x)
1.3 为什么不能只用一个大内存池
Intel Skylake-SP(2017)是一个经典的转折点:单路最多28核但双路72核时,如果使用统一内存池,所有72核争抢2个内存通道——内存延迟从40ns飙升到80ns,I/O密集型应用吞吐量下降60%。NUMA通过将内存分散到各节点本地的内存控制器上,让每个CPU优先直连访问本地DRAM,需要跨节点访问时才经过QPI或UPI,这样本地延迟和总带宽都得到了保证。
2. 内核如何描述NUMA拓扑
2.1 node_data[] — 每个NUMA节点的"身份证"
// 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; // 本节点有多少个Zone
struct page *node_mem_map; // 本节点的页描述符数组
unsigned long node_start_pfn; // 起始页帧号
unsigned long node_present_pages; // 实际可用页数
unsigned long node_spanned_pages; // 总页数(含空洞)
int node_id; // 节点ID
struct task_struct *kswapd; // 本节点的页面回收线程
wait_queue_head_t kswapd_wait;
// NUMA 统计:远程/本地缺页计数
atomic_long_t vm_stat[NR_VM_ZONE_STAT_ITEMS];
} pg_data_t;
内核启动时,BIOS/UEFI通过ACPI SRAT(System ResourceAffinity Table)或设备树(Device Tree)告诉内核拓扑,然后调用setup_node()为每个节点创建pglist_data。在x86_64上,这通常在arch/x86/mm/numa.c的numa_init()中完成。
2.2 Zone — 内存层次分类
// include/linux/mmzone.h
enum zone_type {
zone_DMA, // 0-16MB,ISA DMA设备限定
#ifdef CONFIG_ZONE_DMA32
Zone_DMA32, // 0-4GB,32位DMA设备
#endif
Zone_Normal, // 16MB/4GB ~ 内核直接映射区域(线性映射)
#ifdef CONFIG_HIGHMEM
Zone_HighMem, // 超过直接映射上限(仅32位系统需要)
#endif
Zone_Movable, // 可移动页(内存热插拔用)
Zone_Device, // 设备映射内存
__MAX_NR_ZONES
};
// 典型双路x86_64服务器的Zone分布:
// Node 0: [DMA: 0-16MB] [DMA32: 16MB-4GB] [Normal: 4GB-128GB] [Movable: 0]
// Node 1: [DMA: 0-16MB] [DMA32: 16MB-4GB] [Normal: 4GB-128GB] [Movable: 0]
Zone的核心作用是根据设备需求和硬件限制来限制内存分配。DMA设备只能看到低端地址;ZONE_NORMAL中的页可以直接通过线性映射访问(va = pa + PAGE_OFFSET),这是内核最常驻的内存区;ZONE_MOVABLE支持热插拔和可移动页分配。
2.3 free_area — 伙伴系统的NUMA实例
每个Zone内部用伙伴系统(Buddy System)按2^n页粒度管理空闲页:
struct zone {
free_area_t free_area[MAX_ORDER]; // MAX_ORDER通常 = 11 (即 2^10 = 4MB)
// free_area[0]: 1页 (4KB) 空闲链表
// free_area[1]: 2页 (8KB) 空闲链表
// free_area[2]: 4页 (16KB) 空闲链表
// ...
// free_area[10]: 2048页 (8MB) 空闲链表
}
// 分配流程(__alloc_pages_nodemask):
// 1. 从 node_zonelists 中找到合适的Zone
// 2. 在目标Zone的 free_area[order] 上尝试分配
// 3. 失败则向上翻倍 order,拆分大块
// 4. 还失败则走回收路径(kswapd 或直接回收)
// 5. 最终失败则触发 OOM Killer
2.4 关键数据结构总览
NUMA 内存管理完整拓扑:
+------------------ pglist_data (per-node) ------------------+
| |
| node_id = 0 node_zones[] |
| +------+------+------+------+ |
| | DMA | DMA32|Normal|Movable| |
| +--+---+---+--+---+--+------+ |
| | | | |
| v v v |
| [free_area[0..10]] per Zone (伙伴系统) |
| | |
| v |
| struct page[] node_mem_map (页描述符数组) |
| (node_start_pfn ~ node_start_pfn + node_spanned_pages) |
| |
| struct zonelist node_zonelists[] (回退Zone列表) |
| Node0.Normal → Node0.DMA32 → Node1.Normal → Node1.DMA32 |
+----------------------------------------------------------+
struct page(每页4-8KB对应一个):
- flags: 页状态(PG_slab, PG_swapbacked, PG_lru 等)
- _refcount: 引用计数
- _mapcount: 映射计数(多少进程的页表指向它)
mapping: 指向 address_space 或 anon_vma
index: 在 mapping 中的位置
private: 私有数据(如 Slab 对象指针)
- lru: 用于 LRU 链表(活跃/不活跃)
- page_pgdat(): 反向查询所属 pglist_data
3. Zonelists — 节点间的回退与分配顺序
3.1 默认Round-Robin策略
内核为每个Node构建一个node_zonelists[]数组,记录"如果本节点内存不足,下一次去哪个节点分配"。默认的构建策略(build_zonelists()):
// 双路服务器的 zonelist 示例:
// Local node (id=0):
//
// ZONELIST_FALLBACK (默认分配):
// Node0: Normal → DMA32 → DMA
// Node1: Normal → DMA32 → DMA ← 先本地DMA32,再远端Normal
//
// 这是 "zone local" 策略:优先在本地Zone里找,不行就扩展到远端节点
//
// 为什么先考虑 Zone_DMA32 而非远端 Normal?
// 因为 DMA32 内的 32位可寻址页比远端 Normal 页更有价值(某些设备硬性需要)
3.2 修改 zonelist 顺序的方法
方法1:sysctl - 全局调整
# echo 1 > /proc/sys/zone_reclaim_mode
# 启用后,本地节点回收优先于远端分配
方法2:cpuset cgroup - 进程组限制
echo "0" > /dev/cpuset/mygroup/cpuset.mems # 限制只用 Node0
echo "0-1" > /dev/cpuset/mygroup/cpuset.mems # 允许 Node0+Node1
方法3:NUMA memory policy - 进程级精准控制
set_mempolicy(MPOL_PREFERRED, &nodemask_0, MAXNODE); # 优先 Node0
mbind(addr, len, MPOL_BIND, &nodemask_1, MAXNODE, 0); # 强制 Node1
4. 内存分配策略:从默认到精细控制
4.1 四种策略显式语义
策略1: MPOL_DEFAULT(默认策略)
├─ 行为:只在当前运行CPU所在的Node上分配内存
├─ 优势:延迟最优(99%的访问都是本地)
├─ 劣势:单个Node可能OOM(进程还在运行但换Node了)
└─ 适用:绝大多数应用
策略2: MPOL_BIND(严格绑定)
├─ 行为:只能在 mask 中的节点分配,无可用则触发OOM
├─ 优势:100%可预测的内存访问延迟
├─ 劣势:如果绑定节点内存耗尽,直接OOM不fallback
└─ 适用:关键实时系统、DPDK
策略3: MPOL_PREFERRED(优先节点)
├─ 行为:优先在 mask 中的节点分配,不够时 fallback 到其他节点
├─ 优势:兼顾延迟和可用性
├─ 劣势:无法完全保证本地访问
└─ 适用:AI训练、数据库
策略4: MPOL_INTERLEAVE(交错分配)
├─ 行为:按 mask 顺序在多个节点间轮询分配(类似RAID-0的条带化)
├─ 优势:最大化总内存带宽(每个节点都贡献带宽)
├─ 劣势:平均延迟介于本地和远端之间
└─ 适用:大内存工作、GPU训练场景
4.2 默认策略的NUMA Balancing行为
除了分配策略,Linux还支持运行时均衡——即使进程没有显式设置策略,内核的自动NUMA均衡(AutoNUMA)也会动态迁移页到"最常访问它的CPU"所在的节点。
// NUMA Balancing 三部曲
//
// (1) 周期性扫描(由 numa_balancing_scan_delay_ms 控制,默认1000ms)
// - 内核的 NUMA hinting fault 机制:先将页标记为 Protected(无访问权限)
// - 进程A 在 Node1 的 CPU 上访问了来自 Node0 的页
// - 触发 hinting fault → 内核采样记录 "进程A 在 Node1 访问了这页"
//
// (2) 页迁移决策(由 numa_balancing_scan_period_max_ms 控制)
// - 如果某页被远程访问次数超过阈值 → 标记为待迁移
// - migrate_misplaced_pages() 触发实际迁移
//
// (3) 进程迁移(task_numa_placement)
// - 如果进程的大部分页都在远端节点
// - 内核会把进程也迁移的远端节点去跑
// - 这会导致 CPU 本地性 vs 内存本地性的权衡
// 启用/禁用:
echo 0 > /proc/sys/kernel/numa_balancing # 关闭自动均衡
echo 1 > /proc/sys/kernel/numa_balancing # 打开自动均衡(默认开)
// DPDK/HPC 场景通常关闭 AutoNUMA(因为手动策略更精确)
4.3 显式绑定API
// 进程级策略设置
#include <numaif.h>
// 方式1: 设置整个进程的默认策略
int set_mempolicy(int policy, const unsigned long *nodemask, unsigned long maxnode);
// policy: MPOL_DEFAULT/BIND/PREFERRED/INTERLEAVE/MWEIGHTED
// 方式2: 绑定指定地址范围
int mbind(void *addr, unsigned long len, int policy,
const unsigned long *nodemask, unsigned long maxnode, unsigned int flags);
// flags: MF_MOVE | MF_MOVE_ALL | MF_STRICT
// 方式3: 迁移指定页到目标节点
int move_pages(int pid, unsigned long count, void **pages, const int *nodes, int *status, int flags);
// 示例:绑定到 Node0,分配1GB空间
#include <numa.h>
#include <numaif.h>
unsigned long nodemask = 1UL << 0; // Node0 only
set_mempolicy(MPOL_BIND, &nodemask, 8);
void *ptr = mmap(NULL, 1UL << 30, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
// 分配的 1GB 一定在 Node0 的物理内存上
5. Per-CPU 缓存与NUMA
5.1 Per-CPU Page Allocator (PCP)
在NUMA设计中,struct zone内部为每个CPU维护了一个"私有"的冷热页缓存(per_cpu_pageset),目的是减少伙伴系统的锁争用和跨节点缓存行迁移:
// include/linux/mmzone.h
struct per_cpu_pages {
int count; // 缓存页数
int high; // 高于此值向伙伴系统归还
int batch; // 每次从伙伴系统调入/归还的批量
struct list_head lists[NR_PCP_LISTS]; // 按 migratetype 分类
};
struct per_cpu_pageset {
struct per_cpu_pages pcp; // 热页缓存(新分配)
#ifdef CONFIG_NUMA
s8 expire; // NUMA过期计数(定期刷新避免页在远端沉淀)
s8 vm_stat_diff[NR_VM_ZONE_STAT_ITEMS]; // NUMA统计diff
#endif
};
// PCP 分配路径的快速流程:
// allocate_page() → __rmqueue_pcplist()
// 1. 从当前CPU所在的 pcp->lists[migratetype] 取一页 ← 最快
// 2. 空了 → __rmqueue() 从伙伴系统批量取 batch 页填入pcp
// 3. 伙伴系统也空了 → 页面回收
//
// 释放路径:
// free_page() → __free_one_page() 或 free_pcppage_bulk()
// 1. pcp->count < high → 放回 pcp 缓存
// 2. pcp->count > high → 还回伙伴系统(释放一半)
//
// 为什么 PCP 在 NUMA 下很重要?
// 考虑 64核 × 2路 = 128个 pcpu 缓存,如果所有 CPU 都从 node_zonelists[0] 的
// 同一个 free_area 上取页面,自旋锁争用会极其严重。PCP 把频率最高的
// 单页分配/释放 隔离到 CPU 本地缓存,NUMA 只影响 pcp 和伙伴系统之间的批量交换。
5.2 SLUB Allocator 的NUMA感知
SLUB是内核主要的对象分配器(和SLAB类似但更简单)。每个NUMA节点都有自己的kmem_cpu_cache和kmem_node_cache:
SLUB 分配器的 NUMA 布局:
kmem_cache (全局)
└── kmem_cache_cpu *cpu_slab[NR_CPUS] // 每个CPU一个活跃的slab缓存
└── slab *page // 当前正在分配的slab
└── void **freelist // 空闲对象栈
└── kmem_cache_node *node[MAX_NUMNODES] // 每个NUMA节点一个
└── struct list_head partial; // 部分分配空闲的slubs
└── struct list_head full; // 已满的slabs
└── unsigned long nr_partial;
└── unsigned long nr_slabs;
分配路径 kmem_cache_alloc():
第一阶段: 从 cpu_slab->freelist 取 ← 最快(无锁,CPU本地)
第二阶段: cpu_slab->page 的空了 → 从 node[local_node]->partial 取新slab
第三阶段: local_node partial 空了 → 从伙伴系统分配新slab(附带 node 参数)
第四阶段: 新slab填入 cpu_slab,继续分配
释放路径 kmem_cache_free():
放回 cpu_slab->freelist, 或归还所属 node 的 partial/full 链
关键优化:
- kmem_cache_alloc_node(): 显式指定 NUMA 节点(如 device driver 用本地节点)
- SLUB 的 node 层级链表避免了跨节点全链表扫描
- 按 node 分配减少跨节点缓存行 bounce
6. HugePage 与 NUMA
6.1 为什么 NUMA 下 HugePage 特别重要
4KB小页的TLB未命中代价在NUMA场景下被放大了:128次4KB页的远程访问需要128次TLB命中;而使用2MB大页时,同样128次访问仅需1次TLB命中——这对需要频繁访问大块连续内存的应用至关重要。
6.2 透明大页(THP)的NUMA行为
// THP 在 NUMA 下的行为策略
// 默认:只在发生 NUMA hinting fault 时拆分 THP 为 4KB 小页再逐页迁移
// 或者通过 khugepaged 在后台将本地访问的4KB页合并为 THP
// THP 的 NUMA 控制参数
/sys/kernel/mm/transparent_hugepage/enable = "madvise" // 只在 madvise 区域内启用
/sys/kernel/mm/transparent_hugepage/defrag = "defer" // 延迟整理
// NUMA 下 THP 的两难困境:
// 1. 启用 THP → 减少 TLB miss → 加速大内存遍历
// 2. THP 用 2MB 粒度 → 跨 NUMA 节点迁移代价高昂(2MB 拷贝)
// 3. 不启用 THP → 小页可以按 4KB 粒度迁移 → 更精细的 NUMA 均衡
// 实际决策:
// -数据库 (MySQL/PostgreSQL):通常开启 THP(局部性高,大内存扫描)
// -虚拟机 (QEMU/KVM):通常关闭 THP(避免 EPT 表项数爆炸)
// - AI 训练:看情况,大 batch THP 好,小 batch random access 关闭好
6.3 静态大页(HugeTLB)的NUMA部署
// 查看系统上每个 NUMA 节点的 HugePage 储备
$ cat /sys/devices/system/node/node*/hugepages/hugepages-2048kB/nr_hugepages
node0: 1024
node1: 1024
// 为每个节点预留(避免分配时跨节点)
echo 1024 > /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages
echo 1024 > /sys/devices/system/node/node1/hugepages/hugepages-2048kB/nr_hugepages
// 程序中绑定 HugePage 到特定 Node
void *ptr = mmap(NULL, 2 << 20, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB,
-1, 0);
mbind(ptr, 2UL << 20, MPOL_PREFERRED, &nodemask, 8, 0);
// libnuma 也可以通过 numa_alloc_onnode 配合 HugePage 使用
7. 页面回收与 NUMA
7.1 Zone Reclaim 模式
当本地节点的 Free 内存低于 low watermark 时,默认会在本节点做页面回收(扫描LRU链表、回收不活跃页、写回脏页)。但如果有zone_reclaim_mode启用,内核在本节点内存不足时,会先做Zone Reclaim再考虑跨节点分配:
// /proc/sys/vm/zone_reclaim_mode 的位定义
#define ZONE_RECLAIM_DISABLE 0
#define ZONE_RECLAIM_WRITE (1 << 0) // 写入/回写时触发 Zone Reclaim
#define ZONE_RECLAIM_SWAP (1 << 1) // swap 时触发
#define ZONE_RECLAIM_ZONE_MOVE (1 << 2) // 释放页时移回本节点
#define ZONE_RECLAIM_KSWAPD (1 << 3) // kswapd 中触发 Zone Reclaim
// 关键取舍:
// zone_reclaim_mode = 1: → 优先回收本节点 → 保证新分配页的本地性
// 但如果本节点页冷冰冰(很少被访问),强制回收会
// 造成无效 I/O(回收了却最终不会访问)
//
// zone_reclaim_mode = 0: → 直接去远端节点分配 → 避免无效回收
// 但可能导致本地页堆积(内存分布不均)
//
// Oracle ASM, DPDK 等延迟敏感应用通常开启(需要 100% 本地保证)
// Hadoop/Spark 等大内存吞吐应用通常关闭(避免无效回收)
7.2 NUMA 感知的页面回收
现代内核(4.0+)的页面回收已经NUMA感知:
// kswapd 在每个 NUMA 节点上独立运行
// - kswapd0 负责 Node 0 的内存回收
// - kswapd1 负责 Node 1 的内存回收
// LRU 扫描时不再只考虑全局活跃度,而是结合 NUMA hinting fault 信息:
// 如果某页频繁被远程节点访问,说明该迁移但还没迁移
// 如果某页最后一次访问是从Node0迁移过来的,在Node0上被访问→是好的;在Node1上被访问→还没迁移到位
// 关键参数:
/proc/sys/vm/zone_reclaim_mode
/proc/sys/kernel/numa_zonelist_order // numa/fallout/node(已废弃)
/sys/devices/system/node/node*/numa_hit // 本地命中次数
/sys/devices/system/node/node*/numa_miss // 远程分配次数
/sys/devices/system/node/node*/numa_foreign // 本节点页被远端请求次数
8. 性能测量与 NUMA 可观测性
8.1 numastat — NUMA 全局统计
$ numastat -cm
Per-node stats (in MB):
Node 0 Node 1 Total
------ ------ -----
Mem Total 128000 128000 256000
Mem Free 32000 28000 60000
Mem Used 96000 100000 196000
NumaHit 980000 960000 1940000 ← 从本地节点分配的次数
NumaMiss 20000 40000 60000 ← 被迫从远端分配的次数
NumaForeign 40000 20000 60000 ← 本节点页被其他节点请求的
// NumaHit / (NumaHit + NumaMiss) = 本地命中率
// Node0: 980000 / 1,000,000 = 98% ← 非常好
// Node1: 960000 / 1,000,000 = 96%
// 如果命中率 < 90%,说明进程经常在远端节点分配内存
// 如果命中率 < 80%,需要检查进程绑定和CPU调度策略
8.2 numastat -p— 按进程查看
$ numastat -p $(pidof myprocess)
Per-node process memory (in pages) for PID 12345:
Node 0 Node 1
------ ------
Total 8192 2048 ← 进程总共的 page 分布
Huge 0 512 ← HugePage 全在 Node1
Heap 2048 1024
Stack 16 8
// 解读: 这个进程 80% 的内存都在 Node1
// 但它可能大部分时间在 Node0 上运行 → 80% 的内存访问都是远程的 → 性能爆炸
8.3 perf c2c — 缓存行级别分析
// Intel IBS (Instruction Based Sampling) 或 AMD SAM 下的 perf c2c
// 可以看到具体哪些缓存行经历了跨NUMA节点 bounce
$ sudo perf c2c record -a -- sleep 10
$ sudo perf c2c report --stdio
// 输出说明:
// "Hitm" (Hit Modified) = 远程节点CPU持有这行缓存的 M 状态
// 高 Hitm 率 = 大量跨节点缓存行迁移 = NUMA 性能瓶颈
9. 生产级调优案例
9.1 DPDK — 手动精确控制
// DPDK 的核心思想:绕过内核,直接操作硬件
// 因此需要在启动时精确控制 NUMA
// EAL 初始化参数:
// -m 256 每个节点分配 256MB
// --socket-mem 256,256 Node0 和 Node1 各分配 256MB
// --socket-limit 1024,512 限制每个节点最多分配的内存
// EAL 内部的行为:
// 1. 读取 /sys/devices/system/node/nodeX/meminfo 得知节点内存大小
// 2. 使用 mmap(MAP_HUGETLB | MAP_LOCKED) 分配大页
// 3. 调用 get_mempolicy / MPOL_BIND 确保分配在目标节点上
// 4. rte_memseg_list 记录每个 memseg 所在的 NUMA 节点
// 应用代码中:
// rte_pktmbuf_alloc(pool) → pool 已经绑定在特定 NUMA 节点
// 分配的 mbuf 的 iodev->socket_id = 调用者所在NUMA节点 → 分配到本地内存
// DPDK 的 lcore 绑定:
// 网卡在 PCIe bus 7 上 → 连接到 Node 0 的 CPU
// 把处理线程 lcore 2 绑定到 Node 0 的 CPU 2
// 内存分配从 Node 0 取 → 全程本地访问
9.2 分布式数据库 (CockroachDB / TiDB)
// 数据库场景的NUMA调优模式
// 模式1: 单实例绑定单NUMA节点
// 每节点部署1个实例 → 每个实例独占 Node0 或 Node1 的CPU+内存
// 优势: 零跨节点内存访问
// 劣势: 内存利用率低(每个实例只能用到单节点内存)
// 模式2: 多实例交错部署
// 先路由到 CPU → 按 CPU 找到所属节点 → 从本地节点 buffer pool 取页
// 核心: buffer pool 按 NUMA 节点切分
struct buffer_pool_node {
uint8_t *pages[BUFFER_POOL_SIZE_PER_NODE];
uint64_t access_counter;
} per_node_pool[MAX_NUMA_NODES];
// 访问页的代码路径:
page = per_node_pool[cpu_to_node(smp_processor_id())].pages[page_id];
// 保证访问 buffer pool 时基本上命中本地节点
9.3 PyTorch / TensorFlow AI 训练
// AI 训练的NUMA特征:
// - 单机多卡训练: GPU 在 NUMA Node0, CPU worker 在 Node1
// - NCCL 通信需要用 GPU Direct RDMA (GDR) → 数据直接 GPU→GPU →绕过CPU
// - 数据加载: DataLoader 在 CPU 上做 augmentation → 数据在 CPU 本地 Node
// → 然后拷贝到 GPU GPU 的本地 Node (P2P DMA)
// PyTorch DataLoader NUMA 绑定:
import os
os.environ["OMP_NUM_THREADS"] = "32" # 每 NUMA 节点 32 线程
os.environ["KMP_AFFINITY"] = "granularity=fine,compact,1,0"
// PyTorch Distributed 场景:
// 4卡 A100 全在 Node0 → DataLoader 的 prefetch 线程应绑在 Node0
torch.set_num_threads(32)
torch.set_num_interop_threads(4)
// 最佳实践:
// numactl --interleave=all python train.py # 交错分配(最大化带宽)
// numactl --membind=0 --cpunodebind=0 python train.py # 严格本地
9.4 MySQL / PostgreSQL
// 数据库 NUMA 调优
// MySQL InnoDB:
innodb_numa_interleave = ON # 开启InnoDB buffer pool交错分配
# MariaDB 专属 → InnoDB 启动时用 numactl --interleave=all 类似的效果
// PostgreSQL:
// 使用 numactl 启动 PG:
numactl --interleave=all pg_ctl -D /var/lib/postgresql/data start
# 或
numactl --cpunodebind=0 --membind=0 postgres -C
// Oracle:
// 需要 SGA 和 PGA 全在 Node0:
ALTER SYSTEM SET "_enable_NUMA_optimization"=TRUE SCOPE=SPFILE;
ALTER SYSTEM SET "_NUMA_pool_size"=8589934592; # 8GB
// 数据库为什么通常选择 interleave 而非 bind?
// 1. 多连接并发访问 → 部分连接绑定 Node0, 部分 Node1 → bind 导致不均
// 2. interleave 条带化 → 每个连接的内存均匀分散到所有节点
// → 任一条连接的平均远程访问比例固定 = (N-1)/N (N=节点数)
// → 可预测,容易建模
10. 性能基准与量化数据
10.1 STREAM Triad 基准
// STREAM Triad: a[i] = b[i] + scalar * c[i]
// 双路 AMD EPYC 7763 (128核, 8 个 NUMA 节点)
配置1: --membind=0 (严格本地)
Node 0: 78 GB/s (本地带宽)
配置2: --interleave=all (全局交错)
Aggregated: 92 GB/s (汇总带宽)
配置3: 无任何绑定 (默认)
58% 的分配落在 Node0 (运行线程所在)
Node 0: 42 GB/s, Node 1: 12 GB/s
Aggregated: 54 GB/s
本地 vs 远程延迟对比:
- 本地 DDR4-3200: ~70ns
- 远端 AMD IF link: ~130ns (~1.86x)
- 跨socket 双路: ~135ns
结论: 交错 > 本地 (单节点限制) > 无绑定 (又远又不均)
10.2 Redis 在 NUMA 下的表现
// Redis 单实例 5000 万次 GET 测试
// Intel Xeon Gold 6330 (28核, 2 路)
numactl --membind=0 → 吞吐量 120万 QPS, P99 延迟 89μs
numactl --interleave=all → 吞吐量 98万 QPS, P99 延迟 112μs
无绑定 → 吞吐量 105万 QPS, P99 延迟 155μs (抖动大)
// 关键发现:
// Redis 是单线程 → 绑定到本地节点延迟最优
// interleave 虽然带宽更高,但 Redis 不需要大带宽, 需要低延迟
// 无绑定时, 内核自动均衡可能把页迁移走, 造成抖动
11. 流程总图:NUMA 内存分配完整路径
用户态 malloc() / mmap()
│
v
内核: __alloc_pages_nodemask()
│
+--→ 1. 确定目标节点列表 (zonelist)
│ (进程MPOL_* 策略 → 过滤节点)
│ (默认 → 当前CPU所在节点优先)
│
+--→ 2. 页面分配 (alloc_pages)
│ │
│ +--→ a. 快速路径: PCP(per-CPU)缓存取页
│ │
│ +--→ b. 慢速路径: 伙伴系统 (free_area)
│ │
│ +--→ c. 页面回收: kswapd / direct reclaim
│ │
│ +--→ d. OOM Killer (最后手段)
│
+--→ 3. 返回 struct page → 映射到用户态
分配失败 + 远端节点有可用内存
│
+--→ 新页分配在远端节点
│
+--→ 内核记录 numa_miss++
│
+--→ NUMA Balancing 周期性检测
│ │
│ +--→ hinting fault 远程访问次数超阈值
│ │
│ +--→ migrate_pages_to_node() (迁移到本地节点)
│
+--→ 迁移成功后 numa_hit++
12. 总结:NUMA 调优决策树
你的应用需要大量内存带宽?
├─ YES → 考虑 interleave 策略 (--interleave=all)
│ (多线程并发访问不同地址)
│ 适用: AI训练/推理、视频编解码、
│ 科学计算、OLAP数据分析
│
└─ NO (主要是延迟敏感的单线程/多线程小内存)
│
你的应用可以拆分到多个NUMA节点跑独立实例?
├─ YES → membind per-instance (每实例绑定到不同节点)
│ (充分利用各节点的本地带宽)
│ 适用: Redis、Nginx、HAProxy
│
└─ NO (单应用/需要共享内存)
│
应用需要可预测延迟(实时/交易)?
├─ YES → membind + CPU bind (完全锁死在一个节点)
│ 适用: DPDK、高频交易、RT调度
│
└─ NO → 让 AutoNUMA 均衡
适用: 通用Web服务、
AI推理服务、大多数企业应用
最后检查:
1. numastat - 本地命中率 > 95%? ✅
2. P99延迟抖动受控? ✅
3. 无跨节点带宽瓶颈? ✅
4. 稳定性: 长期不OOM, 无突然的 miss 激增? ✅
Linux的NUMA内存管理是操作系统中最精密的子系统之一。从Zone分类到伙伴系统,从AutoNUMA均衡到HugePage部署,每一层都为核心目标服务:让进程能高效地利用本地节点内存,同时在全局范围内尽量均衡负载。理解这些机制,是大规模生产系统调优的基础功。无论你是DPDK开发者、AI训练工程师还是分布式数据库DBA,NUMA知识都会成为你定位和解决内存性能问题的关键武器。

发表评论 取消回复