从第一性原理理解 NUMA
一、一个反直觉的现象
先看一个例子。
我们有一台双路服务器,里面装了两颗 CPU,跑一个内存敏感的服务。同样的硬件、同样的代码、同样的负载,做两件事:
- 不绑核:让操作系统自由调度
- 绑核:用
numactl把进程绑到一颗 CPU 上
结果绑核的版本性能高了将近 30%。
奇怪吗?理论上"内存就是内存",访问哪一块都一样,为什么仅仅是"换了颗 CPU 来跑",性能就掉这么多?
要回答这个问题,得回到一个更基础的事实:在多 CPU 的服务器上,"内存"早就不是均匀的了。 这个事实有个名字,叫 NUMA。
要理解 NUMA,得先从它的前身 UMA 开始。
二、UMA:所有 CPU 共享一块内存
UMA(Uniform Memory Access,统一内存访问)是 90 年代多 CPU 服务器的标准设计。
它的思路很直觉:一台机器有多颗 CPU,但共享同一块内存。任何 CPU 访问任何内存地址,延迟都一样。
1 | CPU0 CPU1 CPU2 CPU3 |
UMA 最大的好处是对程序员透明——不管几颗 CPU,写代码时都不用关心"内存在哪里"。在 2 颗、4 颗 CPU 的时候,这种方案跑得很好。
但当 CPU 数量继续上涨,UMA 就开始撞墙了。
三、UMA 撞了什么墙?
简单说四件事,每一件都和物理世界过不去。
第一,总线带宽分不够。 所有 CPU 共用一条访问内存的总线。总线的带宽是固定的,CPU 越多,每颗能分到的就越少。8 颗 CPU 等于 8 个人挤一条独木桥,再多就直接堵死。
第二,缓存一致性流量爆炸。 每颗 CPU 都有自己的高速缓存,缓存里的数据要和其他 CPU 保持一致——"我改了这个变量、你那份要作废"。CPU 越多,这种通知就越多,而且呈平方级增长。再加上总线本来就紧张,一致性流量很快把可用带宽吃光。
第三,频繁的缓存失效。 一致性通知发出去之后,接收方本地缓存里那份数据就被标记为"无效"。下次这颗 CPU 再读同一个变量,缓存里找不到(cache miss),只能重新去主内存取——这来回一趟叫做"缓存行乒乓"。乒乓每发生一次,就是一次额外的内存访问延迟;如果多颗 CPU 交替写同一块数据,乒乓会持续不断,缓存形同虚设。
第四,物理距离限制速度。 电信号在主板上一个时钟周期只能跑几厘米。CPU 离内存越远,信号往返一次的时间越长。如果想让所有 CPU 访问任意内存都"一样快",就只能按"最远那段距离"来设计——这是劣化,不是优化。
四件事叠起来的结果:8 颗 CPU 之后,UMA 加得越多,性能反而越差。多加的 CPU 都在排队,不是在干活。需要一种新方案。
四、NUMA:每颗 CPU 配一块自己的内存
NUMA(Non-Uniform Memory Access,非统一内存访问)的思路同样朴素:
既然让所有 CPU 都"等距离"访问所有内存做不到,那就让每颗 CPU 专门负责一块离它最近的内存。
具体做法是把"一颗 CPU + 一块内存"打包成一个单元,叫 NUMA Node。每颗 CPU 优先访问自己那块"本地内存";要访问别人的内存,走一条专用的高速通道(Intel 叫 UPI,AMD 叫 Infinity Fabric)。
Node 内部:CPU 访问自己的内存很快,跟 UMA 时代一样。
Node 之间:也能互相访问内存,但要走互联通道,会慢一些——这就是名字里"Non-Uniform"的来源。
一个容易被忽视的顺序:NUMA 首先是一场硬件层面的重构。
把内存控制器从主板芯片组搬进 CPU 内部、用高速点对点互联(UPI / HyperTransport / Infinity Fabric)替换共享总线——这些都是芯片设计和主板工程上的实质性改变,发生在 2003–2008 年间。硬件结构一旦确定,"内存不再均匀"就成了一个客观事实,无论软件层面承不承认。
操作系统和软件的适配是第二步,是对这个硬件事实的响应:内核需要感知 Node 拓扑、把进程和内存分配在同一个 Node 内(NUMA-aware 调度);数据库、运行时、中间件需要主动做内存亲和性管理。这些都是在既定硬件约束下"尽量减少跨节点访问"的策略,而不是改变硬件本身。
理解这个顺序很重要:软件调优(
numactl、mbind、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 | # 看机器有几个 NUMA Node、每个 Node 多少 CPU 和内存 |
七、一句话总结
UMA 是"所有 CPU 共享一块内存"的早期方案,简单但扛不住 CPU 核数膨胀;NUMA 是"每颗 CPU 配一块自己的内存"的现代方案,解决了 UMA 的扩展性问题,代价是访问"别人的"内存会慢一些。
理解了这一点,再去看 numactl、看服务器性能调优,就不再神秘了——大部分技巧都只是在帮你做同一件事:让数据和 CPU 在同一个 Node 里。