引言:为什么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知识都会成为你定位和解决内存性能问题的关键武器。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部