AI 资讯 知识库 后端技术操作系统内功 · 第 19 篇|NUMA 与巨页:跨节点延迟税与 TLB miss 解药

操作系统内功 · 第 19 篇|NUMA 与巨页:跨节点延迟税与 TLB miss 解药

NUMA:内存不是一块,是一组「本地仓库」

桌面 CPU 是 UMA(统一内存访问):所有核到所有内存的距离一样。双路服务器几乎都是 NUMA(非统一内存访问):每个 socket 自带内存控制器与本地内存条,构成一个 node;跨 socket 访问对方的内存要走 QPI/UPI 互联,远端访问延迟通常比本地高 40%–90%,带宽也减半。核越多、socket 越多的机器,这个税越重——这就是为什么 128 核机器跑单线程程序毫无意义,也是为什么「把进程绑在正确 numa 节点上」是老运维的肌肉记忆。

flowchart LR
    subgraph Node0["Node 0(socket 0)"]
        C0["CPU 0–15"] --- M0["本地内存 128G<br>(快)"]
    end
    subgraph Node1["Node 1(socket 1)"]
        C1["CPU 16–31"] --- M1["本地内存 128G<br>(快)"]
    end
    C0 -.->|"跨 UPI:慢 40–90%"| M1
    C1 -.->|"跨 UPI:慢 40–90%"| M0

Linux 用一组系统调用与文件暴露 NUMA 信息:numactl --hardware 看拓扑,/sys/devices/system/node/ 看每个节点的内存与距离(distance 是归一化的访问代价,10 为本地,20 就是「两倍距离」)。内核维护 per-node 的空闲页链表,分配策略决定了你的内存落在哪个节点。

四种 NUMA 策略:default、bind、preferred、interleave

进程申请内存时,内核按分配策略选择从哪个 node 拿页。四种策略对应四种场景:

  • default(局部优先):优先从「触发缺页的 CPU 所在节点」分配——大多数情况的正确默认。
  • membind:只允许从指定节点分配。数据库绑定专用内存节点时用,防止业务进程挤占。
  • preferred:优先指定节点,不够再用别的。比 bind 温和。
  • interleave:页在多个节点间轮询分布。适合访问模式均匀、无局部性可言的大块内存——把带宽聚合起来。

对长驻服务,工程上的标准动作是 numactl --cpunodebind=0 --membind=0 ./your_server:CPU 与内存锁在同一个节点,彻底消除远端访问。反面教材是经典的「swap 风暴」:进程的内存被分配在 node 0,而某个时刻它的线程跑在 node 1 上,远端访问 + kswapd 回收的叠加把延迟打出毛刺——监控里的 P99 抖动,拓扑对齐后常常直接消失。

验证工具链:numastat -p PID 看进程内存在各节点的分布;perf stat -e node-loads,node-loads-misses 量化远端访问占比。远端访问占比超过 15%,服务就该做 NUMA 对齐了。

TLB:页表查询的成本与巨页的动机

每个虚拟地址访问都要查页表把虚拟页号翻成物理页号(第 2 篇讲过四级页表)。查一次页表 = 4 次内存访问——不可接受,所以硬件用 TLB(translation lookaside buffer)缓存最近的翻译结果。问题在于 TLB 容量极小:典型 L1 dTLB 只有 64–96 项,L2 STLB 一两千项。

算一笔账:4KB 页 × 1000 项 TLB ≈ 只覆盖 4MB 热内存。一个 16GB 堆的 JVM,热工作集随便就超过这个数——TLB miss 率飙升,每次 miss 最多 4 次额外的内存访问,整体性能掉 10%–30% 不等。数据库、大堆 JVM、Redis 大实例都是重灾区。

巨页(huge page)的思路直白:把页变大,同样的 TLB 项数覆盖更多内存。2MB 页让覆盖能力扩大 512 倍(1000 项覆盖 2GB),1GB 页再扩 512 倍。TLB miss 大幅下降,附带收益是页表本身也小了(页表项减少 512 倍),缺页中断次数同步下降。

巨页的两种形态与各自的坑

静态巨页(hugetlbfs):启动时从内核预留(/proc/sys/vm/nr_hugepages 或启动参数 default_hugepagesz),应用通过 mmap(MAP_HUGETLB) 或挂载 hugetlbfs 使用。优点是确定性——内存事先锁定,绝不碎片化、绝不交换;缺点是预留即占用,不用也退不回去。数据库(Oracle、PostgreSQL 的 huge_pages=on)与 DPDK 用这种方式。

透明巨页 THP(transparent huge page):内核自动把连续的 4KB 页在后台合并成 2MB 巨页,应用无感知。听起来美好,但它是本系列反复出现的「自动化税」的又一案例:khugepaged 后台线程扫描与合并会引入延迟毛刺;合并需要内存,内存紧张时可能触发直接回收——延迟敏感服务(Redis 官方文档明确要求 transparent hugepage 设为 never)几乎一律关掉 THP。吞吐型大内存应用(某些 JVM 配置、大数据引擎)反而受益。

维度 静态巨页 THP
分配时机 启动预留 运行时自动合并
延迟确定性 高 有 khugepaged 毛刺
运维成本 需容量规划 零配置
适用 数据库/DPDK/Redis 大实例 吞吐型批处理

落地参数速查

# NUMA 拓扑与距离
numactl -H
# 把服务绑在 node 0(CPU 与内存)
numactl --cpunodebind=0 --membind=0 ./server
# 静态巨页:预留 4096 个 2MB 巨页(8GB)
echo 4096 > /proc/sys/vm/nr_hugepages
# 关闭透明巨页(Redis 官方建议)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
# 验证进程的巨页使用与 NUMA 分布
numastat -p PID; grep -i huge /proc/PID/smaps_rollup

JVM 的对应开关:-XX:+UseLargePages(静态)或 -XX:+UseTransparentHugePages;Redis 启动时的 THP 警告日志就是上一节结论的直接体现;PostgreSQL 的 huge_pages = try/on 配合 nr_hugepages 预留是生产标配。

把两件事串起来:局部性的空间尺度

NUMA 讲的是数据到核心的距离,巨页讲的是地址翻译的粒度——两者其实是同一枚硬币:都在为「内存访问的物理现实」付钱或省钱。上一章的伪共享是核间弹射(同一 socket 内 40–100ns),NUMA 远端访问是跨 socket(100–200ns),TLB miss 是页表遍历(最多 4 次内存访问 ≈ 300ns+)。三层成本叠加起来,解释了为什么「同一个程序换台服务器就变慢」:核数、socket 数、内存容量变了,局部性的形状就变了。

云与容器时代的 NUMA:Kubernetes 的拓扑管理

NUMA 对齐在云原生环境里既是更难了(调度器看不见拓扑)也被解决了(KubeTopologyManager)。Kubernetes 的 CPU Manager 与 Topology Manager(1.18 起稳定)为 Guaranteed QoS 且请求整核的 Pod 做拓扑对齐:CPU 独占分配从同一 NUMA 节点取,内存分配跟随 CPU 的节点,SR-IOV 网卡与 GPU 也按节点就近绑定——机器学习推理与延迟敏感网关是这套机制的典型受益者。代价是碎片化:拓扑约束让调度自由度下降,整机利用率可能掉十个百分点。运维要点三条:其一,numactl 在容器里不生效(cgroup 的 cpuset 与内存节点要用 cpuset.mems 控制),别在容器内跑 numactl 自欺;其二,节点保留内存与内核线程的节点分布影响真实可用容量,大内存节点(512G 以上)要注意 per-node 的 kswapd 行为;其三,监控按 node 维度拆内存水位(numastat 系统级),单节点耗尽会触发跨节点分配,把延迟毛刺带进无关服务。云厂商的托管的节点组通常默认开 topology manager 仅在特定机型,自建集群要显式配置——延迟毛刺排查时,拓扑失配的优先级排在应用调优之前。

一个 NUMA 治理案例:网关 P99 毛刺的根因链

一个把 NUMA 知识串成完整排查链的案例。现象:32 核双路网关,流量平稳但每几分钟出现一次 P99 毛刺(从 2ms 跳到 40ms),单机定位无果。第一层排查(应用):GC 正常、无锁竞争、无慢查询——排除。第二层(资源):vmstat 看 si/so 接近零、无 swap;mpstat 发现毛刺时刻某个核 %sys 突增——是 kswapd 在那个核上跑。第三层(拓扑):numactl -H 显示两个 node 各 128G;numastat 显示该进程的内存约 70% 在 node 0、30% 在 node 1——进程被启动脚本 numa_balancing 默认策略打散了,而网卡中断(第 26 篇)全部绑在 node 0 的核上:跨节点访问叠加 kswapd 回收,毛刺的物理链条闭合。修复三步:systemd 单元加 NUMAPolicy=bind 与 NUMAMask=0(或启动命令套 numactl --membind=0);irqbalance 停用、网卡队列手动绑 node 0;zone_reclaim_mode 设为 1(该节点内存紧张时优先回收本地而非跨节点分配)。修复后毛刺消失,P99 稳定在 2.3ms。这个案例的通用价值在于排查顺序:先排除应用层,再看资源水位,最后查拓扑对齐——NUMA 失配的症状(偶发、周期性、与流量弱相关)与代码 bug 极像,但它的观测指标(numastat、perf 的 node-loads-misses)与修复手段(绑定)都系统级,应用层怎么调都没用。多路服务器的延迟敏感服务,拓扑对齐应该是部署基线而不是故障后的补救。

巨页的另一个收益:页表本身的内存账

巨页的收益除了 TLB 命中率,还有一条常被忽略的账:页表自身的内存开销。4KB 页管理的每 GB 内存需要约 2MB 的页表结构(四级页表的各级条目,含无映射时的稀疏),1TB 内存的机器仅页表就可能占 2GB——这还只是常驻,页表遍历的缓存污染另计。2MB 巨页把这个开销缩小 512 倍(每 GB 约 4KB 页表),1GB 巨页再降一档。对超大内存实例(Redis 100G+、SAP HANA 级数据库、大模型推理的显存映射),页表账从零头变成实质项。它的连带效应在 fork 上更明显:fork 要复制整份页表(CoW 只免数据页,页表结构要复制),页表本身越小 fork 越快——Redis 官方建议开 THP 的反面场景(fork 慢)与开巨页的正场景(TLB 命中)之间的张力,正是「Redis 与 THP 恩怨」的完整技术背景:THP 的合并动作发生在运行时且带 khugepaged 抖动,静态巨页(启动时预订)才是 Redis 的兼容选项,新版本 Redis 的支持矩阵里写得很清楚。运维口径:评估巨页时把「TLB 收益、页表内存、fork 成本、管理开销」四项都列进表里,按工作集大小与 fork 频率加权——没有免费的页粒度,只有匹配场景的粒度。

内存带宽:NUMA 的另一半账本

NUMA 的讨论集中在延迟,另一半账本是带宽——大内存工作负载(数据库扫描、科学计算、大模型推理)的真实瓶颈常在这里。每节点的内存带宽由内存通道数决定(典型双路服务器每节点 8 通道 DDR5,单节点理论带宽 300-400GB/s);跨节点访问走 UPI 互联(典型 3-4 条,聚合 50-80GB/s),跨节点带宽只有本地的六分之一到十分之一。推论有两条。其一,带宽敏感负载的跨节点访问不只是「慢一点」,是「吞吐塌方」——一个线程 80% 时间在拉远端数据,UPI 很快被打满,拖累的是整台机器的所有跨节点流量(包括别人的本地流量经过的干扰)。其二,带宽监控有专门入口:Intel 平台的 PCM(Performance Counter Monitor)工具能读每节点的内存控制器计数与 UPI 流量,pcm-memory 一条命令看「本地/远端带宽比」——远端占比超过 20%,再快的 CPU 也补不回来。应用侧的对策与延迟侧同源但更严格:数据分区按节点切(NUMA-aware 的分片,让数据的消费线程与数据同节点)、大缓冲的 interleave 关闭(均匀交错看似公平,实则让每个访问都变远端)、带宽大户之间避免共享 UPI(分机器而不是分节点)。容量规划时把「每节点带宽」当作独立资源维度:CPU 核够、内存够、UPI 打满的三高机器,加核只会更慢。

小结

NUMA 把服务器内存切成带距离的节点,跨节点访问要付 40%–90% 的延迟税;对长驻服务做 numactl 的 CPU-内存节点对齐是成本最低的优化。TLB 容量与 4KB 页的矛盾让大内存工作集付出频繁翻译的代价,巨页用 2MB/1GB 粒度把 TLB 压力降一个数量级——静态巨页换确定性,THP 换省心但引入毛刺,延迟敏感服务选前者、关后者。下一篇处理内存的另一个极端场景:当物理内存耗尽,内核的 OOM Killer 如何决定杀谁,以及你如何在它动手前干预。

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?