1. 项目概述:从内存访问的“距离”说起

最近在社区里看到不少朋友在讨论性能优化,特别是涉及到多核处理器和高并发场景时,总会提到一个词:NUMA。很多刚接触内核或者系统底层开发的同学,可能对UMA和NUMA这两个概念感觉既熟悉又陌生,知道它们和CPU、内存架构有关,但具体到如何影响我们的程序性能,又有点摸不着头脑。今天,我就结合自己这些年踩过的坑和调优的经验,来和大家深入聊聊CPU体系架构中的UMA和NUMA,以及它们是如何成为现代内存管理,尤其是Linux内核内存管理设计的基石。

简单来说,你可以把UMA和NUMA想象成两种不同的“公司食堂”布局。UMA就像是一个超大食堂,所有员工(CPU核心)无论坐在哪个工位,走去食堂打饭(访问内存)的距离和时间都是一样的,公平但可能拥挤。而NUMA则像是给一栋大楼的每一层都设了一个小厨房,本层的员工吃饭特别快,但要是想去别的楼层蹭饭,就得等电梯,路程就远了。这个“距离”带来的“时延”差异,就是NUMA架构的核心,也是我们编程和调优时必须考虑的关键因素。理解它,不仅能帮你读懂内核源码中那些关于内存节点、区域的选择逻辑,更能让你在编写高性能应用,特别是数据库、大数据计算框架时,做出正确的内存绑定和线程调度决策,避免因为“跨楼吃饭”导致性能无缘无故地下降一半。

2. 核心架构解析:UMA与NUMA的设计哲学与演进

2.1 UMA:均匀内存访问的古典时代

在早期多处理器系统中,UMA是主流的设计。它的结构非常直观:多个CPU核心通过一条共享的系统总线(或交叉开关)连接到同一个内存控制器,进而访问同一片物理内存。所有CPU对内存中任何位置的访问延迟和带宽都是相同的,因此被称为“均匀内存访问”。

其核心特点如下:

  • 单一内存映像 :所有CPU看到的是同一块连续的内存地址空间,没有本地和远程的概念。
  • 对称访问 :无论哪个CPU发起请求,访问内存的路径和代价是一致的。
  • 简单一致性 :由于共享总线,缓存一致性协议(如MESI)的实现相对直接,通过监听总线上的所有事务即可维护多核缓存的数据同步。

UMA的典型实现与瓶颈: 经典的SMP(对称多处理)系统就是UMA的体现。然而,这种架构的瓶颈随着CPU核心数量的增加而急剧凸显。那条共享的系统总线成为了唯一的“独木桥”。当几十个甚至上百个核心都争相通过这座桥去访问内存时,总线带宽和仲裁延迟将成为不可逾越的性能天花板。即使内存速度再快,也会被拥堵的总线拖累。这就好比千军万马过一根独木桥,桥再结实也扛不住。因此,UMA架构很难扩展到高核心数的场景,它更适用于核心数较少(例如早期双核、四核)的处理器。

2.2 NUMA:非均匀内存访问的现代解决方案

为了解决UMA的可扩展性问题,NUMA架构应运而生。它的设计哲学是“化整为零,就近访问”。在NUMA系统中,多个CPU核心被分组,每个组与一部分本地内存直接相连,形成一个“节点”。每个节点内部可以看作是一个小型的UMA系统。节点之间通过高速互连网络(如AMD的Infinity Fabric、Intel的QPI/UPI)进行通信。

NUMA的核心特点如下:

  • 分层内存视图 :内存被物理地划分到各个节点。CPU访问自己节点内的内存(本地内存)速度最快,延迟最低;访问其他节点的内存(远程内存)则需要经过节点间互联网络,延迟更高,带宽也可能受限。
  • 非对称访问 :访问延迟和带宽取决于内存页的物理位置与请求CPU的相对位置,“距离”产生了性能差异。
  • 分布式共享内存 :从程序员视角看,逻辑上仍然是一个统一的地址空间,但物理上已是分布存储。操作系统(内核)负责管理这个映射,并尽可能让进程使用其所在CPU的本地内存。

为什么NUMA成为主流? 因为它完美地解决了扩展性问题。通过增加节点,而不是增强单一总线,系统可以线性地增加CPU核心和内存容量。每个节点有自己的内存通道,总聚合带宽随着节点增加而增长。现代的多路服务器CPU(如双路、四路至强)以及消费级的多CCD(Core Complex Die)设计的锐龙线程撕裂者等,都是NUMA架构。 numactl 命令和内核中的 /sys/devices/system/node/ 目录就是与NUMA交互的直接接口。

注意 :并非所有多核CPU都是NUMA。例如,传统的单路多核台式机CPU,其所有核心访问同一片内存的延迟通常是一致的,在软件视角下可能被呈现为单个NUMA节点(即一个UMA域)。关键在于是否存在物理上分隔且访问延迟不同的内存组。

2.3 从硬件到软件的映射:内核如何抽象NUMA

硬件提供了NUMA拓扑,而操作系统内核的任务是将这些硬件细节抽象化,并提供高效的管理策略。Linux内核用 struct pglist_data 来描述一个NUMA节点(或者UMA系统下的单一节点)。每个节点管理着自己的物理内存页、空闲链表等。

内核的关键抽象层:

  1. 节点发现与初始化 :在启动时,内核通过ACPI(如SRAT表)或硬件特定方式探测系统的NUMA拓扑结构,初始化所有节点。
  2. 内存分配策略 :这是NUMA优化的核心。内核默认的分配策略(称为“本地分配”)会尽量从当前运行CPU所在的本地节点分配内存。这保证了大多数情况下的最佳性能。
  3. 调度器与NUMA亲和性 :现代Linux调度器(如CFS)具备NUMA感知能力。它会倾向于将进程或线程调度到其大部分内存所在的节点上运行的CPU核心上,减少远程访问。这被称为“调度器亲和性”。
  4. 自动NUMA平衡 :在内核中是一个重要的特性。内核线程 kswapd 和特定的迁移线程会监控进程的内存访问模式,如果发现一个进程频繁访问另一个节点的内存页,它会尝试将该内存页迁移到进程当前运行的节点上,或者将进程迁移到内存页所在的节点上。这是一个动态的、后台的优化过程。

查看你的系统NUMA拓扑: 理解理论后,实操一下最能加深印象。在Linux系统上,你可以使用以下命令:

# 1. 安装numactl工具(如果尚未安装)
# 对于基于Debian/Ubuntu的系统:sudo apt-get install numactl
# 对于基于RHEL/CentOS的系统:sudo yum install numactl

# 2. 查看系统NUMA节点布局、CPU核心分布及内存大小
numactl --hardware

# 3. 更详细的节点距离信息(访问延迟的相对成本)
numactl --show

# 4. 内核提供的NUMA信息接口
ls /sys/devices/system/node/  # 列出所有节点,如node0, node1
cat /sys/devices/system/node/node0/meminfo  # 查看node0的内存信息

命令 numactl --hardware 的输出通常会显示类似这样的信息:

available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
node 0 size: 65440 MB
node 0 free: 10234 MB
node 1 cpus: 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
node 1 size: 65536 MB
node 1 free: 45678 MB
node distances:
node   0   1
  0:  10  21
  1:  21  10

这里清晰地展示了一个双节点系统。 node distances 中的数字是抽象的“距离值”,本地访问(如node0访问node0)成本为10,而跨节点访问(如node0访问node1)成本为21,意味着远程访问延迟大约是本地访问的2.1倍。

3. 内存管理实战:NUMA感知的编程与调优

理解了NUMA的原理,最终目的是为了指导我们的实践,写出更高效的程序,或者对现有系统进行调优。否则,一个设计为NUMA优化的系统,可能因为不当的使用方式,性能反而比UMA更差。

3.1 应用程序的NUMA优化策略

对于开发者而言,尤其是开发高性能中间件、数据库或科学计算应用,必须将NUMA纳入架构设计考量。

1. 内存绑定策略:

  • numactl 命令 :这是最直接的工具。你可以在启动进程时,指定其运行在哪些CPU节点上,以及从哪些内存节点分配内存。

    # 示例:将程序`my_app`绑定到节点0的CPU上运行,并且只从节点0和1分配内存(优先本地)
    numactl --cpunodebind=0 --membind=0,1 ./my_app
    
    # 更常用的:优先本地分配,但允许在本地内存不足时使用其他节点
    numactl --cpunodebind=0 --localalloc ./my_app
    

    --localalloc 是默认策略,但显式指定可以确保策略正确。

  • libnuma 编程接口 :在C/C++程序中,你可以通过 libnuma 库进行更精细的控制。

    #include <numa.h>
    #include <numaif.h>
    
    // 设置当前线程的内存分配策略为在节点0上分配
    numa_set_preferred(0);
    
    // 分配一块指定节点上的内存
    void *mem = numa_alloc_onnode(size_in_bytes, 0);
    
    // 将已分配的内存绑定到特定节点(MBIND操作)
    unsigned long nodemask = 1UL << 0; // 绑定到节点0
    mbind(mem, size, MPOL_BIND, &nodemask, sizeof(nodemask)*8, MPOL_MF_MOVE | MPOL_MF_STRICT);
    

    这对于管理大型、长期驻留的内存池(如数据库缓冲池)至关重要。

2. 线程/进程绑定(CPU亲和性): 仅仅绑定内存还不够,如果线程被操作系统调度到其他节点的CPU上,那么它之前分配的“本地内存”就变成了“远程内存”。因此,需要将线程绑定到特定的CPU核心上。

  • taskset 命令 :用于设置或查询进程的CPU亲和性。
    # 将进程PID为1234的进程绑定到0-23号CPU核心(假设属于node0)
    taskset -cp 0-23 1234
    
  • pthread_setaffinity_np 或 sched_setaffinity 系统调用 :在程序内部实现线程绑定。

3. 数据布局与访问模式: 在设计数据结构时,考虑NUMA的影响。例如,避免单个被频繁访问的大数据结构(如一个大数组)被多个节点上的线程随机访问,这会导致大量的跨节点通信(Cache Coherency Traffic)。可以采用分片(Sharding)策略,让每个节点主要访问自己分片内的数据。

3.2 内核参数与系统调优

对于系统管理员或运维工程师,可以通过调整内核参数来优化NUMA系统的整体行为。

关键的内核参数:

  • vm.zone_reclaim_mode :这个参数控制当一个NUMA节点内存不足时的回收行为。

    • 0 (默认):当本地节点内存不足时,允许从其他节点分配内存。这可能导致内存逐渐分散到所有节点,但能避免本地节点频繁回收。
    • 1 :开启本地节点回收,内核会尽量回收本地节点的内存页,而不是轻易去其他节点分配。这对于追求极致本地性、减少跨节点访问的应用(如HPC)有益,但可能增加内存回收压力。
    • 其他位控制更细粒度的回收策略,如是否回收未修改的缓存页等。
    # 查看当前值
    sysctl vm.zone_reclaim_mode
    # 临时设置为激进本地回收
    sysctl -w vm.zone_reclaim_mode=1
    
  • 透明大页与NUMA :透明大页能减少TLB缺失,提升性能。但在NUMA系统上,分配一个大页(如2MB)需要从单个节点找到连续的大块空闲内存。如果系统内存碎片化严重,可能会触发内核从远程节点分配大页,反而损害性能。内核参数 /sys/kernel/mm/transparent_hugepage/defrag 控制着碎片整理行为,需要根据实际情况权衡。

  • numa_balancing :控制内核自动NUMA平衡功能。

    # 查看是否启用
    cat /proc/sys/kernel/numa_balancing
    # 1为启用,0为禁用
    

    对于工作负载稳定、且已做好手动绑定的高性能计算场景,可以考虑禁用自动平衡以减少内核开销。但对于通用服务器或工作负载多变的环境,保持启用通常是更好的选择。

3.3 性能监控与诊断工具

优化离不开监控。你需要工具来发现NUMA相关的性能问题。

  1. numastat :查看各NUMA节点的内存分配统计。关注 numa_hit (本地分配成功)和 numa_miss (本地分配失败,需从其他节点分配)的比值。如果 numa_miss 很高,说明内存分配策略可能不是最优。

    numastat -c your_process_name  # 查看特定进程的NUMA状态
    
  2. perf 性能分析器 : perf 可以监控硬件性能计数器,其中就包括与NUMA相关的事件。

    # 监控远程内存访问次数(事件名可能因CPU架构而异,Intel为`offcore_response.*`,AMD为`rNNN`)
    perf stat -e cpu/event=0xB7,umask=0x01,name=OFFCORE_REQUESTS.DEMAND_DATA_RD/ ./my_app
    # 更简单的方式,使用`perf mem`子命令记录内存访问样本
    perf mem record ./my_app
    perf mem report
    

    perf mem report 可以直观地显示负载的本地/远程内存访问比例,是定位“跨节点访问热点”的利器。

  3. 内核跟踪点 :使用 trace-cmd 或 perf 跟踪内核中的NUMA相关事件,如 mm_numa_migrate_* ,可以深入了解内核的页面迁移行为。

4. 常见陷阱与深度避坑指南

在实际操作中,即使知道了原理和工具,也容易踩进一些坑里。下面分享几个我亲身经历或常见的问题场景。

4.1 陷阱一:默认策略的“静默”性能衰减

这是最隐蔽的问题。你写了一个多线程程序,在UMA机器上跑得很好,一到NUMA服务器上性能就达不到预期。你可能什么都没做错,只是系统默认的“本地优先”策略在内存不足或碎片化时失效了。

场景还原: 一个48核双节点服务器,你启动了一个40线程的应用程序。默认情况下,内核调度器会将线程分散到两个节点的CPU上。每个线程初始分配的内存可能是本地的。但随着程序运行,动态分配释放,内存逐渐碎片化。当某个节点上的线程需要分配一大块内存时,其本地节点可能无法提供连续空间,内核就会从远程节点分配。从此,这个线程就开始了痛苦的“远程访问”之旅。

排查与解决:

  • 监控先行 :上线前,用 numastat 和 perf mem 对程序进行 profiling,确认远程访问比例。
  • 主动干预 :对于性能关键的核心服务,不要依赖默认策略。使用 numactl 在启动时进行明确的CPU和内存绑定。例如,如果你有24个线程,可以绑定到node0的24个核心上,并设置内存分配策略为 --membind=0 。
  • 内存池化 :对于频繁分配释放的小对象,使用内存池技术(如 jemalloc 、 tcmalloc ,它们都有一定的NUMA感知优化),在程序初始化阶段就从本地节点预分配一大块内存,减少运行时向内核申请的次数和碎片化。

4.2 陷阱二:错误绑定的“负优化”

绑定是一把双刃剑。绑得太死,可能失去负载均衡的灵活性,甚至导致性能更差。

场景还原: 你将一个数据库进程的所有工作线程都绑定到了node0,内存也绑定在node0。但node0的内存被缓冲池占满,而大量的连接请求和临时计算需要更多内存。由于 --membind 的严格限制,内核无法从尚有大量空闲内存的node1分配,导致内存分配失败或触发激进的本地回收(如果设置了 zone_reclaim_mode=1 ),引发直接内存回收甚至OOM,性能急剧下降。

避坑策略:

  • 理解策略差异 :
    • --membind :严格绑定,只从指定节点分配。
    • --preferred :优先从指定节点分配,如果失败,则尝试其他节点。
    • --localalloc (默认):优先从当前运行的CPU所在节点分配。
  • 推荐做法 :对于大多数场景,使用 --cpunodebind 绑定CPU节点,配合默认的 --localalloc 内存策略,是一个稳健的开始。这确保了线程在固定的节点上运行,并且内存分配首先尝试本地。只有当本地不足时,才允许使用远程内存,保证了程序的可用性。对于像数据库缓冲池这种明确知道大小、且希望绝对本地化的内存,再使用 libnuma 进行精确的 MBIND 绑定。
  • 预留资源 :在系统层面,可以通过内核参数 vm.nr_hugepages 和 vm.hugetlb_shm_group 等为特定应用预留大页内存,或者使用 cgroups 限制非关键进程的内存使用,为核心应用留出充足的本地内存空间。

4.3 陷阱三:忽视硬件拓扑细节

不是所有标称NUMA的系统都一样。访问延迟( node distances )和互联带宽有很大差异。

场景还原: 你在一台四路服务器上运行应用,简单地认为绑定到任意两个节点效果都一样。但实际上,该服务器的拓扑可能是:CPU0和CPU1在一个物理插槽内,通过超高速链路互联(距离=10/15),而它们与CPU2/3所在的另一个插槽组之间,通过稍慢的链路连接(距离=21)。如果你把需要频繁通信的两个线程分别绑定到CPU0和CPU2,它们之间的数据同步(缓存一致性流量)就会走那条更慢的路径,成为瓶颈。

深度排查:

  1. 详查拓扑 :使用 lstopo (来自 hwloc 包)或 numactl --hardware 查看完整的拓扑和距离矩阵。理解哪些CPU属于同一个物理封装(Socket),哪些封装之间的互联更快。
  2. 绑定策略升级 :在绑定线程时,不仅要考虑内存本地性,还要考虑线程间通信的频繁程度。将通信密集的线程绑定到同一个Socket内的核心上(通常距离更近)。Linux的 taskset 或 libnuma 可以绑定到具体的CPU核心,而不仅仅是节点。
  3. Benchmark验证 :任何绑定策略调整后,都必须用贴近真实场景的负载进行基准测试。监控 perf 中的 LLC-load-misses 和 offcore_requests 等事件,验证性能提升是否达到预期。

4.4 陷阱四:虚拟化与容器环境下的NUMA隔离

在云原生和虚拟化环境中,NUMA的复杂性又提升了一个层级。虚拟机或容器可能被调度到物理机的部分NUMA节点上。

场景分析: 你在一个双节点的宿主机上创建了一个拥有32个vCPU的虚拟机。如果虚拟机的vCPU被调度到两个物理节点上,而虚拟机内的操作系统(Guest OS)并不感知底层NUMA(如果未启用虚拟NUMA,vNUMA),那么Guest OS会认为所有vCPU访问内存的代价相同。但实际上,当运行在node0物理核心上的vCPU访问被宿主机映射到node1物理内存上的页面时,会产生双重的性能损失:Guest OS内的远程访问 + 宿主机层面的跨节点访问。

解决思路:

  • 启用vNUMA :在VMware、Hyper-V、KVM(通过 libvirt 配置)等主流虚拟化平台中,确保为虚拟机启用了vNUMA支持。这会将物理NUMA拓扑暴露给Guest OS,使其内核能够进行NUMA感知的调度和内存分配。
  • 容器环境 :在Kubernetes中,你可以使用 Topology Manager 特性来保证Pod分配的CPU和内存来自相同的NUMA节点,实现“NUMA亲和性”。这对于运行AI训练、数据库等低延迟应用至关重要。
  • 宿主机绑定 :在极端性能要求下,可以考虑将整个虚拟机或容器实例的vCPU和内存绑定到宿主机的特定NUMA节点上,实现物理隔离。

理解UMA和NUMA,绝不仅仅是记住两个概念。它是一条贯穿硬件设计、操作系统内核到应用程序开发的线索。从 /proc/zoneinfo 里看到的内存水位,到内核源码中 alloc_pages 函数里复杂的节点选择算法,再到你写多线程程序时对数据局部性的考量,背后都有它的影子。我个人的体会是,在性能调优这条路上,很多看似玄学的问题,最终往往都能在硬件架构和操作系统原理中找到根本原因。NUMA就是这样一个典型的领域,它要求我们打破“内存就是一块均匀黑板”的简单思维,建立起“内存访问有远近亲疏”的立体认知。下次当你面对一台多路服务器性能不如预期时,不妨先运行一下 numastat 和 perf mem ,看看是不是有线程在“长途跋涉”地访问内存,或许一个简单的绑定策略调整,就能带来意想不到的提升。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐