Sky-Scheduler:面向 AI 训练的拓扑感知与 Quota 两级调度器
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 | ┌──────────────────────────────┐ |
- scheduler:大脑。每秒一个周期,从 cache 快照开一个 session,顺序跑 actions,事务提交。
- topology-discovery:眼睛。专门探测"每张 RDMA 网卡插在哪个交换机上",写进
NodeTopologyCRD。 - 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 | Volcano: Task ───────────────────► Node (一跳) |
Quota 是什么?你可以理解成一个**“资源容器”**:它声明了一份资源额度(多少 CPU/GPU/内存),最终会被绑定到一个具体的物理节点上。一个节点上可以有多个 Quota。调度分两步:
- Task→Quota(allocateJob):决定这个 task 用哪份配额——按 required(任务声明的) > reserved(预留的) > bound/static-bound(已绑/静态绑的) > free(空闲) 四档挑。
- 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 | type QuotaSpec struct { |
QuotaStatus 记录运行时:Phase(Empty→Bound 生命周期)、NodeName(最终绑到哪个节点)、Allocated、ShadowResource(超售池)、StaticBindStatus。所以 Quota 是个人造的中间资源对象——你(或上层编排系统)显式 kubectl apply quota.yaml 创建它,声明"我要一份 8 卡 A100 的配额,限定 spec=A100 节点,归属 research 队列"。
DevicePlugin 是 K8s 原生机制,把硬件设备 advertise 成 extended resource(nvidia.com/gpu、rdma/hca、rdma/roce 等)——这是节点物理事实,由设备厂商 daemonset 跑在节点上向 kubelet 上报。
两者配合而非替代,在 Quota→Node 这一跳相遇:
1 | 物理层(事实): Node ──DevicePlugin──► nvidia.com/gpu: 8, rdma/hca: 8 |
具体交集点:
rdma/roce、rdma/hca:由 RDMA device-plugin 上报到节点,Pod request 它触发拓扑调度开启(NeedRDMA())。这是"读"设备资源决定调度行为,不是"Quota 由它生成"。spot-gpu:Spot GPU 的 extended resource,device-plugin 上报,sylas 插件读它做 spot 池拆分。- 节点 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 | Pending ──► AllocatedToQuota ──► Allocated ──► Binding ──► Bound ──► Running |
AllocatedToQuota 是 Sky-Scheduler 的发明:task 已经认领了 quota,但 quota 还没绑到物理节点。这给"先占逻辑资源、再选物理位置"留出了空间——也带来了状态机复杂度,所以有 fixQuotaBinding 自愈机制:每周期从 live pod 位置重建 quota.Status.NodeName,防止状态漂移。
2.6 Quota→Node 这一跳具体怎么做?
讲清两跳模型后,最自然的追问是:第二跳 Quota→Node 到底怎么挑节点、怎么绑?这是 Sky-Scheduler 调度的核心环节,值得展开。
谁来做:allocateZone 和 allocateQuota 两个 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/gpu、rdma/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),这才真正:
node.AddQuota:QuotaIdle -= quota.Request、QuotaAllocated记账、quota.Status.NodeName = nodeName;- 调 K8s API
Quotas().UpdateStatus()把Status.NodeName/Status.Allocated写回 apiserver(标准 status subresource 更新); - 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 | allocateJob(task→quota)跑完,task 进 AllocatedToQuota,quota.NodeName 还空 |
一个完整例子:8 节点集群跑 4 卡训练 job
设定一个 8 节点小集群,两个 leaf 交换机各挂 4 节点:
1 | ┌──── Spine ────┐ |
每节点装了 NVIDIA device-plugin,上报 nvidia.com/gpu: 8、rdma/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 | task-0 ──► q-train-0 (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 | q-train-0 → N0 (index 0, 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,通过则 Allocate → cache.Bind → 真正给 pod 写 spec.nodeName,kubelet 拉起容器。4 个 pod 分别绑到 N0/N1/N2/N3。
1 | N0: pod worker-0 跑起来(1 gpu + 1 hca) |
事后漂移自愈:若 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 | ① allocateJob task ──► quota "用哪份配额" (逻辑分配,不碰节点) |
核心是中间多了一层 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 | $ kubectl get node N0 -o json | jq '.status.allocatable' |
真正能看到变化的三处:
- Quota CRD(最直观,主战场)
1 | # 调度前: |
kubectl get quota 默认列含 node 列,从空变 N0——这是观测调度进展最直接的窗口。kubectl get quota -w watch 它能看到 quota 一个个落节点。
- Pod(标准 K8s 行为)
1 | $ kubectl get pod worker-0 -o wide |
- Node condition(仅存储感知场景)
若 job 开了存储感知 + 声明 RequiredMountPoints,节点的 node condition 会被存储 daemon 写入(注意:scheduler 只读不写):
1 | $ kubectl get node N0 -o json | jq '.status.conditions[]|select(.type|startswith("infiniai-fs-"))' |
用前面 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 这些都是什么?
前面反复出现 QuotaIdle、Idle、QuotaAllocated 这些值,它们是调度决策的依据,值得专门讲清。分两套: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 变时变 |
关键区分:Idle 和 QuotaIdle 是两本账。一个 pod 走 quota 路径,占 QuotaIdle 不占 Idle;走 spot 直绑,占 Idle 不占 QuotaIdle。两套调度路径互不干扰、不重复计数——这就是两跳调度能并存的根。
用前面 N0 的例子看两本账怎么同步:
1 | N0 初始: Allocatable = 8gpu,8hca |
所以同一台 N0,在 quota 绑定后、pod 绑定前,QuotaIdle=7,7 但 Idle=8,8——节点对"下一份 quota"显示只剩 7 卡,但对"直绑 pod"还显示 8 卡。两本账分开,quota 层和 pod 层不会把同一份资源重复分配。
第二套:QuotaInfo 的生命周期
quoa 自己的状态(QuotaStatus):
| 字段 | 含义 |
|---|---|
Phase |
生命周期:QuotaEmpty → QuotaBound(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 | 慢的摆法(跨交换机): 快的摆法(同交换机域): |
调度器得知道"哪个节点在哪个交换机域",才能把通信组聚拢——这就是拓扑调度的使命。
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 | 节点 N1 上跑的 RoCE discovery: |
IB(InfiniBand)——单 Deployment,一个点看全网:
IB fabric 是个"全局可枚举"的总线:一台有 HCA 的主机跑 ibnetdiscover,能列出整个子网的所有交换机和所有 HCA 端口。所以单 Deployment replicas:1 + Lease 选主就够,请求 rdma/hca:1 经 nodeSelector 落到 IB 节点,10 分钟一轮,一个 collector 写所有节点的拓扑。
1 | IB discovery(单点): |
发现结果写进 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,sw2 或 roce#sw1,sw2;发现侧也会写一个 labels["zone"] = md5(sorted switch names)——交换机集合相同的节点,hash 到同一个 zone。
1 | zone 派生: |
Zone 就是"一组在同一个 RDMA 交换机域内的物理节点",当成一个放置单元(类似把几台物理机当一个"超节点")来调度。
3.4 调度侧:请求 RDMA 即开启,按 spec 分池,升序 index 落位
拓扑调度的启用门槛极低:job 请求 rdma/roce 或 rdma/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 | ① 每个节点有整数 index(注解或节点名数字后缀)→ 物理顺序 |
为什么要"升序 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 | type Action interface { Execute(ssn *Session) } // 每周期跑的调度动作 |
每秒一个周期:OpenSession 快照 cache + 实例化所有 plugin → 顺序跑配置的 actions → CloseSession。变更经 Statement 事务 Commit(写回 cache)/Discard(回滚)。一个周期一个 session,一个事务。
三处升级:
① 三对泛型 PluginFunction——这是两跳调度的根。Session 持有三个 handler 包:
1 | TQFns: Task → Quota (第一跳:task 选 quota) |
每个包有 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 | 节点的 spot-gpu 资源: |
评分时预测要腾出空间需驱逐哪些 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: h100 经 GetResourceSpecFromNodeSelector(最长 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 | 1. 提交 → PodGroup 创建 → cache(schedulerName 过滤)收 pod → job 进 Jobs |
八、与 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 |
九、读后感
写完这篇,几个设计让我反复回味:
-
Quota 两跳 = 解耦。最朴素的设计原则——"资源分配"和"节点选择"是两类问题,不该挤在一跳里。拆开后,quota 层管逻辑(亲和/预留/静态绑定/多规格),节点层管物理(拓扑/存储/亲和),每层复杂度可控。代价是状态机多了
AllocatedToQuota中间态,但fixQuotaBinding自愈兜底,值得。 -
拓扑 = Zone + 升序 index。发现侧只做"NIC→交换机"一跳映射,简单到一眼看穿;调度侧用 namedzone 把同交换机域当一个放置单元,再按节点 index 升序落 task 保持物理 rank。没有花哨的算法,但精准命中"训练通信组要聚拢"的核心需求。
-
请求即开启。
NeedRDMA() == EnableTopologyScheduling()——请求 RDMA 资源自动开拓扑调度,零配置。好的抽象应该让对的事自动发生,而不是逼用户记一堆开关。 -
表达式语言。plugin 回调组合可配置,这是 Volcano 没有的灵活度。生产调度器的需求千变万化,硬编码组合等于把策略焊死在代码里,可配置才是正道。
-
诊断 API。
/diagnose把"为什么不可调度"做成结构化输出——运维不用翻日志猜,直接查 API。这是把"可观测性"当一等公民的设计,很多调度器没做到。
它跟同日几篇形成完整图景:多调度器共存讲"能不能并存",Volcano Queue 全流程讲"Volcano 内部怎么分资源",本篇讲"在 Volcano 框架上怎么为 AI 训练做深度定制"——这是 Volcano 衍生调度器走向垂直纵深的一个实例。
参考
- 调度框架与 Gang 语义:Volcano 设计文档
- 多调度器共存机制:一个集群,多个调度器:Kubernetes 多调度器共存实践指南
- Volcano Queue 全流程:Volcano Queue 全流程拆解
- RDMA/RoCE/IB:InfiniBand Architecture、RoCE 规范、
ibnetdiscover/lldpcli文档
