拓冰建站拓冰建站
首页 / 资讯中心 / 正文

NUMA架构如何拖慢AI训练?用numactl绑核让多卡GPU性能飙升30%

搞过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负载都被调度器扔到Node0Node0的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 10cpus列表表示各个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-gpuindex --formatcsv,noheader); do bdf$(nvidia-smi -i $gpuid --query-gpupci.bus_id --formatcsv,noheader | sed s/00000000://) numa_node$(cat /sys/bus/pci/devices/0000:${bdf}/numa_node 2/dev/null) echo GPU $gpuid bus_id0000:$bdf numa_node$numa_node done如果输出里GPU 0、1、2、3都是numa_node0另几块是numa_node1说明它们正好分别挂在两颗CPU下面。再看nvidia-smi topo -m输出可以确认GPU之间是通过PIX、PHB还是NVLink互联。绑核策略要跟这个拓扑匹配而不是随便选个物理核。这里分享一个实操技巧我一般会把numactl -H、nvidia-smi topo -m以及上面的脚本输出保存在一个文件里每次换机器或重装系统后先对着文件核对一遍。因为同型号服务器也可能因为BIOS配置不同出现GPU挂载位置变化的情况。以前我就吃过亏以为A机器和B机器拓扑一样结果B机器上GPU0挂在Node1按老配置绑核后性能反而掉了一截。2.3 从“一张图”到“一套绑定策略”常见双路服务器怎么选核拿到拓扑之后典型策略是某个rank用哪张GPUdataloader和相关辅助线程就绑到该GPU所在NUMA节点对应的CPU核心上。如果主进程通过CUDA_VISIBLE_DEVICES指定到GPU0numactl就绑Node0。如果一张卡对应16个物理核会按超线程拆成32个逻辑核绑核时根据实际workload选择物理核或者逻辑核都行。一般建议绑到同节点的全部核心或者挑其中一组物理核心而不是只绑一两个核。双路服务器最常见的布局有两种布局情况节点与GPU对应关系推荐策略4卡全挂Node0所有GPU都在一颗CPU下4个rank全绑Node0但要限制单rank线程数防止CPU过载GPU平均分布在Node0/Node10/1号卡挂Node02/3号卡挂Node1前两个rank绑Node0后两个rank绑Node1这里要特别提醒不要把所有进程都绑到Node0尤其是用CUDA_VISIBLE_DEVICES控制多个进程时很容易把4个rank全部压在同一侧CPU上结果Node0忙到不可开交、Node1空转性能反而下降。3. 实战配置用numactl完成多显卡绑核3.1 核心命令numactl的常用参数和组合方式最常用的命令组合numactl --cpunodebind0 --membind0 python train.py参数说明--cpunodebind0只允许进程在Node0的CPU核心上运行。--membind0只允许给进程分配Node0物理内存。--physcpubind0-15细粒度到某个CPU编号区间适合同一个节点上再分不同rank。--interleaveall把内存轮流分布到所有节点适合对内存带宽要求高、对延时不敏感的任务训练里一般不用。运行前可以用numactl --show查看当前策略用numactl -m 0 -N 0这种缩写也等价。如果只是临时想验证一下效果可以先跑一个测试进程比如numactl --cpunodebind0 --membind0 sleep 1000然后开另一个终端看这个进程的绑定状态taskset -cp pid确认命令语法没问题再正式去启动训练任务。3.2 单机多卡训练的绑核启动示例假设一台双路服务器Node0下面挂着GPU0、GPU1Node1下面挂着GPU2、GPU3。我们跑4进程分布式训练每个进程用1张卡CUDA_VISIBLE_DEVICES0 numactl --cpunodebind0 --membind0 python train.py --rank 0 CUDA_VISIBLE_DEVICES1 numactl --cpunodebind0 --membind0 python train.py --rank 1 CUDA_VISIBLE_DEVICES2 numactl --cpunodebind1 --membind1 python train.py --rank 2 CUDA_VISIBLE_DEVICES3 numactl --cpunodebind1 --membind1 python train.py --rank 3 wait这里的关键思路是“卡在哪个节点进程就绑哪个节点”。NVIDIA驱动和CUDA runtime初始化时会根据当前进程的NUMA亲和性分配传输缓冲区起点就对了后面大量数据搬运都能沾光。实测下来这种绑定方式对DataLoader和NCCL通信两个环节的改善最明显。如果用的是torchrun或deepspeed --num_gpus可以在启动命令外层套numactlnumactl --cpunodebind0 --membind0 torchrun --nproc_per_node2 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_THREADS16 export MKL_NUM_THREADS16 export OPENBLAS_NUM_THREADS1616是举例要和实际绑的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/sGPU平均利用率单个Epoch耗时相对A组提升A默认不绑21278%218s基准Btaskset绑核22182%209s4.2%Cnumactl绑节点内存27896%166s31.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里跑YOLOv8numactl -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_DEBUGINFO观察AllReduce耗时确认瓶颈到底在通信还是数据加载。如果多个GPU挂在同一PCIe Switch下面还可以尝试调整NCCL的NCCL_P2P_LEVEL有时比绑核带来的收益更大。注意不要同时绑定进程到两个不同NUMA节点又想让它专注一张卡多节点内存访问跨域本身就有开销宁可一个rank一个节点不要贪多。绑核这件事听起来像运维工程师的活但做算法的人最近也开始把它当常规优化手段。我在实际项目中踩过最深的坑是以为绑了核就万事大吉结果发现dataloader worker被某些框架改写了亲和性又绕回默认调度。每次配置完我建议大家一定要用taskset -cp和numastat -p复查一遍确认两个信息都符合预期进程跑在哪个节点内存分配在哪个节点。确认完了再开始长时间训练比跑完一半才发现异常重来要省时间得多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门