Volcano backfill 不是「调度兜底」,而是为 BestEffort 设计的同周期加速通道
一句话结论:Volcano 的
backfill常被误解成「防止任务被遗忘的兜底机制」。但即使完全去掉它,BestEffort任务在下一个调度周期依然会被重新评估,不会永久滞留。它真正的价值是:在同一个 Session 内、用一条轻量无干扰的路径,把集群的碎片化空闲资源即刻消化掉,同时保护主调度流程不被拖慢。这是一次工程优化,不是正确性必需品。
本文沿「问题 → 设计 → 源码 → 对比」四条线索展开。所有源码结论基于 Volcano master 分支的 pkg/scheduler/actions/backfill/backfill.go(229 行)逐行核对,核对时间 2026-11-04。
一、背景:BestEffort 的「轻」既是优势也是困境
BestEffort 任务的定义很简单:不声明任何资源请求(CPU / Memory request 为空)。它单任务即可独立运行,不依赖其他任务协同就位,也不需要集群做任何复杂预留。
这种「轻」在调度器里却常常变成劣势:
- Volcano 的主流程(
enqueue → allocate)优先处理的是声明了资源请求、需要 Gang 语义的工作负载; BestEffort任务因为没有 request,在资源核算里几乎「不存在」——它既不需要配额,也不参与 Gang 的「全部 or 全无」判断;- 但它却和核心任务挤在同一个待调度队列里,等下一轮
allocate的「垂青」。
直觉上这似乎没问题——反正下个周期还会来,晚一点不影响正确性。
但这正是 backfill 要解决的痛点:晚一点,意味着多一整个调度周期的空转;挤在 allocate 里,意味着核心任务要多等一段调度时间。
二、allocate 为什么「消化不了」BestEffort
要理解 backfill 的必要性,先得看 allocate 这个重量级阶段到底在做什么。
2.1 Gang Scheduling 的「全有或全无」
gang plugin 会注册 jobValidFn、jobReadyFn 等钩子,确保一个 Job 的所有 Task 满足 minMember 才整体提交,否则整体回滚——Volcano 通过 PodGroup + minMember 表达这一语义。
Kubernetes 原生也在 v1.35 以 Alpha 引入了 Gang Scheduling(KEP-4671,基于 Workload API),核心同样是「Pods 在 quorum 满足前不进入调度队列」。
对 BestEffort 任务来说,这套机制完全多余——它没有多 Pod 协同需求。
2.2 Session 快照 + Statement 事务
Volcano 每个调度周期都会打开一个 Session,内部基于 cache 快照运行。所有资源操作(Allocate / Pipeline / Evict)都通过 Statement 记录为操作序列——一个 Job 的所有 Task 全部分配成功才 Commit() 提交,否则 Discard() 按逆序回滚。这是一套典型的 undo log 式事务。
Kubernetes 原生 kube-scheduler 并没有等价机制,它是 Pod 级别的独立调度决策。
BestEffort 任务根本不需要这种事务保障——单 Pod 即可,不存在需要回滚的中间状态。
2.3 Pipeline 的假性占用:最关键的一环
Statement 里除了 Allocate(直接占用节点 Idle 资源、Task 进入 Allocated 状态),还有一个 Pipeline 操作:
1 | func (s *Statement) Pipeline(task *api.TaskInfo, hostname string, evictionOccurred bool) error |
Pipeline 不占用节点 Idle 资源,只在 Node 的 Pipelined 计数里预留,Task 进入 Pipelined 状态。它是为抢占场景设计的:先标记预分配,等受害者真正释放后再绑定。
问题在于——allocate action 处理任务时,对满足部分条件的任务(比如优先级够高但资源暂时不够)会走 Pipeline 而非 Allocate。BestEffort 任务一旦被 Pipeline,就在 Session 的 Pipelined 计数里「占了一个位」,但并没有真正运行。
如果没有专门处理,这些被 Pipelined 的 BestEffort 任务会带着这个假性占用跨越一个甚至多个调度周期,而集群中本可立即使用的碎片资源就被白白浪费。
2.4 让 BestEffort 挤进 allocate 的代价
把三个机制串起来,代价至少三层:
- 调度延迟增加至少一个周期(默认 1s),且每个周期都要与核心任务竞争排序;
- 干扰核心任务调度效率——
allocate的队列排序、gang 检查、事务提交都是为重量级负载设计的,BestEffort 混在里面徒增分支判断和排序负担; Pipelined假性占用跨周期存活——本可立刻消费的碎片资源被「名义上」预留,实际利用率下降。
三、backfill 的精确定义
Volcano 默认配置的 action 流水线就是三段(installer/helm/chart/volcano/values.yaml 里的默认值):
1 | actions: "enqueue, allocate, backfill" |
backfill 是在同一 Session 内、allocate 完成之后立即执行的同周期收尾步骤。它的触发条件、目标对象、处理路径都很明确:
| 维度 | allocate | backfill |
|---|---|---|
| 目标任务 | 所有 inqueue 的 PodGroup 任务 |
仅 task.BestEffort == true 的 Pending / Pipelined 任务 |
| 资源核算 | 基于 Idle / Used / Pipelined 多种计数,走配额与队列公平 | 只看节点是否可放置(无 request → 几乎任何节点都满足) |
| Gang 语义 | 必须满足 minMember 否则 Discard |
跳过 gang-ready 检查,单任务独立判定 |
| 事务复杂度 | Allocate + Pipeline + Evict,需要完整 Commit / Discard 链 | 对 Pipelined 任务先 UnPipeline 再直接 Allocate,操作极简 |
一个关键细节:backfill 会先把 Pipelined 状态的 BestEffort 任务 UnPipeline,把它们从假性占用中释放回 Pending,再统一处理。这一步消除了跨周期残留的 Pipelined 计数,让 Session 关闭时的资源快照更干净。
四、源码级解读
Execute 的主干(已精简,保留结构):
1 | func (backfill *Action) Execute(ssn *framework.Session) { |
pickUpPendingTasks 里挑任务的逻辑:
1 | for _, task := range job.TaskStatusIndex[api.Pending] { |
4.1 一处「注释已过时」的发现
源码里那句 TODO (k82cn): When backfill, it's also need to balance between Queues. 很容易让人得出「backfill 不做队列间排序」的结论。但这句话和当前实现已经对不上了。
看 pickUpPendingTasks 的开头:
1 | queues := util.NewPriorityQueue(ssn.QueueOrderFn) // ← 队列按 QueueOrderFn 排序 |
三级优先队列——队列、Job、Task——各自都挂了排序函数。 文件头注释也印证了这一点,明确列出「Enhanced task selection with queue-based prioritization and systematic task ordering」。
所以准确的表述是:TODO 是历史遗留(作者当年确实没做队列平衡),但现在的实现已经通过 QueueOrderFn / JobOrderFn / TaskOrderFn 三级排序做了队列优先级处理——只是没有做 reclaim 那种「超额回收」意义上的跨队列资源再平衡,两者不是一回事。
这个细节值得单独拎出来,因为它正好演示了读源码时不能只看 TODO 和注释。
4.2 几个值得注意的设计点
UnPipeline 的位置。 它发生在 pickUpPendingTasks 内部、真正开始调度之前——让这些任务与本周期新加入的 Pending BestEffort 站在同一起跑线。
enablePredicateErrorCache 默认开启。 构造时就是 true:
1 | func New() *Action { |
它把 predicate 失败的节点缓存起来,避免下个周期重复计算。
两级 unschedulable 缓存跳过。 job.Skip.Allocate 跳过整个 Job,job.Skip.SkipTask(task.UID) 跳过单个 Task——这是较新版本加的,配合 unschedulable job cache 避免对反复失败的任务做无用功。
Rejection 追踪。 predicate 全部失败时会走 ssn.AddRejectionWithKeys(...) 上报拒绝原因,这是给上层诊断用的。
关于「无事务」的精确说法。 从源码看,backfill 最终也调用 ssn.Allocate(task, node),依然走 Statement。所以更准确的说法是:它使用的是一条极简化的、任务粒度的 Statement 路径,规避了 allocate 为重量级负载准备的开销——而不是「没有事务」。
五、设计哲学:为什么 backfill 必须「轻」
和 HPC 集群(Slurm 等)里的传统 backfill 对比,更能看出 Kubernetes 批调度的取舍。
HPC backfill 的经典做法是 reservation-based backfill:允许低优先级任务「向前填充」,前提是不延迟队列头部高优先级任务的最早启动时间。这需要精确的资源预留模型和复杂的启动时间预测。
Volcano / kube-batch 走了一条完全不同的路子:
- 不做启动时间预测,只看当前周期的 Idle 资源;
- 只针对 BestEffort,因为这类任务的资源代价几乎为零,不会干扰任何 reservation;
- 把复杂度压到最低,让 backfill 在主流程末尾快速跑完,不拖累 Session 周期。
换句话说,Volcano 的 backfill 是「同周期、同 Session 内的资源碎片清道夫」,而不是跨周期的资源预留机制。
顺带一句:本站另一篇 Sky-Scheduler 拓扑感知与 Quota 两级调度 里的
backfill是另一套语义——那里它是 spot 专用的「task → node 直接填空隙」路径,与 quota 路径并存。同名不同物,对照着看会更清楚「backfill」这个词在不同调度器里被赋予了多少种含义。
六、对比:Kueue 为什么没有 backfill
同为 Kubernetes 批调度生态,Kueue 走了一条截然不同的路线——它压根没有 backfill action,因为它不是 action 流水线式调度器,而是一个基于准入的控制器。
Kueue 的核心原语是 admission(准入):Workload 要么被 admitted(绑定到某个 ResourceFlavor 并启动),要么留在队列里等待。ClusterQueue 通过 nominalQuota 与 cohort 内的借用机制控制资源分配。它没有把 BestEffort 拎出来单独处理——因为它假设所有 Workload 都有明确的资源声明,配额核算本身就是准入依据。
Kueue 处理「任务不被遗忘」的机制是重排队 + 退避。核对当前 API(release-0.14 / 0.15)后,实际的相关字段是:
| 机制 | 载体 | 说明 |
|---|---|---|
| 重排队时刻 | Workload.Status.RequeueState.RequeueAt |
记录该 Workload 将在何时被重新入队 |
| 队列内排序策略 | ClusterQueue.Spec.QueueingStrategy |
StrictFIFO 或 BestEffortFIFO |
| 重试上限与退避 | 状态条件 | Requeued / BackoffFinished / WorkloadRequeuingLimitExceeded |
⚠️ 一处需要更正的说法:流传较广的描述是「Workload 通过
requeuingStrategy控制如何回到队列,timestamp字段决定排序依据(Eviction 时间或 Creation 时间),backoffLimitCount/backoffBaseSeconds控制重试节奏」。我核对了 Kueue 从 0.6 到 0.15 各版本的clusterqueue_types.go与workload_types.go,没有找到requeuingStrategy这个字段,也没有backoffLimitCount/backoffBaseSeconds。上表是当前实际存在的 API。引用旧版描述前建议先核自己用的 Kueue 版本。
架构差异值得玩味:
| 维度 | Volcano(backfill 路线) | Kueue(admission / quota 路线) |
|---|---|---|
| 核心抽象 | PodGroup + Action 流水线 | Workload + ClusterQueue + ResourceFlavor |
| BestEffort 处理 | 专门的 backfill action 即刻消费 | 无特殊处理,一切以 quota 准入为准 |
| 周期内二次调度 | 有(allocate 之后的 backfill 阶段) | 无(每周期只做一次 admission 决策) |
| 资源碎片策略 | 同周期内尽快清空碎片 | 通过 cohort 借用、flavor 优先级做分配 |
| 适用场景 | 混部大量轻量 BestEffort 任务的集群 | AI/ML 训练等明确声明资源的工作负载 |
Kueue 的选择与其定位一致:面向资源声明明确的 AI/ML 工作负载,不需要为「零资源请求」的任务单独优化。Volcano 作为从 HPC 批处理演化而来的调度器,更关注集群整体的资源利用率与碎片治理。
七、总结:backfill 解决的是「效率三杀」,不是「遗忘」
回到最初的问题:如果下个周期反正会重新评估 BestEffort 任务,为什么还需要 backfill?
答案是:没有 backfill,正确性不受影响,但效率会遭遇三重打击——
- 调度延迟增加一个或多个周期——本可即刻消费的碎片资源被空置;
- 核心任务被拖慢——BestEffort 混在
allocate里与需要 Gang、事务、复杂排序的核心任务抢调度时间; Pipelined假性占用跨周期残留——Session 快照不干净,下一周期的资源核算被污染。
backfill 通过在同一调度周期内做一次轻量收尾——UnPipeline 释放假占、跳过 gang 检查、任务粒度直接分配——把这三个问题一次性解决。它存在的意义,是让那些「反正总会被调度」的任务,更快、更高效、且不打扰关键业务地被调度。
从源码能看到 Volcano 团队对 backfill 边界的把握相当克制:只碰 BestEffort,只做同周期处理,不引入 reclaim 那套跨队列再平衡。这种「知道自己不该做什么」的设计克制,恰恰是它能长期稳定作为流水线第三段、且默认开启的根本原因。
对运维和二次开发者的实际启示:
- 集群里 BestEffort 任务占比高(大规模混部场景) → backfill 是必须保留且值得调优的阶段;
- 工作负载全部声明了资源请求(Kueue 风格) → backfill 对你几乎是透明的。
理解这一层差异,比死记「backfill 是什么」有价值得多。
相关阅读
- Volcano 调度器源码剖析——Action 流水线与 plugin 注册机制的完整拆解
- Volcano Queue 全流程拆解——队列、配额与准入的完整链路
- AI Infra 全景复盘:调度层深潜——其中 §6.7 讲清了 backfill / preempt / reclaim 三者跨队列语义的边界
- Sky-Scheduler 拓扑感知与 Quota 两级调度——另一套同名的 backfill,spot 专用语义
- QueueQuota 深度解析:从 Volcano 三级模型到大规模 AI 集群的调度治理——生产环境下的队列配额实践
核实说明:本文源码结论基于 Volcano
master分支pkg/scheduler/actions/backfill/backfill.go(229 行)与installer/helm/chart/volcano/values.yaml;Kueue API 核对自kubernetes-sigs/kueue的 release-0.6 至 0.15 各版本类型定义;K8s Gang Scheduling 核对自 KEP-4671。Volcano 迭代较快,具体行为请以你手上的版本为准。