volcano千卡集群训推最佳实践

volcano千卡集群训推最佳实践

1000 张 A800(每台 8 卡、共 125 台)训推共用集群。全文一条规则:生产队列互相不杀,闲卡进 idle,要卡只收 idle。

目录

  1. [30 秒上手](#30 秒上手)
  2. [Volcano 入门](#Volcano 入门)------是什么、五个对象、作业怎么走完
  3. 集群怎么切------队列额度、回收规则、五个模板(策略都在这节
  4. 平台配置------命名空间 / 队列 / 调度器 / 节点打标的 YAML
  5. 怎么交作业------五个模板的完整 YAML
  6. 使用规范------卡数、时长、门户上要写给用户的话
  7. 常见问题排查------没排上 vs 跑起来卡住

30 秒上手

你要干什么 命名空间 / 队列 模板 卡数 会不会被收走
预训练 pretrain pretrain 每机 8 卡,选几台 不会
微调 / 继续预训练 / 对齐 post-pretrain sft 2 / 4 / 8 不会
在线推理(额度内 SLA) inference infer 1 / 2 / 4 / 8 不会
评测、小实验、推理冲高副本 idle idle 1 / 2 / 4 / 8 会,必须能重跑
写代码、调试 dev dev-share 共享 1 张卡 不会(独立节点)

每个作业必填这几行,其余照模板抄:

yaml 复制代码
spec:
  schedulerName: volcano      # 不写就走默认调度器,队列全部失效
  queue: pretrain             # 和命名空间同名
  priorityClassName: p-never  # 全集群只有这一个

三条要记住的:

  1. 跑起来就不会被杀(idle 除外)。额度满了是排队,不是抢。
  2. 没人用的卡自动给 idle 吃;生产队列要卡时只从 idle 收回来,不动其他生产队列。
  3. 卡数只许 1 / 2 / 4 / 8(预训练每机 8 卡),由模板卡住,防止整机被切碎。

怎么读下去:交作业的人看 ;搭平台的人按 顺序看;只想知道为什么这么设计,看 为什么这么定(取舍)


一、Volcano 入门

已经熟悉 Volcano 的可以直接跳到第二节

是什么

Kubernetes 自带的 kube-scheduler 一次只调度一个 Pod。训练常见需求它不管:

  • 32 个 Worker 必须 一起起来(少一个就空转等)
  • 按队列分卡:预训练最多用多少、推理固定多少
  • 没人用的卡交给 idle;生产队列要卡时只从 idle 要回
  • 一组 Pod 绑同一台机器、避开坏节点

Volcano 是 Kubernetes 上的批调度器,专门干这些事。集群里通常两个调度器并存:

调度器 管什么
kube-scheduler 系统组件、不写 schedulerName 的普通 Pod
volcano-scheduler 训练、整卡推理、开发机------所有要排队、要 gang、要按队列分卡的作业

作业 必须写 schedulerName: volcano,否则不会进 Volcano,队列、优先级、整组调度全部不生效。


五个对象

后面 YAML 里反复出现的就是这 5 个。平台配前两个,用户交作业时填后三个。

对象 一句话 谁创建
Queue 队列。这类作业最多能占多少卡;只有 idle 能被别人要回去 平台,事先建好
PriorityClass 全集群一个 p-never,只表示不许杀正在跑的作业 平台,事先建好
Jobvcjob)或 Deployment 你真正要跑的东西 用户提交
PodGroup 「这一组 Pod 算一单,绑到某个队列」 训练 Job 自动建;Deployment 要自己写
schedulerName volcano,告诉 kube-apiserver 别走默认调度器 用户写在模板里

用户交作业时通常只填三行:

yaml 复制代码
spec:
  schedulerName: volcano
  queue: pretrain          # 进哪条队列,决定能用多少卡;生产队列不会被要回
  priorityClassName: p-never

Namespace 和 Queue 同名pretrain 命名空间的作业进 pretrain 队列。捡闲、可被收的作业进 idle。不要建到 default


作业怎么走完

text 复制代码
你 kubectl apply
        │
        ▼
  enqueue   先问队列:还没到上限吗?到了就排队,不会占机器
        │
        ▼
  allocate  找节点、扣 GPU;训练还要 gang:N 个 Pod 一次性够才下发
        │
        ▼
  跑起来
        │
        ▼
  别人缺卡时
        ├─ 有空闲物理卡 → 直接给,谁也不杀
        └─ 不够 → 只 reclaim idle。生产队列(预训练 / 微调 / 推理 / 开发机)互不杀

三个容易混的词:

实际意思
capability 这条队列的 上限,也是 reclaim 时「自己应得」的上限。到了就排队
reclaimable 别人 能不能要回 这条队列正在用的卡。本集群只有 idletrue
weight 这条队列的 保底线:大家都满负荷时至少能分到多少。不是互抢开关
guarantee 保底。本集群 不写。生产队列不可抢,不需要再叠一层保底

生产作业跑起来就不会被杀。 没人占用的 GPU 给 idle 吃。生产队列要卡,先用真正空着的,再只收 idle。收不齐就排队,等别人跑完。

调度器靠 插件 做具体判断,用户不用配。看到后面的 scheduler YAML 时,对上号即可:

插件 干什么
proportion 按 capability 判断谁还能再拿卡;只从 idle reclaim
gang N 个 Pod 一次性够才启动,少一个就全等
predicates 这台机器能不能放(污点、亲和、卡够不够)
nodegroup 队列绑哪组节点(整卡池 vs 开发机)
nodeorder 多台都能放时选哪台(整卡尽量装箱)
deviceshare 开发机选哪张卡、多人 timeslice 共享整卡

平台配什么、用户填什么

平台一次性配好 用户每次填
命名空间 pretrain / post-pretrain / inference / idle / dev 作业放到对应 ns
队列 capability、weight、priority、reclaimable spec.queue
PriorityClass 只有 p-never priorityClassName: p-never
调度模板 5 个:预训练 / 微调 / 推理 / idle / 开发机 选模板 + 允许的卡数
节点标签 / 污点 pool=train 整卡,pool=dev 开发机 一般不用写;队列的 nodeGroup 会自动绑
调度器 volcano-scheduler 的 plugins / actions 只写 schedulerName: volcano

用户 不要自己建 Queue、不要改 capability、不要自己填卡数。卡怎么分、谁能抢谁、一台机器怎么切,是平台策略。用户选模板,只改镜像和命令。

二、集群怎么切

这一节定策略:卡怎么分(队列额度)、不够了怎么办(只收 idle)、用户能选什么(五个模板)。第三节是它的 YAML,第四节是用户照抄的作业。

规则

集群按 1000 张 A800、每台 8 卡、共 125 台 。生产就三类,另加一条 idle 吃闲卡和评测;开发机全员共用:

类型 典型规模 空闲卡给谁 能不能抢别人 正在跑能不能被抢
预训练 1 机 8 卡,或 N 机 × 8 卡 给 idle 只收 idle 不能
微调(SFT / 继续预训练 / 对齐) 2 / 4 / 8 卡 给 idle 只收 idle 不能
推理 1 / 2 / 4 / 8 卡,整卡 给 idle 只收 idle 不能
idle(评测、数据、小实验、推理超额) 1 / 2 / 4 / 8 卡 --- 不能 能,谁缺卡都可以收
开发机 整卡 timeslice 共享,不切显存/算力 不外借 不能 不能

规则就三条:

  1. 开发机用节点标签隔离,约 50 卡(7 台 / 56 卡)。多人 timeslice 共享整卡,不切显存/算力,不和训练整卡混部。
  2. 训练 + 推理 共用整卡池,用队列 capability 限卡。到了上限就排队。评测走 idle。
  3. 只有 idle 能被抢。 闲卡给 idle 吃;生产队列要卡,先用空闲物理卡,不够只 reclaim idle。生产队列之间不杀、同队列也不抢。

开发机标签隔离;整卡一池,队列限卡。生产互不杀。闲卡进 idle,要卡只收 idle。


为什么这么定(取舍)

互抢口难调。预训练杀微调、推理补保底再杀预训练------用户一定有意见,规则也会越写越绕。

改成:

text 复制代码
生产队列(预训练 / 微调 / 推理 / 开发机)
  → 额度内开跑,跑起来不被杀
  → 额度满了排队,等自己队列里有人跑完,或等别人跑完腾出物理卡

idle
  → 评测、数据、小实验、推理超额,专门吃别人暂时不用的卡
  → 谁要卡都可以收它
  → 交到这条队列的人,事先知道作业随时可能被干掉,必须能重跑

缺卡时只做两步:

text 复制代码
1. 空闲物理卡
2. reclaim idle
3. 停:生产队列正在跑的作业一律不动

空闲卡多人同时要时的分配顺序(只影响谁先排上,不杀正在跑的):

text 复制代码
预训练 > 微调 > 推理 > idle

角色固定:

角色 一句话
主业 预训练 额度内跑完为止。缺卡只收 idle
次主业 微调 同上。不再给预训练让路、也不会被推理挤
SLA 推理 额度内长驻,不被训练收。要冲高,多出来的副本交 idle
填缝 idle 评测 / 捡闲。利用率靠它,被收是本职
开发 开发机 独立节点,不抢不占

这样够用的原因:

  • 用户能接受排队,不能接受跑到一半被杀。 互抢利用率好看,口永远调不完。
  • 闲卡不会空转。 没人用的卡进 idle,利用率还在。
  • 抢占只有一条边:大家 → idle。 不成环,不用再写「谁能坐在谁的保底上」。
  • 推理冲高不要写进 inference 队列。 写进去就要谈超额能不能被收,口又复杂了。多出来的副本交 idle,能被收是说在先的。

代价:预训练要整机、idle 不够、别人额度内占着卡时,预训练就排队。这是刻意换来的稳定。不要再为这一点把微调、推理加回 reclaim 名单。

不要做的:

  • 生产队列互相 reclaimable: true(口难调,用户有意见)
  • 推理再拆保底 + 超额两套抢占规则
  • 四类再拆四套机器标签
  • 只靠作业 PriorityClass 跨队列抢
  • 再拆一条 regular 队列(评测走 idle 就够了)
  • 把不能重跑、没有 checkpoint 的作业交 idle
  • 让用户自己填卡数(3/5/6/7 会把整机切碎)
  • 各队列 weight 都写成 1(见下面「weight 到底管什么」)

标签和额度

手段 用来干什么 本方案
节点标签 + 污点 切开 timeslice 机和整卡机 只给开发机用
Queue capability 上限:卡再空也只能用到这里 700 / 200 / 80;idle 写整池 944,才能吃闲卡
Queue reclaimable 别人能不能收走我正在用的卡 只有 idle 为 true,其余全 false
Queue priority 空闲卡谁先排上、谁先向 idle 要 预训练最高,idle 最低
Queue weight 保底线:大家都满负荷时这条队列至少分到多少 700 / 163 / 80 / 1,之和 = 整卡池 944
Queue guarantee 硬保底(预留物理卡,别人一张都不能碰) 不写。 生产队列本来就不可抢,再预留只会让卡空转

开发机必须打标签:共享卡和整卡不能落在同一张 GPU 上。

预训练、微调、推理、idle 都是整卡,不要再各打一套标签 。张数用 capability。生产队列空着的额度变成物理空卡,只给 idle 吃;生产要卡时只从 idle 要回。

推理不要写 400 那种冲高上限,额度就是长驻的量,冲高副本交 idle。


队列怎么配

一台 8 卡机是 一块整机 。卡间 NVLink,跨机走网络。预训练 8 卡必须同一台;多机必须 8×N

text 复制代码
125 台
 ├─ 整卡池    118 台 / 944 卡    pool=train   污点 gpu=exclusive
 │            预训练 / 微调 / 推理 / idle 共用
 │            张数靠队列 capability;闲卡只给 idle;要卡只收 idle
 └─ 开发机池    7 台 /  56 卡    pool=dev     污点 gpu=shared
              约 50 卡,整卡 timeslice 共享,不切分

整卡池队列额度(示例,可按你们实际改):

队列 capability (闲时最多用到) weight (全满时保底) priority reclaimable
pretrain 700 700 100 false
post-pretrain(微调) 200 163 80 false
inference 80 80 60 false
idle 944 1 1 true
dev 另一组节点,另一种资源名 1 0 false

capability 之和 700+200+80 = 980,比 944 多一点,是故意超卖的:谁先来谁先用,超出部分靠排队消化,不靠互抢。weight 之和必须正好是 944,原因见下面两小节。

default 队列关掉 GPU,漏写队列的作业进不去。

reclaim:只收 idle

生产队列互不杀。缺卡时先用空闲物理卡,不够只向 idle 要。自己到了 capability 就停,即使 idle 还占着卡也不再收。

text 复制代码
预训练用了 400 / 700,idle 占着 300
  → 再来 32 卡:从 idle 收 32,微调 / 推理不动

预训练已经 700 / 700,idle 还占着 200
  → 再提交:排队,不收 idle(那些卡不是你的额度)

预训练要 200,idle 只有 80,微调占着 200
  → 只收 idle 80,剩下排队,不杀微调

idle 作业必须写 preemptable: true,否则收不走。

weight 到底管什么

一句话:capability 是上限,weight 是保底线。

text 复制代码
capability   卡空着的时候,最多能用到这里
weight       大家都满负荷、卡不够分的时候,至少能分到这么多

调度器每一轮都会算一个「应得卡数」:

text 复制代码
应得 = min( 按 weight 分到的份额 + 别人不要的余量,  自己实际提交的量,  capability )

三点推论,配之前先看懂:

  • 没提交作业就不占额度。 「自己实际提交的量」是硬上限,队列空着时它的份额自动流给别人(最后落到 idle)。
  • 闲的时候谁都能超过 weight。 只有微调在跑,它照样能用到 200,因为别人不要的余量会加给它。
  • 回收只发生在「占用 > 应得」时。 idle 的 weight 只有 1,所以它一旦占着卡,就随时可以被生产队列收回去;生产队列的 weight 等于自己的 capability,占用永远不超过应得,谁也收不走它。

所以整卡池的 weight 之和要等于整卡池的卡数 944 ,写成 700 / 163 / 80 / 1

队列 weight 全满时保证拿到 闲时最多用到(capability)
pretrain 700 700 700
post-pretrain 163 163 200
inference 80 80 80
idle 1 ≈ 0 944

163 不是拍的,是 944 − 700 − 80 − 1 的余数:全满时先保住预训练 700、推理 80,剩下的归微调。微调 capability 写 200,是留给「别人没用满」时多吃 37 张。

两个常见错配:

  • 全写 1:四条队列平分 236,预训练全满时只保得住 236 张,剩下的收不回来。
  • 全抄 capability(700/200/80):和是 980 > 944,份额按比例缩水,预训练实际只能算到 674 左右,最后 26 张收不回来。

⚠️ 前提:全集群 nvidia.com/gpu 的总量必须真的是 944。 调度器算份额时用的是整个集群上报的 GPU 数,它不认节点标签。开发机那 7 台如果按 nvidia.com/gpu 上报、又开了 replicas: 8 的时间片,会额外上报 448 张虚拟卡,分母直接变成 1392------每条队列的「应得」都被放大,idle 的应得跟着涨到几百张,占着卡也不算超额,reclaim 就再也收不动了 。所以开发机的 device plugin 必须开 renameByDefault: true,把共享卡改名成 nvidia.com/gpu.shared(见 四、开发机)。


五个模板

模板是用来防碎片的。一台 8 卡整机只允许这些切法:

text 复制代码
好:  8          满卡预训练
好:  4+4        两个微调
好:  4+2+2      微调 + idle / 推理填缝
好:  2+2+2+2    idle / 推理填满
差:  3/5/6/7    碎片杀手
差:  每台占 1 卡,训练池全破相

所以门户只放 5 个模板,和队列一一对应。用户选模板、填镜像和命令,再选允许的卡数,不许自己填 3/5/6/7

模板 队列 卡数怎么选 干什么
pretrain pretrain 每机固定 8 卡,选几台机(1 / 4 / 8...) 预训练
sft post-pretrain 2 / 4 / 8 微调
infer inference 1 / 2 / 4 / 8 额度内推理
idle idle 1 / 2 / 4 / 8 评测、捡闲、推理超额
dev-share dev 共享 1 张卡,不切分 写代码、调试

整卡 CPU / 内存跟卡走,request = limit(单机 128 核 / 1Ti,8 卡留 8 核 / 64Gi 给系统):

卡数 CPU 内存
1 15 120Gi
2 30 240Gi
4 60 480Gi
8 120 960Gi

不要按卡数再拆一套模板名(没有 sft-4gpuinfer-2gpu 这种)。第四节的 YAML 就是这 5 个模板的正文,预训练给了单机和 4 机两份,区别只有 replicas / nnodes


三、平台配置

这一节是第二节策略的 YAML 落地,平台配一次,用户不用看。按顺序执行:

顺序 配什么 对应策略
1 [节点打标](#顺序 配什么 对应策略 1 节点打标 整卡池 / 开发机池分开 2 命名空间 五个 namespace 3 PriorityClass 全集群一个 p-never 4 队列 capability / weight / reclaimable 5 调度器 开 reclaim,不开 preempt 6 RDMA 整卡池:Shared device plugin + hostNetwork) 整卡池 / 开发机池分开
2 [命名空间](#顺序 配什么 对应策略 1 节点打标 整卡池 / 开发机池分开 2 命名空间 五个 namespace 3 PriorityClass 全集群一个 p-never 4 队列 capability / weight / reclaimable 5 调度器 开 reclaim,不开 preempt 6 RDMA 整卡池:Shared device plugin + hostNetwork) 五个 namespace
3 [PriorityClass](#顺序 配什么 对应策略 1 节点打标 整卡池 / 开发机池分开 2 命名空间 五个 namespace 3 PriorityClass 全集群一个 p-never 4 队列 capability / weight / reclaimable 5 调度器 开 reclaim,不开 preempt 6 RDMA 整卡池:Shared device plugin + hostNetwork) 全集群一个 p-never
4 [队列](#顺序 配什么 对应策略 1 节点打标 整卡池 / 开发机池分开 2 命名空间 五个 namespace 3 PriorityClass 全集群一个 p-never 4 队列 capability / weight / reclaimable 5 调度器 开 reclaim,不开 preempt 6 RDMA 整卡池:Shared device plugin + hostNetwork) capability / weight / reclaimable
5 [调度器](#顺序 配什么 对应策略 1 节点打标 整卡池 / 开发机池分开 2 命名空间 五个 namespace 3 PriorityClass 全集群一个 p-never 4 队列 capability / weight / reclaimable 5 调度器 开 reclaim,不开 preempt 6 RDMA 整卡池:Shared device plugin + hostNetwork) reclaim,不开 preempt
6 [RDMA](#顺序 配什么 对应策略 1 节点打标 整卡池 / 开发机池分开 2 命名空间 五个 namespace 3 PriorityClass 全集群一个 p-never 4 队列 capability / weight / reclaimable 5 调度器 开 reclaim,不开 preempt 6 RDMA 整卡池:Shared device plugin + hostNetwork) 整卡池:Shared device plugin + hostNetwork

先打标再建队列(队列的 nodeGroupAffinity 依赖节点标签)。节点打标把主机名换成真实的。

命名空间

yaml 复制代码
apiVersion: v1
kind: Namespace
metadata:
  name: pretrain
---
apiVersion: v1
kind: Namespace
metadata:
  name: post-pretrain
---
apiVersion: v1
kind: Namespace
metadata:
  name: inference
---
apiVersion: v1
kind: Namespace
metadata:
  name: idle
---
apiVersion: v1
kind: Namespace
metadata:
  name: dev

两套优先级,别混

排队只分两层,不要按队列再拆一套 PriorityClass。

规则 谁管 本方案
同队列谁先跑 提交顺序(FIFO)+ gang 先到先得;预训练要一次凑齐
跨队列谁先拿空闲 / 谁先收 idle Queue priority / capability / weight pretrain > 微调 > 推理 > idle
不许杀正在跑的 PriorityClass p-never + 不开 preempt 全集群就这一个类
配在哪 字段 本方案怎么用
PriorityClass preemptionPolicy: Never 只表示「不去杀别人」。value 大家相同,不参与跨队列抢卡
Queue priority 跨队列谁先分到空闲卡、谁先向 idle reclaim
Queue weight 全满时的保底线,700 / 163 / 80 / 1([为什么](#配在哪 字段 本方案怎么用 PriorityClass preemptionPolicy: Never 只表示「不去杀别人」。value 大家相同,不参与跨队列抢卡 Queue priority 跨队列谁先分到空闲卡、谁先向 idle reclaim Queue weight 全满时的保底线,700 / 163 / 80 / 1(为什么) Queue reclaimable 只有 idle 为 true Queue nodeGroupAffinity 整卡写 train,开发机写 dev))
Queue reclaimable 只有 idle 为 true
Queue nodeGroupAffinity 整卡写 train,开发机写 dev

必须同时满足:

  1. 调度 actions 里有 reclaim不要preempt
  2. 只有 idlereclaimable: true。生产队列全是 false
  3. 队列 prioritypretrain > post-pretrain > inference > idle
  4. 整卡池 weight 写成 700 / 163 / 80 / 1,之和等于整卡池卡数 944。
  5. 开发机的共享卡改名成 nvidia.com/gpu.shared,不能让它混进 nvidia.com/gpu 的总量里。

PriorityClass:全集群一个

preemptionPolicy 只有两种:Never(不去杀别人)和 PreemptLowerPriority(排不上可以杀低优)。本方案只用 Never。收 idle 不走这条,走 Queue reclaimable + 作业 preemptable

yaml 复制代码
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: p-never
value: 1000
globalDefault: false
preemptionPolicy: Never
description: "全集群作业共用。不许抢正在跑的 Pod。同队列 FIFO,跨队列看 Queue。"

所有 Job / Deployment / PodGroup 都写 priorityClassName: p-never。不要再拆 p0p5

队列

节点只要两套:整卡机 pool=train + volcano.sh/nodegroup-name=train + 污点 gpu=exclusive;开发机 pool=dev + nodegroup-name=dev + 污点 gpu=shared

字段 作用
capability 上限:最多能用到多少张;reclaim 也只收到这里
weight 保底线:全满时至少分到多少张。整卡池之和 = 944
priority 跨队列谁先分到空闲卡、谁先向 idle 要卡
reclaimable 只有 idle 为 true
requiredDuringSchedulingIgnoredDuringExecution 只能上这些节点组。整卡队列都写 train
yaml 复制代码
apiVersion: scheduling.volcano.sh/v1beta1
kind: Queue
metadata:
  name: pretrain
spec:
  weight: 700
  priority: 100
  reclaimable: false
  capability:
    nvidia.com/gpu: 700
  affinity:
    nodeGroupAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - train
---
apiVersion: scheduling.volcano.sh/v1beta1
kind: Queue
metadata:
  name: post-pretrain
spec:
  weight: 163
  priority: 80
  reclaimable: false
  capability:
    nvidia.com/gpu: 200
  affinity:
    nodeGroupAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - train
---
apiVersion: scheduling.volcano.sh/v1beta1
kind: Queue
metadata:
  name: inference
spec:
  weight: 80
  priority: 60
  reclaimable: false
  capability:
    nvidia.com/gpu: 80
  affinity:
    nodeGroupAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - train
---
apiVersion: scheduling.volcano.sh/v1beta1
kind: Queue
metadata:
  name: idle
spec:
  weight: 1
  priority: 1
  reclaimable: true
  capability:
    nvidia.com/gpu: 944
  affinity:
    nodeGroupAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - train
---
apiVersion: scheduling.volcano.sh/v1beta1
kind: Queue
metadata:
  name: dev
spec:
  weight: 1
  priority: 0
  reclaimable: false
  capability:
    nvidia.com/gpu.shared: 448    # 7 台 × 8 卡 × 8 人;和整卡池不是同一种资源
  affinity:
    nodeGroupAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - dev
---
apiVersion: scheduling.volcano.sh/v1beta1
kind: Queue
metadata:
  name: default
spec:
  weight: 1
  priority: 0
  reclaimable: true
  capability:
    nvidia.com/gpu: 0
text 复制代码
空闲 GPU → 生产队列按 priority 先排;没人要才给 idle
生产队列要卡且空闲不够
  → 仅当自己 allocated < capability
  → 只 reclaim idle,收到 min(作业要的, 自己额度剩余, idle 占用)
  → 自己已经到 cap:不收 idle,排队
  → 不动任何生产队列正在跑的作业
idle 自己要卡
  → 只能吃当时真正空着的卡,不能抢任何人

调度器

reclaim 只从 idle 要卡。不开 preempt,同队列也不互杀。

yaml 复制代码
apiVersion: v1
kind: ConfigMap
metadata:
  name: volcano-scheduler-configmap
  namespace: volcano-system
data:
  volcano-scheduler.conf: |
    actions: "enqueue, allocate, backfill, reclaim"
    tiers:
    - plugins:
      - name: priority
      - name: gang
        enablePreemptable: false
      - name: conformance
    - plugins:
      - name: drf
        enablePreemptable: false
      - name: predicates
      - name: proportion
      - name: nodegroup
      - name: nodeorder
      - name: deviceshare
        arguments:
          deviceshare.VGPUEnable: false
          deviceshare.SchedulePolicy: spread
      - name: resource-strategy-fit
        arguments:
          resourceStrategyFitWeight: 10
          resources:
            cpu:
              type: LeastAllocated
              weight: 1
            memory:
              type: LeastAllocated
              weight: 1
            nvidia.com/gpu:
              type: MostAllocated
              weight: 10

整卡池装箱(MostAllocated)。开发机在 7 台共享节点上打散(LeastAllocated + spread),同一张卡上 timeslice 共享整卡 ,不按显存/算力切块。不要再叠高权重 binpack

插件 干什么
priority 同队列 FIFO;跨队列不靠 PriorityClass
gang 多机 8×N 必须一次凑齐
conformance 不抢 kube-system
drf 队列内公平
predicates 卡不够 / 污点 / 亲和:硬过滤
proportion 算每条队列的「应得」:weight 保底 + capability 封顶;占用超过应得且 reclaimable 的才会被收,也就是只有 idle
nodegroup 整卡队列只上 train,开发机只上 dev
nodeorder 亲和、镜像本地等加分
deviceshare 开发机选卡,多人挂同一张物理卡 timeslice
resource-strategy-fit 整卡装箱;开发机打散
text 复制代码
enqueue → 节点池里还有没有卡可排
priority / drf → 谁先试
gang → 8×N 能否一次凑齐
predicates + nodegroup → 哪些节点硬性可放
打分 → 选哪一台
deviceshare → 开发机在本节点选哪张卡,多人挂上去共享整卡
reclaim → 只向 idle 要卡
(不开 preempt)

节点打标

约定:gpu-0001gpu-0118 整卡池(118 台 / 944 卡),gpu-0119gpu-0125 开发机池(7 台 / 56 卡)。

bash 复制代码
for i in $(seq -w 1 118); do
  n="gpu-0$i"
  kubectl label node "$n" pool=train volcano.sh/nodegroup-name=train --overwrite
  kubectl taint node "$n" gpu=exclusive:NoSchedule --overwrite
done

for i in $(seq -w 119 125); do
  n="gpu-0$i"
  kubectl label node "$n" pool=dev volcano.sh/nodegroup-name=dev --overwrite
  kubectl taint node "$n" gpu=shared:NoSchedule --overwrite
done

RDMA:Shared device plugin + hostNetwork

训练走这一套,两件事各干一件,缺一不可:

干什么 不干什么
Shared device plugin 把宿主机 /dev/infinibanduverbs*rdma_cm)送进容器 不切 VF,不做 SR-IOV 独占
hostNetwork Pod 用宿主机网卡,NCCL 直接打 ib0 的地址 不走 CNI、不走 macvlan / IPoIB

hostNetwork 只让容器看见 ib0 这个网口。真正打开 HCA 要的是 /dev/infiniband 里的设备节点,这个由 k8s-rdma-shared-dev-plugin 注入。不要把训练容器开成 privileged 来绕过它。

只在整卡池部署。rdmaHcaMax: 8 是「一台机器最多几个 Pod 能申请到」,不是 8 张网卡。一个作业申请 1 份,插件会把 devices 里的口都送进去。ib0 / ib1 按机器上的 IB 口改。

yaml 复制代码
apiVersion: v1
kind: ConfigMap
metadata:
  name: rdma-devices
  namespace: kube-system
data:
  config.json: |
    {
      "periodicUpdateInterval": 300,
      "configList": [
        {
          "resourceName": "hca_shared",
          "rdmaHcaMax": 8,
          "devices": ["ib0", "ib1"]
        }
      ]
    }
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: rdma-shared-dp-ds
  namespace: kube-system
spec:
  selector:
    matchLabels:
      name: rdma-shared-dp-ds
  template:
    metadata:
      labels:
        name: rdma-shared-dp-ds
    spec:
      hostNetwork: true
      nodeSelector:
        pool: train
      tolerations:
      - key: gpu
        operator: Equal
        value: exclusive
        effect: NoSchedule
      containers:
      - name: k8s-rdma-shared-dp-ds
        image: ghcr.io/mellanox/k8s-rdma-shared-dev-plugin:v1.5.3
        securityContext:
          privileged: true
        volumeMounts:
        - name: device-plugin
          mountPath: /var/lib/kubelet/device-plugins
        - name: plugins-registry
          mountPath: /var/lib/kubelet/plugins_registry
        - name: config
          mountPath: /k8s-rdma-shared-dev-plugin
        - name: devs
          mountPath: /dev/
      volumes:
      - name: device-plugin
        hostPath:
          path: /var/lib/kubelet/device-plugins
      - name: plugins-registry
        hostPath:
          path: /var/lib/kubelet/plugins_registry
      - name: config
        configMap:
          name: rdma-devices
          items:
          - key: config.json
            path: config.json
      - name: devs
        hostPath:
          path: /dev/

节点上会出现 rdma/hca_shared。训练作业申请 1 份,再加 IPC_LOCK(锁内存,RDMA 注册用)。推理同机走 NVLink,不申请这个资源,也不开 hostNetwork。开发机不部署这个 DaemonSet。

bash 复制代码
kubectl get node gpu-0001 -o json | jq '.status.allocatable["rdma/hca_shared"]'
# 正常是 "8"

这个资源名不是 nvidia.com/gpu,不进 944 的份额计算。


四、怎么交作业

先看骨架搞清楚训练和推理的区别,再直接抄对应的完整 YAML:

我要跑的 直接抄
预训练(单机 8 卡) [预训练:单机 8 卡](#我要跑的 直接抄 预训练(单机 8 卡) 预训练:单机 8 卡 预训练(多机) 预训练:4 机 32 卡 微调 微调:4 卡 在线推理 推理:Qwen3.8-27B 开发机 开发机:整卡共享 评测 / 捡闲 / 推理冲高 idle:评测和捡闲)
预训练(多机) [预训练:4 机 32 卡](#我要跑的 直接抄 预训练(单机 8 卡) 预训练:单机 8 卡 预训练(多机) 预训练:4 机 32 卡 微调 微调:4 卡 在线推理 推理:Qwen3.8-27B 开发机 开发机:整卡共享 评测 / 捡闲 / 推理冲高 idle:评测和捡闲)
微调 [微调:4 卡](#我要跑的 直接抄 预训练(单机 8 卡) 预训练:单机 8 卡 预训练(多机) 预训练:4 机 32 卡 微调 微调:4 卡 在线推理 推理:Qwen3.8-27B 开发机 开发机:整卡共享 评测 / 捡闲 / 推理冲高 idle:评测和捡闲)
在线推理 [推理:Qwen3.8-27B](#我要跑的 直接抄 预训练(单机 8 卡) 预训练:单机 8 卡 预训练(多机) 预训练:4 机 32 卡 微调 微调:4 卡 在线推理 推理:Qwen3.8-27B 开发机 开发机:整卡共享 评测 / 捡闲 / 推理冲高 idle:评测和捡闲)
开发机 [开发机:整卡共享](#我要跑的 直接抄 预训练(单机 8 卡) 预训练:单机 8 卡 预训练(多机) 预训练:4 机 32 卡 微调 微调:4 卡 在线推理 推理:Qwen3.8-27B 开发机 开发机:整卡共享 评测 / 捡闲 / 推理冲高 idle:评测和捡闲)
评测 / 捡闲 / 推理冲高 [idle:评测和捡闲](#我要跑的 直接抄 预训练(单机 8 卡) 预训练:单机 8 卡 预训练(多机) 预训练:4 机 32 卡 微调 微调:4 卡 在线推理 推理:Qwen3.8-27B 开发机 开发机:整卡共享 评测 / 捡闲 / 推理冲高 idle:评测和捡闲)

骨架:训练用 Job,推理加 PodGroup

先选 5 个模板之一,再套下面的 YAML。卡数只许模板允许的那几档。

训练、评测:用 Volcano Job

多机预训练完整 YAML 见下面「预训练:4 机 32 卡」。这里只看骨架:schedulerName、队列、gang、svc/env 插件。

yaml 复制代码
apiVersion: batch.volcano.sh/v1alpha1
kind: Job
metadata:
  name: pretrain-32gpu
  namespace: pretrain
spec:
  schedulerName: volcano
  queue: pretrain
  priorityClassName: p-never
  minAvailable: 4              # gang:4 个 worker 同时够才启动
  plugins:
    env: []
    svc: []
  tasks:
    - name: worker
      replicas: 4              # 4 机 × 每机 8 卡
      template:
        spec:
          containers:
            - name: pytorch
              image: your-train:latest
              resources:
                limits:
                  nvidia.com/gpu: 8

minAvailable 必须等于 replicas。用 torchrun --nnodes / --node_rank=${VC_TASK_INDEX},不要多机还写 --standalone。评测 / idle 可以写成 1。

推理:Deployment + 手写 PodGroup

Deployment 不会自动建 PodGroup,不写的话副本各排各的,队列统计不准:

yaml 复制代码
apiVersion: scheduling.volcano.sh/v1beta1
kind: PodGroup
metadata:
  name: infer-svc
  namespace: inference
spec:
  minMember: 1
  queue: inference
  priorityClassName: p-never
---
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    metadata:
      annotations:
        scheduling.k8s.io/group-name: infer-svc   # 绑到上面的 PodGroup
    spec:
      schedulerName: volcano
      priorityClassName: p-never
提交和查看
bash 复制代码
# 训练
kubectl apply -f job.yaml
kubectl get vcjob -n pretrain
kubectl get podgroup -n pretrain
kubectl get queue

# 看队列还剩多少卡(Allocated / capability)
kubectl get queue pretrain -o yaml

Queue 状态里的 allocated 是当前已分出去的量。到了 capability 再提交,作业会停在 Pending / Inqueue,不会超发。


完整 YAML

门户选模板即可。用户只改镜像和命令。不要自己改卡数、CPU、内存、队列。

强制:schedulerName: volcano、对的 queue、对的 priorityClassName、对的污点。CPU/内存按卡数比例写,8 卡作业把整机吃满。

生产队列作业一律 preemptable: false。只有 idle 写 preemptable: true,否则 reclaim 收不走。

分布式训练只用 Volcano Job,不要走 kube-scheduler,也不在本方案里上 Kubeflow。多机靠三样东西:

text 复制代码
gang          minAvailable = replicas,少一个 Pod 都不启动
svc + env     出无头服务和 VC_TASK_INDEX / WORKER_HOSTS
RDMA          Shared device plugin 送进 /dev/infiniband,hostNetwork 让 NCCL 打宿主机 ib0
torchrun      --nnodes / --node_rank / --master_addr,禁止多机还写 --standalone

RDMA 用 [Shared device plugin + hostNetwork](#Shared device plugin + hostNetwork)。作业申请 rdma/hca_shared: "1"(一份就包含 ib0/ib1,不要按卡数申请),并加 IPC_LOCKhostNetwork: true 必须配 dnsPolicy: ClusterFirstWithHostNet,否则解析不了集群 DNS。此时 Pod IP = 节点 IP,master_addr 用 0 号节点的 hostIP,不要用 ClusterIP。一台整卡机只跑一个 8 卡作业,23456 端口不撞。

每个训练 Pod 必须挂大 /dev/shm(NCCL 默认 64Mi 会挂)。多机一张卡坏了整组重来,所以 PodFailedRestartJob,并且作业要写 checkpoint。

训练 YAML 里这几项

下面每个训练例子都带这三块,照抄即可。含义:

IPC_LOCK:允许锁内存。 RDMA 要把一段内存钉住,网卡才能直接读写,不经过 CPU 来回拷。容器默认没有这个权限,不写的话 NCCL 打不开 IB。它不是把容器开成特权模式。

yaml 复制代码
securityContext:
  capabilities:
    add: ["IPC_LOCK"]

resource-strategy-weight:这台机器怎么选。 配合上一行 resource-strategy-scoring-type。整卡池写 MostAllocated,优先塞进已经有作业的机器,空出整台 8 卡给预训练。{"nvidia.com/gpu": 10} 表示按 GPU 装箱,CPU、内存的权重只有 1,不会反过来把卡拆散。开发机写的是 LeastAllocated,方向相反,把人摊开。

yaml 复制代码
annotations:
  volcano.sh/resource-strategy-scoring-type: "MostAllocated"
  volcano.sh/resource-strategy-weight: '{"nvidia.com/gpu": 10}'

NCCL 环境变量:多机通信走哪张网卡。 单机 8 卡主要走 NVLink,这些仍然带上,多机直接复用。

变量 干什么
NCCL_DEBUG WARN 日志只打警告和错误。排查 hang 时临时改成 INFO
NCCL_IB_DISABLE 0 打开 IB。写成 1 就退回普通网卡,带宽掉一个数量级
NCCL_NET IB 卡间数据走 IB,不走 Socket
NCCL_SOCKET_IFNAME ib0 建连用的那条 TCP 绑在 ib0 上。绑到 eth0 会握手失败或绕进 CNI
NCCL_IB_HCA mlx5 允许用的网卡,匹配本机所有 mlx5_*。要指定就写成 mlx5_0,mlx5_1
NCCL_NET_GDR_LEVEL PHB 同一颗 CPU 下的 GPU 和网卡走 GPUDirect,数据不经 CPU 内存;跨 CPU 不走。8 卡机用这一档
CUDA_DEVICE_MAX_CONNECTIONS 1 每张卡一条 CUDA 连接,通信和计算少抢通道

NCCL_NET_GDR_LEVEL 不要改成 SYS(跨 CPU,更慢),也不要改成 PIX(只有和网卡同一条 PCIe 的卡才直连,其余更慢)。

预训练:单机 8 卡

单机也用 torchrun,和多机同一套启动方式,只是 nnodes=1

yaml 复制代码
apiVersion: batch.volcano.sh/v1alpha1
kind: Job
metadata:
  name: pretrain-8gpu
  namespace: pretrain
spec:
  minAvailable: 1
  schedulerName: volcano
  queue: pretrain
  priorityClassName: p-never
  preemptable: false
  plugins:
    env: []
    svc: []
  maxRetry: 3
  tasks:
  - name: worker
    replicas: 1
    policies:
    - event: TaskCompleted
      action: CompleteJob
    - event: PodFailed
      action: RestartJob
    template:
      metadata:
        annotations:
          volcano.sh/resource-strategy-scoring-type: "MostAllocated"
          volcano.sh/resource-strategy-weight: '{"nvidia.com/gpu": 10}'
      spec:
        schedulerName: volcano
        restartPolicy: Never
        hostNetwork: true
        dnsPolicy: ClusterFirstWithHostNet
        priorityClassName: p-never
        nodeSelector:
          pool: train
        tolerations:
        - key: gpu
          operator: Equal
          value: exclusive
          effect: NoSchedule
        containers:
        - name: pytorch
          image: registry.example.com/train:torch2-cuda12
          imagePullPolicy: IfNotPresent
          command: ["bash", "-lc"]
          args:
          - |
            set -euo pipefail
            torchrun \
              --nnodes=1 \
              --nproc_per_node=8 \
              --node_rank=0 \
              --master_addr=127.0.0.1 \
              --master_port=23456 \
              train.py
          env:
          - name: NCCL_DEBUG
            value: WARN
          - name: NCCL_IB_DISABLE
            value: "0"
          - name: NCCL_NET
            value: IB
          - name: NCCL_SOCKET_IFNAME
            value: ib0
          - name: NCCL_IB_HCA
            value: mlx5
          - name: CUDA_DEVICE_MAX_CONNECTIONS
            value: "1"
          resources:
            requests:
              nvidia.com/gpu: "8"
              rdma/hca_shared: "1"
              cpu: "120"
              memory: 960Gi
            limits:
              nvidia.com/gpu: "8"
              rdma/hca_shared: "1"
              cpu: "120"
              memory: 960Gi
          volumeMounts:
          - name: dshm
            mountPath: /dev/shm
          - name: ckpt
            mountPath: /ckpt
          securityContext:
            capabilities:
              add: ["IPC_LOCK"]
        volumes:
        - name: dshm
          emptyDir:
            medium: Memory
            sizeLimit: 64Gi
        - name: ckpt
          persistentVolumeClaim:
            claimName: pretrain-ckpt
预训练:4 机 32 卡

每个 Pod 仍是整机 8 卡。replicas / minAvailable 都是 4。不要写成 32 个 1 卡 Pod。64 卡把两处改成 8 即可。

RDMA 用 Shared device plugin + hostNetwork :插件把 /dev/infiniband 送进容器,NCCL 直接打宿主机 ib0,不走 CNI。hostNetwork 下 Pod IP 就是节点 IP。svc 仍用来解析 0 号 worker;dnsPolicy: ClusterFirstWithHostNet 保证能解析 {Job名}-worker-0.{Job名},解析出来的就是 rank0 的节点 IP,作为 master_addr

任意一台失败整 Job 重拉(RestartJob),从 /ckpt 续训。

yaml 复制代码
apiVersion: batch.volcano.sh/v1alpha1
kind: Job
metadata:
  name: pretrain-32gpu
  namespace: pretrain
spec:
  minAvailable: 4
  schedulerName: volcano
  queue: pretrain
  priorityClassName: p-never
  preemptable: false
  plugins:
    env: []
    svc: []
  maxRetry: 3
  tasks:
  - name: worker
    replicas: 4
    policies:
    - event: TaskCompleted
      action: CompleteJob
    - event: PodFailed
      action: RestartJob
    template:
      metadata:
        annotations:
          volcano.sh/resource-strategy-scoring-type: "MostAllocated"
          volcano.sh/resource-strategy-weight: '{"nvidia.com/gpu": 10}'
      spec:
        schedulerName: volcano
        restartPolicy: Never
        hostNetwork: true
        dnsPolicy: ClusterFirstWithHostNet
        priorityClassName: p-never
        nodeSelector:
          pool: train
        tolerations:
        - key: gpu
          operator: Equal
          value: exclusive
          effect: NoSchedule
        containers:
        - name: pytorch
          image: registry.example.com/train:torch2-cuda12
          imagePullPolicy: IfNotPresent
          command: ["bash", "-lc"]
          args:
          - |
            set -euo pipefail
            NNODES=4
            NPROC_PER_NODE=8
            MASTER_PORT=23456
            MASTER_HOST="${VC_JOB_NAME}-worker-0.${VC_JOB_NAME}"
            MASTER_ADDR="$(getent ahostsv4 "${MASTER_HOST}" | awk '{print $1; exit}')"
            if [[ -z "${MASTER_ADDR}" ]]; then
              MASTER_ADDR="${WORKER_HOSTS%%,*}"
            fi
            IFACE="${NCCL_SOCKET_IFNAME:-ib0}"
            if ip -4 addr show "${IFACE}" >/dev/null 2>&1; then
              export NCCL_SOCKET_IFNAME="${IFACE}"
            fi
            torchrun \
              --nnodes="${NNODES}" \
              --nproc_per_node="${NPROC_PER_NODE}" \
              --node_rank="${VC_TASK_INDEX}" \
              --master_addr="${MASTER_ADDR}" \
              --master_port="${MASTER_PORT}" \
              train.py
          env:
          - name: HOST_IP
            valueFrom:
              fieldRef:
                fieldPath: status.hostIP
          - name: NCCL_DEBUG
            value: WARN
          - name: NCCL_IB_DISABLE
            value: "0"
          - name: NCCL_NET
            value: IB
          - name: NCCL_SOCKET_IFNAME
            value: ib0
          - name: NCCL_IB_HCA
            value: mlx5
          - name: NCCL_NET_GDR_LEVEL
            value: PHB
          - name: CUDA_DEVICE_MAX_CONNECTIONS
            value: "1"
          resources:
            requests:
              nvidia.com/gpu: "8"
              rdma/hca_shared: "1"
              cpu: "120"
              memory: 960Gi
            limits:
              nvidia.com/gpu: "8"
              rdma/hca_shared: "1"
              cpu: "120"
              memory: 960Gi
          volumeMounts:
          - name: dshm
            mountPath: /dev/shm
          - name: ckpt
            mountPath: /ckpt
          securityContext:
            capabilities:
              add: ["IPC_LOCK"]
        volumes:
        - name: dshm
          emptyDir:
            medium: Memory
            sizeLimit: 64Gi
        - name: ckpt
          persistentVolumeClaim:
            claimName: pretrain-ckpt

VC_JOB_NAME 没有时,Job 名写死:MASTER_HOST=pretrain-32gpu-worker-0.pretrain-32gpugetent 失败再退回 WORKER_HOSTS 第一个(hostNetwork 下一般已是节点 IP)。

IB 网卡 / HCA 按机器改:NCCL_SOCKET_IFNAMEib0 / ib1)、NCCL_IB_HCAmlx5mlx5_0,mlx5_1)。RoCE 把 NCCL_NET 改成 IB 仍可用,或按现场改 NCCL_IB_GID_INDEX。不要再给 NCCL 填 eth0 的 CNI 地址。不要改成 SR-IOV 独占,也不要为了看见设备把训练容器开成 privileged

微调:4 卡

卡数选 2 / 4 / 8。要 6 卡就改成 8。下面是 4 卡;2 / 8 只改 GPU 和按表改 CPU/内存。多机套多机预训练,改队列和 nnodes

yaml 复制代码
apiVersion: batch.volcano.sh/v1alpha1
kind: Job
metadata:
  name: sft-4gpu
  namespace: post-pretrain
spec:
  minAvailable: 1
  schedulerName: volcano
  queue: post-pretrain
  priorityClassName: p-never
  preemptable: false
  plugins:
    env: []
    svc: []
  maxRetry: 2
  tasks:
  - name: worker
    replicas: 1
    policies:
    - event: TaskCompleted
      action: CompleteJob
    - event: PodFailed
      action: RestartJob
    template:
      metadata:
        annotations:
          volcano.sh/resource-strategy-scoring-type: "MostAllocated"
          volcano.sh/resource-strategy-weight: '{"nvidia.com/gpu": 10}'
      spec:
        schedulerName: volcano
        restartPolicy: Never
        hostNetwork: true
        dnsPolicy: ClusterFirstWithHostNet
        priorityClassName: p-never
        nodeSelector:
          pool: train
        tolerations:
        - key: gpu
          operator: Equal
          value: exclusive
          effect: NoSchedule
        containers:
        - name: pytorch
          image: registry.example.com/train:torch2-cuda12
          imagePullPolicy: IfNotPresent
          command: ["bash", "-lc"]
          args:
          - |
            set -euo pipefail
            torchrun \
              --nnodes=1 \
              --nproc_per_node=4 \
              --node_rank=0 \
              --master_addr=127.0.0.1 \
              --master_port=23456 \
              sft.py
          env:
          - name: NCCL_DEBUG
            value: WARN
          - name: NCCL_IB_DISABLE
            value: "0"
          - name: NCCL_NET
            value: IB
          - name: NCCL_SOCKET_IFNAME
            value: ib0
          - name: NCCL_IB_HCA
            value: mlx5
          resources:
            requests:
              nvidia.com/gpu: "4"
              rdma/hca_shared: "1"
              cpu: "60"
              memory: 480Gi
            limits:
              nvidia.com/gpu: "4"
              rdma/hca_shared: "1"
              cpu: "60"
              memory: 480Gi
          volumeMounts:
          - name: dshm
            mountPath: /dev/shm
          - name: ckpt
            mountPath: /ckpt
          securityContext:
            capabilities:
              add: ["IPC_LOCK"]
        volumes:
        - name: dshm
          emptyDir:
            medium: Memory
            sizeLimit: 32Gi
        - name: ckpt
          persistentVolumeClaim:
            claimName: sft-ckpt
推理:Qwen3.8-27B

inference 队列,和训练同一 pool=train。额度内不被训练收。一个副本 = 同机 2×A800,vLLM TP=2,卡间走 NVLink,不用 hostNetwork

A800 是 Ampere,权重用 BF16 ,不要上 FP8 权重(没有 FP8 张量核,反而慢)。KV cache 可以用 FP8。权重放 PVC,不要启动时现拉。扩容加 replicas;超过 80 卡的副本交 idle

yaml 复制代码
apiVersion: scheduling.volcano.sh/v1beta1
kind: PodGroup
metadata:
  name: infer-qwen38-27b
  namespace: inference
spec:
  minMember: 1
  minResources:
    nvidia.com/gpu: "2"
  queue: inference
  priorityClassName: p-never
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: infer-qwen38-27b
  namespace: inference
spec:
  replicas: 1
  selector:
    matchLabels:
      app: infer-qwen38-27b
  template:
    metadata:
      labels:
        app: infer-qwen38-27b
      annotations:
        scheduling.k8s.io/group-name: infer-qwen38-27b
        volcano.sh/preemptable: "false"
        volcano.sh/resource-strategy-scoring-type: "MostAllocated"
        volcano.sh/resource-strategy-weight: '{"nvidia.com/gpu": 10}'
    spec:
      schedulerName: volcano
      priorityClassName: p-never
      restartPolicy: Always
      nodeSelector:
        pool: train
      tolerations:
      - key: gpu
        operator: Equal
        value: exclusive
        effect: NoSchedule
      containers:
      - name: vllm
        image: vllm/vllm-openai:v0.26.0
        imagePullPolicy: IfNotPresent
        command: ["bash", "-lc"]
        args:
        - |
          set -euo pipefail
          vllm serve /models/Qwen3.8-27B \
            --host 0.0.0.0 \
            --port 8000 \
            --served-model-name qwen3.8-27b \
            --trust-remote-code \
            --tensor-parallel-size 2 \
            --dtype bfloat16 \
            --kv-cache-dtype fp8 \
            --max-model-len 32768 \
            --gpu-memory-utilization 0.92 \
            --max-num-seqs 64 \
            --max-num-batched-tokens 8192 \
            --enable-prefix-caching \
            --reasoning-parser qwen3 \
            --enable-auto-tool-choice \
            --tool-call-parser qwen3_coder
        ports:
        - name: http
          containerPort: 8000
        env:
        - name: VLLM_WORKER_MULTIPROC_METHOD
          value: spawn
        readinessProbe:
          httpGet:
            path: /v1/models
            port: http
          initialDelaySeconds: 60
          periodSeconds: 10
        livenessProbe:
          httpGet:
            path: /health
            port: http
          initialDelaySeconds: 120
          periodSeconds: 20
        resources:
          requests:
            nvidia.com/gpu: "2"
            cpu: "30"
            memory: 240Gi
          limits:
            nvidia.com/gpu: "2"
            cpu: "30"
            memory: 240Gi
        volumeMounts:
        - name: dshm
          mountPath: /dev/shm
        - name: model
          mountPath: /models/Qwen3.8-27B
          readOnly: true
      volumes:
      - name: dshm
        emptyDir:
          medium: Memory
          sizeLimit: 16Gi
      - name: model
        persistentVolumeClaim:
          claimName: qwen38-27b-weights
---
apiVersion: v1
kind: Service
metadata:
  name: infer-qwen38-27b
  namespace: inference
spec:
  selector:
    app: infer-qwen38-27b
  ports:
  - name: http
    port: 8000
    targetPort: http

上下文要拉到 128K / 256K,把 --max-model-len 加大,显存不够就加副本卡数(4 卡 TP=4)或把 gpu-memory-utilization 降到 0.90。1 / 4 / 8 卡只改 GPU、CPU/内存和 --tensor-parallel-size。额度满了排队,超额副本交 idle

开发机:整卡共享

所有人走 dev 命名空间 + dev 队列。7 台 / 56 卡。一张物理卡上多人同时挂着,看见的都是整卡 80GB / 完整算力 ,由驱动做时间片轮转。不要写 vgpu-memory / vgpu-cores,那是把卡切成几份。

preemptable: falsepriorityClassName: p-never。同时能挂多少人,靠 device plugin 在开发机节点上把每张卡复制成若干份 nvidia.com/gpu.shared(下面 replicas=8 表示一张卡最多 8 人,不是切成 8 块)。

yaml 复制代码
# 只在 pool=dev 节点启用。整卡池不要开。
# nvidia-device-plugin ConfigMap 片段
version: v1
sharing:
  timeSlicing:
    renameByDefault: true      # 必须 true:上报成 nvidia.com/gpu.shared
    resources:
    - name: nvidia.com/gpu
      replicas: 8

renameByDefault: true 不是可选项。写 false 的话,这 7 台会按 nvidia.com/gpu 上报 7×8×8 = 448 张虚拟卡,全集群 GPU 总量从 944 变成 1392,整卡池所有队列的「应得」被一起放大,idle 占着几百张也不算超额,生产队列就再也收不回卡了 (见 [weight 到底管什么](#weight 到底管什么))。改名之后,开发机的卡和整卡池是两种资源,各算各的。

yaml 复制代码
apiVersion: scheduling.volcano.sh/v1beta1
kind: PodGroup
metadata:
  name: devbox-zhangsan
  namespace: dev
spec:
  minMember: 1
  queue: dev
  priorityClassName: p-never
  minResources:
    nvidia.com/gpu.shared: "1"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: devbox-zhangsan
  namespace: dev
spec:
  replicas: 1
  selector:
    matchLabels:
      app: devbox-zhangsan
  template:
    metadata:
      labels:
        app: devbox-zhangsan
      annotations:
        scheduling.k8s.io/group-name: devbox-zhangsan
        volcano.sh/preemptable: "false"
        volcano.sh/resource-strategy-scoring-type: "LeastAllocated"
        volcano.sh/resource-strategy-weight: '{"nvidia.com/gpu.shared": 10}'
    spec:
      schedulerName: volcano
      priorityClassName: p-never
      restartPolicy: Always
      nodeSelector:
        pool: dev
      tolerations:
      - key: gpu
        operator: Equal
        value: shared
        effect: NoSchedule
      containers:
      - name: devbox
        image: registry.example.com/devbox:jupyter
        resources:
          requests:
            cpu: "8"
            memory: 32Gi
            nvidia.com/gpu.shared: "1"   # 不是 nvidia.com/gpu,那是整卡池的资源名
          limits:
            cpu: "8"
            memory: 32Gi
            nvidia.com/gpu.shared: "1"

开发机池满了排队,禁止 到整卡池借卡。不要用开发机跑训练或挂推理。要独占整卡做调试,走 idle

调度只负责把人摊到 7 台、尽量不同物理卡;卡上不切块。显存大家共用,谁占满谁把别人 OOM,门户上说清楚,不要当训练机用。

idle:评测和捡闲

短评测、可重跑实验、推理超额副本走这里。preemptable: true 必须开。门户上写清楚:这条队列的作业随时会被生产队列收掉。

yaml 复制代码
apiVersion: batch.volcano.sh/v1alpha1
kind: Job
metadata:
  name: idle-eval-1gpu
  namespace: idle
spec:
  minAvailable: 1
  schedulerName: volcano
  queue: idle
  priorityClassName: p-never
  preemptable: true
  plugins:
    env: []
  maxRetry: 3
  tasks:
  - name: eval
    replicas: 1
    policies:
    - event: TaskCompleted
      action: CompleteJob
    template:
      metadata:
        annotations:
          volcano.sh/resource-strategy-scoring-type: "MostAllocated"
          volcano.sh/resource-strategy-weight: '{"nvidia.com/gpu": 10}'
      spec:
        schedulerName: volcano
        restartPolicy: Never
        priorityClassName: p-never
        nodeSelector:
          pool: train
        tolerations:
        - key: gpu
          operator: Equal
          value: exclusive
          effect: NoSchedule
        containers:
        - name: eval
          image: registry.example.com/eval:latest
          resources:
            requests:
              nvidia.com/gpu: "1"
              cpu: "15"
              memory: 120Gi
            limits:
              nvidia.com/gpu: "1"
              cpu: "15"
              memory: 120Gi

推理超额副本同样:namespace / queue 改成 idlepriorityClassName: p-nevervolcano.sh/preemptable: "true"。不要和额度内 SLA 副本混在同一个 Deployment 里。


五、使用规范

门户上要写给用户看的几条。选哪个队列、哪个模板见 [30 秒上手](#30 秒上手)。

类型 建议最长运行 被抢了怎么办
预训练 按作业;checkpoint 按小时 不被抢;额度满了排队
微调 数小时~2 天 不被抢;额度满了排队
推理 长驻 额度内不被抢;超额副本交 idle
idle 默认数小时 会被收,必须能重跑
开发机 默认 12 小时,可续期 不被挤;席位满了排队

卡数:预训练每机 8;微调 2/4/8;推理 / idle 1/2/4/8。禁止 3/5/6/7。CPU / 内存跟卡数走,模板里已经填好,见 五个模板

checkpoint:交 idle 的作业必须能重跑,随时会被收。预训练 / 微调也建议写 checkpoint,那是给机器故障用的,不是给抢占用的。


六、常见问题排查

先分清是 没排上 还是 已经在跑但卡住

bash 复制代码
kubectl get vcjob,podgroup,pod -n pretrain
kubectl describe vcjob <作业名> -n pretrain
kubectl get queue pretrain -o yaml          # 看 allocated 有没有顶到 capability
kubectl logs <pod> -n pretrain --tail=200

训练一直 Pending / Inqueue

  • schedulerName 不是 volcano,或进了 default 队列:改模板重提。
  • 队列 allocated 已经到 capability:等自己队列有人跑完,或看是不是该收 idle。
  • minAvailable 大于现在能凑齐的整机数:少一台也不启动,这是 gang,不是 hang。看还有没有 8 卡空机;没有就等,或从 idle 要。
  • 污点 / 节点组不对:必须能忍 gpu=exclusive,且只上 pool=train
  • 事件里写 rdma/hca_shared 不够:整卡池的 Shared device plugin 没起来,或这台机器的 devices 里没有实际的 IB 口。推理和开发机不要申请这个资源。

训练要卡,idle 还占着,就是收不回来

按顺序查:

  1. idle 作业没写 preemptable: true,或 idle 队列的 reclaimable 不是 true。
  2. 预训练已经到 capability 700:不再收 idle,排队是对的。
  3. 调度器 actions 里漏了 reclaim
  4. 全集群 GPU 总量不是 944kubectl get node -o json | jq '[.items[].status.allocatable["nvidia.com/gpu"]|tonumber]|add'。数出来接近 1392,说明开发机的时间片卡没改名,占着 nvidia.com/gpu 把分母撑大了------去把 device plugin 的 renameByDefault 改成 true(见 开发机)。
  5. weight 配错:整卡池必须是 700 / 163 / 80 / 1,之和 944。全写 1 或全抄 capability 都会让预训练收不满(见 [weight 到底管什么](#weight 到底管什么))。

Pod 都 Running,训练 hang 住(步数不涨、NCCL 一直 init)

这不是排队问题,是分布式没进组:

  1. 看是不是所有 worker 都 Running。少一个,torchrun 会一直等,像 hang。
  2. 多机不要写 --standalonenode_rankVC_TASK_INDEXmaster_addr 必须是 0 号节点的 hostIP。
  3. 必须申请 rdma/hca_shared: "1",并且 hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet,NCCL 走 ib0。容器里没有 /dev/infiniband/uverbs*,是 Shared device plugin 没起来或没申请资源。打到 eth0 / CNI 就会卡在握手。
  4. 没挂大 /dev/shm(建议 64Gi),集合通信会挂死。
  5. 一台失败整组重来:PodFailedRestartJob,从 /ckpt 续。不要只重启一个 rank。

一台卡住可以先看日志里有没有 NCCL / connect / Timed out,再对上面 2~4。确认进组失败就删掉 vcjob 重提,不要杀单个 Pod。

只有部分 worker 起来

gang 没凑齐。不要手动先跑起来的那几台。等齐,或取消作业改小 replicas

推理起不来

  • 额度 80 满了:排队,或超额走 idle
  • 没写 PodGroup / scheduling.k8s.io/group-name:副本没进 inference 队列。
  • vLLM 日志 OOM:27B 用 2 卡 BF16;上下文太大就加卡或把 --max-model-len 降下来。

开发机 Pending

  • 开发机 7 台满了:排队,禁止借整卡池。
  • 写了 vgpu-memory / vgpu-cores:去掉,整卡共享即可。
  • 请求写成了 nvidia.com/gpu:开发机上报的是 nvidia.com/gpu.shared,名字不对就永远排不上。

生产作业(预训练 / 微调 / 推理)被杀掉了

不该发生。看是不是生产队列被改成 reclaimable: true,或调度器开了 preempt。机器故障 / 节点被抽走除外,那种从 checkpoint 续。

一句话: 没排上先看队列和 gang;已经 Running 却 hang,先看是不是少 worker、NCCL/IB、/dev/shm

相关推荐
程序员无隅1 小时前
Pi Agent Loop 完整流程源码解析:从 Agent.prompt() 到 agent_end
ai
霸道流氓气质1 小时前
Prompt注入攻击与防御体系
ai
新中地GIS开发老师1 小时前
2026年GIS行业正在发生什么?
ai·gis开发·低空经济
啦啦啦!2 小时前
RAG初阶学习
学习·ai·知识库·rag
合米AI SOP系统2 小时前
标杆客户真实改变,合米科技AI SOP视觉防错系统上线之后制造产线发生了什么?
人工智能·科技·ai
Ai-_Man2 小时前
您您这可以把Mistral AI的多个会话比如说。左侧的多个会话一次性导出吗?不是单条会话里面的多次会对话。
人工智能·ai·小程序·电脑
菩提小狗2 小时前
每日极客日报 · 2026年09月21日
ai·开源·极客日报·it热点·技术资讯
codigger2 小时前
我用 AI 做完整项目后,总结出一套把需求钉死的工作流
ai·程序员·编程·ai编程·#人工智能
程序员清风3 小时前
系统架构设计:模型服务、业务服务与知识库如何拆分
人工智能·ai·架构·aigc