在一台双路服务器上,某个开发者写了一个简单的多线程程序来处理内存数据。原本以为增加线程数能线性提升性能,结果却发现:当线程数从1增加到16时,吞吐量不升反降,甚至比单线程还慢了40%。

这不是代码bug,也不是锁竞争。问题出在这台服务器的内存架构上——NUMA(Non-Uniform Memory Access,非一致性内存访问)。

内存访问的"距离"问题

在个人电脑上,所有CPU核心访问内存的时间是相同的。这种架构称为UMA(Uniform Memory Access,一致性内存访问)。但在服务器领域,当处理器核心数量突破一定阈值后,UMA架构会遇到严重的扩展瓶颈——所有核心争抢同一条内存总线,带宽成为天花板。

NUMA架构的解决方案是:把内存控制器集成到每个CPU封装内,让每个处理器拥有自己的"本地内存"。当CPU访问本地内存时,路径短、延迟低;当它需要访问挂在另一个处理器上的"远程内存"时,数据必须经过处理器间的互连网络传输。

这个设计打破了内存访问的平等性。同一个CPU上的不同核心,访问不同地址的内存,时间可能相差2-3倍。

本地内存访问路径:
CPU Core → L3 Cache → 本地内存控制器 → 本地DRAM

远程内存访问路径:
CPU Core → L3 Cache → QPI/UPI互连 → 远程CPU → 远程内存控制器 → 远程DRAM

延迟差距的量化数据

NUMA带来的延迟差异不是微秒级的误差,而是实打实的数量级差距。根据Intel Memory Latency Checker在双路Xeon系统上的实测数据:

访问类型 典型延迟 相对倍数
L1缓存 ~1 ns 基准
L2缓存 ~3 ns 3x
L3缓存 ~10-15 ns 10-15x
本地DRAM ~90-110 ns 90-110x
远程DRAM ~190-300 ns 190-300x

本地DRAM访问大约需要100纳秒,而跨socket访问远程内存则需要200-300纳秒——差距达到2-3倍。这个差距在单次内存访问中可能微不足道,但对于一个频繁读写内存的程序来说,累积效应足以让性能崩塌。

更关键的是带宽问题。本地内存可以享受全部内存带宽(DDR4-3200约100GB/s),而远程访问必须经过处理器互连网络。Intel的UPI(Ultra Path Interconnect)带宽约41.6GB/s,AMD的Infinity Fabric约50GB/s——这意味着远程内存访问的带宽上限远低于本地访问。

多线程性能下降的三个典型场景

场景一:线程与数据分离

最常见的问题是这样的:主线程在一个NUMA节点上分配了所有内存,然后启动多个工作线程处理这些数据。如果工作线程被调度到了另一个NUMA节点上运行,它每次访问数据都要跨越socket。

// 典型的问题代码
void* data = malloc(large_size);  // 在主线程的NUMA节点上分配

#pragma omp parallel for
for (int i = 0; i < n; i++) {
    process(data[i]);  // 工作线程可能运行在另一个NUMA节点
}

Linux的默认内存分配策略是"本地优先"(local allocation),即内存被分配在执行分配请求的CPU所在的NUMA节点。如果主线程在Node 0上运行,所有数据都会落在Node 0的内存上。当工作线程被调度到Node 1运行时,每次数据访问都变成远程访问。

根据Oracle在双路Intel Xeon Platinum 8167M上的基准测试,一个"线程在Node 0、内存在Node 1"的配置,相比"线程和内存在同一节点",访问延迟增加约80%,吞吐量下降约40%。

场景二:自动NUMA平衡的开销

Linux 3.8引入了自动NUMA平衡(AutoNUMA Balancing)机制,试图在运行时将内存页迁移到访问它的线程所在的NUMA节点。这个机制的原理是:

  1. 周期性地将进程地址空间中的页标记为"NUMA提示页"(清除页表中的某些权限位)
  2. 当线程访问这些页时触发缺页异常
  3. 内核记录访问来源的NUMA节点
  4. 如果发现访问模式偏向某个节点,将页迁移到该节点

这个机制对某些负载有效,但代价不菲。页迁移需要:

  • 锁定页面、分配新页、复制数据、更新页表
  • 刷新TLB(可能涉及多个CPU)
  • 对于DMA绑定的内存页,根本无法迁移

对于内存访问模式频繁变化或大量共享内存的负载,自动NUMA平衡反而会引入大量开销。一篇来自Google的研究论文指出,在某些大数据负载中,开启自动NUMA平衡会导致约15%的性能波动。

更糟糕的是,自动NUMA平衡默认只追踪私有内存页,不追踪DMA绑定的内存(如RDMA缓冲区)。如果程序的内存主体是DMA绑定的,自动NUMA平衡可能根据次要内存访问模式做出错误的迁移决策。

场景三:跨节点共享数据

当多个线程在不同NUMA节点上频繁修改同一块内存时,问题更加复杂。这不仅是NUMA问题,更是缓存一致性协议的负担。

现代x86处理器使用MESI(或其变种如MESIF、MOESI)协议维护缓存一致性。当Core A修改一个缓存行时,其他核心缓存该行的副本必须被作废。在NUMA系统中,如果Core A在Node 0、Core B在Node 1,这个作废消息必须经过处理器互连网络传输,延迟比同节点内的作废操作高得多。

False sharing(伪共享)在NUMA环境下的惩罚更加严重。两个线程分别修改同一个缓存行的不同字段,看似没有数据竞争,却会触发频繁的缓存行所有权转移。每次转移跨越NUMA节点边界的代价,比单socket系统高出2-3倍。

NUMA距离矩阵:理解你的系统拓扑

Linux通过sysfs导出NUMA距离信息。运行numactl --hardware可以看到类似这样的输出:

available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 ...
node 0 size: 64207 MB
node 1 cpus: 52 53 54 55 ...
node 1 size: 64434 MB
node distances:
node   0   1 
  0:  10  21 
  1:  21  10 

距离矩阵的含义是:本节点到自身的距离标准化为10,其他距离是相对于这个基准的倍数。上表中,Node 0访问Node 1的内存,延迟是本地访问的2.1倍。

距离值由BIOS通过ACPI SLIT(System Locality Information Table)表提供。Linux内核在启动时读取这些信息,用于调度器和内存分配决策。

需要注意的是,SLIT中的距离值是相对值,不是绝对延迟。要获取真实的延迟数据,需要使用Intel Memory Latency Checker(MLC)等工具:

# 测量NUMA节点间的延迟矩阵
mlc --latency_matrix

# 输出示例
         Numa node
Numa node     0     1  
       0  108.2 195.7
       1  195.7 108.9

NUMA感知编程:实践指南

策略一:显式内存绑定

最直接的方法是让每个线程只访问其所在NUMA节点的本地内存。使用numactl命令行工具:

# 将进程绑定到Node 0,所有内存分配在Node 0
numactl --cpunodebind=0 --membind=0 ./my_program

# 或使用交错分配策略(适合内存访问模式均匀的场景)
numactl --interleave=all ./my_program

在代码中,可以使用libnuma库进行更细粒度的控制:

#include <numa.h>

// 在特定NUMA节点上分配内存
void* data = numa_alloc_onnode(size, node_id);

// 设置线程的CPU亲和性
numa_run_on_node(node_id);

策略二:First-Touch策略

Linux的内存分配是惰性的。malloc()只是保留虚拟地址空间,真正的物理页是在第一次访问时分配的。这提供了一个优化机会:

// 让每个线程初始化自己要处理的数据
#pragma omp parallel
{
    int tid = omp_get_thread_num();
    int node = numa_node_of_cpu(sched_getcpu());
    
    // 确保线程绑定到当前节点
    numa_run_on_node(node);
    
    // First-touch:线程首次访问的数据会分配在其所在节点
    for (int i = start[tid]; i < end[tid]; i++) {
        data[i] = initial_value;  // 物理页在此分配
    }
}

这种"First-Touch"策略确保数据和访问它的线程在同一NUMA节点上。后续的数据处理阶段,线程访问的都是本地内存。

策略三:数据分区

对于共享数据结构,按照NUMA拓扑进行分区:

struct numa_aware_hash_table {
    struct bucket *local_buckets[MAX_NUMA_NODES];
    // 每个NUMA节点维护独立的桶数组
};

void insert(struct numa_aware_hash_table *ht, key_t key, value_t val) {
    int node = numa_node_of_cpu(sched_getcpu());
    // 只访问本地桶
    bucket_insert(ht->local_buckets[node], key, val);
}

这种设计减少了跨节点访问,但代价是数据隔离——来自不同节点的线程无法访问同一份数据。适用场景包括:每个节点处理独立数据分片的MapReduce任务、每节点独立缓存的Web服务器等。

策略四:谨慎配置自动NUMA平衡

对于NUMA敏感型负载,关闭自动NUMA平衡并手动管理亲和性可能更可控:

# 关闭自动NUMA平衡
echo 0 > /proc/sys/kernel/numa_balancing

# 或者在内核启动参数中添加
numa_balancing=disable

关闭后,需要确保应用层正确设置了线程和内存亲和性。否则,程序将完全暴露在NUMA拓扑带来的延迟差异下。

调度器的角色

Linux调度器(CFS)具备一定的NUMA感知能力。调度域(scheduling domains)数据结构按照硬件拓扑组织,调度器倾向于将任务保持在同一NUMA节点内。

但这种"倾向"是软约束。当负载不均衡时,调度器仍然会跨节点迁移任务。更关键的是,调度器不知道任务的内存访问模式——它只能根据负载均衡做决策,无法优化数据局部性。

每个任务结构(task_struct)中有一个numa_preferred_nid字段,表示任务"偏好"的NUMA节点。自动NUMA平衡会更新这个字段,调度器在迁移决策时会参考它。但在自动NUMA平衡关闭时,这个字段永远是-1(无偏好),调度器的NUMA感知能力基本失效。

Oracle在UEK8内核中引入了一个实验性的prctl()接口,允许应用程序直接设置任务的NUMA偏好节点:

// 设置当前任务的NUMA偏好节点
prctl(PR_PREFERRED_NID, PR_PREFERRED_NID_SET, node_id, 0, 0);

这个接口让应用程序可以将NUMA知识(如"我的主要数据在Node 0")传递给调度器,让调度器做出更明智的迁移决策。

虚拟化环境中的NUMA

虚拟化增加了NUMA问题的复杂度。虚拟机的vCPU和内存分配需要与物理NUMA拓扑对齐。

理想情况下,一个VM的所有vCPU和内存都应该来自同一个NUMA节点。这样VM内的操作系统和应用程序不会感知到底层的NUMA边界。KVM/libvirt的默认行为就是这样——每个VM被"钉"到一个NUMA节点。

但当VM的内存需求超过单个节点的容量时,问题就出现了。VM的内存被分散到多个节点,VM内的操作系统看到的是一个"平坦"的内存空间,但实际访问延迟差异巨大。

libvirt提供了NUMA调优选项:

<numatune>
    <memory mode="strict" nodeset="0"/>
    <memnode cellid="0" mode="strict" nodeset="0"/>
</numatune>

mode="strict"确保所有内存都来自指定节点,如果节点内存不足则分配失败(而非回退到其他节点)。这避免了意外的跨节点内存访问,但也限制了VM的最大内存容量。

何时需要关心NUMA

并非所有程序都需要NUMA感知优化。判断标准包括:

需要关注NUMA的场景:

  • 内存密集型负载(数据库、大数据分析、科学计算)
  • 多socket服务器(2路及以上)
  • 高并发、高吞吐量要求
  • 延迟敏感型应用

NUMA影响较小的场景:

  • 计算密集型(CPU时间主要花在计算而非内存访问)
  • 小内存工作集(大部分命中CPU缓存)
  • 单socket服务器(虽然现代处理器内部也可能有NUMA效应)

在AMD EPYC平台上,单socket内部就存在NUMA效应。EPYC采用多芯片模块(MCM)设计,一个封装内有4个Zeppelin芯片,每个芯片有自己的内存控制器。访问同一芯片内的内存延迟最低,跨芯片访问延迟增加。这使得NUMA优化在EPYC平台上更加重要。

总结

NUMA是服务器处理器扩展内存带宽的必然选择,但它打破了"所有内存访问时间相同"的假设。多线程程序如果忽视了NUMA拓扑,可能反而遭受性能惩罚——线程被调度到远离其数据的节点,或者多个线程频繁跨越节点边界共享数据。

性能优化的核心原则是:让数据靠近访问它的线程。无论是通过显式的内存绑定、First-Touch策略,还是数据分区设计,目标都是最大化本地内存访问的比例。

Linux提供了自动NUMA平衡机制,但它并非万灵药。对于性能敏感型负载,理解系统拓扑、设计NUMA感知的数据布局、手动管理亲和性,往往是更可控的选择。


参考资料

  1. Linux Kernel Documentation, “What is NUMA?”, kernel.org
  2. Linux Kernel Documentation, “NUMA Memory Performance”, kernel.org
  3. Steve Scargall, “Linux NUMA Distances Explained”, stevescargall.com, 2022
  4. Oracle Blogs, “The NUMA Awareness of the Linux Scheduler”, 2025
  5. Intel, “Intel Memory Latency Checker User Guide”
  6. AMD, “AMD Optimizes EPYC Memory with NUMA”, White Paper, 2018
  7. Mel Gorman, “Automatic NUMA Balancing V4”, LWN.net, 2012
  8. Wikipedia, “Non-uniform memory access”
  9. Abhik Sarkar, “NUMA Architecture: Non-Uniform Memory Access”, abhik.ai, 2025
  10. Google Research, “Optimizing Google’s Warehouse Scale Computers: The NUMA Impact”