NUMA架构性能调优实战:从隐形瓶颈到本地内存访问优化
1. 从一个反直觉的性能现象说起如果你在多路服务器上跑过数据库或者高性能计算任务大概率遇到过这样一种情况CPU利用率明明不高内存带宽看起来也没跑满但程序的吞吐量就是上不去延迟忽高忽低像坐过山车一样。你查了锁竞争、看了GC日志、排查了IO瓶颈甚至换了更快的SSD问题依旧。最后有人提醒你一句——“你绑核了吗NUMA看了吗”然后你调整了一下线程和内存的分配策略性能直接翻倍。这不是玄学这是NUMA架构在真实世界里最典型的“隐形瓶颈”表现。NUMA全称Non-Uniform Memory Access翻译过来叫“非统一内存访问”。这个名字本身就点出了核心特征CPU访问不同位置的内存速度是不一样的。有的内存离得近访问快有的内存离得远访问慢。就这么一个看似简单的设计变化却让无数程序在不知不觉中损失了大量性能。这篇文章面向的是所有需要在多路服务器上做性能调优的开发者、运维工程师和系统架构师。不管你是写Java后端、搞C高性能计算、还是维护MySQL和Redis集群只要你的程序跑在多CPU插槽的机器上NUMA就跟你有关。我会从硬件层面的设计动机讲起把NUMA的底层机制拆开揉碎然后落到Linux系统上的具体观测手段和调优操作最后分享几个我在实际项目中踩过的坑和验证过的优化方案。整篇内容不堆砌术语尽量用生活化的类比把原理讲清楚同时保证每个操作步骤你都能直接拿去用。2. 为什么会有NUMA从“大家一起排队”到“各回各家”2.1 UMA架构的天花板在哪里要理解NUMA为什么会出现得先看看它取代的东西——UMA统一内存访问架构。在早期的计算机系统里所有CPU核心通过一条共享的总线连接到内存控制器再访问内存。你可以把它想象成一栋楼里只有一部电梯所有住户上下楼都得排队等这部电梯。住户少的时候没问题电梯运力够用住户一多电梯就成了瓶颈大家全堵在门口。UMA的问题就在这。当CPU核心数量增加到十几个甚至几十个的时候所有核心都去抢同一条内存总线的带宽争用会非常严重。每个核心能分到的有效带宽急剧下降加核反而不能线性提升性能甚至可能因为争用加剧而出现负优化。这就是UMA架构的天花板——内存带宽成为了整个系统的瓶颈而且这个瓶颈是物理层面的软件优化很难绕过去。2.2 NUMA的核心思路把内存“搬”到CPU旁边NUMA的解决思路非常直接既然大家抢一条总线会堵那不如把内存分成几块每块内存靠近一部分CPU让CPU优先访问离自己近的那块内存。这样一来多个CPU可以并行访问各自附近的内存总的带宽就上去了。具体来说在一个典型的双路服务器上你会看到两个NUMA节点Node。每个节点包含一颗物理CPU以及直连它的那部分内存。CPU访问自己节点内的内存叫做本地访问延迟低、带宽高访问另一个节点的内存叫做远程访问需要经过CPU之间的互联链路延迟明显更高、带宽也更受限。这里有一个关键点很多人会忽略NUMA节点之间的互联链路其带宽通常是有限的。比如常见的互联技术节点间带宽可能只有本地内存带宽的一半甚至更低。这意味着如果你的程序频繁进行远程内存访问不仅单次访问变慢大量并发远程访问还会把互联链路堵死形成新的瓶颈。2.3 本地访问和远程访问的差距到底有多大我不想只给你一个模糊的“快”和“慢”的概念直接上数据更直观。在典型的现代双路服务器上本地内存访问延迟大约在80到100纳秒而跨节点远程访问延迟大约在140到180纳秒。也就是说远程访问比本地访问慢了将近一倍。这个差距在单次访问上看起来不大但程序运行过程中内存访问是海量的累积起来的影响非常可观。带宽方面的差距同样明显。以常见的服务器平台为例单节点本地内存带宽可能达到100GB/s以上而跨节点互联带宽可能只有30到50GB/s。如果你的程序有大量跨节点内存访问互联链路会先于本地内存带宽达到饱和成为整个系统的性能瓶颈。我用一个生活化的类比来解释这个差距本地访问就像你从自己家冰箱拿饮料走两步就到了远程访问就像你去邻居家借饮料得穿鞋出门、走过走廊、敲门、等邻居开门再走回来。单次差别不大但如果你一天要喝一百次饮料这个时间差距就非常可观了。3. Linux系统上NUMA信息的完整观测手段3.1 用numactl快速摸清硬件拓扑在Linux系统上最常用的NUMA观测工具是numactl包里的numactl --hardware命令。这条命令会输出系统中所有NUMA节点的信息包括每个节点包含哪些CPU核心、有多少内存、节点之间的距离矩阵。numactl --hardware输出大概长这样available: 2 nodes (0-1) node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 node 0 size: 32768 MB node 0 free: 28456 MB node 1 cpus: 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 node 1 size: 32768 MB node 1 free: 30123 MB node distances: node 0 1 0: 10 21 1: 21 10这里有几个关键信息需要解读。node 0和node 1各自有24个CPU核心和32GB内存。距离矩阵中的数字表示相对延迟本地访问的距离值是10跨节点访问的距离值是21。也就是说跨节点访问的延迟大约是本地访问的2.1倍。这个比例在不同硬件平台上会有差异但通常都在1.5到2.5倍之间。注意距离矩阵中的数值是相对值不是绝对延迟。不同型号的CPU和不同的互联技术会导致具体数值不同但本地永远是10跨节点的数值越大说明远程访问代价越高。3.2 numastat看内存分配的实际分布知道了硬件拓扑之后下一步是看当前系统的内存实际分配情况。numastat命令可以显示每个NUMA节点的内存使用统计包括本地分配和远程分配的情况。numastat输出示例node0 node1 numa_hit 158234567 142345678 numa_miss 2345678 3456789 numa_foreign 3456789 2345678 interleave_hit 1234567 1234568 local_node 156789012 140123456 other_node 3789012 5678901这里需要重点关注的字段是numa_miss和numa_foreign。numa_miss表示本节点内存不足被迫从远程节点分配的次数numa_foreign表示本节点被其他节点“借用”内存的次数。如果这两个数值很大说明系统的内存分配已经出现了明显的跨节点行为性能大概率会受到影响。另一个值得关注的字段是local_node和other_node。local_node表示在本节点上分配且在本节点上访问的内存页数量other_node表示在本节点上分配但被其他节点访问的内存页数量。理想情况下local_node应该远大于other_node。3.3 用perf和numa相关计数器定位远程访问热点如果你需要更精细地定位是哪些代码在产生远程内存访问可以用perf工具配合NUMA相关的硬件性能计数器。perf stat -e node-loads,node-load-misses,node-stores,node-store-misses -a sleep 5这条命令会统计5秒内所有CPU的本地和远程内存访问次数。node-loads表示本地加载次数node-load-misses表示远程加载次数。如果node-load-misses占总加载次数的比例很高说明程序存在严重的远程内存访问问题。更进一步你可以用perf record针对特定进程采样然后通过perf report查看具体是哪些函数在产生远程访问。这个分析过程稍微复杂一些但定位精度非常高适合在已经确认存在NUMA问题、需要精确定位热点代码时使用。4. 内存分配策略把数据放在正确的位置4.1 默认策略为什么容易出问题Linux默认的内存分配策略是localalloc也就是优先在发起分配的CPU所在的NUMA节点上分配内存。听起来很合理对吧但问题在于很多程序的内存分配和内存访问并不是在同一个线程上发生的。举个典型场景一个多线程程序主线程负责初始化数据结构、分配大块内存然后工作线程池负责处理请求、访问这些内存。如果主线程跑在node 0上它分配的所有内存都会落在node 0。但工作线程可能均匀分布在node 0和node 1上跑在node 1上的线程每次访问这些内存都是远程访问。这就是典型的“分配者和访问者不在同一个节点”的问题。另一个常见场景是内存池或者缓存系统。程序启动时预分配一大块内存后续所有线程都从这块内存里读写。如果这块内存恰好分配在某个节点上那么另一个节点上的线程就全程远程访问性能损失非常明显。4.2 五种内存分配策略的适用场景Linux提供了多种NUMA内存分配策略可以通过numactl命令或者set_mempolicy系统调用来设置。每种策略适用于不同的场景选错了反而会适得其反。策略名称行为描述适用场景localalloc优先在本地节点分配单线程或线程与内存亲和性好的程序bind只在指定节点上分配需要将内存固定在特定节点的场景interleave在多个节点间轮询分配大块内存需要带宽聚合的场景preferred优先在指定节点分配不足时回退希望内存尽量集中但允许溢出的场景weighted interleave按权重在节点间分配节点容量不对等的场景localalloc是默认策略适合大多数单线程程序或者线程与内存访问模式高度一致的程序。但如果你的程序是多线程且线程分布在不同节点上localalloc就可能导致大量远程访问。bind策略把内存强制分配在指定节点上。这个策略适合你明确知道某个进程应该使用哪个节点的内存的场景。比如你把一个进程绑核到node 0的CPU上同时用bind策略把它的内存也限制在node 0这样所有访问都是本地的。interleave策略在多个节点之间轮询分配内存。这个策略适合那些需要大内存带宽、且访问模式比较均匀的程序。比如大规模的科学计算数据被所有线程均匀访问interleave可以让内存带宽在多个节点间分摊避免单个节点的带宽成为瓶颈。preferred策略是localalloc和bind的折中。它优先在指定节点分配如果该节点内存不足会回退到其他节点。适合你希望内存尽量集中但不确定内存用量是否会超出的场景。4.3 用numactl控制进程的内存分配行为最直接的操作方式是在启动进程时用numactl指定策略。比如numactl --cpunodebind0 --membind0 ./my_program这条命令把进程的CPU绑定到node 0内存也只在node 0上分配。这样所有内存访问都是本地的适合单节点就能满足资源需求的程序。numactl --interleaveall ./my_program这条命令让内存在所有节点间轮询分配适合需要聚合内存带宽的程序。numactl --cpunodebind0 --preferred0 ./my_program这条命令把CPU绑定到node 0内存优先在node 0分配不够时自动溢出到node 1。提示使用numactl绑定CPU时要注意如果绑定的节点上CPU核心不够用程序可能会因为CPU资源不足而变慢。绑核之前先用numactl --hardware确认每个节点的CPU数量。4.4 在代码层面精细控制内存分配如果你不想在启动命令层面做全局控制也可以在代码里针对特定的内存分配做精细控制。Linux提供了mbind和set_mempolicy等系统调用可以在运行时动态调整内存策略。#include numaif.h #include numa.h // 将一段内存绑定到node 0 unsigned long nodemask 1; // bit 0 set mbind(ptr, length, MPOL_BIND, nodemask, sizeof(nodemask) * 8, 0);这段代码把ptr开始、长度为length的内存区域绑定到node 0。适合你对程序的内存访问模式非常了解能够精确判断哪些数据应该放在哪个节点上的场景。对于Java程序JVM本身也提供了一些NUMA相关的参数。比如-XX:UseNUMA会在新生代启用NUMA感知的内存分配让每个线程优先在自己的节点上分配对象。但这个参数的效果取决于JVM版本和具体的GC策略实测下来差异比较大建议在目标环境上做A/B测试后再决定是否启用。5. 线程与内存的亲和性调优实战5.1 绑核不是万能药先搞清楚瓶颈在哪很多人一遇到NUMA性能问题就想着绑核但实际上绑核只是手段之一而且用错了反而会降低性能。在动手绑核之前你需要先确认三件事第一程序是否真的存在远程内存访问问题第二程序的瓶颈是在内存带宽还是在内存延迟第三绑核之后CPU资源是否够用。确认远程访问问题的方法前面已经讲了用numastat看numa_miss和numa_foreign的比例用perf看node-load-misses的占比。如果远程访问比例很低那NUMA大概率不是你的瓶颈绑核也不会有明显效果。区分带宽瓶颈和延迟瓶颈的方法是看程序的访问模式。如果程序是大规模顺序访问内存比如矩阵运算、大数据扫描那瓶颈更可能是带宽interleave策略比绑核更有效。如果程序是随机访问、指针追逐为主比如图计算、某些数据库索引查询那瓶颈更可能是延迟绑核加本地内存分配更有效。5.2 一个真实项目的调优过程我之前参与过一个实时数据处理系统的性能优化程序跑在双路服务器上每个节点24核总共48核。上线后发现吞吐量只有预期的一半左右CPU利用率不到60%但延迟波动很大。第一步用numactl --hardware确认了硬件拓扑两个节点各24核、各64GB内存。第二步用numastat观察内存分配发现numa_miss和numa_foreign的数值非常高说明大量内存分配发生了跨节点行为。第三步用perf stat统计远程内存访问比例node-load-misses占总加载的35%左右确认存在严重的NUMA问题。分析代码后发现程序启动时主线程预分配了一个大的共享缓冲区用于存放所有工作线程的中间结果。主线程恰好被调度到node 0上所以整个缓冲区都分配在node 0。而工作线程池均匀分布在两个节点上node 1上的线程每次读写缓冲区都是远程访问。解决方案分两步。第一步把共享缓冲区的分配改为interleave策略让内存在两个节点间轮询分配这样两个节点上的线程都有大约一半的访问是本地访问。第二步把工作线程按照CPU亲和性绑定到各自的节点上避免线程在节点间频繁迁移导致缓存失效。调整之后远程访问比例从35%降到了12%左右吞吐量提升了约80%延迟的P99从原来的200毫秒降到了80毫秒左右。这个案例说明NUMA调优的关键不是盲目绑核而是先定位问题、再针对性解决。5.3 自动NUMA平衡好用但有代价现代Linux内核提供了自动NUMA平衡功能内核会周期性地扫描进程的内存页把远程访问频繁的内存页迁移到访问它的CPU所在的节点。这个功能在很多场景下能自动改善性能省去了手动调优的麻烦。你可以通过以下命令查看和调整自动NUMA平衡的状态cat /proc/sys/kernel/numa_balancing返回1表示启用0表示禁用。默认情况下大多数发行版是启用的。自动NUMA平衡的好处是省心但代价是内核需要周期性地扫描和迁移内存页这会消耗一定的CPU资源。对于延迟敏感型的程序内存页迁移带来的抖动可能反而影响性能。另外如果程序的内存访问模式变化很快内核的迁移速度可能跟不上效果也会打折扣。我的经验是对于吞吐量优先、延迟不太敏感的程序保持自动NUMA平衡开启省心且效果通常不错。对于延迟敏感的程序建议关闭自动NUMA平衡改用手动的绑核加内存策略虽然麻烦一些但行为可预测、延迟更稳定。6. 那些文档里不会写的踩坑经验6.1 绑核之后性能反而下降了这是新手最容易踩的坑。你兴冲冲地把进程绑到node 0上结果发现性能不升反降。原因通常有两个一是node 0的CPU核心不够用程序被绑到有限的几个核心上CPU成了瓶颈二是程序的内存用量超过了node 0的容量被迫使用swap或者频繁从node 1分配内存反而增加了远程访问。避免这个坑的方法很简单绑核之前先算清楚资源账。用numactl --hardware确认目标节点的CPU核心数和内存容量然后估算你的程序需要多少CPU和多少内存。如果单节点资源不够要么用interleave策略让两个节点共同分担要么考虑跨节点绑定但接受一定的远程访问开销。6.2 容器环境下的NUMA陷阱在容器化部署越来越普遍的今天NUMA问题在容器环境下变得更加隐蔽。容器默认是共享宿主机内核的容器内的进程看到的CPU和内存拓扑可能和宿主机不一致。如果你在容器里用numactl可能会发现命令报错或者行为不符合预期。更麻烦的是很多容器编排平台默认不会做NUMA感知的调度。一个需要大量内存带宽的容器可能被调度到跨节点的位置导致性能远低于预期。解决这个问题需要在容器编排层面做NUMA感知的调度配置或者在容器启动时显式指定CPU和内存的亲和性。注意在容器内使用numactl之前先确认容器是否有权限访问宿主机的NUMA信息。很多容器运行时默认不挂载/sys/devices/system/node目录导致numactl无法正常工作。6.3 虚拟化环境下的NUMA是一笔糊涂账虚拟机里的NUMA拓扑更加复杂。虚拟机监控器会把物理NUMA拓扑以某种方式暴露给虚拟机但这个映射关系不一定是一一对应的。一个虚拟机可能看到两个NUMA节点但这两个节点实际上都映射到宿主机的同一个物理节点上也可能虚拟机只看到一个节点但实际上横跨了两个物理节点。在虚拟机里做NUMA调优首先要确认虚拟机监控器是否开启了NUMA透传。如果开启了虚拟机内的NUMA拓扑和物理拓扑基本一致调优策略可以参考物理机。如果没有开启虚拟机看到的拓扑是虚拟的调优效果可能和预期不符。最稳妥的做法是在虚拟机监控器层面做NUMA亲和性配置而不是在虚拟机内部折腾。6.4 内存页大小对NUMA调优的影响大页内存对NUMA性能有显著影响。使用大页可以减少页表项数量降低TLB miss率同时减少NUMA相关的页迁移开销。在NUMA架构下大页的收益比UMA架构下更明显因为远程访问的代价更高减少访问次数就更有价值。在Linux上启用透明大页或者显式大页可以显著改善NUMA敏感型程序的性能。但大页也不是万能的对于内存分配和释放非常频繁的程序大页可能导致内存碎片和分配延迟增加。建议在目标程序上做对比测试确认大页确实带来收益后再启用。7. 不同场景下的NUMA调优决策框架7.1 数据库类应用的调优思路数据库是NUMA调优的经典场景。以MySQL为例默认配置下InnoDB的缓冲池是一整块大内存如果分配在单个节点上另一个节点上的查询线程就会全程远程访问。常见的优化方案是把缓冲池大小设置为不超过单个节点内存容量然后用interleave策略让多个实例或者多个缓冲池分片分布在不同的节点上。对于Redis这类内存数据库情况类似。如果单实例内存超过单节点容量跨节点访问不可避免。可以考虑部署多个Redis实例每个实例绑定到不同的NUMA节点通过客户端分片来分散负载。这样每个实例的内存访问都是本地的整体吞吐量会明显提升。7.2 高性能计算场景的调优思路HPC场景下程序通常是大规模并行计算数据在多个线程间均匀分布。这种情况下interleave策略通常是最优选择因为它能让内存带宽在所有节点间均匀分摊避免单个节点的带宽成为瓶颈。同时MPI进程的绑核策略也很关键。通常建议把MPI进程均匀绑定到所有NUMA节点的CPU核心上让每个进程的内存访问尽量本地化。如果进程数少于CPU核心数可以考虑每个NUMA节点绑定相同数量的进程保持负载均衡。7.3 延迟敏感型服务的调优思路对于延迟敏感型的在线服务比如实时推荐、高频交易延迟的稳定性比吞吐量更重要。这类场景下我通常建议关闭自动NUMA平衡手动绑核加本地内存分配确保每次内存访问的延迟都是可预测的。具体操作上把服务进程绑定到单个NUMA节点内存也限制在同一节点。如果单节点资源不够宁可部署多个实例分别绑定到不同节点也不要让单个实例跨节点运行。跨节点带来的延迟抖动对这类服务来说是不可接受的。7.4 混合部署场景的注意事项在实际生产环境中一台服务器上往往跑着多种不同类型的服务。这时候NUMA调优需要考虑服务之间的相互影响。比如一个带宽密集型的批处理任务和一个延迟敏感的在线服务跑在同一台机器上如果批处理任务用interleave策略占用了大量跨节点带宽在线服务的远程访问延迟就会受到影响。这种情况下建议用cgroup或者容器资源限制把不同服务隔离到不同的NUMA节点上各自使用本地资源互不干扰。虽然这样会导致资源利用率略有下降但换来了性能的稳定性和可预测性对于生产环境来说是值得的。8. 我个人的几条实操建议说了这么多原理和操作最后分享几条我在实际工作中总结出来的经验不一定适用于所有场景但至少能帮你少走一些弯路。第一条不要一上来就调NUMA。先用常规的性能分析手段确认瓶颈确实在内存访问上再考虑NUMA调优。很多性能问题其实是锁竞争、IO瓶颈或者算法复杂度导致的NUMA只是背了锅。第二条调优之前先建立基线。记录下调整前的吞吐量、延迟分布、CPU利用率、内存带宽等指标调整之后逐一对比。没有基线的调优就是盲人摸象你根本不知道自己的调整是正效果还是负效果。第三条一次只改一个变量。NUMA调优涉及绑核、内存策略、大页、自动平衡等多个维度同时改多个变量会让你无法判断哪个改动起了作用。每次只改一个验证效果后再改下一个。第四条在目标环境上验证。开发机和测试机的NUMA拓扑可能和生产环境完全不同在开发机上有效的调优方案到了生产环境可能完全无效甚至有害。所有调优方案都必须在和生产环境配置一致的机器上验证。第五条关注P99而不是平均值。NUMA问题往往表现为延迟的长尾平均值可能看起来正常但P99延迟已经严重超标。调优效果也要看P99的改善而不仅仅是平均值的提升。NUMA架构的调优没有银弹不同的硬件平台、不同的程序访问模式、不同的业务场景最优方案都不一样。但只要你理解了本地访问和远程访问的本质区别掌握了观测手段和基本的调优策略就能在面对具体问题时快速定位方向找到适合当前场景的解决方案。