Sky-Scheduler:面向 AI 训练的拓扑感知与 Quota 两级调度器

把一个千卡训练任务调度好,比把一百个 Web 服务调度好难得多。难点不在"算力够不够",而在三件 Web 服务从不操心的事:

  • 通信拓扑要聚拢:TP/PP 切出来的通信组,必须落在同一片 RDMA 交换机域里,跨机架一跳,AllReduce 延迟能差好几倍。
  • 资源要"留得住":训练任务动辄跑几小时几天,中途被别的任务抢占、重启就要从头来;但集群又要混部在线/离线,不能不让抢。
  • 同卡型才好比:A100 节点和 H100 节点不能混进同一个通信组,不然快的等慢的,贵的卡当便宜的用。

通用调度器(kube-scheduler)对这三件事基本无解,Volcano 解决了 Gang 但也没碰拓扑。Sky-Scheduler 就是冲着这三件事来的——一个 Volcano 衍生的 Kubernetes 调度器,在 Volcano 的 session 框架上做了几处关键改造:把"Pod→Node 一跳"改成 “Task→Quota→Node 两跳”,引入 RDMA 拓扑发现与 Zone 感知Spot GPU 调度存储感知多规格资源池

前置阅读:Sky-Scheduler 通过 schedulerName 路由接管任务,与 kube-scheduler 共存。多调度器共存的机制、模式与坑见 一个集群,多个调度器:Kubernetes 多调度器共存实践指南;Volcano Queue 的全流程见 Volcano Queue 全流程拆解


一、先看全貌:三个二进制怎么协作

Sky-Scheduler 不是一个进程,是三个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
           ┌──────────────────────────────┐
│ scheduler(主调度进程) │
│ │
│ 每 1 秒一个调度周期: │
│ 快照 cache → OpenSession │
│ → 跑 actions 流水 │
│ → Statement 事务 Commit │
└──────────────┬───────────────┘
│ watch CRD + Pod(NodeName 过滤)
┌──────────────▼───────────────┐
│ K8s API Server │
│ PodGroup/Queue/Quota/JobQueue│
│ NodeTopology(CRD)/Node/Pod │
└──▲─────────────────────▲─────┘
│ │ 写 NodeTopology CRD
┌─────────────┴──────┐ ┌───────────┴────────────┐
│ topology-discovery │ │ controller-manager │
│ RoCE: DaemonSet │ │ (CRD 控制器) │
│ IB: Deployment │ │ │
└────────────────────┘ └────────────────────────┘
  • scheduler:大脑。每秒一个周期,从 cache 快照开一个 session,顺序跑 actions,事务提交。
  • topology-discovery:眼睛。专门探测"每张 RDMA 网卡插在哪个交换机上",写进 NodeTopology CRD。
  • controller-manager:手。管 CRD 的生命周期。

scheduler 和 topology-discovery 解耦——拓扑发现是个慢活(要读 LLDP/ibnetdiscover),不该挡调度周期;发现完写 CRD,scheduler watch 来用。这是个很干净的"感知-决策"分离。


二、Quota 两级调度:为什么不一跳直接绑节点?

这是 Sky-Scheduler 相对 Volcano 最根本的结构差异,值得先讲透。

2.1 Volcano 的痛:一跳调度的局限

Volcano 是 Pod→Node 一跳:调度器看到一个 Pod,直接挑一个节点绑上去。听起来直接,但在 AI 训练场景会撞上几堵墙:

  • 资源"留不住":训练任务被抢占后重启,想优先用回原来的位置(数据本地性、拓扑已对齐),但一跳调度里 Pod 一旦没了,调度器对"它原来在哪"毫无记忆,重新乱放。
  • 亲和难表达:你想说"这个 Pod 要和那个 Pod 在同节点(或同拓扑域)",在一跳模型里只能靠 K8s 的 podAffinity,但 podAffinity 是 Pod 级的、刚性的、且对"quota/资源池"这种逻辑分组无感。
  • 资源池抽象缺失:实际集群里,资源常常是按"逻辑配额"切的(团队 A 占多少、项目 B 占多少),不是按节点切的。一跳调度把 Pod 直接砸到物理节点,跳过了"该用哪份配额"这一层决策。

2.2 Sky-Scheduler 的解:中间加一层 Quota

Sky-Scheduler 在 Pod 和 Node 之间塞了一个 Quota 对象,调度变成两跳:

1
2
3
4
5
Volcano:     Task  ───────────────────►  Node        (一跳)

Sky-Scheduler: Task ──► Quota ──► Node (两跳)
(逻辑资源池)
① 选哪份配额 ② 配额绑到哪个物理节点

Quota 是什么?你可以理解成一个**“资源容器”**:它声明了一份资源额度(多少 CPU/GPU/内存),最终会被绑定到一个具体的物理节点上。一个节点上可以有多个 Quota。调度分两步:

  1. Task→Quota(allocateJob):决定这个 task 用哪份配额——按 required(任务声明的) > reserved(预留的) > bound/static-bound(已绑/静态绑的) > free(空闲) 四档挑。
  2. Quota→Node(allocateZone/allocateQuota):决定这份配额落到哪个物理节点——这一步才考虑拓扑、存储、节点亲和。

2.3 Quota 从哪来?和 DevicePlugin 是什么关系?

这是读者最容易问的问题:Quota 是从节点的 device-plugin 来的吗? 不是。两者是正交的,一个逻辑、一个物理。

Quota 是个独立的集群级 CRD(scheduling.infini-ai.com/v1alpha1,scope=Cluster,shortName qo),像 Pod/Deployment 一样声明式创建。它的 QuotaSpec 长这样:

1
2
3
4
5
6
7
8
9
type QuotaSpec struct {
QueueName string // 归属哪个 Queue
Request v1.ResourceList // 这份配额声明多少资源(CPU/GPU/内存)
NodeSelector map[string]string // 限制能绑到哪类节点(比如 spec=A100)
Tolerations []v1.Toleration
Affinity *Affinity // 节点/quota 亲和
SharedQueues []string // 共享给哪些 queue
StaticBindNode string // 静态绑定到指定节点
}

QuotaStatus 记录运行时:Phase(Empty→Bound 生命周期)、NodeName(最终绑到哪个节点)、AllocatedShadowResource(超售池)、StaticBindStatus。所以 Quota 是个人造的中间资源对象——你(或上层编排系统)显式 kubectl apply quota.yaml 创建它,声明"我要一份 8 卡 A100 的配额,限定 spec=A100 节点,归属 research 队列"。

DevicePlugin 是 K8s 原生机制,把硬件设备 advertise 成 extended resource(nvidia.com/gpurdma/hcardma/roce 等)——这是节点物理事实,由设备厂商 daemonset 跑在节点上向 kubelet 上报。

两者配合而非替代,在 Quota→Node 这一跳相遇:

1
2
3
4
5
6
7
物理层(事实):  Node ──DevicePlugin──► nvidia.com/gpu: 8, rdma/hca: 8

│ 绑定时校验(node.QuotaIdle 够不够)
逻辑层(抽象): Quota(Request: 8 GPU)──Sky-Scheduler 绑定──► Node

│ AllocateTaskToQuota
任务层: Task ──► 选哪份 Quota

具体交集点:

  1. rdma/rocerdma/hca:由 RDMA device-plugin 上报到节点,Pod request 它触发拓扑调度开启(NeedRDMA())。这是"读"设备资源决定调度行为,不是"Quota 由它生成"。
  2. spot-gpu:Spot GPU 的 extended resource,device-plugin 上报,sylas 插件读它做 spot 池拆分。
  3. 节点 GPU 计数:NodeInfo.QuotaIdle = 节点 allocatable(含 device-plugin 上报的 extended resource)减去已绑 quota 占用。Quota→Node 绑定时 predicate 校验这个——“这节点真有这么多设备吗”。

一句话:Quota 是独立 CRD,由人/编排声明式创建;DevicePlugin 上报的 extended resource 是节点物理事实,Quota 绑节点时被用来校验。 Quota 创建时 status.nodeName 是空的(纯逻辑份额),调度器在 allocateQuota/allocateZone 里经 QNFns predicate + 评分调 BindQuotaToNode 写上 nodeName,Quota 才"落"到具体节点;fixQuotaBinding 每周期从该 quota 下 live pod 反推 nodeName 自愈防漂移。

2.4 这层 Quota 带来的四个超能力

加这一层不是炫技,它解锁了 Volcano 做不到的四件事:

① 静态绑定:任务的 quota 可以预先钉死某个节点(Quota.Status.StaticBindStatus)。allocateJob 优先选静态绑定的 quota。这等价于"这个任务指定要这台机器"——但又不是 Pod 级的 nodeSelector(那种是硬约束、没法被 quota 层的亲和/预留微调),而是"偏好+可调度性校验"的中间态。对应 commit “优先选择静态绑定的 Quota”。

② 资源预留(Reservation):训练任务被抢占时,它的 quota 不立刻释放,而是留一个 ReservedTask + TTL 在 quota 里。任务重启后,新 Pod 可以 AllocateReservedResource(经 AnnoInheritResourceFrom 继承)直接吃回这份预留——保住原来的位置。这对长时间训练的容错体验是质的提升:重启不是从零开始乱放,而是"回到原位"。

③ Quota 间亲和/反亲和:可以声明"quota A 和 quota B 必须同节点"(inter-quota affinity)或"不能同节点"(anti-affinity),以及 pod→quota 亲和。这比 K8s 的 podAffinity 表达力更强,因为 Quota 是个稳定的逻辑对象,Pod 来来去去,Quota 的亲和关系不动。

④ 多规格资源池:Quota 能感知 spec code(节点 label 算出来的"卡型标识"),A100 的 quota 和 H100 的 quota 天然分开管,不会混。

2.5 新状态:AllocatedToQuota

两跳意味着 task 多了一个中间态。Sky-Scheduler 的 task 状态机:

1
2
3
Pending ──► AllocatedToQuota ──► Allocated ──► Binding ──► Bound ──► Running
(新增!选好了quota, (绑到节点) (写Binding) (节点上跑)
还没绑节点)

AllocatedToQuota 是 Sky-Scheduler 的发明:task 已经认领了 quota,但 quota 还没绑到物理节点。这给"先占逻辑资源、再选物理位置"留出了空间——也带来了状态机复杂度,所以有 fixQuotaBinding 自愈机制:每周期从 live pod 位置重建 quota.Status.NodeName,防止状态漂移。

2.6 Quota→Node 这一跳具体怎么做?

讲清两跳模型后,最自然的追问是:第二跳 Quota→Node 到底怎么挑节点、怎么绑?这是 Sky-Scheduler 调度的核心环节,值得展开。

谁来做:allocateZoneallocateQuota 两个 action

每周期 actions 流水里,allocateJob(第一跳 task→quota)跑完后,紧接两个做 Quota→Node 的 action,顺序执行:

  • allocateZone:专管拓扑 job。门槛是 job 开了拓扑调度且 task 已全部分到 quota(canAllocateZone)。它先调 ssn.PredicateZone(job)——拓扑插件按权重取第一个返回结果的算法(namedzone/continuity),把"已绑兄弟 task 所在 zone"的节点集合圈出来,候选节点预先限定在 zone 内,再走打分挑节点。
  • allocateQuota:通用兜底。对剩下所有未绑 quota,在全集群 ssn.NodeList 上挑节点。

两者挑节点的打分机制相同(都用 QNFns),区别只在候选节点集合:allocateZone 限定在 zone 内,allocateQuota 全集群。

怎么挑:QNFns 五阶段流水

QNFns(Quota→Node 的 plugin 函数包)对每个 (quota, node) 跑五阶段,各阶段由不同 plugin 注册、用表达式语言组合:

1
PrePredicate(一次) → Select(每节点) → Predicate(每节点) → NOrder+BatchNOrder(打分) → BestN(选最优)
阶段 默认组合算子 语义 注册的 plugin(QNFns)
PrePredicate or(...) 一次性预计算(算 nodeAffinity 的 CycleState、预测 zone 候选节点进 quotaCache) predicates、quotaaffinity、topology
Select joint(...) 初筛:quota 的 NodeSelector vs 节点 label quotapredicates
Predicate joint(...) 否决式过滤:节点 Ready、quota.Resource ≤ node.QuotaIdle、taint/toleration、inter-quota 亲和/反亲和、存储挂载点就绪 predicates、quotapredicates、quotaaffinity、storage
NOrder + BatchNOrder sum(...) 打分:topology(zone 亲和)、binpack(节点已绑 quota 的紧凑度)、sylas(spot 驱逐代价)、nodeorder(K8s 标准评分:nodeAffinity/interPodAffinity/taintToleration/podTopologySpread) topology、binpack、sylas、nodeorder
BestN or(...) 选最优:topology 的 selectBestNodeForQuota节点 index[preNode, lastNode] 选最小,保持 rank 升序 topology

注意组合算子的含义:joint/or 是"任一非 Success 即否决"(用于过滤),sum 是"分数相加"(用于打分)。表达式语言让"哪个插件参与哪个阶段、怎么组合"可配置,不用改代码。

关键过滤:QuotaIdle —— device-plugin 资源在这里被校验

Predicate 阶段最核心的检查是 quota.Resource.LessEqualWithResourcesName(node.QuotaIdle)QuotaIdle 怎么算?

1
node.QuotaIdle = node.Allocatable − Σ(已绑到该节点的 quota.Request)

node.Allocatable 就是 K8s 标准字段——device-plugin 上报的 extended resource(nvidia.com/gpurdma/hca 等)就在这里。所以 quota 请求 8 卡 GPU 时,Predicate 校验的是"这节点 device-plugin 上报的 GPU 减去已被其它 quota 占用的,还够不够 8 卡"——逻辑配额与物理设备在这一跳相遇LessEqualWithResourcesName 按资源名逐项比较(标量资源精度到 0.01 GPU),quota 请求的 GPU 名若不在 QuotaIdle 里直接判失败。

一个细节:QuotaIdle 只用作过滤(predicate + 两梯度分流 + 绑前复核),不参与打分。打分靠 binpack 的 Allocated = node.QuotaAllocated.Resource(节点已绑多少 quota)——即"挑已经塞得比较满的节点",走 binpack 策略。

两梯度分流 + 绑前复核

allocateQuota 把 Predicate 通过的节点分两档:

  • 第一梯度:当前 QuotaIdle 就够放下 quota 的节点 —— 进打分。
  • 第二梯度:当前不够、但加上 Releasing/Pipelined 未来释放的会够 —— 代码里实际是过滤掉、不打分(注释"两梯度"略乐观,第二梯度目前没真正打分)。

打分挑出 bestNode 后,绑之前再复核一次 quota.Resource.LessEqual(bestNode.QuotaIdle)——双重保险,防打分期间状态变了。

怎么绑:Statement 事务 + BindQuotaToNode

挑出 bestNode 后,调 stmt.AllocateQuotaToNode(quota, bestNode)——但这步只在 session 内存里干活:node.AddQuota(预占 QuotaIdle、记 QuotaAllocated、写 quota.NodeName)、追加 AllocateQuota 操作到 Statement、触发事件 handler。真正落盘在 Commit 之后。

Commit 前还有一道门:ssn.QuotaAllocate(job)——and(...) 表达式,要 gang 插件确认 QuotaReady(够 minAvailable 个 task 的 quota 已绑)且 topology 插件确认 forceNodeOrderWhenAllocateQuota(强制拓扑 job 的节点 index 单调递增)。门通过才 Commit,否则 Discard 回滚整个 job 这一轮的所有 quota 绑定。

Commit 时重放 AllocateQuota 操作 → cache.BindQuotaToNode(quota, nodeName),这才真正:

  1. node.AddQuota:QuotaIdle -= quota.RequestQuotaAllocated 记账、quota.Status.NodeName = nodeName;
  2. 调 K8s API Quotas().UpdateStatus()Status.NodeName/Status.Allocated 写回 apiserver(标准 status subresource 更新);
  3. API 失败则 node.RemoveQuota 回滚记账。

所以 BindQuotaToNode 做两件事:改内存记账(QuotaIdle/QuotaAllocated)+ patch Quota CRD 的 status 到 apiserver。注意它只动 quota 维度的记账,不碰 node.Idle/node.Used(普通 pod 资源不算),因为 pod 还没真绑(那是 bind action 的事)。

静态绑定:跳过整个 QN 流水

Spec.StaticBindNode + Status.StaticBindStatus 可把 quota 预钉到某节点。若 Status.NodeName 已被外部 controller 写上,allocateQuota/allocateZone 顶层 if quota.NodeName != "" { continue } 直接跳过——整个 Select/Predicate/打分/BindQuotaToNode 都不跑,当已绑处理。若只设了 StaticBindStatus 还没写 NodeName,则在第一跳 task→quota 用 NodeToBeScheduled() 影响选 quota(校验该静态节点 Ready),QN 跳本身不限制候选——真正的 Status.NodeName 赋值由外部 controller 完成。

自愈:fixQuotaBinding

万一状态漂移(quota.Status.NodeName 和 pod 实际位置对不上)怎么办?每周期快照时 fixQuotaBinding 兜底(节流 MinFixQuotaInterval):从 live pod 的实际节点反推每个 quota 应绑哪(QuotaBindingFixPlan:遍历所有 QuotaName != "" && NodeName != "" 的 running pod,聚合 quota→node),若与 quota.NodeName 不一致就 patch status 修正,并跨节点迁移 QuotaInfo 的记账(老节点 RemoveQuota 退回 QuotaIdle、新节点 UpdateQuota 扣减)。pod 位置不一致(一个 quota 的 pod 散在多节点)则记错不修。真相来源是 pod,不是 quota 对象——这很合理,pod 是物理事实,quota 是逻辑抽象。

一图总览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
allocateJob(task→quota)跑完,task 进 AllocatedToQuota,quota.NodeName 还空

┌───────────▼───────────┐
│ allocateZone(拓扑job) │ allocateQuota(通用兜底)
│ 候选=zone内节点 │ 候选=全集群
└───────────┬───────────┘
│ 对每个未绑 quota:

PrePredicate → [每节点] Select → Predicate(quota.Resource ≤ node.QuotaIdle)
│ ↑ device-plugin GPU 在 QuotaIdle 里被校验

NOrder+BatchNOrder 打分(topology/binpack/sylas/nodeorder)


BestN(topology 按 node index 选最小,保 rank 升序)


stmt.AllocateQuotaToNode(内存:node.AddQuota 预占 QuotaIdle)


QuotaAllocate 门(gang QuotaReady + topology forceNodeOrder)
│ 通过

stmt.Commit() → cache.BindQuotaToNode

├─► node.AddQuota: QuotaIdle -= Request, QuotaAllocated 记账
└─► Quotas().UpdateStatus(): patch Status.NodeName 到 apiserver


下周期 fixQuotaBinding 从 live pod 反推自愈

一个完整例子:8 节点集群跑 4 卡训练 job

设定一个 8 节点小集群,两个 leaf 交换机各挂 4 节点:

1
2
3
4
5
6
7
         ┌──── Spine ────┐
│ │
┌─── Leaf-S1 ───┐ ┌── Leaf-S2 ───┐
│ N0 N1 N2 N3│ │ N4 N5 N6 N7 │
│ 每节点8卡A100 │ │ 每节点8卡A100 │
└───────────────┘ └──────────────┘
(zone = roce#S1) (zone = roce#S2)

每节点装了 NVIDIA device-plugin,上报 nvidia.com/gpu: 8rdma/hca: 8;topology-discovery(RoCE DaemonSet)探出 N0-N3 接 S1、N4-N7 接 S2,写进 NodeTopology CRD,scheduler 据此算出 zone。

用户要跑一个训练 job:4 个 worker,每个 1 卡 GPU + 1 个 hca,Gang minAvailable=4(4 个必须全起),请求 rdma/hca 所以自动开启拓扑调度。事先 apply 了 4 个 Quota(q-train-0/1/2/3),每个声明 request: {nvidia.com/gpu: 1, rdma/hca: 1},此刻 status.nodeName 都空——4 份逻辑份额,还没落节点。

第①跳 allocateJob(task→quota):4 个 task 各选一个 quota,task 进 AllocatedToQuota 状态,gang 检查 QuotaReady(4 个 task 都有 quota,minAvailable=4 满足),Commit。

1
2
3
4
task-0 ──► q-train-0 (NodeName="")   ┐
task-1 ──► q-train-1 (NodeName="") │ 4份配额都认了主,
task-2 ──► q-train-2 (NodeName="") │ 但都不知道放哪台机器
task-3 ──► q-train-3 (NodeName="") ┘

第②跳 allocateZone(quota→node,zone 内):job 开了拓扑调度,PredicateZone 圈出候选节点。第一个 quota 没兄弟可跟,选起始 zone roce#S1,返回 {N0,N1,N2,N3}。对 q-train-0 跑 QNFns:

  • Select:quota 无 NodeSelector,全过。
  • Predicate:quota.Resource(1gpu+1hca) ≤ node.QuotaIdle?N0.QuotaIdle = Allocatable(8gpu,8hca) − Σ已绑(0) = 8,8 ≥ 1 ✓(device-plugin 上报的 GPU 就在 QuotaIdle 里被校验)。
  • 打分:topology 给同 zone 高分;BestN 按 node index 选最小 → N0。
  • AllocateQuotaToNode(q-train-0, N0):内存里 N0.QuotaIdle -= 1gpu,1hca → 7,7。

第二个 quota q-train-1:namedzone 发现兄弟 task-0 已绑 N0(在 zone S1),PredicateZone 返回 N0 所在 zone {N0,N1,N2,N3}——“和兄弟待同一交换机域”。zone 内打分,BestN 按 index 选最小可用 → N1。同理 q-train-2→N2、q-train-3→N3。

1
2
3
4
q-train-0 → N0  (index 0, zone S1)
q-train-1 → N1 (index 1, 跟兄弟同 zone S1)
q-train-2 → N2 (index 2, 同 zone S1)
q-train-3 → N3 (index 3, 同 zone S1)

关键:4 个 quota 全落在 roce#S1 这一个交换机域,没散到 S2——4 个 worker 的 RDMA 通信全在 S1 内一跳直达,不跨 Spine;且按 index 0→1→2→3 升序落位,保持物理 rank 顺序。QuotaAllocate 门(topology index 单调递增 + gang QuotaReady)通过,Commit。Commit 时对每个 quota BindQuotaToNode:node.AddQuota(QuotaIdle 扣减、QuotaAllocated 记账、Status.NodeName 写上)+ Quotas().UpdateStatus() patch 到 apiserver。

第③跳 bind(pod→node):每个 task 的 quota 已绑节点,在那一节点重验 TNFns,通过则 Allocatecache.Bind → 真正给 pod 写 spec.nodeName,kubelet 拉起容器。4 个 pod 分别绑到 N0/N1/N2/N3。

1
2
3
4
5
N0: pod worker-0 跑起来(1 gpu + 1 hca)
N1: pod worker-1 跑起来
N2: pod worker-2 跑起来
N3: pod worker-3 跑起来
└─ 4 个 worker 全在 Leaf-S1 域,AllReduce 全本地一跳

事后漂移自愈:若 N1 宕机、worker-1 被重调度到 N4,下周期 fixQuotaBinding 发现 q-train-1 的 live pod 在 N4 但 Status.NodeName 还是 N1,patch 修正 Status.NodeName=N4 并把记账从 N1 迁到 N4(N1 退回 1 卡到 QuotaIdle、N4 扣减)——真相来源是 pod 实际位置,不是 quota 对象

三跳对比:

1
2
3
① allocateJob    task ──► quota    "用哪份配额"      (逻辑分配,不碰节点)
② allocateZone quota ──► node "放哪个交换机域" (拓扑感知,zone内选+index升序)
③ bind pod ──► node "真的绑到机器" (K8s pod binding)

核心是中间多了一层 quota:第①跳管逻辑配额分配,第②跳管物理拓扑放置,第③跳才是传统 pod 绑节点。第②跳是精华——把"放哪"拆成"放哪个交换机域(zone)"+“域内选哪个节点(index)”,device-plugin 上报的 GPU 在 node.QuotaIdle 这一步被校验(逻辑配额撞上物理设备)。

2.7 怎么观测调度过程?kubectl 看哪里

讲完机制和例子,实操时最常问的是:调度进行中,kubectl 能看到什么?关键认知:Sky-Scheduler 不改 Node 对象,改的是 Quota 和 Pod——所以盯 kubectl get node 基本看不到调度动静,要盯 Quota。

kubectl get node 看不到调度变化。Node 的 allocatable/capacity 由 device-plugin 上报(nvidia.com/gpu: 8),调度过程不动它;Sky-Scheduler 也不通过加 taint/label 来标记"节点被占",占用记账全在内存(node.QuotaIdle)。所以:

1
2
$ kubectl get node N0 -o json | jq '.status.allocatable'
{ "nvidia.com/gpu": "8", "rdma/hca": "8", ... } # 调度前后都是 8,不变

真正能看到变化的三处:

  1. Quota CRD(最直观,主战场)
1
2
3
4
5
6
7
8
# 调度前:
$ kubectl get quota q-train-0 -o yaml | jq .status
{ "phase": "QuotaEmpty", "nodeName": "" }

# allocateZone 绑定后:
$ kubectl get quota q-train-0 -o yaml | jq .status
{ "phase": "QuotaBound", "nodeName": "N0",
"allocated": { "nvidia.com/gpu": "1", "rdma/hca": "1" } }

kubectl get quota 默认列含 node 列,从空变 N0——这是观测调度进展最直接的窗口kubectl get quota -w watch 它能看到 quota 一个个落节点。

  1. Pod(标准 K8s 行为)
1
2
3
4
5
6
$ kubectl get pod worker-0 -o wide
NAME NODE STATUS
worker-0 N0 Running # bind 后 spec.nodeName=N0

$ kubectl get pod worker-0 -o json | jq '.metadata.annotations'
{ "scheduling.infini-ai.com/quota-name": "q-train-0" } # 标记用了哪个 quota
  1. Node condition(仅存储感知场景)

若 job 开了存储感知 + 声明 RequiredMountPoints,节点的 node condition 会被存储 daemon 写入(注意:scheduler 只读不写):

1
2
$ kubectl get node N0 -o json | jq '.status.conditions[]|select(.type|startswith("infiniai-fs-"))'
{ "type": "infiniai-fs-mp001", "status": "True", "lastHeartbeatTime": "..." }

用前面 4 卡例子串起来,各时刻可观测状态:

时刻 get node get quota get pod
①跳完(task→quota) 无变化 q-train-0..3 node 列空、phase=Empty worker-0…3 Pending,NODE 空
②跳完(quota→node) 无变化 q-train-0 node=N0 phase=Bound;其余 N1/N2/N3 仍 Pending(quota 绑了 pod 还没绑)
③跳完(bind) 无变化 同上(已 Bound) worker-0 NODE=N0 Running;… worker-3 NODE=N3

盯调度的正确姿势是 kubectl get quota -w(watch quota 的 node 列变化),不是 get node。这跟原生 K8s 一样——kube-scheduler 也不改 Node,只改 Pod;Sky-Scheduler 多了 Quota 这层,所以多一个可观测对象。除了 kubectl,还有 /diagnose HTTP API 能查"为什么卡住"(见第七节)。

2.8 中间的状态值:QuotaIdle、Idle 这些都是什么?

前面反复出现 QuotaIdleIdleQuotaAllocated 这些值,它们是调度决策的依据,值得专门讲清。分两套:NodeInfo 上的资源记账(节点视角)+ QuotaInfo 上的生命周期(quota 视角)。

第一套:NodeInfo 的两本账

一个节点同时维护两套资源账,因为有两类"占用"——走 quota 路径的(task→quota→node)和走直绑路径的(task→node,如 spot/backfill):

字段 含义 何时变
Allocatable 节点可分配总量(device-plugin 上报的 nvidia.com/gpu:8 等),只读 device-plugin 改才变
Idle = Allocatable − Used,普通 pod 视角的空闲 pod 绑/解时变(Used 增减)
Used 普通 pod 已占(非 quota 路径) pod 绑/解时变
QuotaIdle = Allocatable − Σ(已绑 quota 的 Request),quota 视角的空闲 quota 绑/解时变
QuotaAllocated 按 quota 名记录的占用明细(map[quota名]Resource) quota 绑/解时变
FutureIdle = Idle + Releasing − Pipelined,未来会空闲的(抢占用) Releasing/Pipelined 变时变

关键区分:IdleQuotaIdle 是两本账。一个 pod 走 quota 路径,占 QuotaIdle 不占 Idle;走 spot 直绑,占 Idle 不占 QuotaIdle。两套调度路径互不干扰、不重复计数——这就是两跳调度能并存的根。

用前面 N0 的例子看两本账怎么同步:

1
2
3
4
5
6
7
8
9
10
11
12
N0 初始:   Allocatable = 8gpu,8hca
Idle = 8,8 QuotaIdle = 8,8 (都满,Used=0)

绑 q-train-0(1gpu,1hca) → AddQuota:
QuotaIdle = 7,7 ← 扣减(quota 视角)
QuotaAllocated[q-train-0] = 1,1
Idle 还是 8,8 ← 没动(pod 还没真绑)

bind worker-0 到 N0 → AddTask:
Used = 1,1 ← 扣减(普通 pod 视角)
Idle = 7,7 ← 现在 pod 真占上了
QuotaIdle 还是 7,7 ← 不再动

所以同一台 N0,在 quota 绑定后、pod 绑定前,QuotaIdle=7,7Idle=8,8——节点对"下一份 quota"显示只剩 7 卡,但对"直绑 pod"还显示 8 卡。两本账分开,quota 层和 pod 层不会把同一份资源重复分配。

第二套:QuotaInfo 的生命周期

quoa 自己的状态(QuotaStatus):

字段 含义
Phase 生命周期:QuotaEmptyQuotaBound(NodeName 写上)→ 释放回 Empty
NodeName 绑到哪个节点(空=未绑)
Allocated 这份 quota 实际被 task 用了多少
Request 声明的资源额度(Spec.Request + shadow)
ShadowResource 超售池(可超 Request 借)
StaticBindStatus 静态绑定状态(NodeName + Bound 标记)
Tasks / ReservedTasks 认领的 task / 预留的 task(重调度用)

Phase 的流转就是 quota 的生命:创建时 Empty → allocateQuota/allocateZone 绑节点后 Bound(Status.NodeName 写上)→ 任务结束释放回 Empty。ReservedTasks 是为重调度保位:TTL 内重启的 task 可继承这份预留,优先回原位。

一句话

NodeInfo 维护"节点还剩多少资源"的两本账(Idle 给 pod 直绑、QuotaIdle 给 quota 绑定);QuotaInfo 维护"这份配额到哪一步了"的生命周期。 QuotaIdle 在 quota→node 绑定时扣减(AddQuota),Idle 在 pod→node 绑定时扣减(AddTask)——两本账分开记账,是两跳调度不重复计数的根。

2.9 一句话:Quota 把"资源分配"和"节点选择"解耦

Volcano 把"用哪份资源"和"放哪个机器"混在一跳里;Sky-Scheduler 拆开:quota 层管逻辑分配(亲和/预留/静态绑定/多规格),节点层只管物理放置。复杂度被分层吸收,每层只解决一类问题——这是软件设计里最朴素也最有效的原则。


三、RDMA 拓扑:发现 + 感知

讲完 Quota,再看拓扑。这是 Sky-Scheduler 为 AI 训练最量身定制的部分。

3.1 为什么拓扑这么重要?

AI 训练的集合通信(AllReduce/AllGather)对网络延迟极敏感。一个千卡训练任务的通信组,如果打散在多个机架的多台 leaf 交换机上,每次通信都要跨交换机,延迟翻几倍;如果聚拢在同一台 leaf 交换机域内,全是本地一跳,快得多。

1
2
3
4
5
6
7
8
慢的摆法(跨交换机):              快的摆法(同交换机域):

┌─Leaf-A─┐ ┌─Leaf-B─┐ ┌────Leaf-A────┐
│ N1 N2 │ │ N3 N4 │ │ N1 N2 N3 N4 │
└────┬───┘ └───┬───┘ └──────────────┘
└──Spine──┘ 全部一跳直达
通信组{N1,N2,N3,N4} 通信组{N1,N2,N3,N4}
每次AllReduce跨Spine 每次AllReduce本地

调度器得知道"哪个节点在哪个交换机域",才能把通信组聚拢——这就是拓扑调度的使命。

3.2 发现侧:只看 NIC→交换机一跳

Sky-Scheduler 的拓扑发现不碰 NUMA/PCIe/NVLink/节点内 GPU 摆放,只回答一个问题:每张 RDMA 网卡插在哪个 leaf 交换机上。这个"NIC→交换机"的映射,就是调度用的 zone 的来源。

两种网络,两种发现姿势(根因是发现机制本身不同):

RoCE(RDMA over Converged Ethernet)——DaemonSet,每节点本地探:

RoCE 走以太网,发现是纯本地操作:读自己节点的 Multus CNI 配置(哪些网卡是 macvlan master)、读 /sys/class/net/*/bonding/slaves(bond 展开成物理从卡)、读本节点 lldpd 的 LLDP 邻居表(网卡对端是哪个交换机)。这些信息每节点只有自己知道,所以必须 DaemonSet 跑在每个节点上:hostNetwork: true + lldpd sidecar + 挂 CNI 配置目录,10 秒一轮。

1
2
3
4
5
6
7
8
9
节点 N1 上跑的 RoCE discovery:
┌─────────────────────────────────────┐
│ Multus CNI 配置 → 候选网卡 enp24s0 │
│ /sys/.../bonding/slaves → 展开bond │
│ lldpcli → enp24s0 对端是 Switch-S1 │
│ rdma link → mlx5_1 = enp24s0 │
│ ───────────────────────────────────│
│ 结论: N1 的 enp24s0 在 S1 域 │
└─────────────────────────────────────┘

IB(InfiniBand)——单 Deployment,一个点看全网:

IB fabric 是个"全局可枚举"的总线:一台有 HCA 的主机跑 ibnetdiscover,能列出整个子网的所有交换机和所有 HCA 端口。所以单 Deployment replicas:1 + Lease 选主就够,请求 rdma/hca:1 经 nodeSelector 落到 IB 节点,10 分钟一轮,一个 collector 写所有节点的拓扑。

1
2
3
4
5
6
7
8
9
IB discovery(单点):
┌───────────────────────────────────────┐
│ ibnetdiscover -C mlx5_0 │
│ → 枚举全网: Switch-S1, Switch-S2... │
│ → 每个 HCA: switchName hostname nic │
│ → Go 侧 hostname→K8s Node 映射 │
│ ─────────────────────────────────────│
│ 结论: 一次性写所有节点的 IB 拓扑 │
└───────────────────────────────────────┘

发现结果写进 cluster-scoped 的 NodeTopology CRD:spec.net.{roce,ib} 是 NIC 列表,每个 NIC 带 Name(ifname)+ SwitchName(交换机名)。scheduler 用 informer watch 它进 cache.NodeTopos

3.3 Zone 怎么派生:同交换机 = 同 zone

每个节点算一个 RDMATopologyKey,优先级:注解覆盖 > zone label > 自动生成。自动生成就是"把该节点所有 NIC 的交换机名排序后拼起来",比如 ib#sw1,sw2roce#sw1,sw2;发现侧也会写一个 labels["zone"] = md5(sorted switch names)——交换机集合相同的节点,hash 到同一个 zone

1
2
3
4
5
zone 派生:
N1: NIC→{S1} → zone = "roce#S1"
N2: NIC→{S1} → zone = "roce#S1" ← 和 N1 同 zone
N3: NIC→{S2} → zone = "roce#S2"
N4: NIC→{S1, S2} → zone = "roce#S1,S2" ← 跨两台交换机的节点

Zone 就是"一组在同一个 RDMA 交换机域内的物理节点",当成一个放置单元(类似把几台物理机当一个"超节点")来调度。

3.4 调度侧:请求 RDMA 即开启,按 spec 分池,升序 index 落位

拓扑调度的启用门槛极低:job 请求 rdma/rocerdma/hca 资源,拓扑调度自动开启(NeedRDMA() == EnableTopologyScheduling()),零配置。也支持注解 enable-topology-scheduling(强制严格 index 顺序)、topology-affinity-key(job 间 zone 亲和)覆盖。

两种算法按权重组合(默认 continuity=0、rdma=100):

  • namedzone(rdma):按 RDMATopologyKey 分 zone。PredicateForTask 找已绑定兄弟 task 的 zone,返回该 zone 内所有节点——“和兄弟待在同一交换机域”。反亲和分 = 1/zoneSize(zone 越大越不聚拢,分越低)。
  • continuity:按节点 index 把空闲节点组成连续块,适合要紧凑物理放置的 job,反亲和分按空闲邻居数 1/0.5/0 避免碎片。

两种都包在 IdenticalSpecTopologyAlgo 里——按 resource-spec(卡型)分别算 zone。A100 节点和 H100 节点即使物理上同交换机,也不会被算进同一个 zone,因为它们 spec code 不同。这从机制上杜绝"快卡等慢卡"。

放置流程:

1
2
3
4
5
① 每个节点有整数 index(注解或节点名数字后缀)→ 物理顺序
② allocateZone: PredicateZone(job) → 拓扑插件返回 zone 内候选节点
③ quota 在 zone 内绑节点: selectBestNodeForQuota
在 [preTaskNode, lastTaskNode] 范围选最小 index 节点
④ task 按升序 index 落位 → 保持跨节点物理 RDMA rank 顺序

为什么要"升序 index"?因为训练通信组对 rank 顺序敏感——GPU 0…7 在物理节点上从左到右排列,跨节点也要保持这个顺序,NCCL 才能高效建环。乱序落位会让通信模式错乱,性能塌方。

3.5 多 job 的 zone 亲和

多个 job 之间若想"挤在同一片 zone"(比如共享同一份预取数据),用 topology-affinity-key 注解。ZoneAffinityTracker 统计每个 zone 里同 key 的 task 数,ZoneScore 优先亲和 task 多的 zone——把相关 job 往同一个交换机域里赶。


四、调度框架:Volcano 的骨架 + 三处升级

骨架照搬 Volcano:Action / Plugin / Session。

1
2
3
type Action interface { Execute(ssn *Session) }       // 每周期跑的调度动作
type Plugin interface { OnSessionOpen(ssn *Session) // 注册回调
OnSessionClose(ssn *Session) }

每秒一个周期:OpenSession 快照 cache + 实例化所有 plugin → 顺序跑配置的 actions → CloseSession。变更经 Statement 事务 Commit(写回 cache)/Discard(回滚)。一个周期一个 session,一个事务

三处升级:

① 三对泛型 PluginFunction——这是两跳调度的根。Session 持有三个 handler 包:

1
2
3
TQFns: Task   → Quota   (第一跳:task 选 quota)
QNFns: Quota → Node (第二跳:quota 选节点)
TNFns: Task → Node (spot 旁路:task 直接选节点)

每个包有 selectFns/prePredicateFns/predicateFns/bestNFns/nOrderFns。Volcano 只有 task→node 一对,这里三对——每一跳都能独立挂 predicate/order/bestN。

② 表达式语言——plugin 回调怎么组合(AND? INTERSECT? SUM?)可配置。expr/expr.go 实现了 OpAnd/OpOr/OpIntersect/OpSum/OpMin/OpFirstNotPass 等,默认组合如 Preemptable = OpIntersect(所有插件的 preemptable)JobReady = and(...)NOrder = sum(...),可用配置覆盖。不用改代码就能调"哪个插件参与哪个决策",这是 Volcano 没有的灵活度。

③ ReconcilablePlugin——插件可以自带 controller(GetController(...))。Volcano 的插件只在 session 周期内活,Sky-Scheduler 的插件(如 jobqueue)能跑长生命周期 controller,跨 session 维护状态。


五、Actions:七步流水

默认配置的 action 序列:

1
allocateJob → allocateZone → allocateQuota → bind → reclaim → backfill → releaseQuota
Action 干什么 形象类比
allocateJob 排序队列/job,task 选 quota(四档优先级),AllocateTaskToQuota 餐厅:给客人分配桌号(quota),还不指定坐哪把椅子
allocateZone job 全到 quota 后,拓扑插件圈 zone,quota 在 zone 内绑节点 再决定坐哪个区(靠窗/包间=zone)
allocateQuota 通用 quota→node 绑定(无拓扑时),按 node.QuotaIdle 评分 给桌号指定具体位置
bind quota 已绑节点,task 重验后 Allocate→pod 绑节点 客人真的坐下
reclaim 抢占:在抢占者节点找 spot victim 驱逐 临时占座的人让位
backfill spot 专用:直接 task→node 填空隙 碎片时间塞满 spot 任务
releaseQuota 不可调度的 job 回收 quota + 退避 取消订座,让位给下一拨

注意:没有独立 preempt action——Volcano 的抢占折进 reclaim,且 victim 只在抢占者自己的绑定节点上找 spot task(比 Volcano 的节点级启发式范围小、更可控)。proportion 逻辑被移除,由 quota 层替代。


六、Plugins:Volcano 继承八个 + 自研六个

继承自 Volcano:priority / gang(minAvailable + jobReady/jobPipelined/jobStarving)/ conformance / drf / predicates(封装 K8s 原生 NodeAffinity/Taints/PodAffinity)/ nodeorder / binpack / backoff / rescheduling

六个自研 plugin 是核心,前面已展开 Quota 和拓扑,这里补另外四个:

6.1 sylas:Spot GPU 调度

spot-gpu 是"可被抢占的便宜 GPU"。每个节点按注解把它拆两个池:

1
2
3
4
5
节点的 spot-gpu 资源:
┌─────────────────────────────────┐
│ shared(lowpower) │ exclusive(idle) │
│ 多个低优 task 共享 │ 独占,要腾就得驱逐 │
└─────────────────────────────────┘

评分时预测要腾出空间需驱逐哪些 exclusive-spot pod(PredictEvictedPod),按 PodGroup 分组,再按四策略加权:优先级类(低/中/高)、驱逐 task 数驱逐资源量运行时长(shortest/longest)。抢占由 reclaim 执行,backfill 填空隙。Spot RDMA job 还兼容拓扑调度(走同 zone 旁路)。

6.2 storage:存储感知

训练任务常要挂载大模型权重/数据集到特定路径。Pod 声明 RequiredMountPoints,节点以 node condition infiniai-fs-<mpID> 上报挂载就绪。checkMountPointsReady:condition 必须 True 且心跳时间晚于节点 Ready 时间(兼容旧 daemon 90s 容忍)。挂三阶段 predicate,失败原因 StorageNotReady

6.3 SpecResourcePool:多规格资源

节点 label 如 spec-prefix: a100 / spec-prefix: h100GetResourceSpecFromNodeSelector(最长 key 优先)转成 spec code。SpecResourcePool 按 spec 维度追踪 queue 的 allocatable/allocated/shadow,反映到 queue.Status.SpecAllocated拓扑算法也按 spec code 实例化——A100 的 zone 和 H100 的 zone 各算各的,从机制上杜绝混卡。

6.4 jobqueue + 诊断

JobQueue CRD(用 Spec.JobSelector 选 job)是优先级子队列;job 的 JobEnqueueable 仅当匹配 JobQueue Enqueueable() 才放行,否则原因 JobBlockedByJobQueue。配合丰富的不可调度原因词表(BlockedByJobQueue/InsufficientQuota/StorageNotReady/PodQuotaAffinityNotMatch…)+ FitAction 阶梯,经 /diagnose/job/:ns/:name HTTP 接口暴露——运维不用翻日志猜,直接查 API 知道为什么卡住


七、典型链路:一个 RDMA 训练 job 怎么被调度

把上面串起来,一个请求 rdma/roce+rdma/hca 的训练 job:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
1. 提交 → PodGroup 创建 → cache(schedulerName 过滤)收 pod → job 进 Jobs

2. allocateJob:
quota 四档排序 → TQFns 选 quota → AllocateTaskToQuota
(task 进 AllocatedToQuota,还没绑节点)
gang + quotapredicates 把关 → commit

3. allocateZone(全到 quota 后):
PredicateZone(job) → 拓扑插件按 spec code 算 zone
namedzone: 已绑兄弟 task 的交换机域
→ QNFns 在 zone 内选最小 index 节点绑 quota

4. bind:
quota 已绑节点 → TNFns 重验 → Allocate → cache.Bind
pod 落节点,按升序 index 保持物理 rank 顺序

5. 资源不足时:
reclaim 在抢占者节点找 spot victim 驱逐
backfill 填 spot 队列

6. 不可调度:
/diagnose 返回 BlockedByJobQueue / InsufficientQuota / StorageNotReady ...

八、与 Volcano 差异一表

维度 Volcano Sky-Scheduler
调度模型 Pod→Node 一跳 Task→Quota→Node 两跳(新增 AllocatedToQuota)
抢占 独立 preempt(节点级) 折进 reclaim,只在自己节点找 spot victim
比例分配 proportion 插件 移除,quota 层替代
拓扑 无原生 RDMA Zone(RoCE/IB 发现 + namedzone/continuity)
Spot sylas(shared/exclusive 池 + 四策略驱逐评分)
存储 无原生 storage(node condition 挂载点就绪)
多规格 SpecResourcePool(按 spec 分池,拓扑也按 spec)
资源预留 ReservedTask + TTL(重启回原位)
准入 queue 层 JobQueue CRD + 优先级子队列
诊断 简单 FitAction 阶梯 + /diagnose API
框架 session + plugin 同 + 三对泛型 PluginFunction + 表达式语言 + Statement 事务 + ReconcilablePlugin

九、读后感

写完这篇,几个设计让我反复回味:

  1. Quota 两跳 = 解耦。最朴素的设计原则——"资源分配"和"节点选择"是两类问题,不该挤在一跳里。拆开后,quota 层管逻辑(亲和/预留/静态绑定/多规格),节点层管物理(拓扑/存储/亲和),每层复杂度可控。代价是状态机多了 AllocatedToQuota 中间态,但 fixQuotaBinding 自愈兜底,值得。

  2. 拓扑 = Zone + 升序 index。发现侧只做"NIC→交换机"一跳映射,简单到一眼看穿;调度侧用 namedzone 把同交换机域当一个放置单元,再按节点 index 升序落 task 保持物理 rank。没有花哨的算法,但精准命中"训练通信组要聚拢"的核心需求。

  3. 请求即开启NeedRDMA() == EnableTopologyScheduling()——请求 RDMA 资源自动开拓扑调度,零配置。好的抽象应该让对的事自动发生,而不是逼用户记一堆开关。

  4. 表达式语言。plugin 回调组合可配置,这是 Volcano 没有的灵活度。生产调度器的需求千变万化,硬编码组合等于把策略焊死在代码里,可配置才是正道。

  5. 诊断 API/diagnose 把"为什么不可调度"做成结构化输出——运维不用翻日志猜,直接查 API。这是把"可观测性"当一等公民的设计,很多调度器没做到。

它跟同日几篇形成完整图景:多调度器共存讲"能不能并存",Volcano Queue 全流程讲"Volcano 内部怎么分资源",本篇讲"在 Volcano 框架上怎么为 AI 训练做深度定制"——这是 Volcano 衍生调度器走向垂直纵深的一个实例。


参考