状态 (Status): Draft 作者 (Authors): @shepherd-cheung 创建日期 (Created): 2026-05-01 更新日期 (Updated): 2026-05-27
NPU上的任务面临故障重调度、驱逐并恢复、扩缩容等场景,当 pod 被驱逐后重新调度到不同节点时,需重新拉取全套镜像,导致任务启动延迟 5~20 分钟。如果 pod 能回到原节点,镜像层已缓存在节点本地(container image cache),启动时间可降至秒级。
核心痛点——镜像拉取耗时:
本提案为 ascend-volcano-plugin 增加 Pod 回原节点调度能力:当 pod 因驱逐(preempt/reclaim)、故障重调度或缩容后再扩容而被重新调度时,优先将其分配回之前运行的节点。
适用范围:
同时给出 推理与训练任务共享集群的最佳实践配置:推理任务根据流量扩缩容时,通过 preempt/reclaim 机制从训练任务回收 NPU 资源,训练任务被驱逐后调度回原节点,形成完整的资源交替闭环。
推理/训练交替场景:
不做此提案的影响:
目标:
实现策略(两级偏好):
非目标:
场景描述: 推理任务通过 preempt/reclaim 驱逐了训练 Job 的部分 pod。训练 Job 的 gang 一致性被打破后,rescheduling 模块级联清除剩余 pod,训练 Job 重新进入 pending。新 pod 调度时,应优先被分配到之前运行的节点,以复用节点上已有的镜像缓存(20~40 GB),避免重新拉取,将启动时间从 5~20 分钟降至秒级。
功能点:
关键指标: pod 回原节点命中率(目标 ≥ 80%)、镜像拉取绕行次数
约束: 仅 NPU task 生效;仅在 ascend-volcano-plugin 内实现
场景描述: 推理任务在低峰期缩容(减少 replicas),高峰期再扩容。扩容的新 pod 应优先分配到缩容前使用的节点,复用该节点上缓存的推理镜像(15~30 GB),快速拉起服务响应流量突发。
约束: 需 PodGroup annotation 中携带 rank index(已有 PodRankIndexKey 机制)
PodRankIndexKey
场景描述: 因为故障被重调度的任务,任务中健康的 pod 优先调度回原节点,故障 pod 优先不调度回原节点。
以上三个用例覆盖了通用的回原节点需求。本节按任务类型(超节点、多级调度)展开各场景下的已有机制和缺失能力,说明本提案对每种任务类型的具体补充作用。
已有能力:重调度路径
超节点任务的重调度路径(FaultJob)已经实现了良好的回原节点保证:
selectNodeFromOriginVSuperPod()
selectNodeFromOriginSpBlock()
getSelectNodes()
scoreNodeForReadyJob()
这些是保证而非偏好——在重调度场景下,只要原节点健康且可用,pod 必定回到原节点。
缺失场景:非重调度路径和跨生命周期
真正缺失的是非重调度路径和跨 Job 生命周期的场景:
selectNodesFromSuperPod
selectOneSpBlock
本提案的补充作用
通过 AffinityCache(以 Owner UID 为 Key,跨 Job 生命周期保持),在策略层的非重调度路径中实现两级偏好:
当前代码:
chip8node8sp/frame.go:316
selectSuperPodForJob()
chip8node8ra64sp/frame.go:175
selectNodesForJob()
已有能力: 多级调度的 rescheduleSubTask 已经在重调度时通过 ScheduleWithPreference + preferNodeNames 偏好当前 Session 中的健康节点。traverseSiblings 中 hasPreferredDescendant 在同分子树间打破平局。
rescheduleSubTask
ScheduleWithPreference
preferNodeNames
traverseSiblings
hasPreferredDescendant
缺失场景:
Schedule()
compareSmallerTreeNodes
本提案的补充:
multilevelscheduling/frame.go:258
tryScheduleTaskInSingleTree()
multilevelscheduling/scheduling.go:273
traverseSiblings()
NPUAllocateFunc (pod 分配到 node) │ └─ 写入内存缓存: {ownerUID/rankIndex → nodeName, timestamp} │ ▼ ┌──────────────────┐ │ 内存缓存 │ (ScheduleHandler 持有,跨 Session 保持) └──────────────────┘ │ ▼ 同一或后续调度 Session(缓存跨 Session 存活) BatchNodeOrderFn (打分阶段) ← 仅对普通 NPU 任务生效 │ ├─ 1. 策略层打分(ScoreBestNPUNodes) ├─ 2. addPreferPreviousNodeScore() ← 查询缓存,统治性得分 ├─ 3. FaultHandle.ScoreBestNPUNodes() ← 故障减分 └─ 4. 乘权 超节点/多级任务: 偏好注入到策略层内部 │ ├─ chip8node8sp: preferOriginSuperPods (一级) + selectNodesFromSuperPod (二级) ├─ chip8node8ra64sp: preferOriginSuperPods (一级) + tpBlock 窗口填充 (二级) └─ multilevel: traverseSiblings 优先排序 (一级+二级合一) │ ▼ NPUAllocateFunc / NPUDeallocateFunc │ ├─ 驱逐时 (task.Status=Releasing) 保留缓存 ← 关键!才能回到原节点 ├─ 分配回滚时 (task.Status=Pending) 清除缓存 ← 防止残留条目误导调度 └─ 分配到新节点时覆盖缓存 ← 更新为最新位置
🆕 蓝色节点 = 本次新增的 AffinityCache 逻辑;白色节点 = 已有逻辑
flowchart TD S0["ScoreBestNPUNodes(task, nodes, sMap)"] S1["1. 取 job; defer 写回"] S2{"2. JobReadyTag?"} S3["3. defer: selectNodeFromCache()"] S4{"4. job 已有 SuperPods?"} S5["5. Standalone 优化"] S6{"6. 节点不足?"} S7["return nil"] S8["7. selectSuperPodForJob()"] S9["8. JobReadyTag=true, 存 SuperPods"] S10["▸ 打分阶段 (defer)"] S0 --> S1 --> S2 S2 -- "false" --> S7 S2 -- "true" --> S3 --> S4 S4 -- "yes" --> S10 S4 -- "no" --> S5 --> S6 S6 -- "yes" --> S_err["JobReadyTag=false; return error"] S6 -- "no" --> S8 --> S9 --> S10 subgraph select["selectSuperPodForJob()"] A1["7a. getSuperPodTop: 节点按物理 SuperPodID 分组"] A2["7b. 创建 vSuperPodID, 全部 false"] A3["7c. 设置 currentOwnerUID"] A4["7d. selectNodesForFaultJob() 重调度路径"] A5["7e. 收集 unReadyID"] A6["7f. preferOriginSuperPods()"] A7["7g. classifySuperPod() 贪心分类"] A8{"7h. 可用 < 需要?"} A9["7i. selectNodes() 贪心选取"] end A1 --> A2 --> A3 --> A4 --> A5 --> A6 --> A7 --> A8 A8 -- "yes" --> A_err["return error"] A8 -- "no" --> A9 --> A_ok["return selectNodes"] subgraph fault["7d. selectNodesForFaultJob()"] F1{"FaultJob?"} F2["isDelayingJob: 等节点释放 ≤10s"] F3{"schedulable: 空位够?"} F4["selectNodeFromOriginVSuperPod<br/>整组保留 数据: SuperPodReschdInfo"] F5["selectNodeFromOriginSuperPod<br/>同超节点替换 数据: SuperPodReschdInfo"] F6["selectNodeForPodLevelRescheduling<br/>Pod级重调度 数据: SuperPodReschdInfo"] end F1 -- "no" --> F_skip["return 空"] F1 -- "yes" --> F2 --> F3 F3 -- "no" --> A_err F3 -- "yes" --> F4 --> F5 --> F6 --> A5 subgraph new["7f. preferOriginSuperPods()"] N1["遍历 unReady vPodID"] N2["getOriginSuperPodID()<br/>查首pod rank→AffinityCache→反查物理超节点"] N3{"原物理超节点 ≥ spBlock?"} N4["selectNodesFromSuperPod<br/>Step1: 预填充 rank→node<br/>Step2: 健康度填充"] N5["从 totalNodes 扣除"] N6["加入 stillUnready 降级"] end N1 --> N2 --> N3 N3 -- "yes" --> N4 --> N5 --> N1 N3 -- "no" --> N6 --> N1 N1 -- "遍历完" --> N7["return stillUnready"] A5 --> N1 subgraph score["▸ selectNodeFromCache() 打分"] C1{"JobReadyTag?"} C2["scoreNodeBatchForReadyJob<br/>sMap[节点]=1亿-rank偏移"] C3["scoreNodeForReadyJob<br/>sMap[节点]+=1亿"] end C1 -- "yes" --> C2 C1 -- "yes,批量" --> C3 C1 -- "false" --> C_nop["无操作"] subgraph bno["BatchNodeOrderFn 后续"] B1["1. 策略层打分: 选中=1亿, 其他=0"] B2["2. addPreferPreviousNodeScore<br/>原节点 = maxScore+bonus"] B3["3. FaultHandle 故障减分<br/>亚健康-1 硬故障-64"] B4["4. × scoreWeight(100)"] end B1 --> B2 --> B3 --> B4 style new fill:#e1f5fe,stroke:#0288d1,stroke-width:2px style N2 fill:#b3e5fc,stroke:#0288d1 style N4 fill:#b3e5fc,stroke:#0288d1 style B2 fill:#b3e5fc,stroke:#0288d1 style A6 fill:#e1f5fe,stroke:#0288d1
普通 NPU 策略的 ScoreBestNPUNodes 按 NPU 数量打分(healthyCardsNum*100 - len(nodeTop)),所有节点分数在同一数量级(数百)。回原节点的优先级应是最高的——只要原节点通过了 Predicate(健康、资源充足、拓扑匹配),就应优先选择,因为复用镜像缓存(避免 5-20 分钟拉取)的收益远大于节点间资源均衡。
ScoreBestNPUNodes
healthyCardsNum*100 - len(nodeTop)
实现方式:addPreferPreviousNodeScore 不再做固定 +4 加分,而是找到当前 scoreMap 中的最高分,将原节点分数直设为 maxScore + bonus,确保其得分超过所有其他候选节点。后续故障减分(步骤 3)仍然生效——如果原节点有硬故障(-64),其净得分可能被其他节点超越,不会强制调度到故障节点。
addPreferPreviousNodeScore
scoreMap
maxScore + bonus
降级条件:原节点不在 scoreMap 中(未通过 Predicate)→ 自然降级到正常打分流程,不强制。
超节点策略的 scoreNodeForReadyJob() 给已选定节点打 1 亿分,其他节点 0 分。+4 分无效。必须在策略层内部实现两级偏好:
selectSuperPodForJob() │ ├─ selectNodesForFaultJob() 已有: 重调度保证 │ ├─ preferOriginSuperPods() ★ 第一级: vSuperPod → 原物理超节点 │ │ 对每个未分配的 vSuperPod,查询缓存中第一个 pod 的原节点 │ │ → 反查物理超节点 ID → 从中选取节点。 │ │ 在 classifySuperPod 之前执行,避免原超节点被抢占。 │ └─ selectNodesFromSuperPod() ★ 第二级: pod → 原节点 │ Step1: 预填充 rank→node + Step2: 健康度填充空位 │ ├─ classifySuperPod() 剩余节点分类 └─ selectNodes() 剩余 vSuperPod 贪心选取
第一级核心逻辑 (preferOriginSuperPods, frame.go:1120):
preferOriginSuperPods
getOriginSuperPodID
totalNodes
stillUnready
第二级 core逻辑 (selectNodesFromSuperPod, frame.go:1006):
spBlock
GetPreferredNodeMap
rank→nodeName
nodes[localRank]
selectNodesForJob() │ ├─ selectNodesForFaultJob() 已有: 重调度路径 │ ├─ preferOriginSuperPods() ★ 第一级: spBlock → 原物理超节点 │ │ 对每个未分配的 spBlock 反查原物理超节点。 │ │ 在 getSuperPodsInfo (分类) 之前执行。 │ └─ selectSpBlockFromOriginSuperPod() ★ 第二级: pod → 原节点 │ rack 分组 → tpBlock 约束选取 → rack 内节点按偏好重排序 │ ├─ getSuperPodsInfo() 剩余节点分类 ├─ strategyInitFactory() └─ selectNodesBySpecStrategy() 剩余 spBlock 策略选取 └─ selectOneSpBlock() 第二级偏好 (rack 内偏好重排序)
与 chip8node8sp 的关键差异:
tpBlock
allPreferredNodes
selectSpBlockFromOriginSuperPod
base.go
A3 superpod (ascend910a3/superpod) 的结构和流程与 chip8node8sp 高度一致。module910SuperPod 嵌入 Base910A3 → 嵌入 base.NPUHandler → 嵌入 plugin.ScheduleEnv,同样可访问 ClusterCache.AffinityCache。采用与 chip8node8sp 完全相同的两级偏好方案:
ascend910a3/superpod
module910SuperPod
Base910A3
base.NPUHandler
plugin.ScheduleEnv
ClusterCache.AffinityCache
classifySuperPod
getPreferredNodesForVPod
修改文件:
type.go
currentOwnerUID types.UID
frame.go
selectSuperPodForJob
多级调度无"物理超节点"概念,节点组成资源树。"块"是资源树节点,通过 hasPreferredDescendant 标记识别。偏好通过 traverseSiblings 中的排序调整实现,同时充当第一级和第二级:
tryScheduleTaskInSingleTree() │ ├─ fJob != nil → mh.reschedule() 重调度路径 │ │ buildCachedPreferNodeNames() 从 AffinityCache 构建 rank→nodeName 映射 │ └─ Reschedule(resourceTree, taskTree, faultNodes, cachedNodes) │ └─ rescheduleSubTask() │ rankToNode = 当前健康节点 + 缓存偏好节点 │ └─ ScheduleWithPreference() │ └─ traverseSiblings() │ ★ 含原节点的子树排最前面优先遍历 │ └─ fJob == nil → ScheduleWithPreference() 非重调度路径 ↑ 从 AffinityCache 注入 rankToNode (rank→nodeName) └─ traverseSiblings() ★ 同上
traverseSiblings 修改 (scheduling.go:273):
sort.SliceStable
hasPreferredDescendant=true
traverseNode
sortFreeTasksByPreferredRank 修改 (scheduling.go:438):
sortFreeTasksByPreferredRank
nodeToRank
minRank / level1Size * level1Size
placed[rank - groupStart]
ScheduleHandler.AffinityCache └─ InitNPUSession: sHandle.ClusterCache.AffinityCache = sHandle.AffinityCache └─ ClusterCache.AffinityCache ← 策略层通过 tp.ScheduleEnv.ClusterCache 访问 ├─ chip8node8sp: tp.ScheduleEnv.ClusterCache.AffinityCache ├─ chip8node8ra64sp: tp.ScheduleEnv.ClusterCache.AffinityCache └─ MultilevelHandler: mh.ScheduleEnv.ClusterCache.AffinityCache
不逐层增加函数参数(超节点策略涉及 10+ 个中间函数),在策略 handler 的入口函数中设置 currentOwnerUID 字段,叶子函数直接读取:
currentOwnerUID
入口函数(有 task, 可查 job): tp.currentOwnerUID = job.Owner.UID — chip8node8sp: selectSuperPodForJob — chip8node8ra64sp: selectNodesForJob — multilevel: tryScheduleTaskInSingleTree/reschedule 叶子函数: cache.GetPreferredNode(tp.currentOwnerUID, rankStr)
Volcano 调度框架单 goroutine 串行执行,无并发问题。
缓存通过 NPUAllocateFunc 在 pod 成功分配到节点时写入。每个 pod 分配时调用 RecordAssignment(ownerUID, rankIndex, nodeName),将 ownerUID → rankIndex → nodeName 的映射记录到内存中。缓存写入发生在策略层节点选择完成后的最终分配确认阶段。
NPUAllocateFunc
RecordAssignment(ownerUID, rankIndex, nodeName)
NPUAllocateFunc(task, nodeName) └─ rankIndex ← task.Pod.Annotation[PodRankIndexKey] └─ job ← task.Job └─ ownerUID ← job.Owner.UID └─ RecordAssignment(ownerUID, rankIndex, nodeName)
通过 NPUDeallocateFunc 根据 task.Status 区分处理,是缓存正确性的核心决策点:
NPUDeallocateFunc
task.Status
NPUDeallocateFunc(task) └─ switch task.Status: ├─ api.Releasing (驱逐/缩容) → 保留缓存 ← 关键!回到原节点的前提 └─ api.Pending (分配回滚) → RemoveAssignment → 防止残留条目误导
缓存为 ScheduleHandler 持有的纯内存结构。ScheduleHandler 在 Volcano 插件注册时创建一次,其生命周期跨越多个调度 Session:
ScheduleHandler
默认 TTL 为 24 小时。在每次 InitNPUSession(新 Session 初始化)时调用 EvictExpired(24h) 清理超时的 Owner 条目。过期机制防止已删除任务或长期不活跃的 Owner 泄漏内存。
InitNPUSession
EvictExpired(24h)
采用两层 Map 结构,以 PodGroup 所属 Owner 的 UID 作为第一层 Key:
Deployment "inference-deploy" (ownerUID=abc-123) ├─ Rank 0 → node-gpu-01 ├─ Rank 1 → node-gpu-02 └─ Rank 2 → node-gpu-03 PodGroup 被驱逐后重建(PG UID 变了,但 Owner UID 不变) 新 Pod Rank 0 → 查 Owner "abc-123" + Rank 0 → 命中 node-gpu-01 → 加分回原节点
选择 Owner UID 而非 PodGroup UID 的原因:
rank index 来自已有的 PodRankIndexKey annotation(hccl/rankIndex),所有任务类型统一使用。
hccl/rankIndex
// PodNodeAffinityCache pod→node 亲和缓存 // 两层 Map: ownerUID → rankIndex → nodeName type PodNodeAffinityCache struct { mu sync.RWMutex // key: ownerUID (PodGroup's owner UID), value: rankIndex → nodeName OwnerToRankNodes map[string]map[string]string // key: ownerUID, value: last update timestamp (Unix seconds) UpdateTime map[string]int64 }
无锁设计——Volcano 调度框架单 goroutine 串行处理 task。
// 核心方法一览 RecordAssignment(ownerUID, rankIndex, nodeName) // 记录/覆盖 GetPreferredNode(ownerUID, rankIndex) // 单 rank 查询 GetPreferredNodeMap(ownerUID, startRank, endRank) // 批量查询 RemoveAssignment(ownerUID, rankIndex) // 删除单条 RemoveOwner(ownerUID) // 删除整个 Owner EvictExpired(ttl) // TTL 过期清理 Size() // 统计
stateDiagram-v2 [*] --> 空缓存: 调度器启动 空缓存 --> 已记录: NPUAllocateFunc<br/>RecordAssignment 已记录 --> 已更新: NPUAllocateFunc<br/>新节点覆盖旧值 已记录 --> 已清理: NPUDeallocateFunc(Pending)<br/>分配回滚 已记录 --> 已保留: NPUDeallocateFunc(Releasing)<br/>驱逐/缩容删除 已保留 --> 已命中: 新Pod调度<br/>查询缓存命中原节点 已命中 --> 已更新: NPUAllocateFunc<br/>重新分配确认 已保留 --> [*]: TTL过期/Job删除 已清理 --> [*] 已更新 --> 已保留: 再次驱逐 已更新 --> 已清理: 分配回滚
func addPreferPreviousNodeScore(scoreMap map[string]float64, ownerUID string, rankIndex string, cache *AffinityCache, bonus float64) { // 1. 查询 AffinityCache 获取原节点 preferredNode := cache.GetPreferredNode(ownerUID, rankIndex) if preferredNode == "" { return // 无缓存记录,不干预 } // 2. 检查原节点是否通过 Predicate score, exists := scoreMap[preferredNode] if !exists { return // 原节点未通过 Predicate(不健康/资源不足),自然降级 } // 3. 查找当前最高分 maxScore := 0.0 for _, v := range scoreMap { if v > maxScore { maxScore = v } } // 4. 统治性得分:原节点 = maxScore + bonus,确保最高优先级 scoreMap[preferredNode] = maxScore + bonus // 注:后续 FaultHandle 故障减分步骤仍会执行 // 若原节点有硬故障(-64),净得分 = maxScore + bonus - 64,可能被其他节点超越 }
第一级:preferOriginSuperPods
func preferOriginSuperPods(unReady []vPodID, totalNodes map[string]*Node, cache *AffinityCache) []vPodID { stillUnready := []vPodID{} for _, vID := range unReady { // 1. 获取 vSuperPod 第一个 pod 的 rank firstPodRank := vID.startRank // 2. 从缓存反查该 rank 的原节点所属物理超节点 ID originSuperPodID := getOriginSuperPodID(cache, currentOwnerUID, firstPodRank) if originSuperPodID == "" { stillUnready = append(stillUnready, vID) continue // 无缓存,降级到正常分类 } // 3. 检查原物理超节点是否有足够节点 spNodes := filterBySuperPodID(totalNodes, originSuperPodID) if len(spNodes) < spBlock { stillUnready = append(stillUnready, vID) continue // 原超节点资源不足,降级 } // 4. 从原超节点选取节点(进入第二级) nodes := selectNodesFromSuperPod(vID, spNodes, spBlock, cache) assignVPod(vID, nodes) removeFromTotalNodes(totalNodes, nodes) } return stillUnready }
第二级:selectNodesFromSuperPod(预填充精确 rank→node 映射)
func selectNodesFromSuperPod(vID vPodID, spNodes []Node, spBlock int, cache *AffinityCache) []Node { // Step 1: 预填充精确 rank→node 映射 prefMap := cache.GetPreferredNodeMap(currentOwnerUID, vID.startRank, vID.startRank + spBlock) nodes := make([]Node, spBlock) for localRank := 0; localRank < spBlock; localRank++ { globalRank := vID.startRank + localRank rankKey := strconv.Itoa(globalRank) if preferredNode, exists := prefMap[rankKey]; exists { if node, found := findInNodes(spNodes, preferredNode); found { nodes[localRank] = node markConsumed(spNodes, preferredNode) } } } // Step 2: 桶优先级填充剩余空位 // 偏好健康 > 普通健康 > 亚健康 buckets := classifyByHealth(spNodes) for localRank := 0; localRank < spBlock; localRank++ { if nodes[localRank] != nil { continue } nodes[localRank] = pickFromBuckets(buckets) } return nodes }
func selectSpBlockFromOriginSuperPod(vID vPodID, spNodes []Node, spBlock, tpBlock int, cache *AffinityCache) []Node { // 1. 一次缓存查询 prefMap := cache.GetPreferredNodeMap(currentOwnerUID, vID.startRank, vID.startRank + spBlock) allPreferredNodes := buildPreferredNodeSet(prefMap) // 所有首选节点集合 // 2. 按 rack 分组 racks := groupByRack(spNodes) // 3. 按 tpBlock 窗口填充 windows := ceil(spBlock / tpBlock) nodes := make([]Node, spBlock) for w := 0; w < windows; w++ { rack := selectRackForWindow(racks, w) windowStart := w * tpBlock windowEnd := min(windowStart + tpBlock, spBlock) // Pass 1: 窗口内各 rank 的精确首选节点 for localRank := windowStart; localRank < windowEnd; localRank++ { globalRank := vID.startRank + localRank rankKey := strconv.Itoa(globalRank) if preferredNode, exists := prefMap[rankKey]; exists { if node, found := findInRack(rack, preferredNode); found { nodes[localRank] = node markConsumed(rack, preferredNode) } } } // Pass 2: 从同 rack 填充空位,跳过 allPreferredNodes for localRank := windowStart; localRank < windowEnd; localRank++ { if nodes[localRank] != nil { continue } node := pickNonPreferredFrom(rack, allPreferredNodes) if node == nil { node = pickAnyFrom(rack) } nodes[localRank] = node } } return compact(nodes) }
// traverseSiblings 修改:含原节点子树优先遍历 func traverseSiblings(siblings []*TreeNode) { // 稳定排序,hasPreferredDescendant=true 的兄弟排前面 sort.SliceStable(siblings, func(i, j int) bool { if siblings[i].hasPreferredDescendant != siblings[j].hasPreferredDescendant { return siblings[i].hasPreferredDescendant // true 的排前面 } // 同优先级保持原碎片分数顺序 return false }) // 后续两轮遍历逻辑不变 } // sortFreeTasksByPreferredRank:L1 组内位置预分配 func sortFreeTasksByPreferredRank(baseMinus1Node *TreeNode, level1Size int, rankToNode map[int]string, nodeToRank map[string]int) { freeChildren := baseMinus1Node.getFreeChildren() if len(freeChildren) == 0 { return } // 1. 推断 L1 组的 rank 偏移量 minRank := findMinRank(freeChildren, nodeToRank) level1Start := (minRank / level1Size) * level1Size // 2. 位置预分配数组 placed := make([]*TreeNode, level1Size) remaining := []*TreeNode{} for _, child := range freeChildren { preferredRank, hasPref := nodeToRank[child.Name] if hasPref && preferredRank >= level1Start && preferredRank < level1Start+level1Size { pos := preferredRank - level1Start placed[pos] = child // 精确放入位置 } else { remaining = append(remaining, child) } } // 3. 非首选按序填充空位 for i := 0; i < level1Size; i++ { if placed[i] == nil { placed[i] = remaining[0] remaining = remaining[1:] } } baseMinus1Node.children = placed }
level1Start = (minRank / level1Size) * level1Size
placed[rank - level1Start]
remaining
placed
flowchart TB subgraph session["调度 Session"] direction TB P["Predicate<br/>节点过滤"] BNO["BatchNodeOrder<br/>节点打分"] ALLOC["Allocate<br/>NPUAllocateFunc"] P --> BNO --> ALLOC subgraph normal["普通 NPU 任务"] APS["addPreferPreviousNodeScore<br/>🆕 统治性得分"] end subgraph superpod["超节点 / 多级任务"] SP["ScoreBestNPUNodes"] subgraph sp_inner["🆕 偏好注入"] POS["preferOriginSuperPods<br/>一级: 反查原物理超节点"] SNSF["selectNodesFromSuperPod<br/>二级: rank→node 预填充"] SFTP["sortFreeTasksByPreferredRank<br/>位置预分配"] end SCORE["scoreNodeForReadyJob<br/>1亿分映射"] SP --> POS --> SNSF --> SCORE SP --> SFTP --> SCORE end BNO --> normal BNO --> superpod ALLOC --> cache["AffinityCache<br/>写入 ownerUID→rank→node"] end style POS fill:#b3e5fc,stroke:#0288d1 style SNSF fill:#b3e5fc,stroke:#0288d1 style SFTP fill:#b3e5fc,stroke:#0288d1 style APS fill:#b3e5fc,stroke:#0288d1
AffinityCache
BatchNodeOrderFn
sequenceDiagram participant SN as Session N participant AC as AffinityCache participant SN1 as Session N+1 participant PL as 策略层 participant SC as Scoring SN->>AC: RecordAssignment(ownerUID, rank, node) Note over AC: 跨 Session 保持 (内存) SN1->>AC: GetPreferredNodeMap(ownerUID, start, end) AC-->>SN1: rank→nodeName 映射 alt 普通 NPU 任务 SN1->>SC: addPreferPreviousNodeScore SC->>SC: 原节点 = maxScore + bonus else 超节点 850SP/A3 SN1->>PL: preferOriginSuperPods PL->>PL: getOriginSuperPodID (首rank→超节点) PL->>PL: selectNodesFromSuperPod (预填充 rank→node) else 超节点 950SP SN1->>PL: preferOriginSuperPods PL->>PL: selectSpBlockFromOriginSuperPod PL->>PL: Pass1: 精确匹配 + Pass2: 非首选优先 else 多级调度 SN1->>PL: ScheduleWithPreference(rankToNode) PL->>PL: markPreferredDescendants PL->>PL: sortFreeTasksByPreferredRank (位置预分配) end PL-->>SC: selectedNodes (rank→node 对齐) SC->>SC: scoreNodeForReadyJob (1亿分) SN1->>AC: RecordAssignment (更新)
graph LR subgraph plugin["plugin/ 公共层"] type["type.go<br/>ClusterCache.AffinityCache"] factory["factory.go<br/>BatchNodeOrderFn"] task["task.go<br/>NPUAllocateFunc / DeallocateFunc"] end subgraph affinity["internal/affinity/ 缓存"] atype["type.go<br/>PodNodeAffinityCache"] end subgraph policy["internal/npu/policy/ 策略"] subgraph sp850["chip8node8sp/"] sp8t["type.go"] sp8f["frame.go 🆕"] end
/label add triaged
状态 (Status): Draft
作者 (Authors): @shepherd-cheung
创建日期 (Created): 2026-05-01
更新日期 (Updated): 2026-05-27
1. 概述
1.1 简介
NPU上的任务面临故障重调度、驱逐并恢复、扩缩容等场景,当 pod 被驱逐后重新调度到不同节点时,需重新拉取全套镜像,导致任务启动延迟 5~20 分钟。如果 pod 能回到原节点,镜像层已缓存在节点本地(container image cache),启动时间可降至秒级。
核心痛点——镜像拉取耗时:
本提案为 ascend-volcano-plugin 增加 Pod 回原节点调度能力:当 pod 因驱逐(preempt/reclaim)、故障重调度或缩容后再扩容而被重新调度时,优先将其分配回之前运行的节点。
适用范围:
同时给出 推理与训练任务共享集群的最佳实践配置:推理任务根据流量扩缩容时,通过 preempt/reclaim 机制从训练任务回收 NPU 资源,训练任务被驱逐后调度回原节点,形成完整的资源交替闭环。
1.2 动机
推理/训练交替场景:
不做此提案的影响:
1.3 目标
目标:
实现策略(两级偏好):
非目标:
2. 用例分析
2.1 Pod 驱逐后回原节点
场景描述: 推理任务通过 preempt/reclaim 驱逐了训练 Job 的部分 pod。训练 Job 的 gang 一致性被打破后,rescheduling 模块级联清除剩余 pod,训练 Job 重新进入 pending。新 pod 调度时,应优先被分配到之前运行的节点,以复用节点上已有的镜像缓存(20~40 GB),避免重新拉取,将启动时间从 5~20 分钟降至秒级。
功能点:
关键指标: pod 回原节点命中率(目标 ≥ 80%)、镜像拉取绕行次数
约束: 仅 NPU task 生效;仅在 ascend-volcano-plugin 内实现
2.2 缩容后再扩容回原节点
场景描述: 推理任务在低峰期缩容(减少 replicas),高峰期再扩容。扩容的新 pod 应优先分配到缩容前使用的节点,复用该节点上缓存的推理镜像(15~30 GB),快速拉起服务响应流量突发。
功能点:
约束: 需 PodGroup annotation 中携带 rank index(已有
PodRankIndexKey机制)2.3 任务重调度回原节点
场景描述: 因为故障被重调度的任务,任务中健康的 pod 优先调度回原节点,故障 pod 优先不调度回原节点。
功能点:
2.4 不同任务类型的已有能力和缺失场景
以上三个用例覆盖了通用的回原节点需求。本节按任务类型(超节点、多级调度)展开各场景下的已有机制和缺失能力,说明本提案对每种任务类型的具体补充作用。
2.4.1 超节点任务
已有能力:重调度路径
超节点任务的重调度路径(FaultJob)已经实现了良好的回原节点保证:
selectNodeFromOriginVSuperPod()/selectNodeFromOriginSpBlock()getSelectNodes()step1scoreNodeForReadyJob()这些是保证而非偏好——在重调度场景下,只要原节点健康且可用,pod 必定回到原节点。
缺失场景:非重调度路径和跨生命周期
真正缺失的是非重调度路径和跨 Job 生命周期的场景:
selectNodesFromSuperPod/selectOneSpBlock按遍历/rack 顺序选节点,不查缓存本提案的补充作用
通过 AffinityCache(以 Owner UID 为 Key,跨 Job 生命周期保持),在策略层的非重调度路径中实现两级偏好:
当前代码:
chip8node8sp/frame.go:316selectSuperPodForJob()— chip8node8sp 入口chip8node8ra64sp/frame.go:175selectNodesForJob()— chip8node8ra64sp 入口2.4.2 多级调度任务
已有能力: 多级调度的
rescheduleSubTask已经在重调度时通过ScheduleWithPreference+preferNodeNames偏好当前 Session 中的健康节点。traverseSiblings中hasPreferredDescendant在同分子树间打破平局。缺失场景:
preferNodeNames仅来自当前 Session 的健康节点,不包含跨 Session 的 AffinityCache 数据Schedule()完全不传偏好compareSmallerTreeNodes中hasPreferredDescendant仅作第三级 tiebreaker(碎片分数 > 容量 > 偏好),碎片分数稍差就会跳过原节点子树本提案的补充:
ScheduleWithPreference,从 AffinityCache 注入偏好preferNodeNamestraverseSiblings排序时将含原节点的子树排到最前面优先遍历(而非仅作同分 tiebreaker),充当第一级+第二级偏好当前代码:
multilevelscheduling/frame.go:258tryScheduleTaskInSingleTree()multilevelscheduling/scheduling.go:273traverseSiblings()3. 方案设计
3.1 总体方案
3.1.1 核心流程
ScoreBestNPUNodes 完整流程图
flowchart TD S0["ScoreBestNPUNodes(task, nodes, sMap)"] S1["1. 取 job; defer 写回"] S2{"2. JobReadyTag?"} S3["3. defer: selectNodeFromCache()"] S4{"4. job 已有 SuperPods?"} S5["5. Standalone 优化"] S6{"6. 节点不足?"} S7["return nil"] S8["7. selectSuperPodForJob()"] S9["8. JobReadyTag=true, 存 SuperPods"] S10["▸ 打分阶段 (defer)"] S0 --> S1 --> S2 S2 -- "false" --> S7 S2 -- "true" --> S3 --> S4 S4 -- "yes" --> S10 S4 -- "no" --> S5 --> S6 S6 -- "yes" --> S_err["JobReadyTag=false; return error"] S6 -- "no" --> S8 --> S9 --> S10 subgraph select["selectSuperPodForJob()"] A1["7a. getSuperPodTop: 节点按物理 SuperPodID 分组"] A2["7b. 创建 vSuperPodID, 全部 false"] A3["7c. 设置 currentOwnerUID"] A4["7d. selectNodesForFaultJob() 重调度路径"] A5["7e. 收集 unReadyID"] A6["7f. preferOriginSuperPods()"] A7["7g. classifySuperPod() 贪心分类"] A8{"7h. 可用 < 需要?"} A9["7i. selectNodes() 贪心选取"] end A1 --> A2 --> A3 --> A4 --> A5 --> A6 --> A7 --> A8 A8 -- "yes" --> A_err["return error"] A8 -- "no" --> A9 --> A_ok["return selectNodes"] subgraph fault["7d. selectNodesForFaultJob()"] F1{"FaultJob?"} F2["isDelayingJob: 等节点释放 ≤10s"] F3{"schedulable: 空位够?"} F4["selectNodeFromOriginVSuperPod<br/>整组保留 数据: SuperPodReschdInfo"] F5["selectNodeFromOriginSuperPod<br/>同超节点替换 数据: SuperPodReschdInfo"] F6["selectNodeForPodLevelRescheduling<br/>Pod级重调度 数据: SuperPodReschdInfo"] end F1 -- "no" --> F_skip["return 空"] F1 -- "yes" --> F2 --> F3 F3 -- "no" --> A_err F3 -- "yes" --> F4 --> F5 --> F6 --> A5 subgraph new["7f. preferOriginSuperPods()"] N1["遍历 unReady vPodID"] N2["getOriginSuperPodID()<br/>查首pod rank→AffinityCache→反查物理超节点"] N3{"原物理超节点 ≥ spBlock?"} N4["selectNodesFromSuperPod<br/>Step1: 预填充 rank→node<br/>Step2: 健康度填充"] N5["从 totalNodes 扣除"] N6["加入 stillUnready 降级"] end N1 --> N2 --> N3 N3 -- "yes" --> N4 --> N5 --> N1 N3 -- "no" --> N6 --> N1 N1 -- "遍历完" --> N7["return stillUnready"] A5 --> N1 subgraph score["▸ selectNodeFromCache() 打分"] C1{"JobReadyTag?"} C2["scoreNodeBatchForReadyJob<br/>sMap[节点]=1亿-rank偏移"] C3["scoreNodeForReadyJob<br/>sMap[节点]+=1亿"] end C1 -- "yes" --> C2 C1 -- "yes,批量" --> C3 C1 -- "false" --> C_nop["无操作"] subgraph bno["BatchNodeOrderFn 后续"] B1["1. 策略层打分: 选中=1亿, 其他=0"] B2["2. addPreferPreviousNodeScore<br/>原节点 = maxScore+bonus"] B3["3. FaultHandle 故障减分<br/>亚健康-1 硬故障-64"] B4["4. × scoreWeight(100)"] end B1 --> B2 --> B3 --> B4 style new fill:#e1f5fe,stroke:#0288d1,stroke-width:2px style N2 fill:#b3e5fc,stroke:#0288d1 style N4 fill:#b3e5fc,stroke:#0288d1 style B2 fill:#b3e5fc,stroke:#0288d1 style A6 fill:#e1f5fe,stroke:#0288d13.1.2 普通 NPU 任务:统治性得分(最高优先级)
普通 NPU 策略的
ScoreBestNPUNodes按 NPU 数量打分(healthyCardsNum*100 - len(nodeTop)),所有节点分数在同一数量级(数百)。回原节点的优先级应是最高的——只要原节点通过了 Predicate(健康、资源充足、拓扑匹配),就应优先选择,因为复用镜像缓存(避免 5-20 分钟拉取)的收益远大于节点间资源均衡。实现方式:
addPreferPreviousNodeScore不再做固定 +4 加分,而是找到当前scoreMap中的最高分,将原节点分数直设为maxScore + bonus,确保其得分超过所有其他候选节点。后续故障减分(步骤 3)仍然生效——如果原节点有硬故障(-64),其净得分可能被其他节点超越,不会强制调度到故障节点。降级条件:原节点不在
scoreMap中(未通过 Predicate)→ 自然降级到正常打分流程,不强制。3.1.3 超节点任务 (chip8node8sp):两级偏好
超节点策略的
scoreNodeForReadyJob()给已选定节点打 1 亿分,其他节点 0 分。+4 分无效。必须在策略层内部实现两级偏好:第一级核心逻辑 (
preferOriginSuperPods, frame.go:1120):getOriginSuperPodID从缓存反查第一个 pod 的原节点所属物理超节点totalNodes中仍有 ≥ spBlock 个节点 → 从原超节点选取totalNodes,失败则加入stillUnready进入后续分类选取第二级 core逻辑 (
selectNodesFromSuperPod, frame.go:1006):spBlock大小的稀疏数组,通过GetPreferredNodeMap(一次缓存查询)获取rank→nodeName映射,将每个 rank 的原节点直接填入nodes[localRank]3.1.4 超节点任务 (chip8node8ra64sp):两级偏好
与 chip8node8sp 的关键差异:
tpBlock的整数倍从同一个 rack 中选取。选取逻辑为 tpBlock 窗口填充:GetPreferredNodeMap+ 构建allPreferredNodes集合(一次缓存查询)nodes[localRank]allPreferredNodes中的节点),避免误消费后续窗口的首选节点allPreferredNodes保护首选节点不被跨窗口误消费。selectSpBlockFromOriginSuperPod与selectOneSpBlock选取逻辑一致(rack 分组 → tpBlock 约束),仅偏好来源不同。详见base.go。3.1.5 超节点任务 (A3 superpod):两级偏好
A3 superpod (
ascend910a3/superpod) 的结构和流程与 chip8node8sp 高度一致。module910SuperPod嵌入Base910A3→ 嵌入base.NPUHandler→ 嵌入plugin.ScheduleEnv,同样可访问ClusterCache.AffinityCache。采用与 chip8node8sp 完全相同的两级偏好方案:preferOriginSuperPods在classifySuperPod之前执行,对未分配 vSuperPod 通过getOriginSuperPodID缓存反查原物理超节点selectNodesFromSuperPod采用预填充精确 rank→node 映射(preferredHealthy > regularHealthy > subHealthy 桶优先级),通过getPreferredNodesForVPod查询缓存偏好修改文件:
type.go: 新增currentOwnerUID types.UIDframe.go: 新增preferOriginSuperPods、getOriginSuperPodID、getPreferredNodesForVPod;修改selectNodesFromSuperPod;selectSuperPodForJob插入偏好调用3.1.6 多级调度任务 (multilevel):子树优先遍历
多级调度无"物理超节点"概念,节点组成资源树。"块"是资源树节点,通过
hasPreferredDescendant标记识别。偏好通过traverseSiblings中的排序调整实现,同时充当第一级和第二级:traverseSiblings修改 (scheduling.go:273):sort.SliceStable将hasPreferredDescendant=true的兄弟排到前面sort.SliceStable稳定排序,原节点子树之间仍保持碎片分数顺序traverseNode失败时正确消耗 taskCount,自然降级到后续子树sortFreeTasksByPreferredRank修改 (scheduling.go:438):nodeToRank反向索引获取 child 的首选 rankminRank / level1Size * level1Size)placed[rank - groupStart]的精确位置3.1.7 AffinityCache 传递路径
3.1.8 ownerUID 传递策略
不逐层增加函数参数(超节点策略涉及 10+ 个中间函数),在策略 handler 的入口函数中设置
currentOwnerUID字段,叶子函数直接读取:Volcano 调度框架单 goroutine 串行执行,无并发问题。
3.1.9 偏好力度分析
3.1.10 缓存维护方式
缓存填充
缓存通过
NPUAllocateFunc在 pod 成功分配到节点时写入。每个 pod 分配时调用RecordAssignment(ownerUID, rankIndex, nodeName),将 ownerUID → rankIndex → nodeName 的映射记录到内存中。缓存写入发生在策略层节点选择完成后的最终分配确认阶段。缓存清理
通过
NPUDeallocateFunc根据task.Status区分处理,是缓存正确性的核心决策点:缓存跨 Session 保持
缓存为
ScheduleHandler持有的纯内存结构。ScheduleHandler在 Volcano 插件注册时创建一次,其生命周期跨越多个调度 Session:ScheduleHandler不被销毁,缓存数据自然保留,下一 Session 仍可查询缓存过期与驱逐
默认 TTL 为 24 小时。在每次
InitNPUSession(新 Session 初始化)时调用EvictExpired(24h)清理超时的 Owner 条目。过期机制防止已删除任务或长期不活跃的 Owner 泄漏内存。缓存 Key 设计
采用两层 Map 结构,以 PodGroup 所属 Owner 的 UID 作为第一层 Key:
选择 Owner UID 而非 PodGroup UID 的原因:
rank index 来自已有的
PodRankIndexKeyannotation(hccl/rankIndex),所有任务类型统一使用。数据结构
// PodNodeAffinityCache pod→node 亲和缓存 // 两层 Map: ownerUID → rankIndex → nodeName type PodNodeAffinityCache struct { mu sync.RWMutex // key: ownerUID (PodGroup's owner UID), value: rankIndex → nodeName OwnerToRankNodes map[string]map[string]string // key: ownerUID, value: last update timestamp (Unix seconds) UpdateTime map[string]int64 }无锁设计——Volcano 调度框架单 goroutine 串行处理 task。
数据流
// 核心方法一览 RecordAssignment(ownerUID, rankIndex, nodeName) // 记录/覆盖 GetPreferredNode(ownerUID, rankIndex) // 单 rank 查询 GetPreferredNodeMap(ownerUID, startRank, endRank) // 批量查询 RemoveAssignment(ownerUID, rankIndex) // 删除单条 RemoveOwner(ownerUID) // 删除整个 Owner EvictExpired(ttl) // TTL 过期清理 Size() // 统计缓存生命周期状态图
stateDiagram-v2 [*] --> 空缓存: 调度器启动 空缓存 --> 已记录: NPUAllocateFunc<br/>RecordAssignment 已记录 --> 已更新: NPUAllocateFunc<br/>新节点覆盖旧值 已记录 --> 已清理: NPUDeallocateFunc(Pending)<br/>分配回滚 已记录 --> 已保留: NPUDeallocateFunc(Releasing)<br/>驱逐/缩容删除 已保留 --> 已命中: 新Pod调度<br/>查询缓存命中原节点 已命中 --> 已更新: NPUAllocateFunc<br/>重新分配确认 已保留 --> [*]: TTL过期/Job删除 已清理 --> [*] 已更新 --> 已保留: 再次驱逐 已更新 --> 已清理: 分配回滚3.1.11 回原节点调度算法
普通 NPU 任务:addPreferPreviousNodeScore
超节点任务 chip8node8sp / A3:两级偏好
第一级:preferOriginSuperPods
第二级:selectNodesFromSuperPod(预填充精确 rank→node 映射)
超节点任务 chip8node8ra64sp:tpBlock 窗口填充
allPreferredNodes集合使每个 Pass 2 跳过其他窗口的首选节点多级调度任务 multilevel:位置预分配
nodeToRank反向索引获取每个 child 的首选 ranklevel1Start = (minRank / level1Size) * level1Sizeplaced[rank - level1Start]精确位置,非首选 append 到remainingplaced的空位3.1.12 4+1 架构视图
逻辑视图 — 功能分层
flowchart TB subgraph session["调度 Session"] direction TB P["Predicate<br/>节点过滤"] BNO["BatchNodeOrder<br/>节点打分"] ALLOC["Allocate<br/>NPUAllocateFunc"] P --> BNO --> ALLOC subgraph normal["普通 NPU 任务"] APS["addPreferPreviousNodeScore<br/>🆕 统治性得分"] end subgraph superpod["超节点 / 多级任务"] SP["ScoreBestNPUNodes"] subgraph sp_inner["🆕 偏好注入"] POS["preferOriginSuperPods<br/>一级: 反查原物理超节点"] SNSF["selectNodesFromSuperPod<br/>二级: rank→node 预填充"] SFTP["sortFreeTasksByPreferredRank<br/>位置预分配"] end SCORE["scoreNodeForReadyJob<br/>1亿分映射"] SP --> POS --> SNSF --> SCORE SP --> SFTP --> SCORE end BNO --> normal BNO --> superpod ALLOC --> cache["AffinityCache<br/>写入 ownerUID→rank→node"] end style POS fill:#b3e5fc,stroke:#0288d1 style SNSF fill:#b3e5fc,stroke:#0288d1 style SFTP fill:#b3e5fc,stroke:#0288d1 style APS fill:#b3e5fc,stroke:#0288d1AffinityCacheBatchNodeOrderFnpreferOriginSuperPods+selectNodesFromSuperPodpreferOriginSuperPods+selectSpBlockFromOriginSuperPodScheduleWithPreference+sortFreeTasksByPreferredRank过程视图 — 跨 Session 调度流
sequenceDiagram participant SN as Session N participant AC as AffinityCache participant SN1 as Session N+1 participant PL as 策略层 participant SC as Scoring SN->>AC: RecordAssignment(ownerUID, rank, node) Note over AC: 跨 Session 保持 (内存) SN1->>AC: GetPreferredNodeMap(ownerUID, start, end) AC-->>SN1: rank→nodeName 映射 alt 普通 NPU 任务 SN1->>SC: addPreferPreviousNodeScore SC->>SC: 原节点 = maxScore + bonus else 超节点 850SP/A3 SN1->>PL: preferOriginSuperPods PL->>PL: getOriginSuperPodID (首rank→超节点) PL->>PL: selectNodesFromSuperPod (预填充 rank→node) else 超节点 950SP SN1->>PL: preferOriginSuperPods PL->>PL: selectSpBlockFromOriginSuperPod PL->>PL: Pass1: 精确匹配 + Pass2: 非首选优先 else 多级调度 SN1->>PL: ScheduleWithPreference(rankToNode) PL->>PL: markPreferredDescendants PL->>PL: sortFreeTasksByPreferredRank (位置预分配) end PL-->>SC: selectedNodes (rank→node 对齐) SC->>SC: scoreNodeForReadyJob (1亿分) SN1->>AC: RecordAssignment (更新)开发视图 — 模块结构
graph LR subgraph plugin["plugin/ 公共层"] type["type.go<br/>ClusterCache.AffinityCache"] factory["factory.go<br/>BatchNodeOrderFn"] task["task.go<br/>NPUAllocateFunc / DeallocateFunc"] end subgraph affinity["internal/affinity/ 缓存"] atype["type.go<br/>PodNodeAffinityCache"] end subgraph policy["internal/npu/policy/ 策略"] subgraph sp850["chip8node8sp/"] sp8t["type.go"] sp8f["frame.go 🆕"] end