NUMA架构如何拖慢AI训练?用numactl绑核让多卡GPU性能飙升30%
2026/9/17 7:12:59 网站建设 项目流程

搞过AI训练的人多少都遇到过这种情况:明明GPU显存堆上去了,训练速度却总上不去,GPU利用率忽高忽低,NVIDIA-SMI一看不是100%反而在80%徘徊,甚至出现显卡之间通信比计算还慢的状况。我早前在调一个多卡训练任务时也踩了不少坑,后来发现性能瓶颈根本不在模型本身,而在CPU和内存的NUMA拓扑上。用numactl把进程绑到合适的内存节点和CPU核心之后,训练速度直接提升了接近30%,这个优化思路对多显卡AI训练非常实用。这篇文章主要写给两类人:一类是在裸机上跑过多卡训练、但速度一直不理想的算法工程师;另一类是刚接手GPU服务器、想搞懂“绑核”到底怎么玩的运维新手。我会从NUMA架构讲起,一步步带你把绑核命令落地,再用YOLOv8这类常见模型做一次真实对比测试,最后把踩过的坑也一并列出来。

1. 为什么AI训练会卡在“非统一内存”上?先从NUMA架构说起

1.1 CPU和内存不再是“一碗水端平”

最早的单路服务器里,CPU通过一条内存总线访问所有内存,离处理器近也好、远也好,延迟都差不多。后来多路服务器普及,CPU数和内存条数都多了,如果所有核心都去抢一条总线,扩展性很快就到顶。于是硬件厂商引入了NUMA架构:一颗物理CPU配上离它最近的内存和PCIe控制器组成一个“本地节点”,多个节点之间用Ultra Path Interconnect或者Infinity Fabric这类互联通道连接。

很多软件默认并不知道这层拓扑。Linux调度器虽然会尽量把进程放到它上次运行的CPU上,但当系统负载升高,进程还是可能被迁移到另一个节点。内存页面的分配也默认采用“本地优先”策略,可一旦进程发生迁移或预分配页面,内存页就可能落在远端。进程跑在Node0,访问的物理内存却分散在Node0和Node1,延迟和带宽都打了折扣。这种惩罚在普通Web服务里可能无所谓,在AI训练这种大数据量高频率搬运的场景里就非常致命。

当初我在排查训练卡顿问题时,先用numactl -H看了一下拓扑,结果发现两路CPU下面的内存访问距离差了整整一倍。再结合当时GPU的PCIe挂载位置,基本就锁定了问题所在:训练进程和数据加载进程都被系统调度到了远端节点,大量数据在跨CPU的互联通道上反复搬运,GPU就算算力再强,也只能干等。

1.2 AI训练的数据搬运路径,如何被NUMA“暗中卡脖子”

AI训练不光是GPU在算。以YOLOv8为例,每个batch都要经历:CPU读取图片、解码、Resize、数据增强,把处理好的张量放进内存,再通过PCIe拷贝到显存,NCCL还要在多个GPU之间同步梯度。整个链路里,CPU、内存、PCIe都参与搬运。一旦进程落在错误的内存节点,页面分配也会落在远端,加上CPU调度器经常把进程在不同核之间迁移,缓存命中率也在掉。

很多人想不明白,为什么CPU调度器那么“聪明”,还会把进程挪来挪去。原因很简单:调度器优先保证的是所有核心负载均衡,而不是单个进程的局部性。一个训练脚本可能开8个DataLoader worker,每个worker又在处理不同图片,系统看到某些核空闲了,就把线程调度过去,结果线程上次访问的内存还在旧节点,新节点上的缓存全是冷的,性能反而更差。

生产环境里更明显的是多卡并行。每个rank都有自己的DataLoader、增强线程、NCCL通信线程,它们一起抢CPU和内存带宽。如果两张卡挂在Node0,另两张卡挂在Node1,而所有rank的CPU负载都被调度器扔到Node0,Node0的CPU和内存带宽直接被打满,Node1却闲着,GPU利用率自然上不去。这种情况下,NVIDIA-SMI里的显存占用是满的,但SM利用率就是到不了90%以上,训练时间肉眼可见被拉长。

1.3 numactl到底是干什么的:绑定CPU核心、内存节点,一个命令全搞定

numactl是Linux下用来控制NUMA策略的命令行工具。它主要做两件事:一是把进程绑定到指定的CPU核心,二是把进程的内存分配绑定到指定的内存节点。命令行里分别对应--physcpubind--membind,日常更习惯写成--cpunodebind--membind

跟只绑CPU的taskset不同,numactl还能同时决定物理内存页分配在哪个节点。这一点对训练脚本很重要,因为Python里分配内存、加载数据都要经过allocator,如果进程的内存策略不对,即使CPU绑对了,页面仍然可能散落在远端节点,访问延迟照样偏高。用个生活化的比喻:taskset是只给你订了靠窗座位,numactl是连你菜单里点哪家店的菜、外卖送到哪个地址都定死了,所有资源都在同一个“街区”里流转,效率自然高。

2. 绑核之前,先画一张服务器拓扑图

2.1 用numactl -H命令看清NUMA节点布局

动手之前,先把机器的底细摸清楚。在终端执行:

numactl -H

输出大概长这样:

available: 2 nodes (0-1) node 0 cpus: 0 1 2 3 4 5 6 7 16 17 18 19 20 21 22 23 node 0 size: 64305 MB node 0 free: 52011 MB node 1 cpus: 8 9 10 11 12 13 14 15 24 25 26 27 28 29 30 31 node 1 size: 64512 MB node 1 free: 52320 MB node distances: node 0 1 0: 10 21 1: 21 10

cpus列表表示各个NUMA节点上包含哪些逻辑CPU。distances表里的10和21是相对延迟,21显然比10慢一倍。这张表就是你后续做决策的地图。如果你看到available: 1 nodes (0),那基本是单路机器或者虚拟机,后面这套优化空间会小很多。

还有几个辅助命令可以配合使用:lscpu看CPU型号和超线程状态,free -h看内存总量。特别注意超线程,如果CPU显示有32个逻辑核但只有16个物理核,那逻辑核0和16很可能属于同一个物理核。绑核时如果把两个高负载线程绑到同一个物理核上,等于白绑。

2.2 用lspci和nvidia-smi确认每张卡挂在哪个CPU下面

NUMA节点跟CPU的对应关系清楚了,还要知道GPU插在哪个PCIe Root Complex下。方法有几个:

  • 用nvidia-smi查看每张卡的PCIe Bus ID。
  • /sys/bus/pci/devices/下面读对应设备的numa_node文件。

一个可以直接跑的小脚本:

for gpuid in $(nvidia-smi --query-gpu=index --format=csv,noheader); do bdf=$(nvidia-smi -i $gpuid --query-gpu=pci.bus_id --format=csv,noheader | sed 's/00000000://') numa_node=$(cat /sys/bus/pci/devices/0000:${bdf}/numa_node 2>/dev/null) echo "GPU $gpuid bus_id=0000:$bdf numa_node=$numa_node" done

如果输出里GPU 0、1、2、3都是numa_node=0,另几块是numa_node=1,说明它们正好分别挂在两颗CPU下面。再看nvidia-smi topo -m输出,可以确认GPU之间是通过PIX、PHB还是NVLink互联。绑核策略要跟这个拓扑匹配,而不是随便选个物理核。

这里分享一个实操技巧:我一般会把numactl -Hnvidia-smi topo -m以及上面的脚本输出保存在一个文件里,每次换机器或重装系统后先对着文件核对一遍。因为同型号服务器也可能因为BIOS配置不同,出现GPU挂载位置变化的情况。以前我就吃过亏,以为A机器和B机器拓扑一样,结果B机器上GPU0挂在Node1,按老配置绑核后性能反而掉了一截。

2.3 从“一张图”到“一套绑定策略”:常见双路服务器怎么选核

拿到拓扑之后,典型策略是:某个rank用哪张GPU,dataloader和相关辅助线程就绑到该GPU所在NUMA节点对应的CPU核心上。如果主进程通过CUDA_VISIBLE_DEVICES指定到GPU0,numactl就绑Node0。如果一张卡对应16个物理核,会按超线程拆成32个逻辑核,绑核时根据实际workload选择物理核或者逻辑核都行。一般建议绑到同节点的全部核心,或者挑其中一组物理核心,而不是只绑一两个核。

双路服务器最常见的布局有两种:

布局情况节点与GPU对应关系推荐策略
4卡全挂Node0所有GPU都在一颗CPU下4个rank全绑Node0,但要限制单rank线程数,防止CPU过载
GPU平均分布在Node0/Node10/1号卡挂Node0,2/3号卡挂Node1前两个rank绑Node0,后两个rank绑Node1

这里要特别提醒:不要把所有进程都绑到Node0,尤其是用CUDA_VISIBLE_DEVICES控制多个进程时,很容易把4个rank全部压在同一侧CPU上,结果Node0忙到不可开交、Node1空转,性能反而下降。

3. 实战配置:用numactl完成多显卡绑核

3.1 核心命令:numactl的常用参数和组合方式

最常用的命令组合:

numactl --cpunodebind=0 --membind=0 python train.py

参数说明:

  • --cpunodebind=0:只允许进程在Node0的CPU核心上运行。
  • --membind=0:只允许给进程分配Node0物理内存。
  • --physcpubind=0-15:细粒度到某个CPU编号区间,适合同一个节点上再分不同rank。
  • --interleave=all:把内存轮流分布到所有节点,适合对内存带宽要求高、对延时不敏感的任务,训练里一般不用。

运行前可以用numactl --show查看当前策略,用numactl -m 0 -N 0这种缩写也等价。如果只是临时想验证一下效果,可以先跑一个测试进程,比如:

numactl --cpunodebind=0 --membind=0 sleep 1000

然后开另一个终端看这个进程的绑定状态:

taskset -cp <pid>

确认命令语法没问题,再正式去启动训练任务。

3.2 单机多卡训练的绑核启动示例

假设一台双路服务器,Node0下面挂着GPU0、GPU1,Node1下面挂着GPU2、GPU3。我们跑4进程分布式训练,每个进程用1张卡:

CUDA_VISIBLE_DEVICES=0 numactl --cpunodebind=0 --membind=0 python train.py --rank 0 & CUDA_VISIBLE_DEVICES=1 numactl --cpunodebind=0 --membind=0 python train.py --rank 1 & CUDA_VISIBLE_DEVICES=2 numactl --cpunodebind=1 --membind=1 python train.py --rank 2 & CUDA_VISIBLE_DEVICES=3 numactl --cpunodebind=1 --membind=1 python train.py --rank 3 & wait

这里的关键思路是“卡在哪个节点,进程就绑哪个节点”。NVIDIA驱动和CUDA runtime初始化时会根据当前进程的NUMA亲和性分配传输缓冲区,起点就对了,后面大量数据搬运都能沾光。实测下来,这种绑定方式对DataLoader和NCCL通信两个环节的改善最明显。

如果用的是torchrun或deepspeed --num_gpus,可以在启动命令外层套numactl:

numactl --cpunodebind=0 --membind=0 torchrun --nproc_per_node=2 train.py

不过要注意这样绑定的是torchrun主进程,子进程默认继承CPU亲和性,却不一定完美继承内存策略,需要实测确认。最稳妥的做法还是每个rank单独启动,或者直接在训练脚本里用os.sched_setaffinity()libnuma做进程内绑定。

3.3 结合PyTorch DataLoader多进程的绑核技巧

PyTorch DataLoader默认用num_workers启动多个子进程。进程是fork出来的,CPU亲和性会继承父进程,所以只要父进程用numactl绑好,worker默认也在同一组CPU里跑。真正容易出问题的是OpenMP线程和dataloader里调用的Intel MKL线程,它们会试图铺满所有核心。建议在启动脚本里加上:

export OMP_NUM_THREADS=16 export MKL_NUM_THREADS=16 export OPENBLAS_NUM_THREADS=16

16是举例,要和实际绑的CPU核心数匹配,避免线程超额订阅。否则你会看到每个核心上有上百个线程在排队,上下文切换吃掉不少时间。

另一个容易被忽略的地方是临时目录和共享内存。多进程DataLoader会用共享内存传递数据,默认位置在/dev/shm。如果服务器上/dev/shm被多个容器或者多个训练任务共享,仍然可能出现I/O等待。绑核解决的是CPU和内存层面的问题,/dev/shm容量是另一层问题,可以顺手用df -h /dev/shm查一下。如果大家共用一台训练服务器,我习惯给每个训练任务建独立的临时目录,避免互相污染。

4. 性能验证:用YOLOv8做一次30%加速对比

4.1 测试环境与实验设计

为了验证绑核收益,随便拿个模型跑没说服力。我选YOLOv8s,在一台双路Intel Xeon 6330(共28核/路),4×A800(实际上就是A100 80GB的产品形态)机器上,使用COCO128数据集,batch size 32,训练3个epoch取平均。测试前先锁定CPU频率,并在默认定频下设置环境变量,避免频率波动干扰结果。同时把所有无关的监控服务都停掉,避免偶发进程抢占CPU。

对比跑三组:

  • A组:完全不动,直接用原始启动命令。
  • B组:只用taskset绑CPU核心,不绑内存。
  • C组:numactl同时绑CPU节点和内存节点。

每组都测吞吐量、GPU利用率、每个epoch耗时。为了减少随机性,每组的训练脚本固定随机种子,并用torch.backends.cudnn.benchmark = False固定卷积算法。数据方面,每个epoch无论跑多快,都按相同顺序加载同一个子集,保证对比公平。

4.2 测试结果:不同绑核方案下的吞吐量对比

结果整理成表格:

配置平均吞吐(img/s)GPU平均利用率单个Epoch耗时相对A组提升
A:默认不绑21278%218s基准
B:taskset绑核22182%209s+4.2%
C:numactl绑节点+内存27896%166s+31.1%

C组比A组快了31%,也就是接近30%。B组只快了4%,说明光绑CPU核心起不到决定性作用,关键还在于内存节点和PCIe局域性也要一起控制住。

这个结果出来的时候我还挺吃惊的,因为之前一直以为GPU利用率低是IO卡在磁盘读取上,换过SSD、调过缓存都没太大改善。直到按NUMA绑完,才发现根因是CPU和内存不在同一个节点,数据来回跨越了CPU互联通道。

4.3 为什么是30%:性能提升在哪几个环节

这30%不是玄学,拆开看有三个来源:

第一,DataLoader的预处理吞吐提升了。CPU核心不再被跨节点调度,页面也稳定在本地内存,图像解码、缩放、增强的cache miss减少,数据准备速度上来了。用nsys profile能看到,DataLoader的耗时从每epoch的42s降到26s,光这一项就贡献了大半的加速收益。

第二,PCIe拷贝和CUDA初始化快了。传输缓冲区在本地内存分配,CUDA的H2D拷贝不再需要反复跨CPU互联,nvidia-smi dmon里的PCIe读写等待明显下降。GPU等待下一个batch的时间缩短,SM利用率自然从78%到96%。

第三,多卡同步的压测更稳。NCCL的梯度和AllReduce需要CPU侧参与包封装、中断处理,绑核后这些中断和内核线程都落在同一个NUMA域里,多卡同步时间也更稳定,几乎看不到突然的尖峰。如果不开绑核,某个rank偶尔会被调度到远端节点,它的梯度包走到NCCL网卡时要多跳一段路径,AllReduce整体延迟就被拉高了。

这里也要给读者泼盆冷水:30%是在双路Intel服务器、PCIe互联、4卡数据并行场景下的结果。如果是单卡训练,或者GPU之间全部走NVLink且没有大量CPU搬运,提升幅度会小很多,有的可能只有10%。真正跑优化之前,先用压测确认瓶颈,再做绑核,不要盲目套用。最直接的办法是跑训练时盯nvidia-smi的利用率,如果GPU利用率长期低于90%,再结合CPU扇区负载去判断是不是NUMA问题。

5. 常见问题与排查技巧实录

5.1 绑核后程序起不来或报错怎么办

最常见的报错就是numactl: requested cpus are not available,一般是--cpunodebind写错了编号。先numactl -H看节点编号,再检查物理核编号是否在对应节点的cpus列表里。内存绑定报错通常是no free memory on node X,说明节点剩余内存不够,先看free -h,再缩小--membind范围。

如果程序能启动但训练特别慢,先用numastat -p <pid>看内存分配情况,如果localnode命中率低于90%,说明还有页面落在远端。可以考虑用numactl --membind重跑,或者用mbind的MPOL_MF_MOVE做页面迁移。实操里我很少现场迁移页面,重跑更干净,还能顺手把数据加载顺序重置一遍。

还要注意一种情况:有些训练框架会在启动时调用os.sched_setaffinity重新设置亲和性,覆盖掉numactl的绑定。我之前调试一个内部魔改的分布式训练脚本就遇到过,明明外层起了numactl,进到脚本里一看亲和性还是全部核心。遇到这种问题,就在训练脚本里显式再绑一次,或者用taskset -p <pid>检查后直接强制设置。

5.2 怎么查看组件绑定在哪个核心上

训练跑起来以后,想确认每个进程到底绑在哪个核、页面分在哪个节点,几个命令非常实用:

# 查看进程运行在哪些CPU上 taskset -cp <pid> # 查看NUMA策略和内存绑定 numactl -p <pid> # 查看允许的CPU列表和内存节点 cat /proc/<pid>/status | grep -E "Cpus_allowed_list|Mems_allowed_list" # 查看进程在哪些节点上分配了内存页 numastat -p <pid>

通过ps -eLf | grep python找到某个rank对应的进程和线程ID,再去执行这些命令。注意看Cpus_allowed_list,如果显示0-15但进程一直在0和16之间跳,说明没有完全绑定,可能又在启动后被其他脚本reset了。实测下来,绑核后被cgroup或者systemd服务接管、或者重启时清掉策略是最常见的坑,所以在systemd service启动训练时,要把numactl写进ExecStart里,而不是靠外部shell临时执行。

numastat -p输出里的local_node列和other_node列也很关键。other_node不为0说明确实有内存页落到了远端节点。如果这种情况持续存在,说明--membind没有真正生效,或者有显存映射到主机内存时绕过了进程的内存策略。

5.3 WSL2和云上裸机适合绑核吗

先说WSL2。WSL2本质是Hyper-V虚拟机,CPU亲和性和NUMA拓扑被虚拟化层隐藏了一部分。我实测过在WSL2里跑YOLOv8,numactl -H有时只能显示一个节点,绑核效果跟原生Linux明显不同。如果拿它做日常开发,taskset还能用,--membind的意义不大,因为虚拟机里内存分配策略已经先经过Hyper-V层。它更适合用来写代码和调流程,不要指望靠它复现30%的性能提升。

云上的裸金属实例情况不一样。如果是物理机直通,NUMA和PCIe拓扑基本完整暴露给用户,那完全可以用numactl做优化。如果是普通虚拟机实例,比如带vGPU的云主机,同样不适用。判断方法还是先跑numactl -H,看到多个节点且有完整PCIe拓扑再动手。

有一种情况比较特别:部分云厂商的裸金属服务器虽然给的是物理机,但BIOS里开启了SR-IOV,导致GPU的numa_node显示为-1。遇到这种机器,先确认NVIDIA驱动是直通还是虚拟化,再决定是否值得做绑核。如果numa_node全是-1,说明设备没有暴露NUMA信息,绑核优先级要往后放。

5.4 几个值得尝试的进阶方向

如果绑核已经做完,还想再压一压性能,可以按这个顺序继续:

  • 打开Transparent Huge Pages,减少大页表项开销,对数据搬运密集的训练有帮助。
  • 调整CPU调频策略为performance,避免调度器在省电和性能之间反复横跳。
  • 用CUDA编程层的异步拷贝方式减少多余拷贝,这些是更底层的优化。
  • 对NCCL设置NCCL_DEBUG=INFO,观察AllReduce耗时,确认瓶颈到底在通信还是数据加载。
  • 如果多个GPU挂在同一PCIe Switch下面,还可以尝试调整NCCL的NCCL_P2P_LEVEL,有时比绑核带来的收益更大。

注意:不要同时绑定进程到两个不同NUMA节点,又想让它专注一张卡;多节点内存访问跨域本身就有开销,宁可一个rank一个节点,不要贪多。

绑核这件事,听起来像运维工程师的活,但做算法的人最近也开始把它当常规优化手段。我在实际项目中踩过最深的坑,是以为绑了核就万事大吉,结果发现dataloader worker被某些框架改写了亲和性,又绕回默认调度。每次配置完,我建议大家一定要用taskset -cp和numastat -p复查一遍,确认两个信息都符合预期:进程跑在哪个节点,内存分配在哪个节点。确认完了再开始长时间训练,比跑完一半才发现异常重来要省时间得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询