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。

通用准则

  1. First-Touch 即是命运:在线程启动时完成数据分配,而非运行时惰性分配
  2. 避免跨节点引用:减少跨 NUMA 节点的指针追逐(pointer chasing)
  3. 监控实时状态:使用 numastat 和 perf c2c 检测跨节点访问热点
  4. 分配合适的结构体大小:将频繁一起访问的数据放在同一 NUMA 节点上
  5. 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 优化的核心原则从未改变:让数据靠近计算,或让计算靠近数据。无论硬件如何演进,这个原则始终成立。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.353484s