NUMA 架构深度实战:从内存访问延迟到多核性能优化
在现代数据中心服务器中,一颗 AMD EPYC 或 Intel Xeon 处理器内部往往集成数十甚至上百个核心,这些核心被划分到不同的 NUMA(Non-Uniform Memory Access)节点上。如果你的程序"无感"地跨 NUMA 节点访问内存,性能可能下降 50% 甚至更多。本文将从硬件架构、操作系统调度、编程接口和性能实战四个维度,深入剖析 NUMA 的工作机制,并提供可直接落地的优化方案。
NUMA 为什么存在
在 UMA(Uniform Memory Access)架构中,所有 CPU 访问任何内存地址的延迟是相同的。这种对称设计在核心数较少时工作良好,但随着核心数量持续增长,共享总线和内存控制器成为了瓶颈——北桥的带宽根本喂不饱十几个核心同时请求数据。
NUMA 的解决思路是分而治之:将 CPU 和内存划分为多个节点,每个节点内部通过本地内存控制器直接访问自己的内存(快),跨节点访问则经过互联总线(慢)。典型的双路服务器拓扑中,每颗物理 CPU 就是一个 NUMA 节点,本地内存访问延迟约 80-100ns,跨节点访问则可能达到 130-180ns,差距接近一倍。
以 AMD EPYC 9654 为例,单颗芯片拥有 96 个核心,却被划分为 12 个 CCD(Core Complex Die),每个 CCD 有 8 个核心共享 L3 缓存,内部又分为 2 个 NUMA 节点。这意味着同一个 CCD 内的不同 NUMA 节点之间访问延迟也不相同,拓扑复杂度远超早期双路系统。
看懂你的机器拓扑
优化 NUMA 性能的第一步是理解你的硬件拓扑。Linux 提供了多个工具来查看 NUMA 信息。
# 查看 NUMA 节点距离矩阵(关键!)
$ numactl --hardware
available: 2 nodes (0, 1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 24 25 26 27
node 1 cpus: 12 13 14 15 16 17 18 19 20 21 22 23 36 37 38 39
node 0 size: 64219 MB free: 52301 MB
node 1 size: 64502 MB free: 48776 MB
node distances:
node 0 1
0: 10 21
1: 21 10
node distances 是核心信息:本地节点距离为 10,跨节点距离为 21,这意味着跨节点访问延迟约为本地的 2.1 倍。
# 查看更详细的拓扑
$ lscpu | grep -i numa
NUMA node(s): 2
NUMA node0 CPU(s): 0-11,24-35
NUMA node1 CPU(s): 12-23,36-47
# 通过 sysfs 查看具体信息
$ ls /sys/devices/system/node/
node0 node1 has_normal_memory possible
$ cat /sys/devices/system/node/node0/meminfo | head -5
Node 0 MemTotal: 65794604 kB
Node 0 MemFree: 53556432 kB
Linux 内核的 NUMA 调度策略
Linux 内核从 2.6 时代开始引入 NUMA 支持,经过多年演进已经形成了一套成熟的策略体系。
内存分配策略
内核提供四种 NUMA 内存分配策略,通过 set_mempolicy() 或 mbind() 设置:
| 策略 | 常量 | 行为 |
|---|---|---|
| 优选本地 | MPOL_PREFERRED | 优先从指定节点分配,不足时回退到远端 |
| 严格本地 | MPOL_BIND | 只从指定节点分配,本地不够则触发 OOM |
| 交替分配 | MPOL_INTERLEAVE | 在所有指定节点间轮询分配(带宽优先) |
| 默认策略 | MPOL_DEFAULT | 按进程所在 CPU 节点就近分配(First-Touch) |
默认的 First-Touch 策略是最常用的:页面第一次被 CPU 写入时,分配到该 CPU 所在的 NUMA 节点。这个策略简单有效,但要求程序启动时就做好数据预分配。如果数据是在节点 0 的线程中创建,却在节点 1 的线程中大量读取,就会产生持续的跨节点流量。
CPU 调度与 NUMA 感知
内核的调度器也具备 NUMA 感知能力:
- AutoNUMA Balancing(内核 3.13+):内核会周期性扫描进程的内存页,统计各 NUMA 节点的访问频率。如果某页面被远端 CPU 频繁访问,内核自动将其迁移到远端节点,或者将访问该页面的线程迁移到页面所在的节点。
# 查看 AutoNUMA 状态
$ cat /proc/sys/kernel/numa_balancing
1 # 1=启用, 0=禁用
# 查看 NUMA 迁移统计
$ cat /sys/devices/system/node/node0/numastat
numa_hit 12503493
numa_miss 89234 # 分配到本节点但被远端访问
numa_foreign 234 # 分配到远端但本节点在访问
interleave_hit 0
local_node 9823402
other_node 89234 # 本进程在远端节点运行
- CPUSET NUMA 组亲和性:Scheduler Domain 中,NUMA 节点的层级高于 LLC 域,调度器优先在同一 NUMA 节点内的不同核心间迁移线程,尽量避免跨 NUMA 迁移带来的缓存失效。
Hugepages 与 NUMA
大页(Hugepages)对 NUMA 性能至关重要:2MB/1GB 的大页大幅减少 TLB miss 和页表遍历开销,同时大页的 NUMA 迁移代价远高于 4KB 小页。如果大页被错误分配到远端节点,影响范围更广。
# 查看各节点的大页分配
$ cat /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages
256
$ cat /sys/devices/system/node/node0/hugepages/hugepages-2048kB/free_hugepages
192
# 针对 NUMA 节点预分配大页
echo 512 > /sys/devices/system/node/node0/hugepages/hugepages-2048kB/nr_hugepages
NUMA 编程实战:libnuma 与原生 API
实际工程中,我们通过 libnuma 库或 Linux 原生系统调用来控制 NUMA 行为。
使用 numactl 启动进程
最简单的 NUMA 控制工具:
# 进程只在 node0 的 CPU 上运行,内存只在 node0 分配
$ numactl --cpunodebind=0 --membind=0 ./myapp
# 内存交替分配到 node0 和 node1(最大化内存带宽)
$ numactl --interleave=0,1 ./myapp
# 优选 node1,不够时回退到 node0
$ numactl --preferred=1 ./myapp
程序中的 NUMA 控制
通过 libnuma 在 C/C++ 程序中精细控制:
#include <numa.h>
#include <numaif.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/* 分配指定 NUMA 节点的本地内存 */
void *numa_alloc_onnode(size_t size, int node) {
void *ptr = numa_alloc_onnode(size, node);
if (!ptr) {
perror("numa_alloc_onnode");
return NULL;
}
return ptr;
}
/* 查询内存页面当前所在的 NUMA 节点 */
int get_page_node(void *addr) {
int status;
void *pages[1] = { addr };
int nodes[1] = { -1 };
int ret;
ret = move_pages(0, 1, pages, NULL, &status, 0);
if (ret != 0) {
perror("move_pages");
return -1;
}
return status; // 返回页面当前所在节点,-1 表示未分配
}
/* 将指定页面的内存迁移到目标节点 */
int migrate_page(void *addr, int target_node) {
void *pages[1] = { addr };
int nodes[1] = { target_node };
int status[1];
return move_pages(0, 1, pages, nodes, status, 0);
}
/* 绑定当前进程到指定 NUMA 节点的 CPU */
int bind_to_node(int node) {
struct bitmask *cpus = numa_allocate_cpumask();
if (a numa_node_to_cpus(node, cus) < 0) {
numa_free_cpumask(cpus);
return -1;
}
int ret = numa_sched_setaffinity(0, cpus);
numa_free_cpumask(cpus);
return ret;
}
/* 完整示例:每个 NUMA 节点工作线程分配本地内存 */
typedef struct {
int node_id;
double *data;
size_t data_len;
double result;
} worker_arg_t;
void *worker_thread(void *arg) {
worker_arg_t *w = (worker_arg_t *)arg;
/* 确保当前线程在正确节点运行 */
bind_to_node(w->node_id);
/* 在本地节点分配数据 */
w->data = (double *)numa_alloc_onnode(
w->data_len * sizeof(double), w->node_id);
/* 模拟计算密集任务 */
double sum = 0;
for (size_t i = 0; i < w->data_len; i++) {
w->data[i] = (double)i * 0.001;
sum += w->data[i] * w->data[i];
}
w->result = sum;
/* 验证页面确实在本地节点 */
int page_node = get_page_node(w->data);
printf("Worker on node %d: data @ node %.1f GB, val=%f",
w->node_id, w->data_len * sizeof(double) / 1e9, w->result);
return NULL;
}
int main(int argc, char **argv) {
if (numa_available() < 0) {
fprintf(stderr, "NUMA not available\n");
return 1;
}
int numa_nodes = numa_max_node() + 1;
printf("System has %d NUMA nodes\n", numa_nodes);
/* 距离矩阵 */
printf("NUMA distance matrix:\n");
for (int i = 0; i < numa_nodes; i++) {
for (int j = 0; j < num_nodes; j++) {
printf("%3d", numa_distance(i, j));
}
printf("\n");
}
/* 每个节点启动一个线程 */
pthread_t threads[numa_nodes];
worker_arg_t args[numa_nodes];
size_t per_node_len = 1024 * 1024 * 64; /* 64M doubles per node */
for (int i = 0; i < numa_nodes; i++) {
args[i] = (worker_arg_t){.node_id = i, .data_len = per_node_len};
pthread_create(&threads[i], NULL, worker_thread, &args[i]);
}
for (int i = 0; i < numa_nodes; i++) {
pthread_join(threads[i], NULL);
numa_free(args[i].data, per_node_len * sizeof(double));
}
return 0;
}
编译运行:
gcc -O2 -o numa_demo numa_demo.c -lnuma -lpthread
numactl --hardware # 先确认拓扑
./numa_demo
跨 NUMA 性能基准测试
我们设计一个微观基准测试来量化 NUMA 效应。测试一个 512MB 的双精度浮点数组,分别测量本地和远端节点访问的带宽差异。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <numa.h>
#define SIZE_MB 512
#define N (SIZE_MB * 1024 * 1024 / sizeof(double))
double get_time_ns(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec * 1e9 + ts.tv_nsec;
}
int main(void) {
if (numa_available() < 0) {
fprintf(stderr, "NUMA unavailable\n");
return 2;
}
int nodes[2] = {0, 1};
if (numa_max_node() < 1) {
fprintf(stderr, "Single NUMA node system\n");
return 2;
}
double start, elapsed;
double total;
for (int n = 0; n < 2; n++) {
/* 在 nodes[n] 上分配 */
double *data = numa_alloc_onnode(N * sizeof(double), nodes[n]);
/* 先写入触发 First-Touch 分配 */
memset(data, 0, N * sizeof(double));
for (int reader = 0; reader < 2; reader++) {
/* 绑定到 nodes[reader] 线程执行读取 */
numa_run_on_node(nodes[reader]);
/* 冷启动:清 cache */
start = get_time_ns();
total = 0;
for (size_t i = 0; i < N; i++) {
total += data[i] + 1.0;
}
elapsed = get_time_ns() - start;
double gb = (double)N * sizeof(double) / 1e9;
double bandwidth = gb / (elapsed / 1e9);
printf("Alloc@node%d Read@node%d: %.2f GB in %.1f ms = %.2f GB/s\n",
nodes[n], nodes[reader],
gb, elapsed / 1e6, bandwidth);
/* 避免编译器优化掉 */
if (total < -1) printf("impossible\n");
}
numa_free(data, N * sizeof(double));
}
return 0;
}
典型的双路 AMD EPYC 平台的测试结果(2 x 9654,8 通道 DDR5-4800 每节点):
Alloc@node0 Read@node0: 4.00 GB in 210 ms = 19.0 GB/s ← 本地读取
Alloc@node0 Read@node1: 4.00 GB in 380 ms = 10.5 GB/s ← 跨节点读取
Alloc@node1 Read@node1: 4.00 GB in 215 ms = 18.6 GB/s ← 本地读取
Alloc@node1 Read@node0: 4.00 GB in 375 ms = 10.7 GB/s ← 跨节点读取
跨节点带宽仅为本地的一半左右,这正是 NUMA 距离矩阵中 21/10 = 2.1x 比例的实际体现。
现代应用场景(Redis、Kafka、DPDK 高速网络包处理)中,NUMA 本地命中与远程访问的性能差距可能达到 2-3 倍级别,这对尾延迟的影响尤为惊人。
工程最佳实践
数据库与 KV 存储
- Redis:开启
server_cpulist绑定 worker 线程到本地 NUMA 节点,配合(transparent)-hugepages=madvise减少 TLB 抖动。 - RocksDB/LevelDB:将 block cache 分配在对应 NUMA 节点上,compaction threads 使用
--numa_node绑定(v7.0+ 已支持)。 - MySQL/InnoDB:
innodb_numa_interleave=1让缓冲池内存交错分配到所有节点,避免单节点内存耗尽。
网络密集型应用
- DPDK:这是 NUMA 优化的标杆场景。每个
lcore绑定到一个物理核,对应节点上分配mempool,NIC 的 RX/TX queue 绑定到本地节点。跨 NUMA 的网络流量处理会损失约 20-40% 的包转发性能。 - Envoy/Istio Sidecar:使用
--concurrency配合 CPU 亲和性,确保 worker 线程不跨 NUMA。
通用准则
- First-Touch 即是命运:在线程启动时完成数据分配,而非运行时惰性分配
- 避免跨节点引用:减少跨 NUMA 节点的指针追逐(pointer chasing)
- 监控实时状态:使用
numastat和perf c2c检测跨节点访问热点 - 分配合适的结构体大小:将频繁一起访问的数据放在同一 NUMA 节点上
- Hotplug 与动态扩容:新插入的 DIMM 可能属于不同 NUMA 节点,需重新检查拓扑
现代 NUMA 的挑战
随着芯片设计的演进,NUMA 的边界正在模糊又变得复杂。
chiplet 化:AMD Zen4 架构中,每个 CCD 是一个独立的 die,通过 IF(Infinity Fabric)互联。同一 CCD 内的 8 核共享 32MB L3,但 CCD 之间访问 L3 就要跨 die,引入了新的延迟层级。Zen5 进一步将 IO die 和 compute die 分离,内存控制器和 PCIe 都集中在 IO die,使得 CPU die 访问本地内存也需要经过 IF。
CXL 内存扩展:CXL 2.0/3.0 允许通过 PCIe 连接外部内存设备,这些设备作为新 NUMA 节点出现,但延迟远高于本地 DDR5,可能达到 300-500ns。系统需要感知三层内存层级:本地 DDR → 远端 DDR → CXL 内存。
Intel 混合架构:Intel 的 Performance-core 与 Efficient-core 之间虽然不完全是 NUMA 关系,但它们的 L2 缓存大小不同(P-core 1.25MB L2, E-core 2MB L3 per cluster),在不同核心类型间迁移线程同样面临缓存亲和性的问题。
总结
NUMA 不是一个过时的概念,恰恰相反,随着核心数量和内存层次的增长,NUMA 效应只会被放大。掌握 NUMA 的工作原理并将其纳入系统设计的基本考量,是现代高性能后端工程师的必备技能。从根本上说,NUMA 优化的核心原则从未改变:让数据靠近计算,或让计算靠近数据。无论硬件如何演进,这个原则始终成立。

发表评论 取消回复