已关闭
[RFC]: ascend-volcano-plugin支持pod优先调度回原节点 #258
Atlas_zxp创建于  5月6日关闭于  9月8日
Atlas_zxp
Atlas_zxp成员
5月6日 创建

状态 (Status): Draft
作者 (Authors): @shepherd-cheung
创建日期 (Created): 2026-05-01
更新日期 (Updated): 2026-05-27

1. 概述

1.1 简介

NPU上的任务面临故障重调度、驱逐并恢复、扩缩容等场景,当 pod 被驱逐后重新调度到不同节点时,需重新拉取全套镜像,导致任务启动延迟 5~20 分钟。如果 pod 能回到原节点,镜像层已缓存在节点本地(container image cache),启动时间可降至秒级。

核心痛点——镜像拉取耗时:

项目 典型值
大模型推理镜像 15~30 GB
大模型训练镜像 20~40 GB
镜像首次拉取时间 5~20 分钟
集群节点数 数十~数百

本提案为 ascend-volcano-plugin 增加 Pod 回原节点调度能力:当 pod 因驱逐(preempt/reclaim)、故障重调度或缩容后再扩容而被重新调度时,优先将其分配回之前运行的节点。

适用范围:

任务类型 是否适用 说明
普通 NPU 任务 ✅ 本提案主要目标场景
超节点任务(super pod) ✅ 在超节点/框亲和性等强制约束满足后,作为节点级偏好补充
多级调度任务(multi-level) ✅ 当前多级调度没有原节点偏好,本提案补齐此能力
VNPU 任务 ✅ 与普通 NPU 任务逻辑一致

同时给出 推理与训练任务共享集群的最佳实践配置:推理任务根据流量扩缩容时,通过 preempt/reclaim 机制从训练任务回收 NPU 资源,训练任务被驱逐后调度回原节点,形成完整的资源交替闭环。

1.2 动机

推理/训练交替场景:

  • 推理任务 根据业务流量动态扩缩容,高峰期需要更多 NPU,低峰期释放。镜像同样 15~30 GB,需快速拉起应对流量突发。
  • 训练任务 长期占用 NPU,有 gang 一致性要求(HCCL 集合通信环完整性),镜像 20~40 GB。
  • 两者资源冲突时,推理优先,训练让路。训练被腾退后恢复时,应回到原节点复用镜像缓存,减少恢复时间。

不做此提案的影响:

  • 被驱逐的训练 pod 随机分配到新节点 → 重新拉取 20~40 GB 镜像 → 延迟 5~20 分钟
  • 推理扩容 pod 随机分配 → 同样面临镜像拉取延迟 → 流量突发时响应慢
  • NPU 碎片化加剧,节点间镜像重复拉取浪费带宽和磁盘

1.3 目标

目标:

  • Pod 重新调度时优先回到原节点(在满足亲和性约束的前提下)
  • 缓存为内存缓存,依托 ScheduleHandler 生命周期跨 Session 保持,无需 ConfigMap 持久化
  • 给出推理优先、训练保护的 preempt/reclaim 最佳实践配置
  • 对所有任务类型统一生效:普通 NPU 任务、超节点任务、多级调度任务均适用

实现策略(两级偏好):

任务类型 偏好机制 说明
普通 NPU 任务 BatchNodeOrderFn 统治性得分 设原节点为 maxScore + bonus,确保最高优先级
超节点任务 (chip8node8sp) 策略层两级偏好 第一级: vSuperPod → 原物理超节点;第二级: pod → 原节点 (预填充精确 rank→node 映射)
超节点任务 (chip8node8ra64sp) 策略层两级偏好 第一级: spBlock → 原物理超节点;第二级: tpBlock 窗口内 rank→node 精确匹配
多级调度任务 (multilevel) traverseSiblings 排序优先 + 组内 rank→node 重排 第一级: 含原节点子树优先遍历;第二级: L1 组内按缓存 rank→node 精确重排
超节点任务 (A3 superpod) 策略层两级偏好 第一级: vSuperPod → 原物理超节点;第二级: pod → 原节点 (预填充精确 rank→node 映射),与 chip8node8sp 逻辑一致

非目标:

  • 不修改 Volcano 框架核心逻辑(全部改动在 ascend-volcano-plugin 内)
  • 不作为硬性的 node affinity 绑定(原节点不满足条件时允许调度到其他节点)
  • 不修改 gang 插件逻辑,仅通过 Tier 配置控制行为
  • 不修改超节点/多级调度策略本身的强制约束逻辑(原节点偏好仅在约束满足后的同级节点中起打破平局或优先选取作用)

2. 用例分析

2.1 Pod 驱逐后回原节点

场景描述: 推理任务通过 preempt/reclaim 驱逐了训练 Job 的部分 pod。训练 Job 的 gang 一致性被打破后,rescheduling 模块级联清除剩余 pod,训练 Job 重新进入 pending。新 pod 调度时,应优先被分配到之前运行的节点,以复用节点上已有的镜像缓存(20~40 GB),避免重新拉取,将启动时间从 5~20 分钟降至秒级。

功能点:

  • 记录 pod 与节点的分配关系(namespace/jobUID/rankIndex → nodeName)
  • 在 BatchNodeOrderFn 中对历史节点加分(+AffScore4)
  • 缓存为 Session 级内存缓存,Pod 分配时实时写入
  • Job 删除时清理对应缓存

关键指标: pod 回原节点命中率(目标 ≥ 80%)、镜像拉取绕行次数

约束: 仅 NPU task 生效;仅在 ascend-volcano-plugin 内实现

2.2 缩容后再扩容回原节点

场景描述: 推理任务在低峰期缩容(减少 replicas),高峰期再扩容。扩容的新 pod 应优先分配到缩容前使用的节点,复用该节点上缓存的推理镜像(15~30 GB),快速拉起服务响应流量突发。

功能点:

  • 通过 rank index 识别"同一角色"的 pod(而非 pod name)
  • 缩容时保留缓存记录(这是回原节点的关键)
  • 扩容时命中缓存,打分加成

约束: 需 PodGroup annotation 中携带 rank index(已有 PodRankIndexKey 机制)

2.3 任务重调度回原节点

场景描述: 因为故障被重调度的任务,任务中健康的 pod 优先调度回原节点,故障 pod 优先不调度回原节点。

功能点:

  • 通过 rank index 识别"同一角色"的 pod(而非 pod name)
  • 重新调度时命中回原节点偏好
  • 故障节点已被 rescheduling 模块大幅度减分(-64),+4 的加成不会覆盖此惩罚

2.4 不同任务类型的已有能力和缺失场景

以上三个用例覆盖了通用的回原节点需求。本节按任务类型(超节点、多级调度)展开各场景下的已有机制和缺失能力,说明本提案对每种任务类型的具体补充作用。

2.4.1 超节点任务

已有能力:重调度路径

超节点任务的重调度路径(FaultJob)已经实现了良好的回原节点保证:

层级 函数 行为
第一层 selectNodeFromOriginVSuperPod() / selectNodeFromOriginSpBlock() vSuperPod 所有节点健康 → 整组完整保留在原来的物理超节点
第二层 getSelectNodes() step1 部分节点故障 → 健康 pod 保留在原节点不动
第三层 scoreNodeForReadyJob() 已选定节点打 1 亿分,确定性映射到具体 pod

这些是保证而非偏好——在重调度场景下,只要原节点健康且可用,pod 必定回到原节点。

缺失场景:非重调度路径和跨生命周期

真正缺失的是非重调度路径和跨 Job 生命周期的场景:

缺失场景 原因
缩容删除 → 重新扩容(新 Job UID) 不走 FaultJob 路径,K8s 重建后是新调度
非重调度的初始调度 selectNodesFromSuperPod / selectOneSpBlock 按遍历/rack 顺序选节点,不查缓存
PodGroup 被删除重建(新 PG UID,同 Owner UID) FaultJob cache key 是 Job UID,已失效

本提案的补充作用

通过 AffinityCache(以 Owner UID 为 Key,跨 Job 生命周期保持),在策略层的非重调度路径中实现两级偏好:

  • 第一级(vSuperPod/spBlock → 原物理超节点): 在分类选取之前,先尝试让 vSuperPod/spBlock 回到之前的物理超节点。如果原超节点不可用,自然降级到正常选取流程。
  • 第二级(pod → 原节点): 在物理超节点内部,每个 pod 优先选取其在缓存中记录的节点。

当前代码:

2.4.2 多级调度任务

已有能力: 多级调度的 rescheduleSubTask 已经在重调度时通过 ScheduleWithPreference + preferNodeNames 偏好当前 Session 中的健康节点。traverseSiblings 中 hasPreferredDescendant 在同分子树间打破平局。

缺失场景:

  • preferNodeNames 仅来自当前 Session 的健康节点,不包含跨 Session 的 AffinityCache 数据
  • 非重调度路径调用 Schedule() 完全不传偏好
  • compareSmallerTreeNodes 中 hasPreferredDescendant 仅作第三级 tiebreaker(碎片分数 > 容量 > 偏好),碎片分数稍差就会跳过原节点子树

本提案的补充:

  • 非重调度路径改为调用 ScheduleWithPreference,从 AffinityCache 注入偏好
  • 重调度路径从 AffinityCache 补充跨 Session 的 preferNodeNames
  • traverseSiblings 排序时将含原节点的子树排到最前面优先遍历(而非仅作同分 tiebreaker),充当第一级+第二级偏好

当前代码:


3. 方案设计

3.1 总体方案

3.1.1 核心流程

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) 清除缓存 ← 防止残留条目误导调度
  └─ 分配到新节点时覆盖缓存 ← 更新为最新位置

ScoreBestNPUNodes 完整流程图

🆕 蓝色节点 = 本次新增的 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

3.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 分无效。必须在策略层内部实现两级偏好:

selectSuperPodForJob()
  │
  ├─ selectNodesForFaultJob()     已有: 重调度保证
  │
  ├─ preferOriginSuperPods()     ★ 第一级: vSuperPod → 原物理超节点
  │   │  对每个未分配的 vSuperPod,查询缓存中第一个 pod 的原节点
  │   │  → 反查物理超节点 ID → 从中选取节点。
  │   │  在 classifySuperPod 之前执行,避免原超节点被抢占。
  │   └─ selectNodesFromSuperPod()  ★ 第二级: pod → 原节点
  │        Step1: 预填充 rank→node + Step2: 健康度填充空位
  │
  ├─ classifySuperPod()          剩余节点分类
  └─ selectNodes()               剩余 vSuperPod 贪心选取

第一级核心逻辑 (preferOriginSuperPods, frame.go:1120):

  • 遍历未分配的 vSuperPod,通过 getOriginSuperPodID 从缓存反查第一个 pod 的原节点所属物理超节点
  • 若原超节点在 totalNodes 中仍有 ≥ spBlock 个节点 → 从原超节点选取
  • 成功则更新 totalNodes,失败则加入 stillUnready 进入后续分类选取

第二级 core逻辑 (selectNodesFromSuperPod, frame.go:1006):

  • Step 1:分配 spBlock 大小的稀疏数组,通过 GetPreferredNodeMap(一次缓存查询)获取 rank→nodeName 映射,将每个 rank 的原节点直接填入 nodes[localRank]
  • Step 2:剩余候选节点仅按健康度二分(healthy > sub-healthy),不再查询缓存。空位按此顺序填充
  • 若原节点不在候选池中(亚健康/已被消耗),对应位置保持空置,由 step 2 的自然顺序填充

3.1.4 超节点任务 (chip8node8ra64sp):两级偏好

selectNodesForJob()
  │
  ├─ selectNodesForFaultJob()       已有: 重调度路径
  │
  ├─ preferOriginSuperPods()       ★ 第一级: spBlock → 原物理超节点
  │   │  对每个未分配的 spBlock 反查原物理超节点。
  │   │  在 getSuperPodsInfo (分类) 之前执行。
  │   └─ selectSpBlockFromOriginSuperPod()  ★ 第二级: pod → 原节点
  │        rack 分组 → tpBlock 约束选取 → rack 内节点按偏好重排序
  │
  ├─ getSuperPodsInfo()            剩余节点分类
  ├─ strategyInitFactory()
  └─ selectNodesBySpecStrategy()   剩余 spBlock 策略选取
      └─ selectOneSpBlock()        第二级偏好 (rack 内偏好重排序)

与 chip8node8sp 的关键差异:

  • tpBlock 硬约束优先:节点必须按 tpBlock 的整数倍从同一个 rack 中选取。选取逻辑为 tpBlock 窗口填充:
    • Step 1:获取 GetPreferredNodeMap + 构建 allPreferredNodes 集合(一次缓存查询)
    • 按 rack 迭代,填充 tpBlock 对齐的窗口。每个窗口内:
      • Pass 1:将窗口内各 rank 的精确首选节点填入 nodes[localRank]
      • Pass 2:从同一 rack 填充剩余空位。优先消耗非首选节点(跳过 allPreferredNodes 中的节点),避免误消费后续窗口的首选节点
    • 全部窗口填满 → compact 结果 → 成功
  • chip8node8sp/A3 无 rack 层约束,可用预填充实现精确 rank→node 映射;ra64sp 有 tpBlock 约束,在窗口内做精确匹配,窗口间通过 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.UID
  • frame.go: 新增 preferOriginSuperPods、getOriginSuperPodID、getPreferredNodesForVPod;修改 selectNodesFromSuperPod;selectSuperPodForJob 插入偏好调用

3.1.6 多级调度任务 (multilevel):子树优先遍历

多级调度无"物理超节点"概念,节点组成资源树。"块"是资源树节点,通过 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 的兄弟排到前面
  • sort.SliceStable 稳定排序,原节点子树之间仍保持碎片分数顺序
  • traverseNode 失败时正确消耗 taskCount,自然降级到后续子树

sortFreeTasksByPreferredRank 修改 (scheduling.go:438):

  • 采用位置预分配模式(非排序模式):
    • 通过 nodeToRank 反向索引获取 child 的首选 rank
    • 推断 L1 组的 rank 偏移量(minRank / level1Size * level1Size)
    • 将每个首选 child 放入 placed[rank - groupStart] 的精确位置
    • 非首选 child 按序填充剩余空位
  • 对比排序模式的关键优势:处理 rank 间隙(部分 rank 无缓存偏好)时不会导致高 rank 首选节点"滑"到低位

3.1.7 AffinityCache 传递路径

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

3.1.8 ownerUID 传递策略

不逐层增加函数参数(超节点策略涉及 10+ 个中间函数),在策略 handler 的入口函数中设置 currentOwnerUID 字段,叶子函数直接读取:

入口函数(有 task, 可查 job):
    tp.currentOwnerUID = job.Owner.UID    — chip8node8sp: selectSuperPodForJob
                                          — chip8node8ra64sp: selectNodesForJob
                                          — multilevel: tryScheduleTaskInSingleTree/reschedule
叶子函数:
    cache.GetPreferredNode(tp.currentOwnerUID, rankStr)

Volcano 调度框架单 goroutine 串行执行,无并发问题。

3.1.9 偏好力度分析

场景 偏好力度 降级条件
chip8node8sp 一级 优先选取原物理超节点 原超节点不可用 → 正常分类选取
chip8node8sp 二级 桶优先级 (偏好 > 普通 > 亚健康) 偏好节点亚健康 → 用普通节点
chip8node8ra64sp 一级 优先选取原物理超节点 原超节点不可用 → 策略选取
chip8node8ra64sp 二级 tpBlock 窗口内 Pass1 精确 + Pass2 非首选优先 tpBlock 约束不满足 → 选取失败;窗口间保护首选节点不被误消费
multilevel 子树优先遍历 + L1 组内位置预分配 子树容量不足 → 下一个兄弟;首选节点不在候选池 → 该位置由非首选填充
普通 NPU 任务 统治性得分 (maxScore + bonus) 原节点不在 scoreMap → 正常打分

3.1.10 缓存维护方式

缓存填充

缓存通过 NPUAllocateFunc 在 pod 成功分配到节点时写入。每个 pod 分配时调用 RecordAssignment(ownerUID, rankIndex, nodeName),将 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)
  └─ switch task.Status:
      ├─ api.Releasing (驱逐/缩容) → 保留缓存 ← 关键!回到原节点的前提
      └─ api.Pending (分配回滚)    → RemoveAssignment → 防止残留条目误导
触发场景 task.Status 操作 理由
Pod 被 preempt/reclaim 驱逐 Releasing 保留缓存 重调度时可回到原节点复用镜像
缩容删除 pod Releasing 保留缓存 再扩容时回原节点
分配回滚(Allocate → Discard) Pending 删除缓存 分配未完成,防止误导后续调度
Unpipeline 操作 Pending 删除缓存 同上

缓存跨 Session 保持

缓存为 ScheduleHandler 持有的纯内存结构。ScheduleHandler 在 Volcano 插件注册时创建一次,其生命周期跨越多个调度 Session:

  • Session 内: Pod 分配时实时写入,同 Session 后续调度立即命中
  • 跨 Session: ScheduleHandler 不被销毁,缓存数据自然保留,下一 Session 仍可查询
  • 调度器重启: 内存缓存丢失,Pod 重新分配后逐步重建

缓存过期与驱逐

默认 TTL 为 24 小时。在每次 InitNPUSession(新 Session 初始化)时调用 EvictExpired(24h) 清理超时的 Owner 条目。过期机制防止已删除任务或长期不活跃的 Owner 泄漏内存。

场景 行为 原因
pod 分配到原节点 保留缓存(不变) 下次仍可回来
pod 分配到新节点 覆盖缓存 更新为最新位置
pod 被驱逐/缩容删除 保留缓存 这是回原节点的关键
pod 分配回滚 (Discard) 删除单条缓存 防止残留条目误导后续调度
Owner 超过 TTL(默认 24h) 清理缓存 防止已删除任务残留内存

缓存 Key 设计

采用两层 Map 结构,以 PodGroup 所属 Owner 的 UID 作为第一层 Key:

  • 第一层 Key: PodGroup 的 Owner UID(如 Deployment/StatefulSet 的 UID)
  • 第二层 Key: rankIndex → nodeName
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 的原因:

  • PodGroup/Job 被驱逐重建后其 UID 会变化,但 Owner(如 Deployment)的 UID 保持不变
  • 同一 Owner 下不同批次的 Pod 共享相同的 rank index 语义,节点偏好应跨批次保持

rank index 来自已有的 PodRankIndexKey annotation(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

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,可能被其他节点超越
}
步骤 说明
1 从 AffinityCache 查询 ownerUID + rankIndex → nodeName
2 原节点不在 scoreMap → 未通过 Predicate,自然降级
3 找到 scoreMap 中所有候选节点的最高分
4 原节点得分为 maxScore + bonus,确保击败所有非故障候选节点

超节点任务 chip8node8sp / A3:两级偏好

第一级: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
}

超节点任务 chip8node8ra64sp:tpBlock 窗口填充

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)
}
特性 说明
Pass 1 窗口内精确匹配缓存中的 rank→node,实现原节点回迁
Pass 2 用同 rack 的非首选节点填充空位,避免误消费后续窗口的首选节点
跨窗口保护 allPreferredNodes 集合使每个 Pass 2 跳过其他窗口的首选节点
降级 rack 内节点不足 → tpBlock 约束不满足 → 函数返回失败

多级调度任务 multilevel:位置预分配

// 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
}
步骤 说明
1 通过 nodeToRank 反向索引获取每个 child 的首选 rank
2 推断 level1Start = (minRank / level1Size) * level1Size
3 首选 child 放入 placed[rank - level1Start] 精确位置,非首选 append 到 remaining
4 非首选按序填入 placed 的空位
关键优势 处理 rank 间隙时不会让高 rank 首选节点"滑"到低位

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:#0288d1
层 组件 职责
缓存层 AffinityCache 内存存储 ownerUID→rank→node,跨 Session 保持
公共层 BatchNodeOrderFn 普通任务统治性得分;DeallocateFunc 缓存清理
策略层-850SP/A3 preferOriginSuperPods + selectNodesFromSuperPod 两级偏好:物理超节点 + rank→node 预填充
策略层-950SP preferOriginSuperPods + selectSpBlockFromOriginSuperPod 两级偏好 + tpBlock 窗口约束
策略层-multilevel ScheduleWithPreference + sortFreeTasksByPreferredRank 子树优先 + L1 组内位置预分配

过程视图 — 跨 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
likedislike
Atlas_zxpAtlas_zxp成员
5月6日 添加了label:rfc
Atlas_zxp
Atlas_zxp成员
5月6日 评论:

/label add triaged

likedislike
ascend-robotascend-robot成员
5月6日 添加了label:triaged
此处折叠了16条事件消息 查看更多
lirui2381lirui2381成员
9月8日 关闭了 issue