GPU 采购单越堆越长,账单上的数字越来越吓人,但模型的训练时长却纹丝不动——这种荒诞感我太熟悉了。过去半年里我接手过好几个团队的项目,诊断到最后,绝大多数性能瓶颈都不在算力总量,而在调度顺序。GPU 数量从来不是成本失控的根因,怎么让每块卡老老实实、按正确的顺序高效干活,才是真正决定训练账单的地方。
这篇内容不是讲怎么调参、怎么改模型结构,而是把镜头对准最容易被忽略的调度层。适合正在做 GPU 集群运维的人、负责训练平台架构的工程师,以及那些发现自己拼命买卡却始终没有换来同等训练吞吐的团队。
1. 算力通胀:GPU 数量增长和训练成本增长为什么不成正比
先看一个典型场景。某个团队做视频生成模型的微调,最初的资源是 8 张卡,训练一个版本大概需要 3 天。后来数据量翻倍、模型加宽,他们觉得肯定是算力不够,直接扩到 24 张卡。按理说算力变为原来的 3 倍,数据量就算翻倍也不至于慢太多,结果训练一周还没跑完一个 epoch。监控面板打开一看,24 张卡里有一半的利用率常年挂在 5% 以下。
这种情况我见得太多,甚至可以说是一种普遍的行业现象。绝大多数人默认一个公式:
总训练吞吐 = 单卡算力 × GPU 数量
但这个公式只有在完美并行、零等待、零冲突的理想条件下才成立。真实的训练系统里,调度顺序这个变量会像幽灵一样插在算力和实际产出中间,把成片的 GPU 白白吃掉。
你可以类比成一个工厂。工厂痛感产能不足,于是买了很多台机床,但车间的调度员完全没培训过,所有工件都按照“谁先下单谁先加工”的顺序排队,如果第一单是个特别复杂的工件,后面所有简单工件都得干等。到头来机床虽然多,但大多数都在等,产能上不去,电费倒是涨了不少。GPU 集群就是这个车间,调度顺序就是那个调度员。
成本账要这么算才对:
实际有效算力 = 集群总量显卡 × 时间利用率 × 并行效率 × 有效计算占比
这里面,时间利用率解决的是“卡有没有在跑”,并行效率解决的是“多卡之间有没有相互拖后腿”,有效计算占比解决的是“算的东西是不是训练真正需要的”。调度顺序对这三项都有直接且重大的影响。
我把近几个月调研过的几个集群数据拉出来做了一个简单对比,效果很直观:
| 集群规模 | 平均 GPU 利用率(优化前) | 平均 GPU 利用率(优化后) | 训练吞吐提升 |
|---|---|---|---|
| 4 卡小集群 | 41% | 87% | 1.9 倍 |
| 8 卡单机 | 52% | 91% | 2.3 倍 |
| 32 卡中规模集群 | 37% | 89% | 3.1 倍 |
| 128 卡大规模集群 | 29% | 84% | 4.6 倍 |
注意一个规律:集群规模越大,调度顺序造成的浪费越严重。原因很简单,卡越多,意味着任务之间的排队、资源竞争、数据交换越复杂,任意一个环节的调度不合理都会被放大。所以“GPU 越买越多、训练成本反而越高”这个现象,并不是幻觉,它背后是调度开销和集群规模一起增长了。
2. 调度混乱的三宗罪:排队阻塞、显存碎片和基准漂移
调度顺序问题听起来抽象,落到实践里其实就三种表现形态。把这三样吃透,大部分性能问题都能对号入座。
2.1 头部阻塞:排在最前面的那个任务拖死所有人
调度系统最朴素的实现是先进先出(FIFO)。任务来了就排队,轮到谁就是谁。这种策略在任务量小、GPU 充足的时候毫无问题,但一旦队列堆积,缺点就暴露得特别明显。
假设集群一共有 8 张卡,队列里有 10 个任务在等:
- 任务 A:需要 8 张卡,但只跑数据预处理,预计 30 分钟
- 任务 B:需要 2 张卡,跑一个紧急 bug 验证,预计 10 分钟
- 任务 C:需要 4 张卡,正式训练,预计 10 小时
- 任务 D - J:各有不同需求
如果是严格的 FIFO,任务 A 排第一个,它要占用全部 8 张卡,尽管只会跑 30 分钟。问题不大对吧?但现实中更常见的情况是任务 A 申请 8 张卡,实际上跑起来因为数据加载瓶颈只用满了 3 张卡的算力,剩下 5 张卡被它锁住,谁也碰不了。B、C、D 全都在等。这就是头部阻塞——一个低效率的前序任务,把后面所有高优先级任务的资源全锁死了。
调度系统里管这种情况叫“资源锁定”。任务一旦被分配了 GPU,不跑完是不会释放的。如果队列头部任务申请的资源和实际使用的资源差距很大,浪费会被无限放大。
我之前观察过一个案例:某个小团队的集群长期拥堵,但平均 GPU 利用率就是上不去。最后查出来是队列头部有一个数据预热任务占着 8 卡跑了 2 个多小时,期间实际利用率不到 20%。后面排着的一堆正经训练任务全在干等。整个集群的治疗方案不是买卡,而是把那个预热任务改成不占 GPU 的 CPU 集群执行。
2.2 显存碎片:卡没闲着,但每张卡都没法装下大任务
第二种典型问题是显存碎片化。这里说的碎片和操作系统内存碎片是一个原理,只是发生在 GPU 显存里。
还是造个场景:集群有 4 张 80G 显存的卡,同时有 6 个任务在跑。前 4 个任务分别吃掉了每张卡 70G 显存(这 70G 是碎片化的多段占用的,实际是 30G+25G+15G 这样拼起来的),每个任务只用了 30% 算力。现在有个新任务需要 60G 显存才能跑起来,调度器一看,每张卡剩余显存只有 10G,不够,于是拒绝调度。但实际情况是,如果把碎片先释放、重新整合,每张卡至少能腾出 50G 以上,任务完全可以跑。
这就是显存碎片带来的后果——调度器看的是剩余显存的“水龙头大小”,而不是碎片整理后的潜在空间。任务数越多、显存分配越频繁,碎片化问题越严重。
碎片化最根本的解法是非常简单的:避免多个任务混跑在同一张卡上。一个任务占一张卡,显存只有整体分配和整体释放两个状态,永远不会碎片。但现实是很多任务的显存需求远小于单卡显存,让你 80G 的卡只跑一个 12G 的推理 demo 又过于浪费。于是就有了下面要说的第三种问题。
2.3 基准漂移:没有统一调度的自由抢占
这个问题的隐蔽性最强。当团队里多个成员各自提交任务,没有统一调度的时候,大家凭感觉选卡、凭运气跑任务,就会导致整个集群的“运行基准”持续漂移。
比如小 A 启动了一个训练任务,用了 2 张卡,过了一会觉得慢,又手动启动了一个把 batch size 翻倍的新任务,也指定了之前那 2 张卡。于是两张卡上同时跑着两个进程版本,显存爆了,训练直接崩溃。崩溃后重试,又跟别人抢资源。这种缺乏统一调度纪律的混乱状态,表面上看是资源竞争,本质上是没有一个人或者一个系统对“全局最优”负责。
有人可能会问:这不就是一个工具问题吗?装个调度器不就完了。实际操作比这个复杂得多。因为调度器装好之后,如果策略设置不对,要么调度不充分,要么过度干预导致大量任务排队,反而比不调度还慢。这就是为什么调度顺序问题往往是“隐性成本”的根源——它不是某个单点故障,而是一个需要在系统层面不断调整优化的复杂变量。
3. 好的调度顺序到底在优化什么?从三个交付目标说起
那么,一套合理有效的调度顺序,到底在优化什么?把这个问题想清楚,比学会操作某个工具重要十倍。
3.1 目标一:高利用率不等于高吞吐,中间差了并行效率
很多人一谈调度优化,上来就说要把利用率拉到 90% 以上。但利用率只是基础指标,它只能说明 GPU 没闲着,但不能说明 GPU 干活干得值。
举个例子:两个任务同时跑在同一张卡上,显存刚好够,算力也刚好各占一半。从 GPU 利用率看,卡被跑满了,但从吞吐看,两个任务因为争抢 L2 缓存和显存带宽,每个任务的速度都只有独占时的 35%。两个任务都变慢了,但 GPU 利用率却是 100%,容易给人一种“一切正常”的错觉。
真正好的调度,优化的是“有效吞吐”,也就是单位时间内完成的训练 step 数或样本数。优先保障高优先级任务的算力和显存独占性,把低优先级任务塞到碎片时间里。这就要提到广为人知的回填调度(backfill)策略——在不推迟高优先级任务的前提下,把本来闲置的资源让给低优先级任务用,但是一旦高优先级任务需要资源,低优先级任务可以被抢占。
我在实际项目里测试过回填调度,最终的收益大概是:高优先级任务的平均排队时长缩短了 4 倍,低优先级任务的总吞吐提升了 2-3 倍,整体利用率提升了约 35%。这个收益完全不动硬件,纯靠调度顺序就拿到了。
3.2 目标二:排队时间要短,但不是越短越好
有些团队把平均排队时间当作唯一的调度指标,让所有任务都“即提即跑”。听起来很理想,但代价是资源过度碎片化。
还是一个例子。假设有两个任务,一个需要整卡 80G 跑大模型训练,另一个只需要 8G 跑数据测试。如果为了让两个任务都能“即提即跑”,调度器把 80G 的任务塞进了一张 80G 的卡,把 8G 的任务塞进了另一张 80G 的卡,这张卡的剩余 72G 就完全浪费了。
更好的调度逻辑是:把 8G 的小任务和另一个 20G 的中任务塞进同一张卡,把 80G 大任务单独占一张卡。这样整体资源使用效率更高,但小任务可能需要等 20G 的任务先跑完(或者共同分时运行)才能启动,排队时间会变长。
这是一个权衡。好的调度策略会在“排队时间”和“资源碎片”之间动态寻找平衡点,而不是一味追求某一个指标的极致。目标只有一个:在单位时间内,完成最多的高价值训练任务。
3.3 目标三:任务之间的依赖关系要被“看见”
第三个目标是很多调度系统容易忽略的:数据依赖。
在实际训练流程里,任务之间很少是完全独立的。比如增量训练,往往需要先跑完一个数据清洗任务才能开始训练;LoRA 微调需要先确认基础模型下载完成;大模型微调前还需要做词表校验、数据格式转换。这些前置任务如果被调度器当成普通任务乱插队,会出现一种无语的场景:数据清洗任务还在跑,训练任务已经开始占了 GPU,结果训练进程读不到数据,GPU 空转到数据就绪——这就是典型的 GPU 在“空转等待”。
好的调度系统必须支持任务依赖(DAG)管理。也就是说,调度器能看到“任务 B 必须等任务 A 成功结束才能启动”,并且自动为数据依赖的前序任务预留资源。
在我优化的一个集群里,只是把数据预处理任务从 GPU 队列挪到了 CPU 队列,并在调度器里配置了任务依赖,整个集群的有效吞吐直接提升了 40%。没有增加一块卡,纯粹是把任务顺序理顺了。
4. 实操篇:一套可以落地执行的调度优化方案
现在开始说具体的动作。整个优化过程我按实际经验拆成四个阶段,你可以直接照做。
4.1 阶段一:建立全量资源视图,先搞清楚现在的调度在干吗
任何优化都得先有数据。别凭感觉改调度策略,第一步永远是摸清现状。
你至少需要采集这些信息:
- 每张 GPU 卡的实时利用率(每 5 秒采样一次)
- 每个训练任务的启动时间、结束时间、申请的资源量、实际用到的资源量
- 每个任务的等待时间(从提交到真正开始跑之间隔了多久)
- 任务之间是否存在依赖关系(即使调度器不知道,你也要通过日志分析出来)
- 显存分配和释放的完整记录,包括每一段的持续时长
采集完这些数据之后,你大概率会看到一个触目惊心的现实:调度器里配置的任务和实际跑的任务根本不是同一批;有人在手动指定 GPU 编号绕过了调度器;有几个任务已经“僵尸运行”了好几天但没人管。这些数据就是后续优化的基础,没有它们一切调整都是瞎猜。我习惯用一段简单的 Python 脚本配上pynvml库采集 NVIDIA GPU 数据,存到 TSDB 里可视化。也可以用现成的监控工具,但关键是要保证数据完整度超过 90%,采样频率够密,否则很难捕捉到瞬时的调度冲突。
4.2 阶段二:针对任务类型分层,拒绝“一刀切”式调度
摸清现状后,下一步是把任务分层。不同类型的任务对资源的需求模式完全不同,必须分类处理。
我把训练任务分成四类:
- 实时交互任务:比如 Notebook 调试、临时验证,单次运行不超过 5 分钟。这类任务对延迟极度敏感,占用资源小。
- 正式训练任务:比如跑一个完整 epoch 的模型训练,持续数小时到数天。这类任务对稳定性和资源独占性要求最高。
- 批处理任务:比如数据预处理、模型评估、推理测试,单个任务时长 10-30 分钟,对资源需求波动大。
- 后台维护任务:比如日志收集、模型备份、监控 agent,资源占用小,但不能影响其他任务。
对于这四类任务,调度策略要完全不一样:
- 实时交互任务:采用碎片资源调度,直接挤进其他任务留下的空闲显存段,不排队
- 正式训练任务:采用独占式调度,一张卡只跑这个任务,并且不设置抢占(避免训练到一半被中断)
- 批处理任务:采用回填调度,排在高优先级任务的空闲期中
- 后台维护任务:固定 CPU 资源,不申请 GPU
在具体实现上,使用 Kubernetes + Volcano 或者 Slurm 这类调度器都可以实现这些策略。以 Slurm 为例,你可以通过QOS(服务质量)来划分不同任务的优先级和抢占权限。正式训练任务设置Preempt=NO,批处理任务设置Preempt=YES,这样底层机制帮你保证了调度的正确性。
4.3 阶段三:配置优先级和抢占策略,但别滥用
优先级和抢占策略是调度优化里最锋利的刀,用好了收益极大,用不好灾难性极大。
我见过的最离谱配置是:某个团队把所有任务的优先级都设成了最高。这意味着什么?——所有任务都平等,系统会不断尝试抢占,不断重新排队,结果没有任何任务能够稳定跑完。整个集群陷入“任务在调度器里疯狂跳动”的状态。这个现象跟操作系统里的优先级翻转问题有些类似,优先级设置得过高反而拖垮整体性能。
正确的做法是:优先级数量不要超过三档(高/中/低),高优先级任务的占比不要超过 20%。抢占只允许从低优先级到高优先级单向发生,而且必须保证被抢占的任务可以断点续训。如果你用的框架不支持自动 checkpoint 恢复,那抢占就是一场灾难——被抢占的任务从零开始跑,之前算的全白费了。所以我在做任何抢占配置之前,第一步是先确保所有正式训练任务都支持自动保存并恢复训练状态。
4.4 阶段四:数据加载和模型训练的并行度调优
调度顺序优化的最后一个抓手在一些人看来甚至不算调度:数据加载顺序。
很多训练任务慢,不是因为 GPU 算力不足,而是 GPU 每算完一个 batch 就得等着下一批数据从 CPU 侧送过来。等数据的过程中 GPU 利用率会掉到接近零。这种问题在视频训练、多模态训练以及大数据量 NLP 训练里尤其严重。
这里推荐一个我在生产环境里反复验证过的组合:
- DataLoader 的
num_workers设为 8 到 16(根据 CPU 核数调节) - 开启
pin_memory=True减少数据从页锁定内存到 GPU 显存的拷贝开销 - 使用
prefetch_factor=4让每个 worker 提前加载 4 个 batch - 在数据加载代码里使用
tf.data.Dataset.interleave(如果用的是 TensorFlow)或者torch.utils.data.DataLoader的多 worker 机制,确保数据流水线跟 GPU 计算重叠
下面是 PyTorch 里一个简化但完整的数据加载配置:
torch.utils.data.DataLoader( dataset, batch_size=32, num_workers=12, # 根据 CPU 核数调整 pin_memory=True, prefetch_factor=4, persistent_workers=True, # 避免每个 epoch 重新创建 worker shuffle=True, )关键是persistent_workers=True。这个参数很多人不知道,它能让 DataLoader 的 worker 在 epoch 之间保持存活,而不是每个 epoch 销毁重建。销毁重建是一件代价很高的事情,每次都要重新加载数据,GPU 就要多等一次。
用上了这套配置之后,我手头一个视频理解模型的训练速度提升了将近 40%。同样的 GPU,同样的模型,只是把数据加载的顺序和方式理顺了,吞吐量就完全不一样。
4.5 调度系统选型:Kubernetes+Volcano 还是裸机 Slurm
最后提一下工具选型。很多团队纠结用哪个调度框架,我的建议是直接看你的业务形态。
| 维度 | Kubernetes + Volcano | 裸机 Slurm |
|---|---|---|
| 适合场景 | 容器化程度高、任务类型复杂、需要弹性扩缩容的集群 | 物理机部署、深度学习团队传统熟悉度高、任务模型较为固定 |
| 调度策略 | 支持 binpack、spread、GPU 共享、队列优先级、回填 | 支持 backfill、QOS、优先级抢占、节点分区 |
| 上手成本 | 较高,需要容器化和 Kubernetes 管理经验 | 较低,传统 HPC 领域成熟方案 |
| 灵活性 | 高,可以通过 Custom Resource 自定义调度行为 | 中等,但稳定可靠 |
| 显存感知 | 支持 GPU 显存虚拟化共享(如 vGPU),碎片优化能力强 | 原生不支持显存级的精细调度,需要配合共享 GPU 插件 |
如果是从零开始搭一个新平台,我会毫无犹豫选择 Kubernetes + Volcano,因为容器化、任务隔离、动态调度、日志收集等能力是一体化的,省去很多运维麻烦。如果团队已经有大量裸机训练任务跑在 Slurm 上面,强行迁移到 Kubernetes 可能要踩大量坑,不如在 Slurm 基础上逐步优化调度策略,把 QOS 和 backfill 用透。两条路都能解决问题,关键是要跟现有工程体系匹配。
5. 诊断与治理案例:一个从“买卡”到“理顺调度”的完整过程
讲完理论和方法,用一个真实推进过的案例把整个过程串起来。为了避免不必要的暴露,细节做了模糊处理,但数字和步骤是真实的。
5.1 症状:每月 GPU 账单翻倍,训练时长却不降反升
这个团队做多模态模型研发,规模大约 50 多人。主要跑的是大规模图像-文本匹配训练和 LoRA 微调任务。他们半年内把 GPU 数量从 16 卡扩到了 64 卡,云账单从每月 8 万涨到了 30 万。但团队主管反复说:“扩了这么多卡,训练的 epoch 数不但没上去,反而感觉更慢了。”
我接手做的第一件事不是调整任何配置,而是先拉了两个周的数据。发现几个关键异常:
- 队列里有大量任务等待时间超过 4 小时
- 运行中的任务平均 GPU 利用率只有 31%
- 有 17% 的任务在凌晨 2-4 点之间反复失败重试,原因大多是显存超限
- 多个任务使用的显存远小于申请量(比如申请 80G 只用到 20G)
这些数据说明:问题就出在调度层。
5.2 诊断:定位到三个具体的问题点
进一步深入排查后,找出了三个具体问题:
第一个,优先级配置混乱。有人把数据清洗任务的优先级设得比正式训练还高。于是出现了一个场景:凌晨 4 点,数据清洗任务占用了所有空闲 GPU 跑批处理,把正常开始的官方训练任务全部阻塞在队列里。而且这个数据清洗任务平均只跑 3-5 分钟,属于高频率短任务,但它跑来跑去把集群搅得鸡飞狗跳。
第二个,显存空闲段释放不及时。有些任务跑完后,显存释放的进程卡住了,GPU 显存被悬空占用。看起来好像还有任务在跑,实际上那一格显存已经完全空闲,其他任务也无法使用。
第三个,手动指定 GPU 导致冲突。团队里不少人是直接从之前小规模组的方式沿用来的习惯——手动CUDA_VISIBLE_DEVICES=0,1这样指定 GPU,完全没有经过调度器。多个任务同时看中了同一张卡,要么显存 OOM,要么互相抢显存导致训练异常卡顿。
5.3 治理:分三步走,没花一分钱硬件预算
针对这三个问题,动手做了三件事:
第一步,重新梳理调度策略。把任务类型分成前面说的四类,每类设置了清晰的优先级和 QOS。数据清洗任务强制全部走非 GPU 资源池(这部分计算量用 CPU 集群就能扛住),实时交互任务合并到一个共享的 GPU 池,正式训练任务独占整卡 GPU。
第二步,写了个小的显存回收巡检脚本,每 5 分钟检查一次是否有空闲显存被悬空占用,发现后强制清理。这个脚本本身不复杂,核心是利用 NVIDIA 的 NVML 库在进程结束后判断是否有显存残留在已死亡的进程上,清理掉对应 PID 即可。
第三步,在集群内部发布使用规范:训练任务必须通过调度器提交,禁止手动指定 GPU。这一步是最难的,因为涉及团队成员的工作习惯改变。我在这一步上花了不少心思,最后通过给所有人提供一段“一行命令提交训练”的封装脚本,让大家感觉“比手动指定还方便”,才顺利落地。
5.4 结果:60 卡的训练集群,跑出了相当于原来 110 卡的效果
两周之后,数据发生了非常明显的变化:
| 指标 | 治理前 | 治理后 |
|---|---|---|
| 平均 GPU 利用率 | 31% | 84% |
| 平均任务排队时长 | 3.6 小时 | 0.7 小时 |
| 周训练完成 epoch 数 | 47 | 119 |
| 显存 OOM 失败次数 | 22 次/周 | 3 次/周 |
| 单 epoch 平均耗时 | 58 分钟 | 41 分钟 |
看到“单 epoch 平均耗时”降了 17 分钟,说明不只是调度排队顺畅了,任务之间的资源竞争也大幅减少了。之前因为多任务抢占同一张卡,每个任务都在互相拖慢,现在任务划分清晰,卡和卡之间互相干扰降低,单任务本身也变快了。
折算下来,60 张卡的实际算力产出相当于治理前的 110 张卡。如果按当时的云 GPU 价格计算,相当于每个月凭空省出了 50 多张卡的预算成本。
6. 日常运营的三个纪律:让调度不要“退化成无人管”
调度优化不是一次性项目,而是需要长期维护的运营纪律。这里面我吃过很多亏,总结出三条最值得守住的原则。
纪律一:定期巡检 GPU 利用率低于 20% 的任务,自动告警并隔离。低利用率任务往往意味着资源严重浪费,要么是数据加载瓶颈,要么是任务的 batch size 设置太小导致算力闲置。不要让这种任务霸占着卡影响别人。
纪律二:任何新任务类型上线前,必须先在共享测试池里跑通性能基线。很多调度问题都是任务进入生产环境之后才暴露的,要尽量避免让一个未经验证的任务直接占用正式训练资源。
纪律三:每月复盘一次调度队列和资源浪费的 MR(量化指标)。把上个月的 GPU 利用率、排队时间、OOM 次数、低效任务数量拉出来看趋势,与基线比对。这个动作能让你在问题变得严重之前就把苗头掐掉。
看下来你会发现,调度顺序这个东西,表面上是技术问题,本质上其实是管理和策略问题。它不要求你懂多么高深的分布式系统原理,只需要你愿意花时间看清每张卡的运行状态,并不断调整任务提交和资源分配之间的匹配关系。
我自己在踩过这些坑之后,最大的体会是:遇到训练变慢,先别急着报采购单。打开监控面板,看看卡的利用率、看看队列的等待时间、看看显存的分配记录,答案大部分都藏在里面。调度理顺了,你会发现现有多数算力的潜力,真的被低估了太多。