一句话结论: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 的代价

把三个机制串起来,代价至少三层:

  1. 调度延迟增加至少一个周期(默认 1s),且每个周期都要与核心任务竞争排序;
  2. 干扰核心任务调度效率——allocate 的队列排序、gang 检查、事务提交都是为重量级负载设计的,BestEffort 混在里面徒增分支判断和排序负担;
  3. 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
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
31
32
33
func (backfill *Action) Execute(ssn *framework.Session) {
backfill.parseArguments(ssn)
predicateFunc := ssn.PredicateForAllocateAction

// TODO (k82cn): When backfill, it's also need to balance between Queues.
pendingTasks := backfill.pickUpPendingTasks(ssn)
for _, task := range pendingTasks {
job := ssn.Jobs[task.Job]
ph := util.NewPredicateHelper()

// 1. 轻量 PrePredicate
if err := ssn.PrePredicateFn(task); err != nil { ...; continue }

// 2. 找可放置节点(无 request 的 BestEffort 几乎总能命中)
predicateNodes, fitErrors := ph.PredicateNodes(task, ssn.NodeList, predicateFunc,
backfill.enablePredicateErrorCache, ssn.NodesInShard)
if len(predicateNodes) == 0 { ...; continue }

// 3. 多节点时打分选优
node := predicateNodes[0]
if len(predicateNodes) > 1 {
candidateNodes := util.GetPredicatedNodeByShard(predicateNodes, ssn.NodesInShard)
for _, nodes := range candidateNodes {
nodeScores := util.PrioritizeNodes(task, nodes, ssn.BatchNodeOrderFn, ...)
node = ssn.BestNodeFn(task, nodeScores)
...
}
}

// 4. 直接 Allocate
if err := ssn.Allocate(task, node); err != nil { ...; continue }
}
}

pickUpPendingTasks 里挑任务的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
for _, task := range job.TaskStatusIndex[api.Pending] {
if !task.BestEffort { continue } // ← 只挑 BestEffort
if task.SchGated { continue } // ← 跳过调度门控
if job.Skip.SkipTask(task.UID) { continue }
tasks[job.UID].Push(task)
}

for _, task := range job.TaskStatusIndex[api.Pipelined] {
if !task.BestEffort { continue }
stmt := framework.NewStatement(ssn)
err := stmt.UnPipeline(task) // ← Pipelined → Pending
...
tasks[job.UID].Push(task)
}

4.1 一处「注释已过时」的发现

源码里那句 TODO (k82cn): When backfill, it's also need to balance between Queues. 很容易让人得出「backfill 不做队列间排序」的结论。但这句话和当前实现已经对不上了。

看 pickUpPendingTasks 的开头:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
queues := util.NewPriorityQueue(ssn.QueueOrderFn)     // ← 队列按 QueueOrderFn 排序
jobs := map[api.QueueID]*util.PriorityQueue{}
tasks := map[api.JobID]*util.PriorityQueue{}
...
jobs[job.Queue] = util.NewPriorityQueue(ssn.JobOrderFn) // ← 队列内 Job 按 JobOrderFn 排序
...
for !queues.Empty() {
queue := queues.Pop()
for !jobs[queue.UID].Empty() {
job := jobs[queue.UID].Pop()
for !tasks[job.UID].Empty() {
pendingTasks = append(pendingTasks, tasks[job.UID].Pop().(*api.TaskInfo))
}
}
}

三级优先队列——队列、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
2
3
4
5
func New() *Action {
return &Action{
enablePredicateErrorCache: true, // default to enable it
}
}

它把 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,正确性不受影响,但效率会遭遇三重打击——

  1. 调度延迟增加一个或多个周期——本可即刻消费的碎片资源被空置;
  2. 核心任务被拖慢——BestEffort 混在 allocate 里与需要 Gang、事务、复杂排序的核心任务抢调度时间;
  3. Pipelined 假性占用跨周期残留——Session 快照不干净,下一周期的资源核算被污染。

backfill 通过在同一调度周期内做一次轻量收尾——UnPipeline 释放假占、跳过 gang 检查、任务粒度直接分配——把这三个问题一次性解决。它存在的意义,是让那些「反正总会被调度」的任务,更快、更高效、且不打扰关键业务地被调度。

从源码能看到 Volcano 团队对 backfill 边界的把握相当克制:只碰 BestEffort,只做同周期处理,不引入 reclaim 那套跨队列再平衡。这种「知道自己不该做什么」的设计克制,恰恰是它能长期稳定作为流水线第三段、且默认开启的根本原因。

对运维和二次开发者的实际启示:

  • 集群里 BestEffort 任务占比高(大规模混部场景) → backfill 是必须保留且值得调优的阶段;
  • 工作负载全部声明了资源请求(Kueue 风格) → backfill 对你几乎是透明的。

理解这一层差异,比死记「backfill 是什么」有价值得多。


相关阅读

核实说明:本文源码结论基于 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 迭代较快,具体行为请以你手上的版本为准。