深入理解NUMA架构:从UMA到NUMA的内存访问演进与Linux内核实现
1. 从“统一”到“非统一”:内存访问的演进与内核设计的基石
如果你写过C语言程序,或者用过
malloc
,你可能觉得内存就是一块连续、均匀的空间,CPU访问它就像我们伸手拿桌上的水杯一样,距离和速度都是恒定的。但在多处理器(多核)系统的世界里,这个朴素的认知在几十年前就被打破了。内存访问并非总是“一碗水端平”,其背后是两种截然不同的体系架构思想:UMA(Uniform Memory Access,统一内存访问)和NUMA(Non-Uniform Memory Access,非统一内存访问)。理解这两者,尤其是NUMA,是深入Linux内核内存管理、性能调优乃至虚拟化、云计算等领域不可或缺的一课。这不仅仅是硬件架构的差异,更是操作系统内核(特别是像Linux这样的现代内核)设计时必须面对的根本性约束和优化出发点。
为什么内核开发者、系统架构师和性能工程师需要关心这个?因为内存访问的延迟和带宽,直接决定了应用程序的吞吐量和响应时间。在一个NUMA系统中,一个进程如果“跑错了”位置,其性能可能下降数倍。随着多核处理器成为绝对主流,从数据中心服务器到高端PC,NUMA架构无处不在。因此,无论是为了诊断线上服务的性能抖动,还是为了设计一个能榨干硬件潜力的高性能应用,亦或是单纯想理解
numactl
、
/sys/devices/system/node
这些工具和接口背后的原理,从UMA和NUMA这一对概念入手,都是最坚实的起点。
2. UMA:对称多处理的理想模型
在深入复杂的NUMA之前,我们先回顾一下它的前身——UMA。理解UMA有助于我们看清NUMA要解决什么问题,以及它带来了哪些新的挑战。
2.1 UMA的核心架构与工作模式
UMA,顾名思义,其核心特征是 所有处理器(CPU核心)访问整个系统内存的任何部分,所花费的时间(延迟)和可用的带宽都是相同的、一致的 。想象一个圆桌会议,所有参会者(CPU核心)到桌子中央的文件堆(内存)的距离完全相等,任何人取放文件的速度都一样。
在硬件实现上,典型的UMA系统有一个共享的 系统总线 (System Bus)或 交叉开关 (Crossbar Switch)。所有CPU和内存控制器都连接在这个共享互连上。当CPU需要读取内存时,它通过这个共享通道发送请求,内存控制器响应后,数据再通过同一通道返回。由于所有CPU都使用同一条“高速公路”访问唯一的内存“仓库”,因此访问特性是均匀的。
为什么说它是“理想模型”? 因为它极大地简化了操作系统内核的设计。内核的内存管理子系统可以认为系统只有“一块”内存,所有CPU对它的访问成本相同。因此,内核在分配内存、调度进程、进行负载均衡时,完全不需要考虑“内存的位置”这个因素。它只需要关注哪个CPU空闲,就把进程调度到哪个CPU上;需要分配内存时,从全局的空闲页面链表中分配即可。这种简化的抽象,使得早期SMP(Symmetric Multi-Processing,对称多处理)系统的内核设计相对直接。
2.2 UMA的瓶颈与 scalability 问题
然而,UMA的“理想”模型存在一个物理上的根本瓶颈: 共享互连的带宽和仲裁延迟 。
随着CPU核心数量的增加,所有核心对内存的访问请求都挤在一条共享总线上。这会导致:
- 总线争用 :多个核心同时发起内存访问时,需要仲裁,增加了延迟。
- 带宽饱和 :总线的最大带宽是固定的。当核心数增加到一定程度,总带宽需求超过总线能力时,就会成为系统性能的瓶颈。即使增加更多CPU,系统的整体性能也无法线性提升,甚至可能下降。
这就引出了计算机体系结构中的一个关键概念: 可扩展性 。UMA架构在核心数较少时(例如2-4个核心)工作良好,但当核心数量达到几十甚至上百个时,那个共享的“中央通道”就会成为整个系统的性能瓶颈。这就像一个小镇只有一条主干道,当居民(CPU核心)和车辆(数据请求)激增时,必然导致严重的交通堵塞。
3. NUMA:为可扩展性而生的分布式内存架构
为了解决UMA的可扩展性问题,NUMA架构应运而生。它的设计哲学从“集中共享”转向了“分布式协作”。
3.1 NUMA的基本原理与物理拓扑
NUMA的核心思想是: 将物理内存和与之紧密关联的CPU核心分组,形成一个“节点”。每个节点内的CPU访问本节点内的内存,速度很快(延迟低,带宽高),称为“本地访问”。而访问其他节点内存的速度则较慢(延迟高,带宽可能受限),称为“远程访问”。
这种“快慢有别”正是“非统一”一词的由来。在硬件上,这通常通过以下方式实现:
- 每个CPU插槽(Socket) 通常构成一个NUMA节点。一个服务器可能有2个、4个甚至8个CPU插槽。
- 每个插槽 集成了多个CPU核心、集成内存控制器(IMC)和一部分物理内存(称为本地内存)。
- 节点之间 通过高速互连链路连接,如Intel的QPI(QuickPath Interconnect)、AMD的Infinity Fabric、ARM的CMN(Coherent Mesh Network)等。这些链路负责处理跨节点的内存访问和缓存一致性协议。
以一个双路服务器为例:
- Node 0 : 包含 CPU Socket 0(比如24个核心)和直接插在该插槽附近的128GB内存(A组)。
- Node 1 : 包含 CPU Socket 1(另外24个核心)和它自己的128GB内存(B组)。
- Socket 0 上的核心访问内存A,路径极短(通过集成的内存控制器),速度极快。
- Socket 0 上的核心如果需要访问内存B,请求必须通过CPU间的QPI/Infinity Fabric链路,转发到Socket 1的内存控制器,再返回数据。这个路径更长,延迟通常是本地访问的1.5到3倍,带宽也可能更低。
3.2 NUMA带来的优势与挑战
优势是显而易见的:可扩展性。 通过将内存访问“本地化”,大部分内存访问流量被限制在各个节点内部,避免了全局总线的拥堵。节点之间的互连链路虽然慢于本地访问,但远优于所有流量挤在一条总线上。这使得系统可以通过增加节点(CPU插槽)来近乎线性地增加总内存带宽和整体计算能力,从而支持拥有数百个核心的大型服务器。
然而,挑战转移到了软件层,尤其是操作系统内核:
- 意识 :内核必须首先“知道”系统是NUMA拓扑的。它需要在启动时探测硬件,构建出系统的NUMA节点图。
- 分配策略 :内核不能像在UMA下那样随意分配内存。最优策略是总是尝试从 当前正在运行的CPU所在的本地节点 分配内存。这被称为“本地分配”策略。
- 调度关联性 :进程调度和内存分配需要协同工作。理想情况是,一个进程被调度到某个CPU核心上运行,并且它所需的内存也尽可能从该核心所属的节点分配。如果进程在Node 0上运行,却大量使用Node 1上的内存,性能就会显著下降。这种现象称为“NUMA失衡”。
- 数据共享与迁移 :当多个进程(或线程)需要频繁访问同一块内存,而它们又运行在不同节点上时,这块内存应该放在哪里?内核有时需要在“让所有访问者都承受远程访问代价”和“在节点间迁移内存页面以跟随访问者”之间做出权衡。
4. Linux内核中的NUMA支持与实现细节
Linux内核从2.5版本开始就引入了NUMA支持,并随着时间推移不断强化。理解内核如何抽象和管理NUMA,是进行系统级调优的关键。
4.1 内核数据结构:
pg_data_t
与内存节点
在UMA系统中,内核用一个全局的
mem_map
数组管理所有物理内存页。而在NUMA系统中,这个结构被分解了。内核为每个NUMA节点定义了一个
pg_data_t
结构(在源码中常称为
pglist_data
)。
每个
pg_data_t
结构体管理一个节点内的所有物理内存。它包含了:
-
node_zones: 该节点内的内存区域数组(ZONE_DMA, ZONE_DMA32, ZONE_NORMAL等)。注意,每个节点都有自己的这些区域。 -
node_mem_map: 指向该节点所有物理页面描述符(struct page)数组的指针。 -
node_start_pfn: 该节点起始物理页帧号。 -
node_spanned_pages: 该节点跨越的总页面数(包含空洞)。 -
node_present_pages: 该节点实际可用的页面数。 -
多个
per_cpu的页面缓存(如per_cpu_pageset),用于加速本地内存分配。
你可以通过
/sys/devices/system/node/
目录查看系统中的NUMA节点信息。例如,
node0
目录下的内容就对应着第一个
pg_data_t
结构管理的内存和CPU信息。
4.2 内存分配策略:伙伴系统与zonelist
当内核或应用程序请求分配内存时(例如通过
alloc_pages
),内核需要决定从哪个节点分配。这是通过
分配策略
和
zonelist
实现的。
1. 分配策略: 内核定义了几种NUMA内存分配策略,最常用的是:
-
MPOL_DEFAULT: 默认策略。内核根据当前线程的CPU关联性(cpuset)和调度情况,自动选择“本地节点优先”。 -
MPOL_BIND: 将分配严格绑定到指定的一个或一组节点。 -
MPOL_PREFERRED: 优先从指定节点分配,如果失败则尝试其他节点。 -
MPOL_INTERLEAVE: 在指定的多个节点间交错(轮询)分配页面,可以用于均衡内存带宽压力。
这些策略可以通过
set_mempolicy()
系统调用,或者更常用的
numactl
命令行工具来设置。
2. Zonelist(区域列表):
在每个进程的
struct task_struct
中,有一个
nodemask_t
表示其允许的内存节点掩码。内核为每个节点和每种分配策略(如
GFP_KERNEL
,
GFP_USER
)预计算了一个
zonelist
。这是一个有序列表,指明了当从当前节点分配失败时,应该按什么顺序去尝试其他节点。
对于默认策略,
zonelist
的顺序通常是:
本地节点 -> 最近的其他节点(根据互联距离) -> 更远的节点
。这个“距离”信息来自ACPI SLIT(System Locality Information Table)或硬件提供的信息,内核用其构建一个“节点距离矩阵”,你可以在
/sys/devices/system/node/nodeX/distance
中看到。
4.3 调度器与NUMA平衡
现代Linux调度器(CFS)与NUMA紧密集成,其目标之一就是实现“NUMA亲和性”:让进程尽量运行在它的内存所在的节点上。
内核中有一个重要的后台内核线程叫
kswapd
,而在NUMA环境下,还有一个更关键的线程叫
migrated
(或与NUMA平衡相关的任务)。它们负责监控系统的NUMA状况,并在必要时执行两种关键操作:
- 页面迁移 :如果一个页面被某个节点上的进程频繁访问,但该页面却位于另一个节点,内核可能会尝试将这个页面迁移到访问者所在的本地节点。
- 进程迁移 :如果一个进程的大部分内存都在某个节点上,但它却长时间在另一个节点上运行,内核可能会考虑将整个进程(或其线程)迁移到内存所在的节点。
这个平衡过程是动态的、持续的。内核会统计“NUMA失效率”(远程访问次数),作为平衡的依据。你可以通过
/proc/vmstat
中的
numa_hit
,
numa_miss
,
numa_foreign
等指标来观察系统的NUMA局部性情况。
5. 实战:观察、诊断与优化NUMA系统
理论最终要服务于实践。我们来看看如何在Linux系统上操作和优化NUMA。
5.1 探查系统NUMA拓扑
首先,你需要了解你的系统硬件拓扑。
# 1. 使用 numactl 查看硬件拓扑
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: 128841 MB
node 0 free: 56432 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: 129019 MB
node 1 free: 98765 MB
node distances:
node 0 1
0: 10 21
1: 21 10
这个输出清晰地告诉我们:系统有2个节点(Node 0和1),每个节点有24个CPU核心和约128GB内存。
node distances
显示,节点自身距离是10(基准值),跨节点距离是21(值越大,访问越慢)。
# 2. 使用 lscpu 命令
lscpu | grep -i numa
# 输出会显示每个CPU核心属于哪个NUMA节点。
# 3. 查看 /sys 文件系统
ls /sys/devices/system/node/ # 列出所有节点
cat /sys/devices/system/node/node0/cpulist # 查看node0的CPU列表
cat /sys/devices/system/node/node0/meminfo # 查看node0的内存信息
5.2 使用numactl控制进程的NUMA策略
numactl
是管理NUMA策略最直接的工具。
# 1. 将程序绑定到Node 0的CPU上运行,并且内存只从Node 0分配
numactl --cpunodebind=0 --membind=0 ./my_application
# 2. 将程序绑定到Node 0和1的CPU上,内存交错分配(适用于内存带宽密集型应用)
numactl --cpunodebind=0,1 --interleave=0,1 ./my_application
# 3. 查看一个正在运行进程的NUMA状态
numactl --show <pid>
# 或者查看 /proc/<pid>/numa_maps
cat /proc/$(pidof my_application)/numa_maps | head -20
/proc/<pid>/numa_maps
文件非常强大,它显示了该进程的每一段虚拟内存映射对应的物理页面分布在哪个NUMA节点上,是分析NUMA局部性的金钥匙。
5.3 诊断NUMA性能问题与常见陷阱
症状 :系统CPU使用率不高,但应用吞吐量上不去,性能监控显示较高的“内存访问延迟”或“跨节点流量”。
诊断步骤:
-
检查是否启用了NUMA平衡
:
cat /proc/sys/kernel/numa_balancing。如果为1,表示启用。对于某些延迟极度敏感的应用,有时禁用平衡(设为0)并手动绑定策略可能更稳定,但需要深入理解应用行为。 -
监控NUMA统计信息
:
# 查看全局NUMA事件 cat /proc/vmstat | grep numa_ # 关注 numa_hit(本地访问), numa_miss(本该本地却远程), numa_foreign(其他节点访问本节点) # 如果 numa_miss 和 numa_foreign 持续很高,说明局部性很差。 # 使用 perf 观察内存访问 perf stat -e node-loads,node-load-misses <command> # node-load-misses 高意味着很多访问落到了远程节点。 -
分析应用行为
:使用
numastat命令查看各节点的内存分配和使用情况。
如果发现某个进程的内存大量分布在非其运行节点的其他节点上,这就是典型的NUMA失衡。numastat -c <program_name> # 查看特定程序名在各节点的内存分布 numastat # 查看系统全局各节点的内存统计
常见陷阱与优化建议:
-
默认策略不一定最优
:内核的默认“本地优先”策略在大多数情况下很好,但对于一些特殊场景,如:
-
内存密集型单线程应用
:将其绑定到单个节点(
--membind)可以避免内存碎片化到多个节点,可能提升缓存命中率。 -
大规模并行初始化
:如果很多线程同时启动并大量分配内存,可能导致所有内存都集中分配到主节点(通常是Node 0),造成“节点0热点”。使用交错分配(
--interleave)可以缓解。
-
内存密集型单线程应用
:将其绑定到单个节点(
-
大页(Hugepages)与NUMA
:使用大页时,NUMA策略同样重要。一个大页(如2MB)的所有物理页面必须来自同一个NUMA节点。在分配大页池时(如通过
/sys/kernel/mm/hugepages),内存就是从当前节点分配的。因此,需要确保在每个节点上都配置了足够的大页。 -
虚拟化环境(KVM)
:在给虚拟机分配vCPU和内存时,必须考虑NUMA亲和性。最佳实践是将虚拟机的vCPU和内存绑定到同一个物理NUMA节点上。Libvirt的
<numatune>和<cpu>的<topology>标签可以用于此目的。否则,虚拟机内的性能会因跨节点访问而严重受损。 -
数据库系统
:像MySQL、PostgreSQL这样的数据库,通常对内存和CPU缓存非常敏感。强烈建议为数据库进程配置NUMA策略,通常将其绑定到一个或一组特定的节点,并关闭全局的NUMA平衡(在数据库启动脚本中使用
numactl),因为数据库自身的内存管理可能比内核的通用平衡策略更优。
理解UMA和NUMA,是从“程序员视角”升级到“系统架构师视角”的重要一步。它让你看到硬件与软件之间那道关键的抽象层,并学会在必要时穿透这层抽象,让软件更好地贴合硬件,从而释放出真正的性能潜力。在云计算和微服务时代,虽然容器和编排平台试图隐藏底层复杂性,但在处理性能关键型服务时,NUMA依然是那个无法绕过的底层真相。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)