volcano千卡集群训推最佳实践
1000 张 A800(每台 8 卡、共 125 台)训推共用集群。全文一条规则:生产队列互相不杀,闲卡进 idle,要卡只收 idle。
目录
- [30 秒上手](#30 秒上手)
- [Volcano 入门](#Volcano 入门)------是什么、五个对象、作业怎么走完
- 集群怎么切------队列额度、回收规则、五个模板(策略都在这节)
- 平台配置------命名空间 / 队列 / 调度器 / 节点打标的 YAML
- 怎么交作业------五个模板的完整 YAML
- 使用规范------卡数、时长、门户上要写给用户的话
- 常见问题排查------没排上 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 # 全集群只有这一个
三条要记住的:
- 跑起来就不会被杀(idle 除外)。额度满了是排队,不是抢。
- 没人用的卡自动给 idle 吃;生产队列要卡时只从 idle 收回来,不动其他生产队列。
- 卡数只许 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,只表示不许杀正在跑的作业 |
平台,事先建好 |
Job (vcjob)或 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 | 别人 能不能要回 这条队列正在用的卡。本集群只有 idle 为 true |
| 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 共享,不切显存/算力 | 不外借 | 不能 | 不能 |
规则就三条:
- 开发机用节点标签隔离,约 50 卡(7 台 / 56 卡)。多人 timeslice 共享整卡,不切显存/算力,不和训练整卡混部。
- 训练 + 推理 共用整卡池,用队列
capability限卡。到了上限就排队。评测走 idle。 - 只有 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-4gpu、infer-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 |
必须同时满足:
- 调度 actions 里有
reclaim,不要 开preempt。 - 只有
idle的reclaimable: true。生产队列全是false。 - 队列
priority:pretrain>post-pretrain>inference>idle。 - 整卡池
weight写成700 / 163 / 80 / 1,之和等于整卡池卡数 944。 - 开发机的共享卡改名成
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。不要再拆 p0~p5。
队列
节点只要两套:整卡机 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-0001~gpu-0118 整卡池(118 台 / 944 卡),gpu-0119~gpu-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/infiniband(uverbs*、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_LOCK。hostNetwork: true 必须配 dnsPolicy: ClusterFirstWithHostNet,否则解析不了集群 DNS。此时 Pod IP = 节点 IP,master_addr 用 0 号节点的 hostIP,不要用 ClusterIP。一台整卡机只跑一个 8 卡作业,23456 端口不撞。
每个训练 Pod 必须挂大 /dev/shm(NCCL 默认 64Mi 会挂)。多机一张卡坏了整组重来,所以 PodFailed 用 RestartJob,并且作业要写 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-32gpu。getent 失败再退回 WORKER_HOSTS 第一个(hostNetwork 下一般已是节点 IP)。
IB 网卡 / HCA 按机器改:NCCL_SOCKET_IFNAME(ib0 / ib1)、NCCL_IB_HCA(mlx5 或 mlx5_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: false,priorityClassName: 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 改成 idle,priorityClassName: p-never,volcano.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 还占着,就是收不回来
按顺序查:
- idle 作业没写
preemptable: true,或idle队列的reclaimable不是 true。 - 预训练已经到 capability 700:不再收 idle,排队是对的。
- 调度器 actions 里漏了
reclaim。 - 全集群 GPU 总量不是 944 :
kubectl get node -o json | jq '[.items[].status.allocatable["nvidia.com/gpu"]|tonumber]|add'。数出来接近 1392,说明开发机的时间片卡没改名,占着nvidia.com/gpu把分母撑大了------去把 device plugin 的renameByDefault改成true(见 开发机)。 - weight 配错:整卡池必须是 700 / 163 / 80 / 1,之和 944。全写 1 或全抄 capability 都会让预训练收不满(见 [weight 到底管什么](#weight 到底管什么))。
Pod 都 Running,训练 hang 住(步数不涨、NCCL 一直 init)
这不是排队问题,是分布式没进组:
- 看是不是所有 worker 都 Running。少一个,
torchrun会一直等,像 hang。 - 多机不要写
--standalone。node_rank用VC_TASK_INDEX,master_addr必须是 0 号节点的 hostIP。 - 必须申请
rdma/hca_shared: "1",并且hostNetwork: true+dnsPolicy: ClusterFirstWithHostNet,NCCL 走ib0。容器里没有/dev/infiniband/uverbs*,是 Shared device plugin 没起来或没申请资源。打到 eth0 / CNI 就会卡在握手。 - 没挂大
/dev/shm(建议 64Gi),集合通信会挂死。 - 一台失败整组重来:
PodFailed用RestartJob,从/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。