在一台双路服务器上,某个开发者写了一个简单的多线程程序来处理内存数据。原本以为增加线程数能线性提升性能,结果却发现:当线程数从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节点。这个机制的原理是:
- 周期性地将进程地址空间中的页标记为"NUMA提示页"(清除页表中的某些权限位)
- 当线程访问这些页时触发缺页异常
- 内核记录访问来源的NUMA节点
- 如果发现访问模式偏向某个节点,将页迁移到该节点
这个机制对某些负载有效,但代价不菲。页迁移需要:
- 锁定页面、分配新页、复制数据、更新页表
- 刷新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感知的数据布局、手动管理亲和性,往往是更可控的选择。
参考资料
- Linux Kernel Documentation, “What is NUMA?”, kernel.org
- Linux Kernel Documentation, “NUMA Memory Performance”, kernel.org
- Steve Scargall, “Linux NUMA Distances Explained”, stevescargall.com, 2022
- Oracle Blogs, “The NUMA Awareness of the Linux Scheduler”, 2025
- Intel, “Intel Memory Latency Checker User Guide”
- AMD, “AMD Optimizes EPYC Memory with NUMA”, White Paper, 2018
- Mel Gorman, “Automatic NUMA Balancing V4”, LWN.net, 2012
- Wikipedia, “Non-uniform memory access”
- Abhik Sarkar, “NUMA Architecture: Non-Uniform Memory Access”, abhik.ai, 2025
- Google Research, “Optimizing Google’s Warehouse Scale Computers: The NUMA Impact”