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 列表进行绑定。
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 定期监控远程访问比例。