文档目录

这是多插槽服务器(如双路 Xeon、AMD EPYC、ARM 服务器)上最容易被忽视的性能杀手。你在单颗桌面 CPU 上跑得飞快的程序,搬到多插槽服务器上可能性能直接腰斩——十有八九是 NUMA 在作祟。


1. 什么是 NUMA?(物理拓扑视角)

在传统的 UMA(Uniform Memory Access,统一内存访问) 架构中,所有 CPU 核心通过同一条总线(或同一个内存控制器)访问所有物理内存,延迟一致。

在 NUMA 架构中,每个 CPU 插槽(Socket)拥有自己独立的内存控制器和本地内存。CPU 插槽之间通过高速互联(如 Intel UPI、AMD Infinity Fabric)连接。

+-----------------------------------------------------------+
|                    NUMA 双插槽服务器                        |
+-----------------------------------------------------------+
|  Socket 0 (CPU 0)          |  Socket 1 (CPU 1)            |
|  +-------------------+     |  +-------------------+        |
|  | 核心 0-7          |     |  | 核心 8-15         |        |
|  | L1/L2/L3 缓存     |     |  | L1/L2/L3 缓存     |        |
|  +-------------------+     |  +-------------------+        |
|  | 内存控制器 (MC0)  |     |  | 内存控制器 (MC1)  |        |
|  +--------+----------+     |  +--------+----------+        |
|           |                |           |                   |
|  +--------v----------+     |  +--------v----------+        |
|  | 本地内存 (Node 0) |     |  | 本地内存 (Node 1) |        |
|  | 延迟: ~80ns       |     |  | 延迟: ~80ns       |        |
|  +-------------------+     |  +-------------------+        |
|           |                |           |                   |
|           +----[UPI 互联]---+-----------+                   |
|               跨插槽访问延迟: ~160ns (2倍!)                  |
+-----------------------------------------------------------+

核心事实:

  • 访问本地内存(同插槽):~80ns(与普通 UMA 系统一致)。
  • 访问远端内存(另一插槽):~160ns(2 倍延迟!)。
  • 带宽:本地内存 ~100GB/s,远端内存可能降到 ~40GB/s(取决于互联带宽)。

2. NUMA 如何影响并发程序?(三个典型场景)

场景 1:MPSC 队列跨越 NUMA 节点

[生产者线程] → (MPSC 队列) → [消费者线程]
  运行在 Socket 0              运行在 Socket 1
  (数据在 Node 0 内存)        (跨节点读取 Node 0 内存)
                                    │
                                    ▼
                            每次 dequeue 都访问远端内存
                            延迟 ~160ns (vs 80ns)
                            吞吐量下降 30%~50%

场景 2:多线程共享数据结构

[线程A] 在 Socket 0 修改共享变量
        │
        ▼
[缓存行状态变为 M (Modified)] 在 Socket 0 的 L3 缓存中
        │
        ▼
[线程B] 在 Socket 1 读取同一变量
        │
        ▼
UPI 互联传递缓存行 (~100ns 额外延迟)
Socket 1 的 TLB/缓存行失效

场景 3:内存分配器(jemalloc)的 arena 分配

  • jemalloc 默认会把内存分配到调用线程所在的 NUMA 节点。
  • 但如果消费者线程在另一个节点上消费,就会变成跨节点访问。

3. 如何诊断 NUMA 问题?

命令 1:查看系统的 NUMA 拓扑

### 查看 NUMA 节点分布
numactl --hardware

### 输出示例:
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7
node 0 size: 32768 MB
node 0 free: 28000 MB
node 1 cpus: 8 9 10 11 12 13 14 15
node 1 size: 32768 MB
node 1 free: 27500 MB
node distances:
node   0   1 
  0:  10  20   ← 10 = 本地延迟,20 = 远端延迟(2倍)
  1:  20  10

命令 2:查看进程的 NUMA 内存分布

### 查看进程 (PID=1234) 在各个 NUMA 节点上的内存分配
numastat -p 1234

### 输出示例:
Per-node process memory usage for PID 1234 (your_program)
Node 0: 256 MB
Node 1: 10 MB   ← 大部分内存在 Node 0,但程序可能在两个节点都有线程!

命令 3:perf 检测跨节点访问

### 统计远端内存访问次数
perf stat -e node-loads,node-load-misses ./your_program

4. NUMA 优化策略(四层解决方案)

策略 1:绑核(CPU Affinity)—— 最常用

把线程绑定到特定 CPU 核心,并分配该核心所在节点的内存。

#include <pthread.h>
#include <numa.h>

// 绑定线程到 NUMA 节点 0 的 CPU 核心
void bind_to_node(int node_id) {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    
    // 把该节点上的所有核心加入集合
    struct bitmask* mask = numa_allocate_cpumask();
    numa_node_to_cpus(node_id, mask);
    
    for (int i = 0; i < mask->size; ++i) {
        if (numa_bitmask_isbitset(mask, i)) {
            CPU_SET(i, &cpuset);
        }
    }
    
    pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);
    numa_free_cpumask(mask);
}

使用方式:

void producer_thread() {
    bind_to_node(0);  // 生产者在 Node 0 运行,内存在 Node 0 分配
    // ...
}

void consumer_thread() {
    bind_to_node(0);  // 消费者也绑到 Node 0,避免跨节点
    // ...
}

策略 2:内存绑定(Memory Policy)—— 让内存在指定节点分配

#include <numa.h>

void* allocate_on_node(size_t size, int node_id) {
    // 强制在指定节点分配内存
    return numa_alloc_onnode(size, node_id);
}

void free_on_node(void* ptr, size_t size) {
    numa_free(ptr, size);
}

与 SPSC 队列配合:把整个环形缓冲区分配在消费者所在的 NUMA 节点上。

// 在 Node 0 分配 SPSC 队列的缓冲区
auto* buffer = static_cast<Frame*>(allocate_on_node(
    sizeof(Frame) * QUEUE_SIZE, 0));

策略 3:使用 libnuma 的 API 控制内存分配策略

#include <numa.h>

// 绑定当前线程的内存分配到本地节点(默认)
numa_set_localalloc();

// 绑定到特定节点
numa_set_preferred(0);  // 优先在 Node 0 分配

// 内存交错分配(Round-Robin,均衡但牺牲局部性)
numa_set_interleave_mask(numa_all_nodes_ptr);

策略 4:MPSC 队列的 NUMA 感知设计

在 NUMA 系统中,理想设计是生产者和消费者在同一节点。

[Node 0]                      [Node 1]
+---------------------------+  +---------------------------+
| 生产者线程 (Core 0-3)     |  | 生产者线程 (Core 8-11)    |
| 消费者线程 (Core 4-7)     |  | 消费者线程 (Core 12-15)    |
| 队列内存 (分配在 Node 0)   |  | 队列内存 (分配在 Node 1)    |
+---------------------------+  +---------------------------+
          UPF 互联
          ↑
    仅在需要时跨节点通信

实现:为每个 NUMA 节点创建独立的 MPSC 队列,线程只操作本地队列,全局协调只在必要时发生。


5. 硬件层面的 NUMA 优化:QM(Quality of Service)与缓存分配

Intel 和 AMD 提供了缓存分配技术(Intel RDT / AMD QoS),可以给特定的核心分配更多的 L3 缓存,减少远端内存访问。

### Intel RDT:为特定核心预留 L3 缓存
### 需要内核支持 CONFIG_RESCTRL

6. 笔记存档:NUMA 核心概念速查

概念 解释
NUMA 多 CPU 插槽架构,每个插槽有独立内存控制器,访问本地内存快,远端内存慢。
本地内存 同一插槽上的内存,延迟 ~80ns。
远端内存 另一插槽上的内存,延迟 ~160ns(2倍)。
互联总线 Intel UPI / AMD Infinity Fabric,连接多插槽,带宽 ~40GB/s。
绑核 用 pthread_setaffinity_np 把线程绑定到特定核心。
内存绑定 用 numa_alloc_onnode 在指定节点分配内存。
适用场景 多插槽服务器(双路/四路 Xeon、EPYC、鲲鹏)。
不适用场景 单路桌面 CPU(所有内存访问延迟一致)。

本节关键词:NUMA、本地内存、远端内存、绑定、numa_alloc_onnode、UPI、Infinity Fabric。 与前期知识的关联:

  • SPSC/MPSC 队列 → 在 NUMA 上,队列内存应分配在消费者所在节点。
  • jemalloc → 默认优先分配在调用线程所在节点(可用 MALLOC_CONF=retain:true 调整)。
  • mmap → 可用 mbind 系统调用控制映射的物理页在哪个 NUMA 节点。