通过一个 Kueue 测试清理,看懂 TAS 的 Rank 与 Greedy Assignment
在运行 GPU 或 TPU 分布式训练时,我们通常希望同一组 Pod 被放到物理位置更接近的机器上,例如同一个机架或同一个数据中心区块。节点之间越接近,网络延迟通常越低,训练效率也越高。Kueue1 提供的 Topology Aware Scheduling(TAS)正是为了解决这个问题:它会在工作负载进入准入阶段时,基于拓扑信息提前计算出一份“每个 Pod 应该放在哪里”的放置计划。
但这里有一个很容易被忽略的问题:这份放置计划是在工作真正运行之前算好的。等到 Pod 实际创建或替换时,集群的现场状态可能已经变了。节点可能故障、Pod 可能被驱逐、机器也可能被替换。此时,原始计划就可能与真实运行状态脱节。这正是 TAS 中最微妙、也最关键的一环。
我最近在参与 Kueue 贡献时,提交了一个很小的测试清理 PR:#97962。表面上看,它只是把测试里不必要的 JobSet 相关代码移除了;但在整理过程中,我发现这个测试非常适合用来解释 TAS 的三个核心概念:
- Rank:同一工作负载内每个 Pod 的稳定编号
- TopologyAssignment:工作开始前计算出的放置计划
- Greedy Assignment:当原始计划与运行时现实不一致时启用的回退机制
本文就从这个实际测试案例出发,一步一步说明 Kueue TAS 的设计思路。
Kueue Topology Aware Scheduling(TAS)概览
Kueue 的 Topology Aware Scheduling(TAS)是一种进阶调度能力。它不只关心“这个 workload 需要多少 CPU / GPU / TPU”,也关心“这些 Pod 应该放在拓扑结构中的哪个位置”。
根据 Kueue 官方文档3,TAS 通过节点上的 topology labels 来表示数据中心中的层级结构,例如:
cloud.provider.com/topology-block:数据中心区块cloud.provider.com/topology-rack:机架kubernetes.io/hostname:主机名,最低层级
在 workload 进入 admission 阶段时,Kueue 会根据这些拓扑标签计算出一个 TopologyAssignment。你可以把它理解为一份“Pod 到拓扑域的放置计划表”。
Topology Tree:Kueue 的拓扑树
Kueue 会结合 ResourceFlavor、Topology 配置与 Nodes,把集群中的节点组织成一棵拓扑树。树中的每个节点都代表一个 topology domain,每一层对应一个物理或逻辑边界。一个典型的四层拓扑结构如下:

各层对应的 Kubernetes Label 如下:
| 拓扑层级 | Topology Label | 说明 |
|---|---|---|
| Zone | cloud.provider.com/topology-zone |
可用区,最高层级 |
| Block | cloud.provider.com/topology-block |
数据中心内的区块 |
| Rack | cloud.provider.com/topology-rack |
机架 |
| Node | kubernetes.io/hostname |
主机名,最低层级 |
在放置 Pod 时,TAS 会根据 workload 指定的 requiredTopologyLevel,尽可能把同一组 Pod 放在更紧凑的 topology domain 中。比如当最低层级要求是 rack 时,TAS 会优先尝试把所有 Pod 放进同一个机架;如果单个机架容量不足,再向上扩展到同一个 block 下的多个 rack。
Rank:工作负载中 Pod 的稳定编号
在 TAS 的上下文里,rank 很容易被误解。它不是优先级,也不是 NCCL 或 MPI 里的分布式训练 rank。这里的 rank 含义很简单:
- 同一工作负载内,每个 Pod 的稳定编号。
对于带编号的 Indexed Job,Kubernetes 会通过 batch.kubernetes.io/job-completion-index 给每个 Pod 分配从 0 到 .spec.completions - 1 的编号,并以 annotation 与 label 的形式暴露出来4。例如:
workload
└─ Indexed Job
├─ pod A (job-0) -> rank 0
└─ pod B (job-1) -> rank 1
TopologyAssignment:准入阶段计算出的计划
当 Kueue 完成 admission 计算后,会生成一份 TopologyAssignment,把每个 rank 映射到具体的拓扑域。假设最低拓扑层级是 kubernetes.io/hostname,一个包含两个 Pod 的 workload 可能得到如下结果:
| Rank | Pod | 分配节点 |
|---|---|---|
| 0 | Pod A | x3 |
| 1 | Pod B | x1 |
这份 assignment 本质上是一份“计划”。它在计算完成的当下是正确的,但运行过程中一旦发生节点故障、Pod 驱逐等事件,现场状态就可能偏离这份计划。
问题场景:Replacement Pod 与 Running Pod 发生冲突
问题是怎么出现的?
这个问题最早是在 Issue #92105 中被发现的。场景如下:
假设某个 workload 已经在运行,Kueue 在 admission 时已经为它规划好了两个 Pod 分别要跑在哪台机器上。过一段时间后,其中一台机器故障,需要进行节点替换。此时现场状态会变成:
- Pod A:原本跑在故障节点上,现在正在终止(terminating)
- Pod A 的替身:一个新的 replacement Pod 已经被创建出来,正在等待 ungate
- Pod B:继续在别的地方运行,没有直接受影响
问题出在 controller 决定“Pod A 的替身该放到哪里”时。如果它只看 admission 阶段生成的原始 TopologyAssignment,却没有先确认“当前每台机器上到底有哪些 Pod 正在运行”,它就可能把 replacement Pod 放到一个已经有别的运行中 Pod 的节点上。这样就会让两个 Pod 撞到同一个节点,违反 TAS 原本想保证的放置正确性。
下面这张图展示了这个问题的三个阶段:

三个阶段分别是:
- Step 1:admission 完成后,Pod A 和 Pod B 分别运行在各自计划好的节点上
- Step 2:一段时间后,
node-x3出现故障。Pod A 正在终止,Pod B 已经实际运行在node-x3上,而node-x1变成空闲。同时,一个 replacement Pod 正在等待被 ungate - Step 3:如果 controller 死守旧的 assignment(
rank 0 -> node-x3),它也会把 replacement Pod 放到node-x3,从而与已经运行中的 Pod 发生冲突
测试如何精确模拟这个场景
测试 should fallback to greedy assignment when replacement pod conflicts with already running pod 精确复现了这一情况2。
原始的 TopologyAssignment 是:
rank 0 -> node-x3
rank 1 -> node-x1
但测试刻意构造了一个与原始 assignment 不一致的运行时状态:
node-x3: rank 1 (running)
node-x1: empty
也就是说,rank 1 的 Pod 实际已经跑在 node-x3 上,而这原本是 rank 0 的计划位置。如果 controller 仍坚持把 rank 0 的 replacement Pod 调度到 node-x3,就会与正在运行的 rank 1 Pod 冲突。
Kueue 原始计划中的预期放置
┌────────┬────────────┬──────────────┐
│ rank │ pod │ node │
├────────┼────────────┼──────────────┤
│ 0 │ p0 │ x3 │
│ 1 │ p1 │ x1 │
└────────┴────────────┴──────────────┘
实际运行时状态(p1-running 已经跑到 x3)
┌────────┬──────────────┬──────────────┐
│ rank │ pod │ node │
├────────┼──────────────┼──────────────┤
│ 1 │ p1-running │ x3 │
└────────┴──────────────┴──────────────┘
Greedy assignment 的结果(新的 rank 0 Pod 改放到 x1)
┌────────┬────────────────┬──────────────┐
│ rank │ pod │ node │
├────────┼────────────────┼──────────────┤
│ 1 │ p1-running │ x3 │
│ 0 │ p0-replacement │ x1 │
└────────┴────────────────┴──────────────┘
这并不是人为捏造出来的边界场景,而是一个真实的生产风险:admission 时算出的 topology assignment 只是计划,运行时的现场状态却可能已经不是原来的样子。
Greedy Assignment:运行时的安全回退机制
当原始放置计划与真实运行状态不一致时,TAS 的 topology ungater 不会生硬地套用旧 mapping,而是会自动回退到 greedy assignment,基于当前运行时状态重新计算放置结果。
Greedy assignment 并不是“随便找个地方塞进去”的粗放策略。它背后有明确的设计目标:
- 不能发生碰撞:绝不把 replacement Pod 放到已经有同一 workload 其他运行中 Pod 的节点上
- 先看实时状态再决定:如果旧 assignment 已经过时,就根据当前真正空闲的位置来选节点
- 优先保证放置正确性:宁可选择不同但合法的位置,也不要强行遵守过时映射导致错误放置
TopologyAssignment 与 Greedy Fallback 流程
下面这张图展示了 TAS 从 admission 规划到运行时回退的完整流程:

它说明了两个关键点:
- Topology Tree:Kueue 会基于 topology labels,把集群节点组织成 Zone -> Block -> Rack -> Node 的层级结构
- Admission -> Runtime -> Greedy Fallback:一旦 admission 计划与 runtime 现场状态出现偏差,controller 就会回退到 greedy assignment,以保证放置正确性
最终正确的 Placement
在这个测试场景中,greedy assignment 给出的最终结果是:
p1-running (rank 1) -> node-x3
p0-replacement (rank 0) -> node-x1
重点不在于“rank 0 一定要不惜代价留在 node-x3 上”,而在于 controller 不能死守过时的 rank-to-node 映射,最终把两个 Pod 放到同一个节点上。
从这个角度看,greedy assignment 不是退化(downgrade),而是一层安全网:当基于 rank 的 assignment 已经和运行时现实脱节时,它用来保护 placement correctness。
为什么这个测试要从 JobSet 清理成 Job?
原始测试的问题在哪里?
这个测试原本用了几处 JobSet 特有的 subgroup labels。但它真正要验证的事情其实很简单:当 Pod 的 rank 对应节点和实际运行状态不一致时,TAS 是否会正确回退到 greedy assignment?
而这个 rank 身份,本来就可以直接用 Kubernetes 原生 Indexed Job 的 batch.kubernetes.io/job-completion-index 表达,不需要依赖 JobSet。
另外还有一个很现实的原因:集成测试环境通常并不会安装 JobSet CRD,这一点在 Issue #92846 中也有提到。把 JobSet 相关细节留在测试里,容易让读者误以为测试在验证 JobSet 集成行为,但实际上核心逻辑与 JobSet 无关。
PR #9796 做了什么?
PR #97962 做的是一组小而明确的清理:
- 删除测试里 JobSet 特有的 labels 和配置
- 使用
batch.kubernetes.io/job-completion-index表达 Pod 的 rank - 让整个测试只依赖原生 Job 语义,不再与 JobSet 耦合
清理之后,测试表达的重点就非常清楚了:当基于 rank 的放置与真实运行状态冲突时,TAS 能否正确切换到 greedy assignment?现在这个测试只验证这件事,没有额外噪音。
结语
这个测试清理很好地体现了 Kueue TAS 在抽象设计与容错机制之间的平衡。TopologyAssignment 负责给出理想的放置计划,greedy assignment 则负责在运行时处理现实世界中的状态漂移。
分布式调度真正困难的地方,不是只在某个时刻算出一份“完美计划”,而是在生产环境不可避免变化时,依然保证放置正确性。TAS 通过把“计划中的放置”和“运行时的回退机制”分开处理,解决了这个问题。
希望这篇文章能帮助你更清晰地理解 TAS 的设计思路,特别是 rank、TopologyAssignment 与 greedy assignment 三者是如何配合工作的,也帮助你看懂为什么通过简化测试,可以更准确地验证真正的核心机制。
参考资料
-
Kueue PR #9796 — cleanup: use Job instead of JobSet in TAS rank ordering case ↩ ↩2 ↩3
-
Kubernetes documentation — Jobs and Indexed completion mode ↩
-
Kueue Issue #9210 — Wrong pod placement when node replacement is performed ↩
-
Kueue Issue #9284 — Simplify test case for rank-based ordering to use Job rather than JobSet ↩