放弃MIG,拥抱Time-slicing:我们如何在Kubernetes上把GPU显存榨出30%额外利用率

去年Q4,CTO在季度复盘会上把GPU成本报表摔在桌上:“200张A100-80GB,平均利用率41%,你告诉我这不是在烧钱?”我盯着Grafana上那条软绵绵的绿线,GPU显存占用率确实很少冲过60%,可作业队列里天天堵着一堆等着要8卡整机的训练任务。问题不在算力不够,而在「碎片化」——每个跑在单卡上的轻量训练只吃20GB显存,却独占一整张80GB卡;有些在线推理服务挂着个2GB的模型,照样锁死一块A100不放手。那时候我们用的还是Kubernetes默认的扩展资源调度,nvidia-device-plugin按整卡分配,调度器眼里只有“有一块空闲GPU”或“没有”。这种粗粒度分配,就像给一个只喝半杯水的客人上了一大桶水,还锁着不让他人喝。

后来我带着SRE团队踩了三个月坑,终于把集群GPU利用率拉到平均72%,训练成本压低了31.4%,而且没额外买一张卡。秘诀就是把NVIDIA的Time-slicing(分时复用)机制怼进Kubernetes调度层,再用Volcano的弹性队列和优先级驱逐把资源挪给最需要的训练作业。这条路远比想象的血腥,调度器扩展、显存隔离、OOM风暴……每一步都像在给飞机换引擎。这篇文章就是那三个月的完整复盘:不做教程,只讲决策——为什么放弃MIG?为什么选Volcano而不是原生调度器?配置里的哪些参数差点把整个集群搞崩?读完你就会明白,GPU碎片化利用不是一个开关,而是一场架构取舍。

30秒速览

  • - 我们通过在Kubernetes上引入NVIDIA Time-slicing和Volcano弹性调度,将A100集群GPU利用率从41%提升到72%,训练成本降低31.4%
  • - 架构选型上放弃了硬件隔离的MIG,因为其Profile固化导致严重碎片化;采用时间片共享与调度层弹性组合,用3%性能损耗换取大幅利用率提升
  • - 配置Time-slicing时replicas数字必须匹配工作负载显存需求,并结合显存软限制防止OOM跨容器传播
  • - Volcano的gang scheduling和弹性队列让分布式训练等待时间从9小时缩短到15分钟,但需要处理好抢占导致的推理服务中断
  • - 生产环境需要完善的GPU指标监控、Node Problem Detector和显存限制机制,否则碎片化和OOM会给团队带来无尽痛苦

一张A100卡上只跑一个训练任务,是2023年最大的资源浪费

让我们先量化这份罪恶感。在我们的200节点集群里,典型AI工作负载被拆成三类:大规模分布式训练(需32-64卡,跑DeepSpeed)、中等规模微调(4-8卡,LoRA或全参)、小规模实验和推理(1卡,甚至0.5卡)。当时集群的GPU分配策略极其简单:任何Pod声明nvidia.com/gpu: 1,kubelet就会通过device plugin把一整块GPU塞给它,直到Pod退出。结果大量单卡实验性的训练任务——比如调个超参、跑个几十MB的小模型——只用到20-30%显存和15%左右的SM(流处理器)占用,其余算力白白闲置。而那些排队的4卡训练任务却因“资源不足”干等几小时,哪怕集群里明明有大量未被利用的显存和SM。

成本浪费有多严重?一张A100-80GB按云厂商按需实例价格大约$3.06/小时,200张卡一个月就是44万美元。利用率41%意味着18万美元直接打水漂。更要命的是,研发效率被拖垮:训练Job平均排队时长从2小时飙到9小时,算法团队开始抱怨“为什么不用更贵的专用集群?”——但迁移专用集群意味着重新设计CI/CD、监控、数据管道,成本又会翻倍。

弹性调度的价值此时凸显:如果能让一个物理GPU被多个小任务分时共享,同时保持关键训练作业的显存和计算隔离,就能在不增加GPU数量的前提下,大幅压缩排队时间、提升利用率、降低单位成本。Kubernetes生态恰好提供了这个能力——不是简单的device plugin升级,而是一整套从调度层到运行时的重构。你需要决定:是走MIG(多实例GPU)硬件隔离路线,还是走Time-slicing软件分时复用?是继续用默认kube-scheduler,还是引入Volcano这样的批调度器?后面的部分会逐一拆解。

调度器插件、device-plugin、还是Volcano?一个让我纠结了三周的架构选型

GPU共享的实现路径有三条,每一条都有人鼓吹,每一条也都有几乎致命的缺陷。我花三周时间把三个方案都按到测试集群里压了一遍,最终定下的方案连我自己都意外。

方案 原理 显存隔离 故障隔离 调度复杂度 性能损耗 可运维性
NVIDIA MIG(多实例GPU) 硬件级物理切分A100/SM,每个MIG实例独立显存、缓存、计算单元 强(硬件隔离) 低(原生K8s扩展资源) 接近0 差:切完后不可动态调整,碎片化严重
NVIDIA Time-slicing + device-plugin配置 驱动层将GPU时间片轮转分配给容器,显存共享但靠CUDA上下文隔离 弱(显存是同一池,仅限逻辑隔离) 弱(一个容器OOM可能影响同卡其他容器) 上下文切换开销~3-5% 好:GPU Operator一键配置,动态可调副本数
Volcano + elastic quota + gang scheduling 在调度层实现队列、弹性配额、任务编排,底层仍依赖前两种之一实现GPU共享 取决于底层 取决于底层 高(需维护自定义调度器) 无额外开销 中:需要团队理解Volcano CRD和调度策略

MIG硬件隔离看起来很完美,但实际用起来会让人抓狂。A100-80GB的MIG配置只能选择预定义的Profile——比如3g.40gb、2g.20gb、1g.10gb——一旦把卡切成几个固定大小的实例,再想动态改变就必须清空所有实例重新配置,这在生产环境里几乎是不可接受的。更致命的是,MIG实例不能随意组合:一个4卡训练Job需要4个MIG实例,它们必须来自不同的物理GPU,且Profile必须一致,这对调度器提出了大量约束,原生kube-scheduler根本无法胜任。我们尝试过用Volcano的node affinity和task grouping去凑,最终因为Profile碎片化导致30%的MIG实例闲置,整体利用率甚至比之前还低了5个百分点。于是MIG被果断否决。

我最终选择的组合是:NVIDIA Time-slicing + device-plugin作为GPU共享底层,Volcano作为调度层。Time-slicing允许我们在一块A100上创建20个虚拟GPU(时间片副本),调度器就能把20个仅需1GB显存的推理Pod或小训练Pod同时调度上去。显存没有硬件隔离,但我们通过设置容器环境变量CUDA_VISIBLE_DEVICES限制可见设备,再结合cgroup限制显存使用(虽然NVIDIA目前不提供显存硬限制,但可通过自定义启动脚本监控并超限杀容器),加上Volcano的优先级调度和弹性队列,能确保关键训练Job总能得到足够资源,同时让低优先级任务填满剩余空隙。这个方案的性能损耗在3%以内(来自CUDA上下文切换),完全可接受。代价是运维复杂度飙升,接下来我会细说那些差点把人逼疯的配置细节。

把GPU切成20份,我踩的坑比显存碎片还多

实施的第一步是在Kubernetes集群里启用Time-slicing。我们使用NVIDIA GPU Operator v24.9.0(当前最新稳定版),它通过修改device plugin的配置,让每个物理GPU上报多个虚拟资源。具体做法:创建一个ConfigMap,指定时间片配置,然后让GPU Operator的device-plugin读取。

下面是我们最终稳定运行的ConfigMap,它让每张A100向kubelet暴露20个nvidia.com/gpu资源,但内部还是同一张卡被轮转共享。

apiVersion: v1
kind: ConfigMap
metadata:
  name: time-slicing-config
  namespace: gpu-operator
data:
  any: |-
    version: v1
    flags:
      migStrategy: none
    sharing:
      timeSlicing:
        resources:
        - name: nvidia.com/gpu
          replicas: 20

然后通过集群策略让GPU Operator引用该配置。这个配置看似简单,但replicas数字的选定差点把我们送走。起初我盲目设成50个,结果一批推理Pod上线后,显存瞬间打满,OOM Killer在十分钟内干掉了40多个容器,连带拖垮了正在跑的数据预处理Job。原因很简单:一个物理GPU的显存是固定的80GB,50个容器哪怕每个只用2GB,总计也会超过80GB,显存不隔离,分配时没有任何检查。后来我把replicas降到20,并配合每容器显存限制的软实现(通过启动命令检查nvidia-smi显存占用),才勉强稳住。真正安全的数字取决于你的工作负载平均显存需求:我们的微调任务平均需12GB显存,20个副本意味着同时运行20个任务是不可能的,但实际调度器因为优先级和弹性会自然筛选,最终并发数很少超过6个。20只是给调度器更大的灵活性。

第二步是在调度层引入Volcano。原生kube-scheduler根本不懂“训练作业”这个概念——它只会FIFO地分配GPU,导致一个需要8卡同步启动作业的Pod可能因为资源碎片(有8张卡但分布在不同节点)而永远饥饿。Volcano的gang scheduling确保一组Pod要么全部调度成功,要么全部等待,完全契合分布式训练的要求。我们还配置了弹性队列(elastic queue),定义两个队列:train-high(高优先级,权重10)和inference-low(低优先级,权重1)。当高优训练作业提交时,Volcano可以驱逐低优队列中正在运行的推理Pod,把资源让给训练,训练完成后推理Pod重新调度回来。

下面是一个实际使用的Volcano Job定义,它提交一个需要4块GPU的分布式训练,使用PyTorch,挂载在train-high队列中:

apiVersion: batch.volcano.sh/v1alpha1
kind: Job
metadata:
  name: llm-finetune-4gpu
spec:
  minAvailable: 4
  schedulerName: volcano
  queue: train-high
  priorityClassName: high-priority
  tasks:
  - replicas: 4
    name: worker
    policies:
    - event: TaskCompleted
      action: CompleteJob
    template:
      spec:
        containers:
        - name: pytorch
          image: pytorch/pytorch:2.5.1-cuda12.4-cudnn9-devel
          command: ["torchrun", "--nnodes=1", "--nproc_per_node=4", "train.py"]
          resources:
            limits:
              nvidia.com/gpu: 1
          env:
          - name: NVIDIA_VISIBLE_DEVICES
            value: all
          - name: NVIDIA_DRIVER_CAPABILITIES
            value: compute,utility

注意这里的minAvailable: 4是gang scheduling的关键,意味着必须同时获得4个GPU才启动。如果集群中可用的虚拟GPU不够4个,整个Job会pending。当高优Job pending时,Volcano的drf(Dominant Resource Fairness)策略会计算低优队列中Pod的占用,触发抢占驱逐。我们配置了抢占策略,允许从inference-low队列抢资源。这个组合使得训练Job的等待时间从9小时骤降到平均15分钟——代价是推理服务可能被频繁打断。我们在推理端加了重试逻辑和预热机制,总算接住了。

还有一个隐性大坑:Pod退出后,GPU显存并不是立即释放。NVIDIA驱动在容器退出后需要一定时间清理上下文,这段时间新Pod可能调上去却因为显存不足而CrashLoopBackOff。解决方法是在Volcano中配置podEvictionTimeout和Pod驱逐后的重调度策略,并加入容忍逻辑。

省下的每一分钱都是调度器的心跳:监控、成本分析和那些半夜报警的OOM

引入Time-slicing和弹性调度后,GPU利用率必须精细监控,否则显存过载引发的雪崩会让你后悔当初按下这个开关。我们搭建了一套监控栈:dcgm-exporter负责导出每块GPU的SM利用率、显存占用、温度等指标,Prometheus每30秒抓取,Grafana绘制面板。

最重要的两个指标:GPU显存分配率DCGM_FI_DEV_FB_USED / DCGM_FI_DEV_FB_TOTAL)和SM利用率。在Time-slicing模式下,SM利用率被所有容器共享,但显存是被显式分配的——如果一个容器申请了10GB却没有实际使用,仍会被计入已用。我们利用PromQL按节点聚合,算出集群整体平均显存占用率从41%提升到72%,SM利用率从35%攀升到68%。成本对比更直观:优化前一个月GPU费用约$442,000,优化后因为平均利用率提升,能够支撑的工作负载量翻倍,但GPU数量不变,折算成单位训练作业的成本降低了31.4%。实际上,我们还释放出了20张A100用于其他项目,避免了采购新卡。

但监控也揭示了血淋淋的现实:某天凌晨两点,Grafana报警显存占用率瞬间打到98%,同时数十个推理Pod被OOMKilled。排查发现是一个研究员的实验性脚本错误地申请了全部80GB显存,Time-slicing没有硬件隔离,导致同卡上其他小Pod全被挤出。从那以后,我们强制在所有GPU容器启动时运行一个init脚本,利用nvidia-smi限制可见显存上限(虽然不是硬限制,但足以防止恶意申请)。脚本大致逻辑是:读取环境变量GPU_MEMORY_LIMIT_GB,然后设置CUDA_MPS_PINNED_DEVICE_MEM_LIMIT和监控退出。我们还启用了Kubernetes的ResourceQuota,限制每个命名空间的GPU申请总量,防止单个用户占满集群。

生产环境的最佳实践清单我直接列出来,不加修饰:

  • Time-slicing replicas按工作负载最小公倍数设置,建议20-30,同时部署显存监控边车。
  • 用Volcano的queue+priority控制资源流动,不要只依赖Time-slicing做公平共享,否则重要的训练会被推理挤死。
  • 为GPU节点打上special label,将高优先级训练调度到MIG或整卡预留节点,低优先级才去共享节点,形成资源池化。
  • 在CI/CD中强制设置显存上限环境变量,集成到镜像入口点。
  • 部署Node Problem Detector监听GPU XID错误,一旦出现75(GPU fallen off bus)或79(ECC uncorrected)立刻隔离节点。
  • 定期整理GPU碎片:通过descheduler周期性驱逐低优Pod,重新平衡节点上的虚拟GPU分布,避免某些节点过热而其他空闲。

这趟旅程没有银弹。Time-slicing + Volcano的组合是用软件复杂度换取硬件利用率,团队必须接受调度器偶尔的调皮和凌晨的报警。但当你看到训练成本曲线扎扎实实下降了30%,排队的算法工程师终于能准时下班时,那些熬过的夜就都值了。

本文由 AI 辅助生成,经人工审核后发布。内容由 陈硕 基于实战经验指导完成。

觉得有用?

陈硕

后端架构师,在互联网公司干了10年,从单体应用到微服务再到Service Mesh都踩过。技术栈偏Java和Go,但对好技术不挑语言。喜欢画架构图,喜欢刨根问底看源码,认为「能用」和「好用」之间隔着一个量级的工程能力。