Sorry, your browser cannot access this site
This page requires browser support (enable) JavaScript
Learn more >

从第一性原理理解 NUMA

一、一个反直觉的现象

先看一个例子。

我们有一台双路服务器,里面装了两颗 CPU,跑一个内存敏感的服务。同样的硬件、同样的代码、同样的负载,做两件事:

  • 不绑核:让操作系统自由调度
  • 绑核:用 numactl 把进程绑到一颗 CPU 上

结果绑核的版本性能高了将近 30%。

奇怪吗?理论上"内存就是内存",访问哪一块都一样,为什么仅仅是"换了颗 CPU 来跑",性能就掉这么多?

要回答这个问题,得回到一个更基础的事实:在多 CPU 的服务器上,"内存"早就不是均匀的了。 这个事实有个名字,叫 NUMA。

要理解 NUMA,得先从它的前身 UMA 开始。

二、UMA:所有 CPU 共享一块内存

UMA(Uniform Memory Access,统一内存访问)是 90 年代多 CPU 服务器的标准设计。

它的思路很直觉:一台机器有多颗 CPU,但共享同一块内存。任何 CPU 访问任何内存地址,延迟都一样。

1
2
3
4
5
6
7
8
9
10
11
CPU0      CPU1      CPU2      CPU3
│ │ │ │
└─────────┴────┬────┴─────────┘

┌────▼────┐
│ 内存总线│
└────┬────┘

┌────▼────┐
│ 内存 │
└─────────┘

UMA 最大的好处是对程序员透明——不管几颗 CPU,写代码时都不用关心"内存在哪里"。在 2 颗、4 颗 CPU 的时候,这种方案跑得很好。

但当 CPU 数量继续上涨,UMA 就开始撞墙了。

三、UMA 撞了什么墙?

简单说四件事,每一件都和物理世界过不去。

第一,总线带宽分不够。 所有 CPU 共用一条访问内存的总线。总线的带宽是固定的,CPU 越多,每颗能分到的就越少。8 颗 CPU 等于 8 个人挤一条独木桥,再多就直接堵死。

第二,缓存一致性流量爆炸。 每颗 CPU 都有自己的高速缓存,缓存里的数据要和其他 CPU 保持一致——"我改了这个变量、你那份要作废"。CPU 越多,这种通知就越多,而且呈平方级增长。再加上总线本来就紧张,一致性流量很快把可用带宽吃光。

一致性通知数量 = N×(N-1)/2,随 CPU 数量平方级增长 4 颗 CPU CPU 0 CPU 1 CPU 2 CPU 3 6 条一致性链路 总线压力:尚可接受 8 颗 CPU CPU0 CPU1 CPU2 CPU3 CPU4 CPU5 CPU6 CPU7 28 条一致性链路 总线被打满,CPU 在排队

第三,频繁的缓存失效。 一致性通知发出去之后,接收方本地缓存里那份数据就被标记为"无效"。下次这颗 CPU 再读同一个变量,缓存里找不到(cache miss),只能重新去主内存取——这来回一趟叫做"缓存行乒乓"。乒乓每发生一次,就是一次额外的内存访问延迟;如果多颗 CPU 交替写同一块数据,乒乓会持续不断,缓存形同虚设。

同一缓存行在两颗 CPU 之间反复失效与重载("缓存行乒乓") 时刻① CPU A 缓存:✓ 有效 CPU B 缓存:✓ 有效 时刻② CPU A 写入 缓存:已修改 发出失效通知(Invalidate) CPU B 缓存:✗ 已失效 时刻③ CPU A 缓存:有效 CPU B 读取 Cache Miss! 主内存(~100 ns 往返) 慢! 反复乒乓… 每次乒乓 = 一次 cache miss + 一次内存往返,性能瞬间跌入"慢路径"

第四,物理距离限制速度。 电信号在主板上一个时钟周期只能跑几厘米。CPU 离内存越远,信号往返一次的时间越长。如果想让所有 CPU 访问任意内存都"一样快",就只能按"最远那段距离"来设计——这是劣化,不是优化。

四件事叠起来的结果:8 颗 CPU 之后,UMA 加得越多,性能反而越差。多加的 CPU 都在排队,不是在干活。需要一种新方案。

四、NUMA:每颗 CPU 配一块自己的内存

NUMA(Non-Uniform Memory Access,非统一内存访问)的思路同样朴素:

既然让所有 CPU 都"等距离"访问所有内存做不到,那就让每颗 CPU 专门负责一块离它最近的内存

具体做法是把"一颗 CPU + 一块内存"打包成一个单元,叫 NUMA Node。每颗 CPU 优先访问自己那块"本地内存";要访问别人的内存,走一条专用的高速通道(Intel 叫 UPI,AMD 叫 Infinity Fabric)。

NUMA Node 0 Core 0 L1 Cache L2 Cache Core 1 L1 Cache L2 Cache Core 2 L1 Cache L2 Cache Core 3 L1 Cache L2 Cache L3 Cache(Node 内共享) 内存控制器(IMC) 本地内存(DDR) 延迟 ~80 ns | 带宽 100% ✓ 本地访问(快) 进程绑定在同一 Node 内时走此路径 NUMA Node 1 Core 0 L1 Cache L2 Cache Core 1 L1 Cache L2 Cache Core 2 L1 Cache L2 Cache Core 3 L1 Cache L2 Cache L3 Cache(Node 内共享) 内存控制器(IMC) 本地内存(DDR) 延迟 ~80 ns | 带宽 100% ✓ 本地访问(快) 进程绑定在同一 Node 内时走此路径 UPI / Infinity Fabric 延迟 ~140 ns 带宽 ~50% ✗ 跨节点访问(慢)

Node 内部:CPU 访问自己的内存很快,跟 UMA 时代一样。
Node 之间:也能互相访问内存,但要走互联通道,会慢一些——这就是名字里"Non-Uniform"的来源。


一个容易被忽视的顺序:NUMA 首先是一场硬件层面的重构。

把内存控制器从主板芯片组搬进 CPU 内部、用高速点对点互联(UPI / HyperTransport / Infinity Fabric)替换共享总线——这些都是芯片设计和主板工程上的实质性改变,发生在 2003–2008 年间。硬件结构一旦确定,"内存不再均匀"就成了一个客观事实,无论软件层面承不承认。

操作系统和软件的适配是第二步,是对这个硬件事实的响应:内核需要感知 Node 拓扑、把进程和内存分配在同一个 Node 内(NUMA-aware 调度);数据库、运行时、中间件需要主动做内存亲和性管理。这些都是在既定硬件约束下"尽量减少跨节点访问"的策略,而不是改变硬件本身。

理解这个顺序很重要:软件调优(numactlmbind、NUMA-aware 分配器)能把跨节点访问的代价降到最低,但永远无法消除——因为那 ~60 ns 的额外延迟刻在了物理距离和信号传播速度里。

商用 x86 服务器从 2003 年开始走上 NUMA 这条路。AMD 在 Opteron 上把内存控制器搬进 CPU、用 HyperTransport 连接多颗 CPU;Intel 在 2008 年的 Nehalem 上跟进。从那之后,所有多路 x86 服务器默认都是 NUMA。这不是某个厂商的设计偏好,而是物理约束推出来的必然方向。

五、NUMA 解决了什么,又带来了什么?

解决了:扩展性。 多个内存控制器并行工作,每颗 CPU 独享自己的内存带宽,整机的总带宽随 CPU 数量线性增长。CPU 之间也不再共用一条总线,仲裁开销和一致性广播都被局限在每个 Node 内部。今天动辄一百多核的服务器,靠的就是 NUMA。

带来的代价:访问"自己的"内存快,访问"别人的"内存慢。 用一台典型双路服务器的数字感受一下:

访问类型 延迟 带宽
本地内存 ~80 ns 100%
跨节点内存 ~140 ns(慢 70%–80%) ~50%

差不多多一半的延迟,少一半的带宽。在一个内存敏感的服务里,这点差距会被放大成肉眼可见的性能问题。

六、回到开头那个 30%

现在可以回答开头那个问题了。

不绑核的时候,操作系统会出于负载均衡的考虑,把进程在不同 CPU 之间挪来挪去。挪到另一颗 CPU 上之后,原来在 Node 0 分配的内存就成了"远端",每次访问都要走互联通道,延迟变长、带宽变小。再加上多线程之间跨 Node 的缓存同步开销——所谓"内存不是均匀的"代价,全部体现在那 30% 里。

绑核以后,进程稳稳地待在一个 Node 内:内存在本地、缓存是热的、线程之间的通信也不跨 Node。性能自然就回来了。

所以 NUMA 时代写代码、调服务的核心原则其实就一句话:

让数据和处理数据的 CPU,待在同一个 Node 里。

如果想在自己的机器上看一眼 NUMA 长什么样,下面两条命令就够:

1
2
3
4
5
# 看机器有几个 NUMA Node、每个 Node 多少 CPU 和内存
numactl --hardware

# 把进程的 CPU 和内存都绑到 Node 0
numactl --cpunodebind=0 --membind=0 ./your-app

七、一句话总结

UMA 是"所有 CPU 共享一块内存"的早期方案,简单但扛不住 CPU 核数膨胀;NUMA 是"每颗 CPU 配一块自己的内存"的现代方案,解决了 UMA 的扩展性问题,代价是访问"别人的"内存会慢一些。

理解了这一点,再去看 numactl、看服务器性能调优,就不再神秘了——大部分技巧都只是在帮你做同一件事:让数据和 CPU 在同一个 Node 里。

评论