4. NUMA 感知与内存分配策略

现代多路服务器普遍采用 NUMA(Non-Uniform Memory Access)架构。处理器被划分为多个节点,每个节点拥有本地内存和一组核心。访问本地内存延迟远低于跨节点访问(Remote Access),这一差异在内存密集型应用中可产生 2-5 倍的性能差距。本章将讲解如何识别 NUMA 拓扑、通过 mbind / libnuma 控制内存分配策略,并通过实际对比验证优化效果。

4.1 NUMA 架构原理与延迟差异

NUMA 系统的核心特征:每个物理 CPU 插槽及其直连内存构成一个节点(Node)。核心访问本节点内存通过内存控制器直达(延迟约 80-120 ns),访问远端节点内存则需要经过 QPI/UPI 总线跨节点转发(延迟约 140-200 ns)。带宽也会因跨节点而下降 30%-50%。

架构内存访问特征延迟相对值适用场景
SMP(对称多处理)所有内存统一访问,延迟一致1.0x低核数、小规模共享内存
NUMA(非统一内存访问)本地节点内存延迟低,远程节点延迟高本地 1.0x,远程 1.5-2.5x多路大内存、虚拟化、数据库
常见陷阱:默认分配策略(MPOL_DEFAULT)通常优先分配在本节点,但若本地内存不足会 fallback 到远端节点,导致延迟抖动。数据库、内存密集型 HPC 应用必须显式绑定节点。

4.2 NUMA 节点识别与拓扑查看

操作系统通过 ACPI 表识别 NUMA 拓扑,可用以下工具查看节点数量、CPU 和内存归属。

4.2.1 命令行工具

# 查看完整拓扑
numactl --hardware

# 输出示例(4 节点系统)
# available: 4 nodes (0-3)
# node 0 cpus: 0 1 2 3 4 5 6 7
# node 0 size: 131072 MB
# node 0 free: 120000 MB
# node 1 cpus: 8 9 10 11 12 13 14 15
# node 1 size: 131072 MB
# ...

# 查看进程/内存分配
numastat -p <PID>

4.2.2 文件系统接口

# 每个节点信息
ls /sys/devices/system/node/node*/cpu*/

# 查看具体节点的内存范围
cat /sys/devices/system/node/node0/meminfo

4.3 内存分配策略与 mbind 系统调用

Linux 提供 mbind() 系统调用,可以为特定内存区域绑定 NUMA 节点或指定分配策略。策略定义在 <numaif.h> 中。

4.3.1 核心策略对比

策略宏含义适用场景
MPOL_BIND强制内存分配在指定节点,不允许跨节点高频内存访问、绑定计算节点
MPOL_PREFERRED优先从指定节点分配,若不足可 fallback中等工作量,容忍偶尔跨节点
MPOL_INTERLEAVE在指定节点间轮询分配,均衡带宽多线程共享内存、降低单节点瓶颈
MPOL_LOCAL优先从当前运行核心所在节点分配进程随时迁移场景

4.3.2 mbind API 与代码示例

#define _GNU_SOURCE
#include <numaif.h>
#include <stdlib.h>
#include <stdio.h>

int main() {
    size_t size = 1024 * 1024 * 1024;  // 1 GB
    void *mem = mmap(NULL, size, PROT_READ|PROT_WRITE,
                     MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
    if (mem == MAP_FAILED) {
        perror("mmap");
        exit(1);
    }

    unsigned long nodemask = 0;
    // 绑定到节点 0
    nodemask |= (1UL << 0);
    unsigned long maxnode = 4;  // 系统节点总数

    int ret = mbind(mem, size, MPOL_BIND, &nodemask,
                    maxnode, MPOL_MF_STRICT);
    if (ret != 0) {
        perror("mbind");
        exit(1);
    }

    // 此时 mem 区域全部落在节点 0 上
    printf("Memory bound to node 0\n");
    return 0;
}

4.3.3 调试验证

# 进程启动后查看各节点内存分配
numastat -p <PID>

# 输出示例
# Per-node process memory usage (in MBs) for PID 1234 (myapp)
# Node 0 Node 1 Total
# ------ ------ -----
# 1024.0 0.0 1024.0

4.4 libnuma 编程接口

libnuma 封装了 mbind、get_mempolicy 等底层调用,提供便捷的节点位图操作和内存分配函数。

4.4.1 常用 API

函数说明
numa_available()检查 NUMA 是否可用
numa_max_node()返回最大节点编号
numa_alloc_onnode(size, node)在指定节点分配内存
numa_run_on_node(node)将当前线程绑定到指定节点
numa_set_membind(bitmask)设置内存绑定节点

4.4.2 完整示例

#include <numa.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    if (numa_available() < 0) {
        fprintf(stderr, "NUMA not available\n");
        exit(1);
    }

    int node = 1;
    size_t size = 512 * 1024 * 1024;  // 512 MB
    void *buf = numa_alloc_onnode(size, node);
    if (buf == NULL) {
        perror("numa_alloc_onnode");
        exit(1);
    }

    // 使用 buf 进行密集计算
    // ...

    numa_free(buf, size);
    return 0;
}

4.4.3 编译与链接

gcc -o numa_example numa_example.c -lnuma
# 运行时可能需要设置 LD_LIBRARY_PATH 或使用 -rpath

4.5 性能对比实验

在 4 节点 NUMA 系统上,运行 Stream 基准测试对比不同分配策略的内存带宽。

4.5.1 测试方法

  • 使用 numactl 启动多线程 Stream 测试
  • 对比:默认策略 vs BIND 到本地节点 vs INTERLEAVE 跨节点
# 默认策略(可能跨节点)
numactl --cpunodebind=0 stream.exe

# 强制绑定到节点 0
numactl --cpunodebind=0 --membind=0 stream.exe

# 跨节点交错分配
numactl --cpunodebind=0 --interleave=all stream.exe

4.5.2 典型结果

策略Triad 带宽 (GB/s)延迟抖动 (ns)说明
默认(MPOL_DEFAULT)45.2±30远程分配导致带宽下降
BIND 本地节点62.8±5全本地访问,带宽最高
INTERLEAVE 全部节点58.1±8带宽均衡,略有远端开销

4.5.3 观察与分析

  • BIND 策略收益最大,尤其适合 CPU 绑定节点且内存占用不超过本地容量的场景。
  • INTERLEAVE 适合多线程读共享数组,避免单节点带宽饱和。
  • 默认策略因随机 fallback 导致带宽不稳定,需避免。

4.6 常见错误与优化实践

关键原则:
1. 先识别应用的内存访问模式(本地 vs 远程)。
2. 对计算密集型线程,CPU 绑定和内存绑定必须在同一节点。
3. 使用 numa_all_cpus_ptr 获取节点 CPU 列表进行绑定。

4.6.1 典型错误

  • 错误 1:只绑定了 CPU 节点,未绑定内存节点,导致线程在节点 0 运行,但内存(默认策略)可能溢出到节点 1,产生大量远程访问。
  • 错误 2:使用 MPOL_BIND 但未设置 MPOL_MF_STRICT 标志,实际分配时若节点不足会静默 fallback。
  • 错误 3:未考虑 per-node 内存容量,BIND 到已满的节点导致分配失败。

4.6.2 调试命令汇总

# 查看进程当前内存分配节点
cat /proc/<PID>/numa_maps

# 检查页面迁移
numastat

# 实时内存访问延迟(需 root)
perf stat -e r20C4 -a sleep 1   # 远程节点内存访问事件
生产建议:对于数据库实例或虚拟化场景,建议在启动脚本中显式调用 numactl --membind=<node>,并配合 cgroups 确保分配稳定;通过 numastat 定期监控远程访问比例。

4.7 NUMA 感知工作流程总览

NUMA 感知内存分配决策流程 应用启动 numa_available() 拓扑查询 内存访问模式分析 MPOL_BIND 本地节点 MPOL_PREFERRED 优先+fallback MPOL_INTERLEAVE 交叉分配 mbind / numa_alloc_onnode numastat / perf 验证 策略选择依据: 1. 内存容量 2. 跨节点延迟