万卡GPU训练集群调度实战:从资源碎片化到故障恢复
2026/9/11 2:14:20 网站建设 项目流程

一万张 GPU 是什么概念?普通训练集群能有三五百张卡已经算中产了,能上一千张的团队,光机柜和供电就够喝一壶。但真正当集群规模推到几千上万卡之后,最先崩掉的根本不是显存,也不是 NCCL 通信,而是"谁能上机、什么时候上、卡怎么分"这件事。调度器,就是训练场里那个从来不出现在 PPT 上、却决定每个训练任务生死的隐形老板。

第五章上半部分聊了训练任务从启动到跑通的经典路径:PyTorch 怎么起分布式进程,NCCL 怎么初始化,模型并行和数据并行怎么切分。这一篇把镜头拉高,从单卡视角切到集群视角。我会用实际运维中踩过的调度坑、排障思路和设计取舍,讲清楚一万张 GPU 的排班逻辑,以及为什么很多团队卡在"明明有卡,任务就是上不去"的困境。

1. 调度器的一天:从作业入队到 NCCL 握手,它到底在做多少事

1.1 一个训练任务提交后经过的黑盒环节

大多数工程师对调度器的理解停留在"我提交了任务,排队,然后等"这个层面。实际上,一次普通的 Slurm 或 Kueue 提交,背后要经历七八个环节。

假设你运行的是 PyTorch DDP 训练,命令长这样:

srun --partition=model_train \ --nodes=16 \ --ntasks-per-node=8 \ --gpus-per-node=8 \ --cpus-per-task=16 \ --mem-per-cpu=8G \ deepspeed train.py --config ds_config.json

作业提交到集群后,调度器先做资源描述解析,生成一个内部 job spec。接着进入优先级计算,这个优先级不是拍脑袋定的,而是由年龄、公平因子、队列权重、用户当前占用资源量、QoS 限制等一堆参数堆出来的。然后才是资源匹配——从几万个 GPU 节点里,找出一个满足"16 节点、每节点 8 卡、同分区、满足网络拓扑约束"的候选集合。

匹配到节点后,调度器不会直接把脚本扔过去跑,而是要完成一堆环境初始化:创建 cgroup、绑定 CPU 核心与内存 NUMA 节点、设置 GPU 可见变量、分配端口范围、注入MASTER_ADDRMASTER_PORT等分布式训练需要的密钥信息。等所有进程都起来后,各节点上的训练进程会通过 NCCL 做一次集合通信握手,确认拓扑和通信链路。如果这一步失败,调度器要把整个作业标记为失败,把资源释放回资源池。

我见过很多人的第一反应是:我的训练代码明明没问题,为什么跑不动?其实问题可能出在 job spec 里的网络不可达、端口冲突、甚至 CPU 配额绑得太死。

1.2 决定作业能不能跑的隐藏参数:GPU 规格、队列分组与运行时长

调度器表面上管的是 GPU,实际上管的是"资源账本"。GPU 规格很关键,但很多时候更要命的是配套资源。

--gpus-per-node=8看起来简单,意味着你必须同时申请两倍于 GPU 数量的 CPU 核心,以及足够喂饱这些 GPU 的内存带宽和网络带宽。调度器把这些记在一个计费账本里,如果用户只申请 GPU 而不申请 CPU,那么数据预处理、DataLoader 多进程、NCCL 通信线程就会互相抢核心,最终 GPU 利用率反而更差。

还有队列分组。大型集群通常不会只有一个队列,一般会划分成:日常试跑队列、大规模训练队列、紧急实验队列。不同队列有不同的超时策略,比如试跑队列最长 2 小时,训练队列最长 7 天。这个设计不是为了增加复杂度,而是防止有人提交一个永远跑不完的 Arxiv 论文复现任务,把整个集群熬死。

运行时长是调度器做 backfill 的关键依据。如果你只提交 30 分钟的任务,调度器会把它塞进任何未来一小时内都不会被长任务占用的间隙里,哪怕那个节点的卡只剩碎片。这也解释了为什么有些短任务几乎不用排队。

2. 装箱与碎片化:为什么一万块 GPU 还是排不出 128 卡的任务

2.1 碎片化是怎么被几十个小任务吃掉的

先说一个反直觉的结论:一个拥有一万张卡的数据中心,完全可能同时接不下一个 128 卡的大任务。这就是 GPU 资源碎片化。

想象一个机架有 8 个节点,每个节点 8 卡,总共 64 卡。十个人各自提交了一个 4 卡的任务,随机分布在各个节点上,每个节点被占了 4 卡,又空了 4 卡。从总账上看,64 卡里还剩 24 卡,完全满足一个 24 卡的任务。但从单节点视角看,没有任何一个节点有连续的 8 卡可供分配,而大多数多卡训练任务要求节点内连续分配,因为 NCCL 在 NVLink 域内的通信带宽远高于跨机带宽。如果调度器强行凑 3 个节点给 24 卡任务,跨机通信会成为瓶颈,训练性能直接掉 30%。

这里的核心冲突是:bin-packing(紧凑装箱)和 spread(打散分布)两个策略方向相反。

  • bin-packing:尽量把任务塞满少量节点,留出整节点给大任务,适合训练密集型集群。
  • spread:把任务均匀打散到所有节点,可以分散功耗和散热,适合推理或者交互式开发集群。

一万卡的训练集群,绝大多数应该选 bin-packing,而且要按 GPU 卡数从大到小做降序装箱。否则就会看到经典的"大任务排队,小任务散落满地"的场景。

我见过一个集群,运维团队为了让每个人体验更好,采用的策略是 spread,结果所有节点都成了半满状态。后来改成按作业规模降序装箱,大任务从排队 6 小时缩短到 20 分钟,整体吞吐反而提升了。

2.2 backfill 与超卖:提升吞吐的代价要算清楚

调度器通常会开启 backfill 功能,翻译成人话就是"见缝插针":一个长任务未来某个时刻才会启动,那么在它启动之前,调度器会把这段时间租给一个更短的任务跑。这样短任务不用等,长任务也不会被插队,整体利用率马上变得好看。

backfill 的前提是调度器必须对任务的"预估运行时长"有准确认知。如果用户习惯把预计时长填成一个非常保守的天数,backfill 就没法做,因为调度器无法判断间隙是否塞得下。我们团队后来硬性要求:训练任务必须填一个可被检查点机制保障的时长,跑完了可以续,但一开始必须给出一个合理上限。

还有超卖问题。有些人会想,GPU 计算和显存是两回事,我能不能开 MIG 或者时间片,把一张卡切成更多份?这对推理服务有用,但对大规模训练来说,代价十分惨烈。训练任务需要持续独占的显存和稳定的内核执行时间,一旦时间片切换频繁,不仅显存装不下大模型,迭代时间还会被拉长,再加上上下文切换开销,整体吞吐经常负优化。所以训练集群里,尽量不要超卖 GPU,宁可空几张卡等待大任务,也不要让十几个小任务把一个节点推向发热过载。

资源碎片化的问题,单靠调度策略只能缓解,不能根治。更实际的手段是:定期对集群做稳态清理,把长期闲置的半挂任务砍掉,把已完成但进程还活着的孤儿作业清理干净。无人认领的僵尸进程,是碎片化的最大帮凶。

3. 拓扑感知:同一张卡,放在不同物理位置,性能差三倍

3.1 单机多卡:NVLink、PCIe Switch 与 NUMA 的距离感

调度器分配 GPU 时,很多初学者觉得只要选一张显存够的卡就行。但真正的性能差异,在物理拓扑上就已经注定。

以一个标准 8 卡 GPU 节点为例:8 张卡通过 NVLink 全互联,形成一个内部高速网络。但这 8 张卡连接 CPU 的方式很可能是 4 组 PCIe Switch,每组负责两张卡,然后分别接到一颗 CPU 上。这就产生了一个 NUMA 亲缘性问题:某些卡之间的通信走 NVLink,另一些可能要走 PCIe Switch,再绕到 CPU 的 PCIe Root Complex,延迟差一个量级。

调度器分配 GPU 时,必须保证一个作业拿到的多张卡处于同一 NUMA 域内,最好还是 NVLink 直连的邻居。这个约束在 Slurm 里通过--gpu-bind=closest或者--ntasks-per-node配合CUDA_VISIBLE_DEVICES实现。很多调度器甚至会把"GPU 到 CPU 的 NUMA 距离矩阵"纳入节点得分。

我踩过一个很典型的坑:有一次一个用户只申请了 2 卡,但调度器把 node 上编号 0 和编号 7 的两张卡分给了他。从资源占用角度没有任何问题,但这张节点的拓扑里,0 号卡和 7 号卡要通过两颗 CPU 的 UPI 链路中转。结果就是这个小实验任务的通信开销占了 40%,训练速度比预期慢了一半。后来凡是交互式作业,我都在调度配置里强制开启 GPU 亲和性筛选。

3.2 多机训练:调度器也得懂一点 NCCL 集合通信

单机内的问题还算好解决,真正难的是多机训练时的拓扑放置。

NCCL 初始化时,每个进程会交换网卡信息,探测集群网络拓扑。如果调度器把 16 个节点随机分散在几个不同的交换机群下面,NCCL 的通信会在跨交换机的链路上拥塞,虽然链路带宽足够大,但延迟会显著升高。尤其在张量并行和流水线并行场景下,每一次集合通信都要求全网同步,慢的链路会拖住整个训练步。

所以调度器必须具备两层拓扑感知能力:

  1. 机架感知:优先把一个大任务的所有节点放在同一个机架或同一个 ToR 交换机下,减少跨交换机流量。
  2. 高层网络感知:对跨越 Pod 或数据中心核心网络的作业,给出显式的网络成本标识,或者直接限制某些超大作业只能放置在指定区域。

实际操作中,我们会在 Slurm 里用--switches参数来约束节点落在同一交换机范围内,并用--exclusive避免大任务和小任务混布导致网络扰动。Kueue 这类云原生调度器也支持 topology spread constraints,可以按 zone 打布或按 node 打布。

这里有个经验之谈:多机任务的放置,优先考虑"同机架 + 连续节点",而不是"均匀负载 + 分散散热"。训练任务不怕局部发热,怕的是跨机通信延迟。你可以在功耗允许的范围内把集群分成多个逻辑 pool,一个 pool 专门跑超大模型训练,另一个 pool 跑小规模开发和推理,减少互相干扰。

4. 抢占、优先级与公平:多用户混跑时调度器的"职场"生存法则

4.1 高优任务进场,谁该被牺牲:抢占的目标选择逻辑

当集群资源不足,又有新任务不断提交时,调度器最重要的能力不是"分卡",而是"抢卡"。一个高优先级训练任务进来,如果等不到足够的空闲资源,调度器会考虑抢占已经在跑的低优先级任务。

抢占有两种常见实现:

  • 直接 kill:粗暴但有效。风险是任务没有保存 checkpoint,前面几十个小时的算力白费。
  • 抢占并挂起:先把低优任务挂到磁盘镜像,等资源充足了再恢复。听上去更优雅,但对 GPU 任务来说,挂起时显存和网络链路要完整保持状态,实际上很难做到无缝恢复。

更务实的做法是:抢占前先给低优任务一段宽限期,让训练框架保存 checkpoint,然后自动退出。也就是说,调度器不是直接 kill,而是向任务发送一个 SIGTERM 信号,训练脚本里注册 hook,收到信号后保存模型并退出。同时调度器把节点状态标记为 draining,不再接收新任务,等存量任务全部退出后,再把资源让给高优作业。

这个机制的可靠性完全取决于训练脚本是否在框架层做了优雅退出。我们在 PyTorch 训练里一般是加一个信号处理器,监听 SIGTERM,触发后调用torch.savedist.barrier,把模型参数和优化器状态保存到共享存储,然后再退出。没有这个 hook,抢占就是一场灾难。

4.2 fair share 不是平均主义,怎么算才不吵架

多用户混跑集群,每天吵得最多的就是"为什么他排我前面"。调度器的公平性算法,决定了大家会不会信任这套系统。

比较好的做法是分层 fair share:每个用户有一个基础权重,同时根据最近 7 天的实际资源使用量做衰减结算。用得多的用户,短期优先级降低;长期没用的用户,优先级会回升。这不是平均主义,而是更接近带宽分配里的"加权公平队列"。

一个常见的误区是只看 GPU 卡数,不看 GPU 类型和显存。比如用 A100 80G 跑一个实验,和用 A10 24G 跑一个实验,占用的算力资源完全不同。所以公平份额应该按"等效算力"折算,而不是按卡数。我们内部会把每张卡按月折算成标准卡时,A100 按 4 倍折算,A10 按 1 倍折算,这样用户在提交作业时也会谨慎选择资源规格,而不是盲目要最高配。

还有一个特别容易踩坑的点:交互式开发任务和训练任务抢资源。一个人开个 Jupyter Notebook 挂着 8 卡,占着资源却只做调试,对训练调度是致命的。我们后来给交互式会话设置严格时长限制(1 小时),超时自动释放。虽然开发者会抱怨,但整体队列等待时间缩短了非常多。

5. 故障恢复才是重头戏:训练跑了一半,显卡掉了怎么办

5.1 心跳丢失、Xid 报错与节点隔离的自动化处理

GPU 是电子设备,用多了一定会有故障。一万张卡同时运行,每天有一两块卡报错是常态。关键问题不是硬件会不会坏,而是坏了之后调度器能不能在几分钟内感知并隔离。

最常见的故障信号是心跳丢失。GPU 在正常工作时会周期性上报状态,如果节点上的 GPU 心跳停止,调度器应当立刻把节点标记为 unhealthy,停止分配新任务,然后触发诊断脚本做二次确认。如果确认是硬件故障,就把节点移入 maintenance 状态,通知运维换卡。

另一种典型故障是 NVIDIA 的 Xid 错误。Xid 是驱动程序报告的硬件异常码,常见的有 Xid 79、Xid 64、Xid 43,每一种都对应不同的 GPU 状态。我们会在节点上跑一个守护进程抓dmesgnvidia-smi日志,一旦发现 Xid 报错,就把 GPU 从资源池里摘除。否则调度器还会继续把任务放到这张坏卡上,训练任务会在任意迭代步随机崩掉,异常排查成本极高。

还有一类隐蔽故障是"假死":任务还在跑,但训练 loss 卡住不动了,心跳和 GPU 利用率看起来完全正常。这种问题调度器很难自动识别,只能靠训练框架层级的心跳信号,比如日志进度监控,如果超过 N 分钟没有新的日志产出,就自动重启作业。

5.2 checkpoint 和自动重排,少一个都会让你半夜起来修集群

再稳定的集群也会遇到节点掉电、网线松动、光模块过热。如果训练任务没有 checkpoint 机制,任何一次节点故障都意味着从头再来。这也是为什么训练框架一定要支持周期性的异步 checkpoint 保存。

我们通常把 checkpoint 保存到分布式存储,每 5 到 10 分钟保存一次。调度器检测到节点故障后,会自动把作业从失败状态重新入队(requeue),训练框架从最近的 checkpoint 恢复,而不是从零开始。这个机制看起来很简单,但真正落地时有两个容易被忽略的点:

  1. checkpoint 的保存不能阻塞训练主线程,否则每次保存都会造成一次全集群同步停顿,浪费大量算力。要用独立线程或异步写盘。
  2. 恢复时调度器可以给一个更小的资源集合,因为其他节点可能没有空闲。所以保存 checkpoint 时要把"模型结构 + 参数 + 数据加载器的进度 + 随机数种子"全部存起来,保证能在不同数量的节点上恢复。

我见过最惨的一次事故:一个团队训练一个 7B 模型,节点故障后的自动重排逻辑依赖于作业的退出码来判断是否需要恢复,结果训练框架因为 GC 内存溢出退出,退出码正好和正常完成一样。调度器认为作业成功了,没有重排,团队第二天起来发现几百个 GPU 时段的算力全部白费。从此我们规定,任何非零退出码都视为异常,都走 checkpoint 自动恢复流程。

6. 监控与排障:GPU 利用率 99% 也可能是一场幻觉

6.1 那些不监控就会骗你的指标

很多团队判断 GPU 是否被充分利用,就打开nvidia-smi看一眼 GPU Util。但 GPU Util 99% 这个数字,欺骗性很强。

nvidia-smi里显示的 GPU利用率,反映的是"采样时间内有计算内核在执行的百分比",不是"计算单元被充实的百分比"。一个训练任务如果频繁在等待数据传输、同步梯度,GPU 的 SM 上很多周期其实是在空转,但利用率计数器仍然可能接近 99%。

真正有价值的指标是 SM 活跃率、显存带宽利用率和卡间通信流量。NVIDIA 提供的 DCGM(数据中心 GPU 管理器)里,dcgm-exporter可以直接采集这些指标,再配合 Prometheus 和 Grafana 展示。比如sm_app_clock_limitsdram_activenvlink_tx_bytes,这些指标能真实反映训练流水线是否耦合紧密。

监控调度器的核心指标也不只是"集群总利用率",更应该看平均排队时间、作业启动时长、GPU 空闲率、节点碎片率。总利用率高不代表调度得好,有可能是大量任务在错误的物理位置上把集群塞满了,虽然看起来满,但大任务进不来。

6.2 日常值班最该盯的几个作业状态与周转指标

调度器相关的复合状态需要分成两层来看:作业层和集群层。

作业层重点看 READY 状态耗时。作业提交后,镜像拉取、环境初始化这些动作如果不并行优化,单个节点可能要多等两三分钟。规模大的时候,千卡作业光初始化可能就要 10 分钟以上,这部分时间被算作调度耗时。如果发现节点初始化是瓶颈,就要考虑预拉取镜像和预分配 IP。

集群层重点看两个比率:排队任务占提交任务的比例,以及被抢占任务占比。排队率长时间高于 30%,说明资源规格配置不合理或公平算法权重有问题;被抢占率太高,说明高优作业和低优作业混部过度,长任务频繁被打断。

还有一个容易被忽略但很有用的工具:作业生命周期日志。开机自动记录每个作业从入队到资源分配、初始化完成、训练开始、最后退出的时间戳,把所有阶段耗时串联起来。这样一旦调度变慢,可以快速定位到底是排队等资源、初始化慢、还是运行中节点故障。我们就把这套时间戳做成了内部 API,后续可以按队列、用户、节点维度统计分析,对调度策略调优非常管用。

训练集群和传统的 CPU 集群有个本质区别:CPU 集群可以容忍任务频繁启停,但 GPU 训练任务必须保证连续、可预测的执行环境。调度器设计背后真正的核心原则是:资源匹配要快、拓扑放置要准、故障恢复要稳。这三件事做好,一万张卡的大集群才会真正变成一个统一的训练机器。我在实际调集群时最大的体会是,调度的最优解永远不是通过复杂算法刷出来的,而是通过把"装箱策略"和"故障恢复"这两个基本盘打扎实,再根据集群的供给结构和团队的训练模式,小步快跑地持续调整。也希望这篇基于我多年运维经验总结的内容,能让你在面对自家集群时,少走一些弯路。

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

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

立即咨询