1. GPU 利用率困局:为什么你的卡明明买了却总在“摸鱼”
做企业级 AI 基础设施这行十来年,我见过太多机房里堆着几十上百张卡、账面算力看着很唬人、实际利用率却长期趴在 15% 到 30% 之间的场景。老板看着采购单心疼,算法团队抱怨排队排到天亮,运维夹在中间两头受气。这个矛盾的核心,其实不是卡不够,而是GPU 资源池化没做透。
先把话说直白:GPU 资源池化不是装个调度器就完事,它是一整套从硬件抽象、隔离切分、调度策略到计量回收的工程体系。很多团队一上来就冲着“共享”去,结果发现显存打架、上下文切换开销爆炸、任务互相拖垮,最后又退回一人一卡的原始模式。问题出在跳步了——池化这件事有它内在的先后顺序,跳过任何一步,后面都会以更痛的方式还回来。
这篇文章面向的是正在或准备做 GPU 统一调度的运维工程师、平台开发、AI 基础设施负责人,以及被“卡不够用”折磨的算法团队 leader。我会把资源池化拆成 5 个必须走完的步骤,每一步讲清楚为什么这么排、关键参数怎么算、实操里会踩什么坑。你不需要是内核专家,但看完应该能拿着这套框架去评估自己团队的现状,知道下一步该补哪块短板。
先给一个我常用的判断标准:如果你的集群里,单卡日均有效计算时间占比低于 40%,或者任务平均排队时长超过实际运行时长,那基本可以确定池化没做到位。这两个数字不是拍脑袋来的,后面讲计量的时候会展开。
2. 第一步:硬件抽象与统一纳管,把异构卡变成“同一种资源”
2.1 为什么必须先做抽象而不是直接调度
很多人会问,我直接上 Kubernetes 加个 device plugin 不就能调度了吗?能,但那只解决了“发现卡”,没解决“描述卡”。不同型号的卡,显存容量、算力档位、支持的精度格式、互联拓扑都不一样。如果不先做一层统一的资源描述,调度器眼里的“一张 GPU”可能是 16G 的入门卡,也可能是 80G 的旗舰卡,它没法做正确的匹配决策。
硬件抽象这一步的目标,是把物理卡映射成一组标准化资源属性:显存容量、计算能力等级、互联带宽、是否支持特定精度、健康状态。这层抽象通常落在设备插件和节点标签体系里。我习惯用节点标签把卡的型号、显存、代际打上去,比如gpu.model=a100、gpu.memory=80g、gpu.arch=ampere,调度时用 nodeSelector 或亲和性去匹配。
注意:抽象层不要做得太细。我见过有团队把卡的每一个硬件参数都做成标签,结果标签组合爆炸,调度器匹配性能急剧下降。抓住显存、算力档、互联这三四个维度就够了。
2.2 异构纳管的实操要点
异构环境是常态,别指望全集群一个型号。实操里我会把卡按“能力档位”归成几类,而不是按具体型号。比如把 80G 显存、支持高带宽互联的归为 A 档,把 24G 显存、无高速互联的归为 C 档。任务提交时声明需要哪一档,而不是声明具体型号。这样调度灵活度最高,也不会因为新采购一批卡就要改所有任务的配置。
纳管过程中有个容易被忽略的点:驱动和运行时版本的一致性。同一档位里的卡,如果驱动版本差异过大,容器镜像里的 CUDA 运行时可能在某些节点上跑不起来。我的做法是在抽象层里额外记录驱动大版本,调度时做一次软约束——优先匹配驱动一致的节点,匹配不到再降级。这个逻辑写在调度器的打分插件里,比事后排查“为什么这个任务在 A 节点能跑 B 节点报错”要省心得多。
2.3 健康检查与故障隔离
抽象层还要承担健康检查的职责。卡是会坏的,ECC 报错、掉卡、温度墙降频都是家常便饭。我见过最坑的一次是某张卡间歇性算错结果,任务不报错但输出全是垃圾,排查了整整两天。所以纳管时必须定期跑健康探针:简单的矩阵运算校验、显存读写测试、温度与功耗读取。探针失败的节点自动打上不可调度标记,任务重新排队。
这一步做完,你得到的是一个“卡池”的逻辑视图:所有卡被描述成带属性的资源,健康状态实时可见,调度器有了做决策的基础数据。但注意,这只是池化的地基,还没到真正的“共享”。
3. 第二步:隔离与切分,让一张卡安全地服务多个任务
3.1 隔离的三个层次
池化要共享,共享就要隔离,否则就是灾难。GPU 隔离分三个层次,从粗到细分别是:整卡独占、时间片轮转、空间切分。整卡独占最简单,一个任务占一张卡,利用率天然上不去,但胜在稳定。时间片轮转是让多个任务分时复用同一张卡,靠上下文切换实现,开销大但兼容性好。空间切分是把显存和计算单元硬切,多个任务真正并行,效率最高但对硬件和软件栈要求也最高。
我的经验是:训练任务优先整卡或空间切分,推理任务适合时间片或小粒度切分。训练对显存和算力连续性要求高,频繁切换会拖垮收敛速度;推理任务通常短小、并发高,时间片轮转反而能填满空隙。
3.2 显存隔离的参数计算
空间切分里最关键的是显存分配。假设一张 80G 的卡,你要切给 4 个任务,不能简单除以 4 给 20G,因为每个任务除了模型权重还有激活值、梯度、优化器状态、通信缓冲区。经验公式是:单任务显存 = 模型参数量 × 精度系数 × 冗余倍数。以 FP16 训练为例,精度系数约 2 字节/参数,加上梯度、优化器状态和激活,冗余倍数通常取 4 到 6。也就是说 10B 参数的模型,FP16 训练大约需要 80G 到 120G 显存,一张 80G 卡根本放不下,得靠模型并行。
推理就宽松得多,FP16 推理的冗余倍数取 1.2 到 1.5 即可。所以切分策略要按任务类型区分,不能一刀切。
提示:显存切分一定要留安全余量。我一般预留 10% 到 15% 不分配,防止某个任务显存峰值超出预期把整张卡拖崩。这个余量在监控里单独看,长期用不满可以考虑缩小。
3.3 计算单元切分的现实约束
计算单元(SM)的切分比显存难得多。显存是静态资源,切了就是切了;计算单元是动态的,任务运行时抢占 SM 会导致互相干扰。目前比较成熟的做法是给每个切分实例限定 SM 配额,靠底层运行时做限流。但要注意,限流太狠会让任务跑不满,限流太松又失去隔离意义。我通常按任务的算力需求档位来配,比如高优先级任务给 50% SM,低优先级给 20%,剩下的留作弹性。
这里有个实操心得:切分粒度不要小于单卡算力的 1/8。再细下去,调度开销和上下文切换的损耗会超过共享带来的收益,得不偿失。我实测过 1/16 的切分,小任务确实能塞进去,但整体吞吐反而下降了 20% 多。
4. 第三步:调度策略设计,决定谁先用、用多少、用多久
4.1 调度器的核心决策维度
调度策略是池化的“大脑”。它要回答三个问题:哪个任务先跑、分到哪些卡、跑多久。对应的维度是优先级、亲和性、时限。优先级好理解,高优任务插队。亲和性是指任务和卡的匹配关系,比如大模型训练需要高带宽互联的卡放在同一拓扑域内,推理任务则无所谓。时限是给任务设一个最长占用时间,超时释放,防止某个任务长期霸占资源。
我见过不少团队只做了优先级,结果高优任务一来就把低优任务全挤掉,低优任务永远跑不完。正确的做法是优先级加配额:每个团队或用户有基础配额,保证低优任务也有最低资源;高优任务可以借用空闲资源,但空闲资源被收回时要能优雅让出。
4.2 抢占与让出的实现细节
抢占是池化里最容易出问题的环节。抢占不是简单地把任务杀掉,而是要保证被抢占的任务能保存状态、重新排队、恢复运行。训练任务如果没做 checkpoint,抢占一次等于白跑几小时,团队会跟你拼命。所以抢占策略必须和 checkpoint 机制绑定:只抢占那些最近有 checkpoint 的任务,或者抢占前先触发一次 checkpoint。
让出则是抢占的反面。高优任务结束后,资源要还给低优任务。这里有个坑:如果低优任务已经被抢占重新排队了,资源还回去它也不一定马上能拿到,因为可能被其他任务占了。我的做法是给被抢占的任务保留一个“优先恢复”标记,资源释放后优先匹配给它,减少重复排队。
4.3 调度策略的配置示例
下面是一个简化的调度策略配置思路,用伪代码表达,方便你对照自己团队的实现:
scheduler: policies: - name: high-priority-training priority: 100 resource: gpu-tier-a preemption: true checkpoint-required: true max-duration: 72h - name: low-priority-inference priority: 10 resource: gpu-tier-c preemption: false max-duration: 4h quota: team-alpha: 40% team-beta: 30% shared-pool: 30%这个配置的意思是:高优训练任务可以抢占,但必须支持 checkpoint,最长跑 72 小时;低优推理任务不可抢占,最长 4 小时;资源按团队配额分配,留 30% 作为共享池给突发需求。配额不是硬隔离,而是软约束,空闲时可以互相借用。
注意:max-duration 不要设得太短。我见过设 1 小时的,结果大任务刚热身就被踢,反复重启反而更浪费。训练任务至少给 24 小时,推理任务可以短一些。
5. 第四步:运行时管理与弹性伸缩,让资源跟着负载走
5.1 运行时环境的标准化
池化做到调度层,任务能分到卡了,但能不能跑起来还取决于运行时环境。CUDA 版本、驱动版本、Python 依赖、框架版本,任何一个不匹配都会导致任务失败。所以运行时管理的第一步是镜像标准化:为每一档 GPU 能力维护一个基础镜像,里面预装好匹配的驱动、CUDA、常用框架。任务提交时基于基础镜像构建,避免各自为政。
我踩过最深的坑是驱动和 CUDA 的版本矩阵。某个框架的新版本要求 CUDA 12.x,但集群里一半节点还是 11.x 的驱动,结果任务随机失败。后来我们强制所有节点升级到统一驱动大版本,基础镜像按 CUDA 小版本分几个 tag,任务声明需要的 tag,调度时匹配。这个改动之后,环境类故障率下降了 70% 以上。
5.2 弹性伸缩的触发条件
弹性伸缩是池化效率的关键。负载高时扩容,负载低时缩容,听起来简单,触发条件却要仔细设计。常见的触发指标有:队列等待任务数、平均 GPU 利用率、显存占用率。我一般用队列等待数加利用率的组合:等待任务超过阈值且利用率高于 70%,触发扩容;利用率低于 30% 持续一段时间,触发缩容。
缩容要特别小心。正在跑的任务不能直接杀,要等它自然结束或者迁移。迁移训练任务成本极高,所以缩容通常只针对推理任务和无状态任务。有状态任务靠 max-duration 自然释放。
5.3 弹性伸缩的实操记录
分享一次真实的扩容记录。某次业务高峰,队列里积压了 30 多个推理任务,平均等待 40 分钟。监控显示现有卡池利用率 85%,显存占用 78%。触发扩容后,从备用节点池拉起了 8 张卡加入集群,纳管、打标签、加入调度池,整个过程大约 6 分钟。新卡加入后,等待任务在 15 分钟内消化完,利用率回落到 60% 左右。
这里的关键是备用节点池要预热。如果临时去申请机器、装驱动、拉镜像,没有半小时下不来,高峰早过去了。我们平时保持 10% 的备用容量,驱动和基础镜像都装好,扩容时只需要加入调度池,几分钟就能生效。
6. 第五步:计量、计费与回收,池化的闭环
6.1 为什么计量是池化的最后一环也是最重要一环
没有计量的池化是不完整的。你不知道谁用了多少、用得好不好,就没法做配额调整、成本分摊和效率优化。计量要采集的指标包括:任务占用 GPU 时长、显存峰值、算力利用率、排队时长。这些数据既能用于内部计费,也能用于发现浪费。
我见过一个团队,做完池化后利用率从 25% 提到了 55%,但再往上就上不去了。后来上了计量,发现有几个任务长期占着卡但利用率只有 5%,一问是调试任务忘了关。清理掉这些“僵尸任务”后,利用率直接冲到 70%。所以计量不只是算账,更是发现浪费的眼睛。
6.2 计量数据的采集与聚合
采集粒度建议到分钟级,太粗了看不出波动,太细了存储压力大。每个任务记录开始时间、结束时间、分配的卡数、显存配额、实际显存峰值、实际算力利用率。聚合维度按团队、用户、任务类型、时间段。这些数据落到时序数据库里,配合看板展示。
算力利用率的采集要注意:不能只看 GPU 的 SM 占用率,还要看显存带宽利用率和 Tensor Core 利用率。有些任务 SM 占用很高但都在做访存,实际算力没跑满。我一般把这三个指标加权成一个综合利用率,权重按任务类型调整。
6.3 回收机制与僵尸任务清理
回收分两种:正常回收和异常回收。正常回收是任务结束后释放资源,这个靠调度器自动完成。异常回收是任务卡死、节点失联、进程僵死等情况下的强制释放。异常回收要配合健康检查,节点失联超过阈值就标记不可用,上面的任务重新排队。
僵尸任务的清理我建议做成定期巡检:每天扫一遍所有运行中的任务,利用率低于阈值且运行超过一定时长的,发告警给负责人确认。确认是僵尸的直接回收,确认是正常但低效的,记录下来做优化。这个机制跑起来之后,我们集群的“无效占用”从 15% 降到了 3% 以内。
7. 常见问题与排查技巧实录
7.1 池化落地过程中的典型问题速查
| 问题现象 | 可能原因 | 排查思路 | 解决方法 |
|---|---|---|---|
| 任务分到卡但启动报 CUDA 错误 | 驱动与运行时版本不匹配 | 对比节点驱动版本和镜像 CUDA 版本 | 统一驱动大版本,镜像按 CUDA 小版本分 tag |
| 多任务共享一卡时互相拖慢 | 隔离不足,SM 抢占严重 | 查看各任务 SM 占用和切换频率 | 提高切分粒度,限制单任务 SM 配额 |
| 高优任务抢占后低优任务饿死 | 只有优先级没有配额 | 统计各团队任务完成率 | 引入基础配额,保证低优最低资源 |
| 扩容后新卡任务仍排队 | 新卡未正确纳管或标签缺失 | 检查节点标签和 device plugin 状态 | 完善纳管流程,扩容后自动打标签 |
| 利用率虚高但吞吐上不去 | 计量指标单一,只看 SM 占用 | 补充显存带宽和 Tensor Core 指标 | 综合利用率加权,识别访存瓶颈 |
| 任务卡死占用资源不释放 | 缺乏异常回收机制 | 检查任务心跳和节点健康状态 | 加健康探针,失联节点自动回收 |
7.2 独家避坑技巧
第一个坑是过度切分。前面提过,切分粒度小于 1/8 卡算力时收益为负。我建议从 1/2 或 1/4 开始,观察实际效果再决定要不要更细。很多团队一上来就追求极致切分,结果调度开销吃掉了所有收益。
第二个坑是忽视网络拓扑。多卡训练任务如果分到的卡跨了 NUMA 节点或跨了交换机,通信带宽会断崖式下跌。调度时一定要考虑拓扑亲和性,把需要高速通信的卡放在同一拓扑域内。这个在抽象层打标签时就要规划好。
第三个坑是计量数据不准。有些采集工具在容器里读不到真实的 GPU 指标,读到的是宿主机的或者干脆是 0。上线计量前一定要做校准,拿一个已知负载的任务跑一遍,对比采集数据和实际值。数据不准比没有数据更可怕,会误导所有决策。
第四个坑是忽略冷启动。任务从提交到真正开始计算,中间有镜像拉取、环境初始化、模型加载的时间。这段时间卡是被占用的但没产出。优化冷启动能显著提升有效利用率,方法包括镜像预热、模型缓存、环境预初始化。我们做过统计,冷启动优化后,短任务的卡占用时间平均缩短了 40%。
7.3 一个真实的排查案例
有段时间集群利用率突然从 60% 掉到 35%,但任务队列并不长。排查发现是某个团队提交了一批大显存任务,每个任务申请 70G 显存但实际只用 20G,导致大量卡被“占着茅坑不拉屎”。调度器按申请量分配,卡分出去了但算力闲置。
解决方法是引入显存超卖加实际监控:允许任务申请超过实际需求的显存,但运行时监控实际占用,超过配额就告警甚至限流。同时调整调度策略,优先把任务塞进已有部分占用的卡,而不是开新卡。这个改动之后,利用率回到了 65% 以上。
这个案例的教训是:调度不能只看申请量,要看实际使用量。申请量是用户的“保险”,实际使用量才是真实需求。两者之间的差距,就是池化可以挖掘的空间。
8. 从 5 个步骤看池化的长期演进
走完这 5 步,你的 GPU 池化算是成型了:硬件抽象让卡可描述,隔离切分让卡可共享,调度策略让卡可分配,运行时管理让卡可运行,计量回收让卡可优化。这是一个闭环,缺一环都会漏水。
但池化不是一劳永逸的。业务在变,模型在变大,硬件在迭代,池化策略也要跟着演进。我个人的体会是,每半年做一次池化健康度评估:看利用率趋势、看排队时长、看故障率、看计量数据里的浪费点。根据评估结果调整切分粒度、调度权重、配额分配。
最后分享一个我一直在用的小技巧:给池化系统本身也做监控。调度器的决策延迟、纳管层的健康检查频率、计量数据的采集延迟,这些元指标能提前预警池化系统自身的问题。池化系统挂了,整个集群就瘫了,它的稳定性优先级应该和业务任务一样高。
这套框架我在几个不同规模的集群上都落地过,从几十张卡到上千张卡,核心逻辑是通的,区别只在实现的复杂度和自动化程度。小集群可以手动多一些,大集群必须全自动化。你可以根据自己的规模,从这 5 步里挑最痛的一步先做,逐步补齐。