课程目录(第 33 章 / 共 33 章)
课程/平台化与规模化

AI 平台:批调度、分布式训练与模型服务

33 章 / 共 33·24 分钟·进阶批调度KueueVolcano分布式训练模型服务

当 GPU 从几张变成一群:用 Kueue/Volcano 把整卡资源排成队列,用 MPI/NCCL/Ray/LWS 跑起分布式训练,用 KServe 与推理网关把模型变成稳定服务。

学完这一章,你将能够

  • 说清 GPU 集群为什么必须有排队与 gang scheduling,以及队列、优先级、抢占三者的关系
  • 在 Kueue 与 Volcano 之间按团队规模与运维成本做出选择,并读懂队列与配额的配置
  • 用 NCCL 测试判断多卡通信是否达标,并解释 MPI Operator、Ray、LeaderWorkerSet 的分工
  • 为推理服务算清副本与显存,配置模型缓存与冷启动,知道推理网关解决什么问题

为什么 GPU 集群必须先排队

上一章 GPU 与 AI 工作负载 解决的是「一张卡怎么被 Pod 用上」。当集群从 2 张卡变成 200 张卡、从一个人用变成一个部门用,问题就换了:不是「能不能用」,而是谁先用、能用多少、抢不到怎么办

三个物理事实决定了这件事不能靠原生调度器硬扛:

  • 整卡占用、不可超卖nvidia.com/gpu 只能申请整数,一张卡不会被拆给两个 Pod。于是碎片是真实存在的:8 卡机器被 8 个单卡任务占满,下一个要 16 卡的任务就永远等不到。
  • 一个训练任务是一组 Pod:数据并行要 N 个 worker 加一个 launcher。这组 Pod 只要有一个上不去,其余先起来也是白占卡——它们在等对端,谁都不干活。
  • 多团队共享一台集群:有人跑长训练、有人做交互式微调、有人线上推理。没有优先级和抢占,长训练会把集群堵死;没有配额,一个团队能吃掉所有卡。

gang scheduling:要么全给,要么都不给

上面第二条就是 gang scheduling(组调度):把一组 Pod 当作一个整体调度,全部放得下就一起放行,否则一个都不放。它不是「更聪明的装箱」,而是避免一种死锁

text
集群剩 4 卡,两个任务各要 4 卡(每任务 4 个 Pod)

不组调度:A 拿到 2 卡、B 拿到 2 卡 → 两边都在等队友 → 4 张卡被永久占住却零计算
组调度:  A 整组不放行、B 整组不放行 → 0 卡被浪费,等其中一个能整组凑齐再放行

原生调度器是逐个 Pod 决策的,它不知道 Pod3 和 Pod4 属于同一个任务,所以会做出「先给一部分」这种局部最优、全局死锁的决定。队列入场后,判断单位从 Pod 变成「整组」,问题才消失。

队列层要回答的四个问题

问题只用原生调度器队列层(Kueue/Volcano)
什么时候创建 PodPod 一提交就创建,然后排队等资源资源不够时先不放行,任务停在「已提交未准入」
一组 Pod 怎么算够不知道它们是一组,逐个尽力而为按组评估,凑不齐就整组等
谁该先跑只有 PriorityClass 能表达优先级,且只影响排队顺序队列 + 优先级 + 配额一起决定准入顺序
团队配额怎么限ResourceQuota 只管「命名空间内创建总量」,管不了整组准入ClusterQueue 是集群级配额池,LocalQueue 是命名空间入口
ResourceQuota 和队列不是二选一:配额管「这个团队最多占多少」,队列管「什么时候能占、谁先占」。

三个关键词:队列、优先级、抢占

  • 队列(queue):任务的入口。Kueue 里是 LocalQueue(命名空间)→ ClusterQueue(集群配额池)→ ResourceFlavor(节点池);Volcano 里是一棵 Queue 树,安装后自带 rootdefault
  • 优先级(priority):同一队列里谁先被放行。Volcano 用 Kubernetes 原生 PriorityClass;Kueue 有独立的 WorkloadPriorityClass(也可沿用 Pod 的 PriorityClass),别混着理解。
  • 抢占(preemption):高优先级任务插队时,把已占资源的低优先级任务驱逐掉。代价是被抢占的任务必须能从检查点恢复,否则前面几小时白跑。

批调度三件套:Kueue、Volcano 与 scheduler-plugins

社区有三条路线,差别不在于「谁功能多」,而在于在链路的哪一层做组判断

维度KueueVolcanoscheduler-plugins
切入层面准入层(webhook 拦截 Job 创建)调度层(自带 scheduler)kube-scheduler 插件
资源不够时Job 保持 suspend0 个 Pod 被创建Pod 已创建,整组 Pending 等凑齐逐个 Pod 反复尝试,凑不齐全部打回
额外组件1 个:kueue-controller-manager3 个:scheduler + controller + admission一个额外的 scheduler 实例
配额来源手工填 nominalQuota,不自动发现容量root Queue 默认继承集群总资源无配额概念
优先级对象WorkloadPriorityClass(或 Pod PriorityClass原生 PriorityClass
抢占粒度Workload 级(整组)PodGroup 级
适用场景绝大多数 AI 训练、精准配额、想要轻量极大规模纯 GPU 集群、多租户、DRF/Binpack基础验证、快速测试
生产建议首选备选不建议(资源不足时产生海量 FailedScheduling 事件)

选型可以简化成一句话:想要「配额 + 排队」就用 Kueue,想要「复杂调度策略 + 多租户公平」才上 Volcano。 二者功能重叠,同时装在一个集群会打架(见下)。Kubernetes 原生的 gang scheduling(PodGroup / 调度组)已在 v1.35 引入 Alpha,默认关闭、不要用于生产,成熟度按最新 release 核对。

Kueue 的资源模型

一句话记住:ResourceFlavor 说「哪种机器」,ClusterQueue 说「总共多少」,LocalQueue 说「谁能来排」

text
LocalQueue(ns=team-a) ──► ClusterQueue(nominalQuota: 8 卡) ──► ResourceFlavor(H100 节点池)
LocalQueue(ns=team-b) ──►      ▲ 同一个配额池,按队列分配            ▲ 通过 nodeLabels 绑定

关键点:nominalQuota手工配额,Kueue 不会自动去数集群有多少卡。填多了会绕过兜底,填少了会让任务白排队。经验做法是填该节点池 allocatable 的汇总(注意是 allocatable,不是 capacity)。

不能把 Kueue 和 Volcano 装在一起(除非你改配置)

这是实际踩过的最坑:Kueue 的 Pod mutating webhook 会拦截 Pod 创建,如果 Pod 没带 kueue.x-k8s.io/queue-name label,它会注入 `suspended=true` 并覆盖 `schedulerName`。Volcano 的 Pod 正好不带这个 label,于是全部被改回默认调度器并挂起,Volcano 直接瘫痪。规避方式只有一条:非要共存就把 Kueue 的 integrations.frameworks 限制成只处理 Job 类资源(如 batch/jobtrainer.kubeflow.org/trainjob),绝对不要包含 `pod` 和 `deployment`。对多数集群来说,二选一更省事。

参考:reference/k8s-in-action/ai/scheduling-comparison.mdreference/k8s-in-action/ai/kueue/README.mdreference/k8s-in-action/ai/volcano/README.md

任务从提交到落卡:一张全景图

text
  用户 / 提交入口
   │  kubectl apply TrainJob / Job / RayJob / InferenceService

┌───────────────────────────────────────────────────────────────┐
│ 队列与准入层(Kueue 准入 / Volcano 调度)                     │
│  LocalQueue(ns) ──► ClusterQueue(配额池) ──► ResourceFlavor   │
│    ├── 资源够 ──► Admit ──► 创建 Pod                          │
│    ├── 资源不够 ──► 排队(Kueue: 0 Pod / Volcano: Pending)   │
│    └── 更高优先级 ──► 抢占(驱逐低优先级 Workload)           │
└───────────────────────────────────────────────────────────────┘

  工作负载层:训练 TrainJob / RayJob / LWS · 推理 Deployment / InferenceService

  GPU 节点池:Device Plugin 分配整卡 ──► 容器内可见 /dev/nvidia*

  训练任务生命周期:
  提交 ──► 排队(suspend) ──► 准入(Admit) ──► 拉镜像/加载数据 ──► 运行
   ▲                                        ├─► 定期写 checkpoint ─► 成功
   └── 失败重试 / 被抢占后从最近 checkpoint 恢复 ◄── 节点故障、OOM、超时

分布式训练:从单卡到多机多卡

单卡放不下模型、或者单卡跑得太慢,就要并行:数据并行(每卡一份模型、分数据)、张量并行(一层拆到多卡)、流水线并行(不同层放不同卡)。无论哪种,跨卡跨机通信都会成为瓶颈——同节点走 NVLink/NVSwitch,跨节点走 RDMA(InfiniBand 或 RoCE)。网络没配好,加卡只会更慢。

MPI Operator 与 Kubeflow Trainer

MPI Operator 提供 MPIJob CRD,把作业展开成 1 个 launcher Pod 加 N 个 worker Pod,用 SSH 打通后用 mpirun 启动各个 rank;它要求镜像里有一个能跑的 sshd(这一点比想象中更容易漏)。定位是「以 MPI 为进程启动器」的 HPC 风格训练,NCCL 测试也常走这条路。

Kubeflow Trainer 是目前更推荐的统一入口:TrainJob + TrainingRuntime 屏蔽 PyTorchJob/MPIJob 等底层差异,自动注入 rank / world_size / master_addr,基于 JobSet 管理 launcher 与 worker 多副本,并原生对接 Kueue / Volcano 做 gang 调度。想少写胶水代码,从这里开始。

用 NCCL 测试验证多卡通信

训练慢,先别改模型——先证明通信达标nccl-testsall_reduce_perf 是标准做法,一次正确性扫描加一次持续压测:

目的参数判据
正确性 + 性能曲线-b 8 -e 8G -f 2 -g 1日志出现 Out of bounds values : 0 OKWrong values : 0 OK
长稳压测-b 8G -e 8G -i 0 -g 1持续输出 8G 行的 busbw,无 NCCL error
bash
# 提交测试任务后看 launcher 日志
kubectl logs -l trainer.kubeflow.org/replicated-job-name=launcher -f --tail=-1

看末尾的 busbw(总线带宽):理论峰值约等于「网卡数 × 单口带宽 ÷ 8」,90% 以上算优秀,80~90% 可接受,低于 80% 就是异常。异常时按顺序查:NCCL_SOCKET_IFNAME 是否选对网卡、NCCL_IB_HCA 是否命中 RDMA 设备、MTU 与 GID index 是否正确、两个 Pod 之间能否互相解析与连通。RoCE 用 hostNetwork 时别忘 hostNetwork: true

Ray 与 LeaderWorkerSet:两种编排路线

  • KubeRay(Ray)RayCluster / RayJob / RayService 三个 CRD,覆盖创建集群、提交作业、无停机升级。Ray 的定位是通用分布式计算引擎(Task/Actor/Object + Train/Tune/Serve 库),适合 Python 生态、超参搜索、需要弹性的训练与在线服务;代价是引入一整套运行时,调试栈变长。
  • LeaderWorkerSet(LWS):更轻的抽象——一个 leader 加一组 worker 视为一个整体,整组扩缩、整组滚动更新,天然提供 gang 语义和稳定序号。它不关心框架是 vLLM 还是 SGLang,只负责「把这组 Pod 一起管起来」,因此特别适合跨节点大模型推理(例如需要 2 台机器 16 张卡才装得下的 671B 模型)。
参考:reference/k8s-in-action/ai/kuberay/README.mdreference/k8s-in-action/ai/lws/README.md

检查点与失败重试

在「队列 + 抢占」的集群里,任务被中断是常态而不是事故:被高优先级任务抢占、节点故障、OOM、超过 activeDeadlineSeconds。所以训练任务的设计原则和普通 Job 不同:

  • 定期写 checkpoint 到持久卷:本地盘随 Pod 消失,必须写到 存储与 PVC 或对象存储;除了权重还要保存 optimizer 状态与当前 step,否则恢复后学习率调度会错乱。
  • 恢复要幂等:启动时先找最近 checkpoint,找到就续训,找不到才从头开始。写成代码逻辑,别指望人工干预。
  • 重试要有限度restartPolicy: Never 配合 backoffLimit,让「代码有 bug」的任务尽快失败退出,而不是无限循环烧卡。
  • 检查点本身也要能备份:卷坏了、误删了怎么办,见备份与恢复

模型服务与平台周边

训练产出的模型最终要变成服务。裸的 Deployment + Service 就能跑推理,但一旦要管理几十个模型、灰度发布、按流量伸缩,就需要控制面。

维度KServeKubeflow
是什么推理服务控制面(InferenceService CRD)端到端 AI 平台套件(Pipelines/Trainer/Notebooks/KServe 等)
解决什么把「部署一个模型」标准化:多框架运行时、灰度、自动伸缩给整个团队提供自助式 AI 能力,串起数据、训练、流水线、服务
依赖可搭配 Knative 做 Serverless 伸缩组件多,通常还要 Istio/Knative/cert-manager,控制面自身 requests 就有数核
什么时候用只需要推理标准化组织级平台,有专人运维

副本与显存:先算清楚再扩容

推理副本数不是想加就加,它受显存硬约束:副本数 × 单副本显存 ≤ 卡容量。单副本显存大致是「模型权重 + KV cache + 框架开销」,其中 KV cache 随并发请求线性增长,所以并发上限约等于「(可用显存 − 权重 − 预留)÷ 每请求 KV」。显存不够时先降 max_model_len 或调低框架的显存占用比例,而不是盲目加副本——两个副本挤一张卡只会互相 OOM。张量并行需要多卡同节点(走 NVLink)甚至跨节点(走 RDMA),这时要用 LWS 这类能把一组 Pod 一起管的编排方式。

冷启动与模型缓存

大模型权重几十 GB,冷启动是推理服务最容易被低估的一环:

  • 权重放镜像里:镜像巨大、拉取慢,每个节点都存一份,升级一次全量重拉。
  • 权重放 PVC 或对象存储:镜像小,但每个副本启动都要读一遍几十 GB,首字节延迟高。
  • 预热到节点本地盘 + 多副本共享只读模型:目前最优解,配合 initContainer 在节点空闲时提前把权重拉到本地缓存。
  • 探针必须配合:加载期间用 startupProbe 圈出启动期,livenessProbe 不要查下游依赖,否则模型没加载完就被重启,陷入 CrashLoopBackOff(见探针与资源管理)。
  • Serverless 缩到 0 要谨慎:冷启动几十秒到几分钟,缩到 0 省钱但会让第一个请求超时,线上服务通常至少保 1 个副本。

llm-d 这类推理网关解决的正是上面剩下的问题:把请求按 KV cache 命中和负载路由到合适的副本,并把 prefill 与 decode 拆到不同实例上调度,从而提高整集群吞吐。它需要 Gateway API Inference Extension 等上游组件配合,具体能力与版本按上游最新 release 核对,这里只做定位。

周边组件一句话定位

组件一句话定位
Agent SandboxKubernetes SIG 项目,为 AI Agent 提供有状态单例 + gVisor/Kata 强隔离 + 预热池
EvalScopeModelScope 的模型评估与推理压测框架,用来给模型质量与推理性能定基线
OMESGLang 生态的 Kubernetes 原生分布式推理框架,依赖 LWS 与 cert-manager,面向 PD 分离
RBGRoleBasedGroup,为 PD 分离架构的大规模推理提供角色化工作负载管理
参考:reference/k8s-in-action/ai/kubeflow/README.mdreference/k8s-in-action/ai/llm-d/README.mdreference/k8s-in-action/ai/llm/README.md

动手:用 Kueue 建队列并提交一个 1 卡任务

这个练习的目标不是跑出模型,而是看见排队本身:配额只有 1 卡,两个各要 1 卡的任务,第二个必然要等第一个结束。先装 Kueue(版本按最新 release 替换):

bash
helm repo add kueue https://kubernetes-sigs.github.io/kueue/charts
helm repo update
helm install kueue kueue/kueue -n kueue-system --create-namespace --version 0.11.0
kubectl -n kueue-system get pods        # controller-manager 应为 Running

动手练习:配额 1 卡的队列 + 排队观察

第一步:建队列。 存成 kueue-queue.yaml

yaml
# apiVersion 以集群实际提供的版本为准:kubectl api-resources --api-group=kueue.x-k8s.io
# 较新版本(v0.13+)会提供 v1beta2,换成它即可,字段结构一致。
apiVersion: kueue.x-k8s.io/v1beta1
kind: ResourceFlavor
metadata:
  name: gpu-flavor
spec: {}
---
apiVersion: kueue.x-k8s.io/v1beta1
kind: ClusterQueue
metadata:
  name: gpu-cluster-queue
spec:
  namespaceSelector: {}
  resourceGroups:
    - coveredResources: ["nvidia.com/gpu"]
      flavors:
        - name: gpu-flavor
          resources:
            - name: nvidia.com/gpu
              nominalQuota: 1        # 手工填写,Kueue 不会自动发现集群容量
---
apiVersion: kueue.x-k8s.io/v1beta1
kind: LocalQueue
metadata:
  name: gpu-local-queue
  namespace: default
spec:
  clusterQueue: gpu-cluster-queue
bash
kubectl apply -f kueue-queue.yaml
kubectl get clusterqueue gpu-cluster-queue     # ACTIVE 列应为 True
kubectl get localqueue -n default              # 应看到 gpu-local-queue

第二步:提交一个占 1 卡的任务。 关键是那个 label 和 suspend: true:Kueue 只接管带 kueue.x-k8s.io/queue-name 的 Job,suspend: true 让 Job 一开始就停在「未创建 Pod」的状态,由 Kueue 决定何时放行。存成 gpu-batch-demo.yaml

yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: gpu-batch-demo
  labels:
    kueue.x-k8s.io/queue-name: gpu-local-queue
spec:
  suspend: true                  # Kueue 准入后会自动改成 false
  backoffLimit: 1
  template:
    spec:
      restartPolicy: Never
      # GPU 节点有污点时补上,否则 Kueue 放行了 Pod 也上不去
      # tolerations:
      #   - key: nvidia.com/gpu
      #     operator: Exists
      #     effect: NoSchedule
      containers:
        - name: train
          image: nvcr.io/nvidia/cuda:12.6.2-base-ubuntu24.04
          command: ["bash", "-c", "nvidia-smi -L && sleep 60"]
          resources:
            requests:
              nvidia.com/gpu: 1
            limits:
              nvidia.com/gpu: 1
bash
kubectl apply -f gpu-batch-demo.yaml
kubectl get workloads -n default                                   # ① ADMITTED 列:True 表示已准入
kubectl describe workload -n default job-gpu-batch-demo | grep -A5 "Conditions:"
# Workload 名字形如 job-gpu-batch-demo(Kueue 在属主名前加类型前缀,以 get 的输出为准)
kubectl get job gpu-batch-demo -o jsonpath='{.spec.suspend}{"\n"}' # ② 应为 false
kubectl get pods -n default -l job-name=gpu-batch-demo             # ③ Pod 真的起来了
kubectl logs job/gpu-batch-demo                                    # 容器里看得见卡

第三步:观察排队。 第一个任务还在 sleep 60 时,再提交一个同款任务(只改名字),配额只有 1 卡,第二个必然拿不到准入:

bash
sed 's/gpu-batch-demo/gpu-batch-demo-2/' gpu-batch-demo.yaml | kubectl apply -f -
kubectl get workloads -n default                                       # 第二个的 ADMITTED 应为 False
kubectl get job gpu-batch-demo-2 -o jsonpath='{.spec.suspend}{"\n"}'   # 仍为 true
kubectl get pods -n default -l job-name=gpu-batch-demo-2               # 0 个 Pod

这就是 Kueue 与「Pod 先起来再等」的本质差别:队列没放行之前,集群里一个 Pod 都没有,不占调度器、不占 etcd。等第一个任务结束释放配额,第二个会在几秒内自动放行。

第四步:清理。

bash
kubectl delete -f gpu-batch-demo.yaml
kubectl delete job gpu-batch-demo-2
kubectl delete -f kueue-queue.yaml

成功判据:第一个任务 Completed、日志里能看到 GPU 0: 开头的一行;第二个任务在第一个结束前 suspend 一直是 true 且没有任何 Pod。

如果集群装的是 Volcano,同一件事换成 PodGroupspec.minMember 必须等于工作负载的 Pod 数)+ Pod 上指定 schedulerName: volcano 并加 scheduling.k8s.io/group-name annotation。注意顺序陷阱:Job/TrainJob 不会自动创建 PodGroup,必须先 apply PodGroup 再提交负载,否则永远匹配不到 Gang。验证用 kubectl get podgroups.scheduling.volcano.sh -A,若集群只剩 1 张空卡,你会看到两个 Pod 都 Pending——这就是 gang scheduling 的直观表现。参考:reference/k8s-in-action/ai/volcano/README.md

常见错误速查表

排 AI 平台的问题,先分清是没被放行放行了上不去,还是跑起来才出问题

现象原因怎么确认怎么办
任务一直 Suspended / 一个 Pod 都没有没打 kueue.x-k8s.io/queue-name label、LocalQueue 不存在或名字写错、nominalQuota 已用完kubectl get workloads -Akubectl describe workload <name> 看 Conditions;kubectl get localqueue -n <ns>补 label 并把 suspend 交给 Kueue;核对 LocalQueue 与命名空间;配额不足就排队或调大配额
Workload 已 Admit,但 Pod 仍 Pending配额放行了,节点上没有卡,或节点有污点没容忍kubectl describe pod 的 Events(Insufficient nvidia.com/gpu / untolerated taint检查节点 allocatable 与污点容忍;配额要填 allocatable 而不是 capacity
整组 Pod 都 Pending,事件反复出现gang 没凑齐(Volcano minMember 或 Coscheduling 组成员不足),或剩余卡不够一整组kubectl describe podgroup <name>;数一下同组 Pending 的 Pod 数量等资源凑齐、把任务拆小、给队列加配额;别用 Coscheduling 扛大任务
Pod 的 schedulerName 被改回 default-schedulerKueue 与 Volcano 同时安装,Kueue 的 Pod webhook 覆盖了它kubectl get pod <name> -o jsonpath='{.spec.schedulerName}'把 Kueue integrations.frameworks 里的 pod/deployment 去掉,只留 Job 类资源
NCCL 初始化超时或 unhandled system error走错网卡、NCCL_SOCKET_IFNAME/NCCL_IB_HCA 配错、MTU 或 GID index 不对、跨节点 DNS 不通日志里搜 NET/IBNET/Socketshow_gidskubectl exec 到两个 Pod 互 ping按机型核对 NCCL 环境变量;RoCE 用 hostNetwork 时确认 hostNetwork: true;先跑通单机多卡再上多机
训练中途 CUDA out of memorybatch 过大、多进程共享一张卡、KV cache 或梯度累积吃满显存nvidia-smi 看同卡进程;DCGM DCGM_FI_DEV_FB_USED 曲线调小 batch/max_model_len;给每个进程限显存;改用 MIG 或独占卡
推理 Pod CrashLoopBackOff,日志还在加载权重livenessProbe 在模型加载完之前就判定容器已死kubectl describe podLast State 与探针失败次数startupProbe 圈出启动期,livenessProbe 只负责真正的假死
服务起来后第一个请求特别慢,之后正常权重没缓存,每次冷启动都从对象存储拉几十 GBkubectl describe pod 看启动耗时;对比节点磁盘与网络带宽把模型预热到节点本地盘/PVC;多副本共享一份只读模型;避免缩到 0
高优先级任务插队后,低优先级任务从头重跑抢占(preemption)驱逐了低优先级 Workloadkubectl get events 里的 Preemptedkubectl describe workload给长训练加定期 checkpoint 并支持续训;把「被中断」当成设计前提

三个最容易踩的坑

  • 配额填成 `capacity`allocatable = capacity − kubelet 预留,填大了会让 Kueue 以为还有卡,表现为「Workload 已准入但 Pod 永远 Pending」。
  • 只写 `requests` 不写 `limits`:扩展资源必须写 limits,调度器与 Kueue 都按 limits 记账;两者不一致还会被 API Server 直接拒绝,详见 GPU 与 AI 工作负载
  • Kueue 与 Volcano 共存却不管 webhook:不带 kueue.x-k8s.io/queue-name 的 Pod 会被注入 suspended=true 并覆盖 schedulerName,Volcano 直接瘫痪。二选一,或严格限制 Kueue 的 integrations.frameworks

自测题

自测:为什么必须有 gang scheduling,不能「先起一部分等一等」?(点击展开答案)

因为原生调度器是逐个 Pod 决策的,它不知道这些 Pod 属于同一个任务。集群剩 4 卡、两个任务各要 4 卡时,逐个调度会让两个任务各拿到 2 卡:每个任务的 Pod 都在等另外 2 个,谁也凑不齐,于是 4 张卡被永久占住却没有任何计算发生——这是资源死锁,不是「慢一点」。

gang scheduling 把判断单位从 Pod 换成「整组」:凑得齐就整组放行,凑不齐就整组不放。更进一步,Kueue 在准入层做这个判断,资源不够时连 Pod 都不创建,既不占调度器也不占 etcd;Volcano 在调度层做,Pod 会创建但整组 Pending,可见性更好但有调度开销。两种实现解决的是同一个问题:让「局部最优」不再导致「全局死锁」。

自测:Kueue 和 Volcano 到底该怎么选?(点击展开答案)

先看差别在哪一层:Kueue 是准入层的队列管理器,资源不够时 Job 保持 suspend、零 Pod 创建,组件只有一个 controller-manager,配额靠手工填 nominalQuota;Volcano 是调度层的批处理调度器,Pod 先创建再整组评估,自带 scheduler/controller/admission 三个组件,Queue 默认继承集群总资源,还提供 DRF、Binpack、比例分配等复杂策略。

所以选择标准不是「谁更流行」,而是你需要什么:绝大多数训练场景、想要精准配额且运维成本低 → Kueue极大规模纯 GPU 集群、重度多租户、需要复杂调度策略 → Volcano。scheduler-plugins 的 Coscheduling 只适合验证,资源不足时会产生海量 FailedScheduling 事件。如果集群里已经有 Kueue,就别再装 Volcano,除非你能确保 Kueue 的 webhook 不碰 Pod。

自测:什么时候该自建 AI 平台,而不是用托管服务?(点击展开答案)

先算三笔账。规模账:卡少(比如 8 张以内)、任务不多时,托管服务的溢价换来的是免运维,往往更划算;卡多、利用率压力大时,自建才能把调度、抢占、共享这些手段用足。能力账:自建不是装一个 Operator 就完了,你要承担 Kueue/Volcano 的升级与排障、Kubeflow 这类套件的组件依赖、多租户配额与计费、镜像与模型仓库、监控告警,以及「训练任务被抢占后怎么恢复」这类流程——没有专职平台团队,这些会拖垮业务迭代。约束账:数据能不能出机房、要不要特定机型与 RDMA 网络、成本模型是否可控,托管服务在这些点上通常有硬边界。

一条实用的判断线:如果团队已经在用 Kubernetes 管业务、且 GPU 规模足以让调度优化带来明显收益,自建才成立;否则先用托管服务把模型跑起来,等规模和团队都到位再迁移。反过来,如果合规要求数据与模型不出内网,自建就不是选择题而是必选项。

小结

  • GPU 集群的问题不是「有没有卡」,而是整卡不可超卖 + 一组 Pod 必须一起起 + 多团队要公平;队列层负责回答「何时创建、谁先跑、谁被抢」。
  • gang scheduling 把调度单位从 Pod 换成整组,避免「各占一半、谁也跑不动」的死锁;Kueue 在准入层做(零 Pod),Volcano 在调度层做(整组 Pending)。
  • 选型上 Kueue 优先、Volcano 备选、Coscheduling 不要上生产;两者共存必须先处理 Kueue 的 Pod webhook,否则 Volcano 会被覆盖 schedulerName
  • 分布式训练先验证通信:用 NCCL `all_reduce_perf`0 OK 与 busbw 是否达到理论峰值的 90%;编排上 Kubeflow Trainer 是统一入口,Ray 适合弹性与 Python 生态,LWS 适合把一组 Pod 整体管理的多节点推理。
  • 训练任务必须能续训:定期 checkpoint 落持久卷,重试有限度,把「被抢占」当成设计前提而不是意外。
  • 推理服务受显存硬约束,副本数 × 单副本显存 ≤ 卡容量;冷启动靠模型预热与探针配合;KServe 负责把部署标准化,Kubeflow 是更大的平台套件,llm-d 这类推理网关解决请求路由与 PD 分离。

相关章节:GPU 资源模型、共享与探针细节见 GPU 与 AI 工作负载;标签、污点与优先级抢占的基础见调度入门;检查点落盘与共享存储见存储与 PVC;被抢占后的数据保护见备份与恢复;GPU 指标与告警见可观测性;命名空间配额与多租户见命名空间与 RBAC;模型与镜像的分发缓存见镜像与缓存

练习

  1. 你的集群有 3 台 8 卡节点,一个团队要跑「每次 16 卡、单次 6 小时」的训练,另一个团队要跑「随时提交、每次 1 卡、5 分钟」的微调。请写出你会怎么划分 ClusterQueue 配额、怎么设置两个 WorkloadPriorityClass,并说明长训练被抢占后靠什么恢复。
  2. nccl-testsall_reduce_perf 在你环境里跑一次单机 2 卡测试,记录最大 size 那一行的 busbw,对照网卡理论峰值算出百分比。如果低于 80%,列出你会依次检查的三个环境变量和一条连通性命令。
  3. 假设要部署一个 70B 模型:权重约 140GB(FP16),单卡 80GB。请判断需要几张卡、用哪种并行方式(TP/PP 还是量化后单卡)、用 Deployment 还是 LWS,并说明冷启动时你会怎么让 startupProbe 与模型预热配合。

到这里,平台化模块就收尾了:把 GPU 变成队列、把训练变成可重试的作业、把模型变成可交付的服务。回到可观测性备份与恢复把这条链路的「看得见」和「回得来」补上,再回到综合实战用一套完整集群把它们串起来。