引言
在传统的SMP(对称多处理)系统中,所有CPU核心通过一条共享总线访问同一块内存,所有内存访问延迟是均匀的(UMA)。然而,随着CPU核心数量的增加,共享总线成为了巨大的性能和 scalability 瓶颈。为了解决这个问题,NUMA(Non-Uniform Memory Access,非统一内存访问) 架构应运而生。它带来了更高的可扩展性,但也引入了新的复杂性:内存访问速度取决于内存相对于执行CPU的位置。Linux内核必须感知并优化这种架构差异,否则性能将急剧下降。本文将解析NUMA的特点、内核的亲和性调度策略以及跨节点访问的优化手段。
一、 NUMA架构特点:距离产生延迟
NUMA架构的核心思想是将大量处理器分组,每个组成为一个节点(Node)。每个节点包含:
- 一组CPU核心(通常是一个物理CPU插槽或多个核心的集合)
- 一片本地内存(Local Memory)
- 一个内存控制器集成在节点内
节点之间通过高速互连(如Intel的QPI、AMD的Infinity Fabric)连接。
关键特性:
-
访问延迟不对称:
- 本地访问(Local Access):CPU访问其所属节点的本地内存,路径最短,速度最快,延迟最低。
- 远程访问(Remote Access):CPU访问其他节点的内存,必须通过节点间互连,速度较慢,延迟更高(通常比本地访问慢1.5到2倍甚至更多)。
-
访问带宽不对称:
- 每个节点的本地内存带宽是独享的。
- 节点间互连的总带宽是有限的,并且被所有节点共享。频繁的远程访问会饱和互连带宽,成为系统瓶颈。
对操作系统的影响:内核的内存分配策略不能再是“随便找一块空闲内存”。它必须尽量保证一个进程所使用的内存,其“归属”与运行该进程的CPU所在节点一致,即遵循节点亲和性(Node Affinity),否则应用程序将遭受性能损失。
二、 节点亲和性调度:将进程绑定在家门口
Linux内核提供了一套强大的机制来保证NUMA亲和性,其目标是 “尽量让任务在分配内存的同一个节点上运行,并尽量在任务运行的节点上为其分配内存”。
1. 自动的NUMA平衡
现代Linux内核(CONFIG_NUMA_BALANCING
)包含一个重要的后台特性——自动NUMA平衡。
- 工作原理:
- 跟踪:内核周期性地标记进程的页表项为“未访问”(清除Accessed位)。
- 扫描:稍后再次检查这些页。如果发现一个页被频繁访问,但其所在的NUMA节点与当前正在运行的CPU节点不一致,则判定该页存在跨节点访问。
- 迁移:内核会尝试执行两种迁移:
- 页面迁移(Page Migration):将“热”的内存页迁移到当前运行的CPU的本地节点。
- 任务迁移(Task Migration):将进程本身调度到内存页所在的节点上运行。
- 目标:通过动态迁移,减少远程访问次数,优化运行时性能。这对于不具备NUMA意识的应用程序尤其重要。
2. 手动调度策略与绑定
对于性能要求极高的应用(如数据库、高性能计算),自动平衡可能不够及时或会产生开销。因此内核提供了手动控制的接口:
- NUMA调度策略:通过
set_mempolicy()
系统调用或numactl
命令,可以设置进程的内存分配策略。MPOL_BIND
:严格只在指定的一个或多个节点上分配内存。MPOL_PREFERRED
:优先从首选节点分配,失败时再从其他节点分配。MPOL_INTERLEAVE
:在指定的多个节点之间交错分配内存页,用于均匀分散内存带宽压力。
- CPU亲和性(Affinity):通过
sched_setaffinity()
系统调用或taskset
命令,可以将进程或线程绑定(pinning) 到特定的CPU核心上运行。 - 联合使用:最优策略通常是将进程绑定到一组核心,并设置其内存分配策略与这些核心所在的NUMA节点一致。
# 使用 numactl 命令启动一个程序,将其CPU和内存都限制在Node 0
numactl --cpunodebind=0 --membind=0 ./my_app# 使用 taskset 将进程绑定到特定CPU,再通过 numactl 设置内存策略
taskset -c 0-7 numactl --membind=0 ./my_app
三、 跨节点内存访问优化:无法避免时的补救措施
尽管有亲和性策略,但某些场景下跨节点访问仍无法避免(例如,一个节点内存不足)。内核为此提供了多种优化机制。
1. 每节点伙伴分配器
内核并非运行一个全局的伙伴系统。在NUMA系统中,每个节点(Node)都拥有自己独立的struct zone
和伙伴分配器(Buddy Allocator)。
- 当在一个节点上请求分配内存时,分配器会首先尝试从当前节点的本地内存中分配。
- 只有当本地节点内存不足时,才会根据策略 fallback 到其他节点。
- 这从分配源头就最大限度地保证了内存的本地性。
2. SLAB分配器的每节点缓存
SLAB分配器同样支持NUMA优化。它为每个CPU和每个节点都创建了缓存。
kmalloc()
等函数在分配内存时,会优先从当前CPU所在节点的缓存中获取对象。- 这确保了被频繁分配和释放的小对象具有极好的访问局部性。
3. 回退(Fallback)列表
每个NUMA节点都维护一个内存分配回退列表。当本地节点无法满足分配请求时,内核会按照此列表的顺序去尝试其他节点。列表的顺序通常由节点间的距离(Distance) 决定,优先选择更“近”(访问延迟更低)的节点。
4. 负载均衡与Interleave
对于需要巨大内存带宽的应用,如果所有内存都集中在一个节点,其本地内存带宽可能成为瓶颈。
- Interleave策略:内核的
MPOL_INTERLEAVE
策略或硬件自带的内存交错功能,可以将连续的内存页轮流分配到多个节点上。 - 效果:这允许应用程序同时利用多个节点的内存控制器和带宽,从而聚合出比单个节点更高的总带宽。这对于大规模流式处理等场景非常有效,但代价是失去了局部性,所有访问都变成了“远程”。
总结
NUMA架构是高性能计算的基石,但也带来了管理的复杂性。Linux内核通过一套组合策略来应对:
- 感知(Awareness):内核清晰地了解系统的NUMA拓扑结构,包括节点、CPU和内存的归属关系以及节点间的距离。
- 亲和(Affinity):通过自动平衡和手动绑定策略,极力保证任务在其内存所在的节点上运行,最大化本地访问比例。
- 优化(Optimization):在架构上采用每节点分配器,在无法避免远程访问时,通过回退列表选择最近的节点,或在需要带宽时采用交错分配。
对于系统管理员和开发者而言,理解NUMA意味着:
- 使用
numastat
命令监控各节点的内存分配和跨节点访问(numa_miss
)情况。 - 使用
numactl
和taskset
对关键应用进行精细化的资源调度的绑定。 - 在编写程序时,考虑数据局部性,避免线程在CPU间频繁迁移而导致内存访问模式恶化。
掌握NUMA内存管理,是从“让程序能运行”到“让程序在高端硬件上飞起来”的关键一步。