上一篇讲了 swap 如何为匿名页提供外部 backing store。讨论中一直隐含一个假设:物理内存是一块均匀的资源,任何 CPU 访问任何物理页的代价相同。在 NUMA 架构下这个假设不成立。

这篇要回答的核心矛盾:

NUMA 让"物理内存"不再是均匀资源;分配位置、CPU 位置和迁移策略共同决定访问成本。

问题从哪里来

在 UMA(Uniform Memory Access)系统中,所有 CPU 共享一条总线访问同一组内存控制器。每个 CPU 访问任何物理地址的延迟相同。早期单路和低端双路机器大多是这种架构。

当 CPU 数量增加,单一总线成为瓶颈。NUMA(Non-Uniform Memory Access)把系统拆成多个节点(node),每个节点包含若干 CPU 核心和一组本地内存。节点内部通过本地总线通信(快),节点之间通过互联(QPI、UPI、Infinity Fabric 等)通信(慢)。

1
2
3
4
5
6
7
8
9
10
11
UMA:
CPU0 CPU1 CPU2 CPU3
\ | | /
shared bus
|
[ DRAM ]

NUMA (2 nodes):
[CPU0 CPU1] --- interconnect --- [CPU2 CPU3]
| |
[local DRAM 0] [local DRAM 1]

CPU 访问本节点内存延迟约 80-100ns(典型值),跨节点访问可能增加 40-80% 的延迟,具体取决于互联拓扑和跳数。带宽差异同样存在。

这对内核内存管理的影响是根本性的:分配一页物理内存不再只关心"有没有空闲页",还要关心"离谁近"。

node、zone 和 CPU 的关系

Linux 内核用三层结构组织物理内存:

层级 含义 数据结构
node 一个 NUMA 节点,对应一组本地内存 struct pglist_datapg_data_t
zone 节点内按地址范围或属性分区 struct zone
page 单个物理页帧 struct page / struct folio

每个 node 有自己的一组 zone(DMA、DMA32、Normal、Movable)。每个 node 有自己的 lruvec、自己的 kswapd、自己的空闲页统计。

numactl --hardware 的输出直接展示这个结构:

1
2
3
4
5
6
7
8
9
10
11
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5
node 0 size: 32768 MB
node 0 free: 12456 MB
node 1 cpus: 6 7 8 9 10 11
node 1 size: 32768 MB
node 1 free: 15234 MB
node distances:
node 0 1
0: 10 21
1: 21 10

node distances 表是关键:对角线上的 10 表示本地访问(基准距离),非对角线的 21 表示远程访问代价是本地的 2.1 倍。在更大系统(4 路、8 路)中,这个矩阵不对称且有多级距离。

默认分配策略:本地优先

内核的默认内存分配策略是本地分配(local allocation):在当前 CPU 所属的 node 上分配物理页。这是最简单也最常见的情况——进程在哪个 CPU 上触发 page fault,物理页就从哪个 node 分配。

如果本地 node 空闲页不足,分配回退到其他 node(按距离排序,近的优先)。这个回退列表叫 zonelist,在系统启动时根据 NUMA 拓扑构建。

本地分配对大多数工作负载是合理的:进程在某个 CPU 上运行并访问自己分配的内存,数据局部性自然满足。问题出在两种场景:

第一,进程被调度器迁移到另一个 node 的 CPU 上运行。原来的本地内存变成了远程内存。

第二,分配时的 CPU 位置与后续主要访问的 CPU 位置不同。例如一个线程池在初始化阶段集中分配内存,但后续由不同 node 上的 worker 线程分别使用。

内存策略

Linux 提供 set_mempolicymbind 系统调用,允许进程显式控制内存分配位置。

策略 行为
MPOL_DEFAULT 回退到系统默认(本地分配)
MPOL_BIND 只从指定的 node 集合分配
MPOL_PREFERRED 优先从指定 node 分配,失败时回退
MPOL_INTERLEAVE 按页粒度在指定 node 集合间轮转分配
MPOL_PREFERRED_MANY 优先从指定集合分配,全部压力下回退到全局
MPOL_WEIGHTED_INTERLEAVE 按权重在 node 间分配(如 node0:5, node1:2)

策略按优先级从低到高分层:系统默认 < 进程策略(set_mempolicy)< VMA 策略(mbind)< 共享策略。cpuset 的限制优先于所有策略——策略指定的 node 如果不在 cpuset 允许的集合中,取交集。

numactl 命令是用户空间设置策略的常用工具,内部通过 set_mempolicy + fork + exec 实现:

1
2
3
4
5
6
7
8
# 绑定到 node 0 的内存
numactl --membind=0 ./my_program

# 在 node 0 和 1 之间交织分配
numactl --interleave=0,1 ./my_program

# 绑定 CPU 到 node 0,内存也本地分配
numactl --cpunodebind=0 ./my_program

模式提炼:资源标识包含位置

1
resource identity includes location
维度 UMA 视角 NUMA 视角
物理页 只有 PFN PFN + node + zone
分配决策 有没有空闲页 哪个 node 有空闲页 + 离 CPU 多远
回收 全局平衡 per-node 水位线 + per-node kswapd
性能模型 访问延迟固定 延迟取决于 CPU 与内存的拓扑关系

这种"资源带位置属性"的思路在分布式系统中无处不在:CDN 的边缘节点 vs 源站、数据库读副本的区域感知路由、Kubernetes 的 topology-aware scheduling。核心思想是:访问延迟不只取决于资源本身,还取决于请求者与资源之间的距离。

NUMA balancing

即使分配时做了本地优先,后续调度器可能把进程迁移到其他 node。此时内存访问变成远程的。NUMA balancing 机制试图检测这种情况并迁移页面到当前 CPU 所在 node。

原理:内核周期性地把某些 PTE 标记为"无效"(清除 present 位,设置特殊标记)。进程访问这些页面时触发 NUMA hint fault(一种特殊缺页)。内核统计每个页面被哪个 node 上的 CPU 访问,如果页面被远程 node 频繁访问,将其迁移到该 node。

1
2
3
4
5
6
NUMA balancing cycle:
1. scan PTEs, mark some as NUMA (remove present bit)
2. process accesses marked page -> NUMA hint fault
3. kernel records: page P was accessed by CPU on node N
4. if page P is on a different node -> consider migrating
5. migrate page: copy data, update all PTEs (via rmap)

NUMA balancing 的配置入口:

1
2
3
4
5
6
7
8
# 查看是否启用
cat /proc/sys/kernel/numa_balancing
# 0 = off, 1 = on

# 扫描速率控制
cat /proc/sys/kernel/numa_balancing_scan_delay_ms
cat /proc/sys/kernel/numa_balancing_scan_period_min_ms
cat /proc/sys/kernel/numa_balancing_scan_period_max_ms

NUMA balancing 不是免费的。每次 NUMA hint fault 都有开销(类似一次软件 TLB miss);迁移页面需要分配新页、复制数据、更新反向映射中的所有 PTE。对于频繁被多个 node 共享访问的页面,迁移可能导致"乒乓"——页面在 node 之间来回搬,反而更慢。

一个最小实验:观察 NUMA 拓扑与访问延迟

下面这个实验测量本地访问 vs 远程访问的延迟差异。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <sched.h>

static long diff_ns(struct timespec *start, struct timespec *end) {
return (end->tv_sec - start->tv_sec) * 1000000000L +
(end->tv_nsec - start->tv_nsec);
}

static long measure_access(char *buf, size_t size, long page_size) {
struct timespec t0, t1;
volatile char sink = 0;
int rounds = 100;

/* warmup */
for (size_t off = 0; off < size; off += (size_t)page_size)
sink += buf[off];

clock_gettime(CLOCK_MONOTONIC, &t0);
for (int r = 0; r < rounds; r++) {
for (size_t off = 0; off < size; off += (size_t)page_size)
sink += buf[off];
}
clock_gettime(CLOCK_MONOTONIC, &t1);
(void)sink;

long pages = (long)(size / (size_t)page_size);
return diff_ns(&t0, &t1) / (rounds * pages);
}

int main(void) {
long page_size = sysconf(_SC_PAGESIZE);
size_t size = 64UL * 1024 * 1024; /* 64MB */

printf("Page size: %ld bytes\n", page_size);
printf("Test buffer: %zu MB\n", size / (1024 * 1024));

/* 在当前 CPU 所属 node 分配并测量 */
char *buf = malloc(size);
if (!buf) { perror("malloc"); return 1; }
memset(buf, 'A', size);

int cpu = sched_getcpu();
printf("\nRunning on CPU %d\n", cpu);
printf("Local access: ~%ld ns/page\n", measure_access(buf, size, page_size));

free(buf);

printf("\nNote: To observe NUMA effects, run with numactl:\n");
printf(" numactl --cpunodebind=0 --membind=0 ./numa_demo (local)\n");
printf(" numactl --cpunodebind=0 --membind=1 ./numa_demo (remote)\n");
printf("\nOn UMA machines, both measurements will be similar.\n");

return 0;
}

编译与运行:

1
2
3
4
5
6
7
8
9
10
cc -Wall -Wextra -O2 numa_demo.c -o numa_demo

# 基准测试(本地分配)
numactl --cpunodebind=0 --membind=0 ./numa_demo

# 远程内存测试
numactl --cpunodebind=0 --membind=1 ./numa_demo

# 交织分配测试
numactl --cpunodebind=0 --interleave=0,1 ./numa_demo

如果机器是 UMA(只有一个 node),所有测试结果会接近相同。这本身是一个观察:“NUMA 问题只在多 node 系统上存在”。

完成实验后清理:

1
rm -f numa_demo numa_demo.c

读取实验结果

在双路或多路 NUMA 机器上预期看到的差异:

本地访问(CPU 在 node 0,内存绑定 node 0):每页访问延迟作为基准。典型值在 80-120ns(取决于具体硬件和访问模式)。

远程访问(CPU 在 node 0,内存绑定 node 1):每页访问延迟比本地高 30-80%。在两跳 NUMA 拓扑中差异更大。

交织分配:延迟介于本地和远程之间,接近两者的加权平均。交织的好处不在于单页延迟,而在于带宽分摊——大块顺序访问时两个 node 的内存控制器同时服务请求。

关键观察:

  • 延迟差异来自物理互联的传输开销,软件无法消除。软件能做的只是让热数据尽量在本地。
  • numastat 命令可以查看每个 node 的分配命中/未命中统计。numa_miss 高意味着大量分配落到了非本地 node。
  • /proc/<pid>/numa_maps 显示进程每个 VMA 的页面分布在哪些 node 上。

模式提炼:拓扑感知分配降低尾延迟

1
2
topology-unaware allocation -> remote accesses -> tail latency
topology-aware allocation -> local accesses -> predictable latency

在生产系统中,NUMA 不感知的分配往往不影响平均延迟,但会显著影响 P99/P999 尾延迟。因为偶尔的远程访问叠加到某些请求的关键路径上时,该请求的延迟突然增高。这和分布式系统中"跨 AZ 调用偶尔导致尾延迟抖动"是同一个现象。

per-node 回收与水位线

在 NUMA 系统中,每个 node 独立维护水位线和 kswapd。node 0 的内存紧张不影响 node 1(除非全局都紧张)。

这意味着:一个进程如果只绑定到 node 0(MPOL_BIND),即使 node 1 有大量空闲内存,该进程仍可能触发 direct reclaim 甚至 OOM。从全局看内存充裕,但从该进程的策略约束看已经耗尽。

/proc/zoneinfo 中每个 node 的每个 zone 都有独立的 min/low/high 水位线。numastat -m 可以观察 per-node 的内存使用细节。

核心结构:mempolicy

struct mempolicy 控制进程/VMA 级别的 NUMA 分配策略(include/linux/mempolicy.h,精简):

1
2
3
4
5
6
7
struct mempolicy {
atomic_t refcnt;
unsigned short mode; /* MPOL_DEFAULT/BIND/PREFERRED/INTERLEAVE/LOCAL */
unsigned short flags; /* MPOL_F_STATIC_NODES 等 */
nodemask_t nodes; /* 允许的 NUMA node 集合 */
int home_node; /* MPOL_PREFERRED_MANY 的首选 node */
};

分配路径通过 alloc_pages_mpol() 读取当前生效的 mempolicy,决定从哪个 node 的 free list 取页面。mode 不同行为差异很大:BIND 失败不回退,PREFERRED 失败可回退。

源码锚点

入口 读它的目的
mm/mempolicy.c set_mempolicy()mbind()、策略解析与应用
mm/page_alloc.c zonelist 构建、fallback 顺序、NUMA 感知分配
mm/migrate.c migrate_pages():NUMA balancing 使用的页迁移核心
kernel/sched/fair.c NUMA balancing 与调度器的交互
include/linux/mmzone.h struct pglist_data、node 与 zone 定义
Documentation/admin-guide/mm/numa_memory_policy.rst 策略语义的权威描述

mm/mempolicy.c 中的 alloc_pages_mpol() 时可以带着一个问题:当策略指定的 node 集合内所有 node 都达到水位线以下时,fallback 路径是什么?

研究生迁移表

Linux 概念 一般系统设计
NUMA node 数据中心的可用区(AZ)
本地 vs 远程访问延迟 同 AZ vs 跨 AZ 延迟
MPOL_BIND 严格区域约束(pod anti-affinity with zone)
MPOL_INTERLEAVE 负载均衡跨区域分散
NUMA balancing(页迁移) 数据重新分布(re-sharding、replica placement)
NUMA hint fault 采样探测访问模式
per-node kswapd per-AZ 资源管理独立性
zonelist fallback 跨区域降级路由

NUMA 架构的核心教训——“资源的物理位置影响访问性能”——在任何非均匀系统中都成立。网络拓扑中的 hop count、存储系统中的 tier 距离、甚至 CPU cache 的 L1/L2/L3 距离,都是"位置决定延迟"的实例。

常见误解

第一个误解是认为 NUMA 只存在于多路物理服务器。云主机也可能暴露 NUMA 拓扑(取决于虚拟化配置)。CXL 内存和异构内存池也会引入类似的非均匀访问特征。

第二个误解是认为 NUMA balancing 总是有益的。对于被多个 node 共享读取的数据(如只读配置表),NUMA balancing 可能导致无效迁移。此时用 MPOL_INTERLEAVE 分散分配比依赖自动迁移更好。

第三个误解是把"远程访问慢"理解为"必须避免所有远程访问"。大多数应用的工作集中在少量热页面上。只要热页面在本地,偶尔的远程访问不会构成性能问题。优化应该集中在高频访问路径上。

第四个误解是认为 MPOL_BIND 只是"偏好"。MPOL_BIND 是硬约束——如果指定的 node 集合没有空闲内存,分配会触发 direct reclaim 甚至 OOM,不会回退到其他 node。MPOL_PREFERRED 才是"尽量但可以回退"。

第五个误解是认为进程绑定 CPU 就自动获得了 NUMA 感知。CPU 绑定影响进程在哪执行,但不影响已分配页面的物理位置。如果在绑定 CPU 之前分配了大量内存,这些页面可能仍然在其他 node 上。需要配合 mbind 或内存迁移才能把数据搬到本地。

第六个误解是认为 NUMA 距离在硬件确定后就固定不变。CXL(Compute Express Link)2.0/3.0 允许动态挂载和移除内存设备,内核需要在运行时更新 NUMA 拓扑。未来的分层内存(tiered memory)架构中,同一 node 内部也可能有不同延迟的介质层级,NUMA 抽象正在从"离散 node"向"连续延迟梯度"演化。

练习

第一,在一台 NUMA 机器上(numactl --hardware 显示多个 node),按文中实验分别测量本地访问和远程访问的延迟。计算远程/本地比值,与 node distances 表中的值比较。

第二,写一个分配 1GB 内存的程序,分别用 --membind=0--membind=1--interleave=0,1 运行。用 numastat -p <pid> 观察页面在各 node 的分布。

第三,在一个 NUMA 机器上启用 NUMA balancing(echo 1 > /proc/sys/kernel/numa_balancing),用 --membind=1 --cpunodebind=0 运行一个反复访问自己内存的程序。观察 /proc/vmstatnuma_hint_faultsnuma_pages_migrated 是否增长。

第四,阅读 mm/mempolicy.cdo_mbind() 的实现。画出策略安装到 VMA 上的路径,标出如何处理"策略 node 集合与 cpuset 不相交"的情况。

第五,查看一个 Java 或 Go 服务进程的 /proc/<pid>/numa_maps,识别哪些内存段分布在多个 node 上。思考:对于这个应用,哪种策略(bind、interleave、preferred)最合适?

系列导航

参考资料