ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

NUMA架构下用numactl绑核优化AI训练性能的完整指南

NUMA架构下用numactl绑核优化AI训练性能的完整指南 先说个真事。自己装机跑AI训练机器是双路EPYC加四张卡之前训练YOLOv8loss掉得挺顺但GPU利用率总是跳来跳去天气一热甚至降到70%以下。一开始怀疑是数据加载线程太少调了num_workers没用。后来发现CPU跑在remote内存上的比例高得吓人换到本地内存后训练时间直接缩了快三成。这背后的工具就是numactl平时很多人都当它是个“查拓扑的小命令”实际上在AI训练场景里它是个调性能的好东西。这篇文章就把这套绑核玩法整个拆开聊——先说清楚为什么绑核能提速再说怎么看拓扑、怎么绑、怎么验证顺手把WSL2环境里的坑也填了。1. 为什么绑核能带来接近30%的训练加速1.1 先说清楚NUMA到底是怎么回事多路服务器或者桌面级工作站比如双路EPYC、双路Xeon甚至单个大核心数的处理器内部都是NUMA架构。NUMA全称是Non-Uniform Memory Access非一致内存访问。啥意思呢每个CPU都有自己的本地内存访问本地内存快访问别的CPU的内存慢。拿双路工作站来说CPU0和CPU1各自挂着一半内存条。如果你的CPU0上的进程去读CPU1上的内存路径要经过两个CPU之间的互连总线延迟高带宽也打折。两种访问差异有多大用latency测试工具可以测本地内存访问延迟在100纳秒上下远端轻轻松松超200纳秒极端情况下翻倍都不止。AI训练恰恰就是“内存带宽敏感 访问延迟敏感”的典型负载。每轮迭代要读图、做增强、把数据搬到GPU显存参数更新又要读写内存。一旦数据所在的内存节点和计算所在的核心不匹配额外的跨节点访问会拖慢整个pipeline。很多时候大家训练慢不是GPU不够好是CPU和内存之间的“路”太堵了。1.2 绑核的真正意义不只是锁CPU所谓“绑核”字面上是让进程只跑在指定CPU核心上但真正值钱的是把内存访问也一起“钉死”在同一个NUMA节点上。numactl的两个关键参数就能干这事--cpunodebind指定进程运行在哪个NUMA节点的CPU核上。--membind指定内存分配只从哪个NUMA节点取。两者一组合进程跑在节点0的CPU上内存也从节点0分配本地访问性能自然好。有人问只用taskset -c绑CPU不行吗taskset确实能锁核但它管不了内存分配策略。Linux内存分配默认是“优先本地分配”第一次缺页时哪个核发起的请求就优先从那个节点分配。如果你进程运行一会儿后核心被调度器赶走了后续的内存分配就可能落到别的地方。用numactl --membind可以把内存策略锁死比单独taskset可靠得多。1.3 为什么AI训练收益尤其大AI训练的数据管线通常包含几个环节CPU读图片、解码、缩放、归一化拼成batch放到pin memory然后GPU通过DMA直接搬进显存。这个链路里有大量CPU与内存的交互而GPU搬数据、同步数据又依赖CPU持续响应。跨NUMA访问会让每一步都变慢最终表现为GPU一个step等数据等很久。PyTorch训练里那种“GPU利用率在90%和40%之间反复横跳”的现象八成离不开CPU管线没优化好。绑核之后数据管线稳定跑在同一个NUMA节点上GPU侧不用干等吞吐量自然上来了。标题里说30%的加速在不同场景下我也实测过数据量小、迭代快的任务收益尤其明显最夸张的一次是练一个小型分类模型耗时降了接近四成。2. 动手前先看清机器的CPU和GPU拓扑2.1 用lscpu和numactl拿到基础布局绑核第一步是搞清楚机器到底有几个NUMA节点、每个节点包含哪些核心。打开终端执行lscpu | grep -E NUMA|Socket|Core|Thread输出大概长这样NUMA node0 CPU(s): 0-31,64-95 NUMA node1 CPU(s): 32-63,96-127意思是两颗CPU每个CPU各管一个NUMA节点每个节点32个物理核开了超线程后总共64个逻辑核。再看内存分布numactl --hardware输出里会列出每个node的可用内存大小。注意如果两路内存条插得不均比如CPU0那侧插了128GBCPU1那侧只插了64GB那大容量侧往往更适合放AI训练任务因为本地内存大不容易出现内存不足被迫跑到远端的情况。很多服务器的BIOS默认把内存interleave打开也就是两个节点的内存交叉使用此时numactl --hardware看到的可能就是均分状态。对AI训练来说建议关掉interleave保持本地内存优先性能更稳定。2.2 用nvidia-smi topo -m确认GPU挂在哪个CPU下面服务器主板上PCIe槽位是有讲究的。GPU插在CPU0的PCIe控制器上还是CPU1的直接影响数据通路。执行nvidia-smi topo -m输出是一张GPU间的互连矩阵还会显示每个GPU和CPU/NIC的连接关系。重点关注CPU Affinity这一列比如GPU0 CPU Affinity NUMA Affinity GPU0 0-31,64-95 N/A GPU1 32-63,96-127 N/A这就说明GPU0挂在CPU0那边GPU1挂在CPU1那边。绑定的时候跑GPU0的数据管线任务要把CPU绑到node0跑GPU1就绑到node1。另一招更直观看GPU挂在哪个PCIe控制器下lspci -s $(nvidia-smi --query-gpupci.bus_id --formatcsv,noheader | head -n1 | sed s/0000://;s/\.0$//) -v | grep -E NUMA node能看到这张卡所属的NUMA节点号。多卡机器建议每张卡都查一遍做到心中有数。2.3 结合HBM和CPU拓扑规划训练任务GPU显存属于GPU内部资源和CPU端NUMA关系不大但HBM和显存带宽会影响数据回传。CPU端绑好NUMA了GPU端也要选对卡尽量避免跨PCIe switch访问另一颗CPU下的显卡。用nvidia-smi topo -m看互连矩阵时两个GPU之间的连接类型如果是PIX同一个PCIe switch下那它们之间通信最快如果是PXB说明隔了一个PCIe switch如果是SYS说明它们分别挂在不同的CPU下要走CPU之间的总线。多卡训练做数据并行时同节点训练速度明显优于跨节点绑核要结合这个矩阵来安排。3. 实操多卡训练绑核的完整步骤3.1 第一步确认你的训练框架支持什么级别的并行PyTorch里有多卡并行方案最常见的是DistributedDataParallelDDP。DDP启动时通常用torchrun每个GPU对应一个进程进程数量等于GPU数量。要配合绑核最干净的方式是让torchrun的每个rank自动绑到对应GPU所在NUMA节点的核上。实测可用的启动脚本大致如下#!/bin/bash export CUDA_VISIBLE_DEVICES0,1,2,3 torchrun --nproc_per_node4 \ --master_port29500 \ train.py --epochs 100 --batch-size 64这样启动的所有进程默认可能跑在任意核心上。绑核要做的就是给每个rank指定CPU亲和性。3.2 第二步给每个rank单独绑核常见做法是在训练脚本里通过os.sched_setaffinity限制当前进程的CPU集合。比如rank0对应GPU0GPU0挂在node0就把它绑到node0的核上import os import torch def bind_process_to_node(rank, node_cores): 将当前进程绑定到指定的CPU核心列表 node_cores: 例如 [list(range(0, 8))] pid os.getpid() os.sched_setaffinity(pid, node_cores) if __name__ __main__: rank int(os.environ.get(LOCAL_RANK, 0)) # 假设GPU0/1在node0GPU2/3在node1 if rank 2: node_cores list(range(0, 32)) list(range(64, 96)) bind_process_to_node(rank, node_cores) else: node_cores list(range(32, 64)) list(range(96, 128)) bind_process_to_node(rank, node_cores) # 接下来正常开始训练这里有个容易犯的错直接绑全部逻辑核包括超线程对。AI训练大部分是计算密集兼内存密集不需要同时占满所有硬件线程。把两个逻辑核绑同一个物理核可能导致资源争抢性能反而下降。更合理的绑定策略是只绑每个物理核上的一个逻辑核。比如双路机器node0物理核范围可能是0-31超线程对是64-95那绑核就只绑0-31不要带上64-95。3.3 第三步用numactl命令直接启动绕过代码改动如果用命令行直接启动训练不想改代码用numactl也能实现同样的效果# rank0进程绑定到node0的CPU和内存跑GPU0 CUDA_VISIBLE_DEVICES0 numactl --cpunodebind0 --membind0 \ torchrun --nproc_per_node1 --master_port29500 train.py # rank1进程绑定到node1的CPU和内存跑GPU1 CUDA_VISIBLE_DEVICES1 numactl --cpunodebind1 --membind1 \ torchrun --nproc_per_node1 --master_port29500 train.py这种方式对不熟悉的同学更直观每个GPU一个终端窗口或者用后台任务启动就行。但注意torchrun默认可能还会启动一些子进程numactl只对直接启动的命令生效子进程是否继承要看--nproc_per_node的机制。通常torchrun --nproc_per_node1时子进程就是当前进程自己继承没问题。3.4 第四步给DataLoader的worker也加上亲和性光绑主进程不绑数据加载worker效果会打折扣。PyTorch的DataLoader开启num_workers0后worker是独立进程CPU调度器可能把worker放到任意核心上甚至跑到别的NUMA节点。推荐的解法是在worker初始化时也绑核。设置worker_init_fndef worker_init_fn(worker_id): pid os.getpid() # 根据worker_id分配不同的核心,避免大家都挤在同一个核上 core_list list(range(worker_id * 4, worker_id * 4 4)) os.sched_setaffinity(pid, core_list) DataLoader( dataset, batch_size64, num_workers8, worker_init_fnworker_init_fn, pin_memoryTrue, )注意pin_memoryTrue也很关键它会在CPU端分配page-locked memory让GPU搬运数据更快。绑核加上pin memory数据管线通常能稳不少。3.5 第五步验证绑定是否生效绑定不是绑完就完了要确认到底绑定到了哪里。最直接的验证方法# 查看当前训练进程跑在哪个核上 ps -eo pid,psr,comm | grep train.pypsr列就是当前进程实际运行的核心号。如果进程在不同核心间跳动说明绑定没生效。绑定的情况下psr的值应该稳定在你设定的范围内。更准确的方式是用taskset查看CPU亲和性掩码taskset -cp pid输出类似pid 12345s current affinity list: 0-31这里显示0-31说明绑核成功。内存绑定的验证相对抽象。可以先看进程的NUMA内存分配统计numastat -p pid这个命令显示进程在哪个node上实际分配了多少内存。如果--membind0生效且本轮训练数据都从node0分配你会看到node1那列数值很小node0那列才是大头。3.6 第六步动态调整核心数找到最优绑核区间绑核不是绑得越少越好也不是绑得越多越好。核心数太少解码和预处理速度跟不上核心数太多多进程混在一起反而争抢L3缓存和内存带宽。建议的调参方法是先用numactl --hardware确认每个节点的物理核心数然后从“一半物理核”开始试比如node0有32个物理核先绑16个再逐步递增观察训练吞吐量。记录指标时用nvidia-smi dmon -s pu -c 100盯GPU利用率同时用mpstat -P ALL 1观察各核心使用率。核心绑少了GPU利用率会周期性掉到0绑多了出现CPU上下文切换开销训练时长反而增加。找到一个“GPU利用率长期稳定在95%以上”的区间就对了。4. 如何查看组件到底绑在哪个核上4.1 ps加上排序快速全览训练过程中想快速知道所有子进程分别跑在哪个核心上ps -eo pid,psr,comm | awk $3 ~ /python/ {print $1, $2, $3}如果显示的结果中psr值都在你绑定的范围内说明没问题。如果跳出范围了那就是有地方没绑住最常见的是torchrun又拉起了别的进程或者有第三方库自己起线程池。4.2 用htop可视化核对htop打开后按F2进入设置在Display options里勾选Detailed CPU time和CPU frequency每个核心的使用率会单独显示。训练时能看到哪些核在满负荷跑哪些核闲得流油。绑核后应该是你指定的那些核心高负载其他核心基本空闲。如果所有核心都均匀忙说明你可能误绑了整个NUMA节点且超线程也被算进去了。4.3 用mpstat查看实时负载分布mpstat -P ALL 1每行对应一个核心%usr高说明计算繁忙%sys高说明系统调用频繁。AI训练里如果%sys占比偏高往往意味着有大量内存分配或GPU相关的系统调用考虑优化内存分配策略。4.4 在PyTorch脚本里打印所属NUMA节点还可以在训练脚本中临时打一行日志确认当前进程实际所在的NUMA节点import os import numa # 需要安装: pip install python-numa def print_numa_info(): pid os.getpid() node numa.numaset_run_node_mask() print(f[PID {pid}] allowed NUMA nodes: {node}) print_numa_info()不过python-numa在部分环境里编译容易出问题。如果不想额外装依赖直接检查/proc/pid/status中的Cpus_allowed_list和Mems_allowed_listgrep -E Cpus_allowed_list|Mems_allowed_list /proc/pid/status输出清晰明了不依赖Python库。5. WSL2环境下的绑核指南5.1 WSL2的NUMA拓扑和原生Linux不一样现在很多人直接在Windows上用WSL2跑AI训练。WSL2底层是虚拟机默认把宿主机的CPU资源全部暴露给VM但NUMA拓扑不一定透传。在WSL2里执行lscpu很可能看到NUMA node0 CPU(s): 0-15 NUMA node1 CPU(s): 16-31或者干脆只有一个NUMA节点。这取决于Windows宿主机是否开启了嵌套虚拟化透传以及WSL版本。如果WSL2里只有一个NUMA节点那numactl --cpunodebind0 --membind0相当于没绑因为它把所有资源都归到一个节点了。想恢复真实的NUMA拓扑要检查Windows侧是否启用了nested virtualization或者在.wslconfig中配置[wsl2] nestedVirtualizationtrue memory64GB processors16注意processors16表示给WSL2分配16个逻辑核不是物理核也不是节点数。想要模拟多NUMA节点需要你的CPU本身支持并且BIOS里没有关闭。5.2 WSL2里绑核的常见坑WSL2里numactl命令可能默认没装先补上sudo apt install numactl然后执行numactl --hardware看拓扑。如果只有一个node强行--membind1会直接报错。另外一个坑是WSL2的GPU显存访问走的是/dev/dxgCPU与GPU之间的数据拷贝路径跟原生Linux不完全一样整体数据管线的热点也可能不同。在WSL2环境里绑核收益不像原生Linux那么稳定但把数据加载线程绑到物理核上依然有正面效果尤其是当Windows宿主机负载高的时候。踩过几次坑之后我自己的经验是如果只是做实验、调模型WSL2绑不绑核影响不大如果要做正式训练直接进原生Linux环境绑核收益比WSL2明显得多。5.3 WSL2里查看绑核是否生效WSL2里同样可以用taskset -cp pid和grep Cpus_allowed_list /proc/pid/status查看。唯一要注意的是WSL2的CPU编号可能与Windows任务管理器显示的逻辑核编号差一个偏移别按Windows那边的编号去核对。6. 常见问题与排查技巧实录6.1 绑了核反而更慢了怎么回事绑核后性能下降多半是你的进程没有独占这些核心。机器上还有别的服务在跑比如监控agent、数据库、桌面环境这些进程会把你的核心抢走。排查方法# 查看CPU0-7上的进程 ps -eo pid,psr,comm | awk $2 0 $2 7如果发现系统进程挤在你绑定的核心上要么把系统服务挪到别的核心要么把训练进程绑到一段更“干净”的核心区间。另一个原因是超线程在捣乱。绑了包含超线程对的核心两个逻辑核共享同一个物理核的执行单元计算型任务抢资源抢得厉害。解决办法是只绑物理核中的一个逻辑核让出超线程对给系统用。6.2 GPU利用率看着高训练速度却没提升GPU利用率高只是表象要区分是计算繁忙还是等待繁忙。用nvidia-smi dmon看nvidia-smi dmon -s pu -c 50列里关键看fb显存使用和sm流处理器利用率。sm高才是真的在算fb高说明数据堆积在显存里了。如果sm不高但fb已经很高说明模型计算主导绑核帮不上太大忙瓶颈在显存带宽或者算子本身。6.3 numactl命令不存在或参数不对有些精简系统没装numactlsudo apt install numactl # Debian/Ubuntu sudo yum install numactl # CentOS/RHEL安装后注意--cpunodebind和--physcpubind的区别。--cpunodebind需要NUMA节点编号--physcpubind需要逻辑核心编号。AI训练建议用--cpunodebind0 --membind0这种节点级绑定让系统在节点内自由调度核心。6.4 多进程下内存分配不均衡用numastat -p查看每个rank的进程内存分布时如果发现node1的进程分配了node0的内存说明--membind没完全生效。这种情况往往出现在torchrun的父进程先分配了内存然后fork出子进程子进程继承了父进程的内存分配结果。解决办法是在脚本入口显式调用os.sched_setaffinity和numactl类库重新设置或者尽量用脚本里的worker_init_fn、torch.cuda.set_device来绑定设备后再重新分配内存。顺序很关键先绑核再初始化CUDA再分配训练数据。6.5 验证脚本里绑核失败的常见原因最常见的原因是在真正的worker进程里没有执行绑定代码。torchrun --nproc_per_node4会拉起4个子进程如果你的代码只在主进程里调用了bind_process_to_node子进程没有继承这个绑定。要在每个进程入口的最前面执行绑定逻辑而且必须在torch.cuda.set_device之前执行。6.6 无法确认哪些核属于哪个NUMA节点懒人版方法ls /sys/devices/system/node/node0/cpulist ls /sys/devices/system/node/node1/cpulist这个路径下的内容直接给出节点对应的核心列表比lscpu输出更明确。查看GPU挂载的节点cat /sys/bus/pci/devices/0000:01:00.0/numa_node设备编号从nvidia-smi --query-gpupci.bus_id --formatcsv,noheader拿。注意有些老GPU驱动不给numa_node写有效值会显示-1这时候用nvidia-smi topo -m的CPU Affinity更靠谱。7. 一套通用绑核脚本模板平时我自己用的一套脚本基本上拿到任何一台多卡Linux机器都能直接用。核心思路是探测GPU的NUMA归属然后把训练进程绑到对应节点同时把DataLoader worker分散到该节点的不同核心上。#!/bin/bash # 绑定GPU worker到正确的NUMA节点 # 用法: ./bind_train.sh 4 0,1,2,3 NUM_RANKS$1 GPU_LIST$2 IFS, read -r -a GPU_ARR $GPU_LIST for ((i0; iNUM_RANKS; i)); do GPU_ID${GPU_ARR[$i]} # 查询GPU挂在哪个NUMA节点 BUS_ID$(nvidia-smi --query-gpupci.bus_id --formatcsv,noheader -i $GPU_ID | sed s/0000://;s/\.0$//) NUMA_NODE$(cat /sys/bus/pci/devices/$BUS_ID/numa_node 2/dev/null) if [[ $NUMA_NODE -1 || -z $NUMA_NODE ]]; then echo 警告: GPU ${BUS_ID} 没有有效的NUMA节点信息跳过绑定 continue fi CPULIST$(cat /sys/devices/system/node/node${NUMA_NODE}/cpulist) echo GPU $GPU_ID - NUMA节点 $NUMA_NODE, CPU核心: $CPULIST # 启动一个rank绑定到对应节点 CUDA_VISIBLE_DEVICES$GPU_ID \ numactl --cpunodebind$NUMA_NODE --membind$NUMA_NODE \ torchrun --nproc_per_node1 --master_port$((29500 i)) \ train.py --local-rank$i done wait这套脚本有几个地方按需改训练脚本接收的--local-rank参数名可能不同有的用--rankmaster_port不冲突就行机器人起来后注意清理进程。生产环境里我更倾向用mpirun或者SLURM来统一管理核绑定但大多数自己搭的机器上上面这个脚本够用到退役。8. 绑核之外还有两个能和白折腾钱的配置8.1 确认BIOS里没傻傻地关掉NUMA有的服务器BIOS默认Memory Interleaving开启此时系统把两根CPU上的内存统统交叉使用物理上看起来就是一个大内存池没有远端访问的概念。但对多路CPU训练来说关掉interleave、让每个CPU紧贴自己的内存整体性能通常更好。开机进BIOS找NUMA Optimization或Memory Interleaving相关选项设为NUMA/Enabled取决于厂商喜欢用哪种叫法。8.2 CPU频率策略要调成性能模式绑核优化了“去哪个核跑”但如果核心本身一直在低频摸鱼带宽还是上不来。多数Linux发行版默认的CPU调频策略是powersave或ondemand对训练这种持续高负载并不友好。# 查看当前策略 cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor # 全部核心设为performance sudo cpupower frequency-set -g performance注意设成performance后功耗会上升风扇会更吵机房或者家里都要做好散热。笔记本用户慎用实在要跑训练还是外接电源并监控温度。9. 验证加速效果到底怎么测才是准的不少人绑完核之后拿time一算总时长发现“好像快了点”但说不清快了多少。要严谨地验证建议用固定的随机种子跑固定轮数把时间统计拆开来看。data loading time数据加载占总时长比例。compute time模型前向反向计算时间。sync time多卡通信时间。PyTorch自带torch.profiler能把这些时间拆出来import torch from torch.profiler import profile, ProfilerActivity with profile(activities[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof: for i, batch in enumerate(train_loader): loss train_step(batch) if i 50: break print(prof.key_averages().table(sort_bycuda_time_total, row_limit15))对比绑核前后的结果重点看DataLoader相关的耗时有没有下降。训练总时长虽然可能只有5%-15%的提升但data loading这一项通常能砍掉30%以上。瓶颈越倾向于CPU侧收益越明显。10. 最后再多说一句自己的经验掉过不少坑之后悟出来一个道理绑核这事不是“抄个命令就完事”而是要理解你的机器到底是几路CPU、每张卡挂在哪个PCIe控制器下面、内存条怎么插。拓扑对了命令就变得很机械CPU绑到GPU对应的节点内存绑到同一节点worker分散到不同核心。只要这些做对了训练速度的提升是可以稳定复现的。如果你现在训练的机器经常出现GPU利用率波动、数据加载卡顿、或者多卡训练时各卡利用率不均先从绑核查起性价比极高。查完绑核再查内存频率和PCIe带宽一步一步来别一上来就怀疑显卡坏了。这套方法在单卡机器上同样适用哪怕只有一张卡绑对NUMA节点也能减少数据加载的抖动。反正花不了几分钟试试不吃亏。
返回列表