跳转至

平衡调度重构

摘要 现有的 patch_balance_schedule.py 逐字复制了三个大型上游单元,以注入大约5行实际逻辑:Scheduler.schedule()(约520行)、DPEngineCoreProc.run_busy_loop()(约40行)和 EngineCoreProc.run_engine_core()(约55行)。本次重构在严格保留 balance_flag 语义的前提下,首先删除了两个已经过时的副本 run_busy_loop() / run_engine_core()(将其替换为基于 _has_global_unfinished_reqs 的引擎核心钩子,以及用于条件激活的模块级名称交换)。schedule() 的副本暂时保留——上游没有提供更细粒度的钩子供借用,删除它依赖于向上游贡献一个覆盖接缝,计划在后续的 Phase 2B 中完成。因此,该文件并未缩减到几十行:schedule() 的主体仍然是上游的逐字副本(与发布标签 v0.24.0 逐字对齐,仅包含3处平衡增量),文件约830行。与稳定的发布标签(而非变动的主分支验证提交哈希)对齐,使得“与上游逐字比较”成为可复现的漂移检查——固定标签在每次CI运行中都指向相同的源代码。本轮实际移除的是 run_busy_loop / run_engine_core 副本的陈旧漂移风险,并修复了一个启用平衡时的死锁(最初是“在 schedule() 内部收集”,后来在一次迭代中改为“在 _process_engine_step 内部收集”);收集操作现在紧跟在 _has_global_unfinished_reqs 跨秩全归约之后。

背景

平衡调度的作用

data-parallel-size 较大且并发度 ≈ DP × max-num-seqs 时,请求往往会堆积在部分DP秩上:饱和的秩同时处理预填充和解码,速度变慢,而其他秩则不断接纳新请求,导致差距扩大。平衡调度不会主动重新平衡每个秩的运行计数。相反,它提供了一个全局准入门控:一旦任何一个秩的运行计数达到上限,所有秩都停止从WAITING队列接纳新请求,让饱和的秩有机会排空其正在处理的请求,从而阻止差距继续扩大。它不是“让落后的秩追上领先的秩”(该语义在此处被明确拒绝——请参见行为保持契约第1条)。

该功能通过 additional_config.enable_balance_scheduling = true 启用(环境变量 VLLM_ASCEND_BALANCE_SCHEDULING 已弃用)。它仅支持PD混合模式;验证逻辑位于 vllm_ascend/platform.pyvllm_ascend/ascend_config.py 中。

实际逻辑(仅两处)

整个功能归结为两个操作:

  1. 运行计数的跨秩同步 — 每个引擎步骤执行一次 all_gather,收集每个秩的 len(self.running)
def balance_gather(self):  # dp_group is injected into self.dp_group by the engine core
    running_tensor = torch.tensor([len(self.running)], dtype=torch.int, device="cpu")
    dist.all_gather(self.balance_queue, running_tensor, group=self.dp_group)
  1. WAITING调度循环内部的准入门控 — 由于每个秩都持有相同的收集向量,此检查在每个秩上产生相同的结果。只要在上一步骤结束时任何一个秩的运行计数达到上限,所有秩在此步骤中都停止接纳新的WAITING请求:
balance_flag = max(t.item() for t in self.balance_queue) == self.max_num_running_reqs
if balance_flag:
    break

语义(必须逐位保留): "领先者达到上限 ⇒ 全局冻结准入"。它不是 "让落后的秩追上领先的秩"。请参见行为保持契约

问题陈述

为了注入上述两个片段,现有的 patch_balance_schedule.py 逐字复制了三个大型上游单元:

复制的单元 行数 复制原因
Scheduler.schedule() ~520 在循环中间插入3行的 balance_flag 门控
DPEngineCoreProc.run_busy_loop() ~40 在每一步之后调用 balance_gather
EngineCoreProc.run_engine_core() ~55 当DP>1时替换为 BalanceDPEngineCoreProc

这种“复制整个单元”的方法有三个具体危害:

  1. schedule() 的副本现已与发布标签(生产固定版本,当前为 v0.24.0)逐字对齐。 发布标签的唯一真实来源.github/vllm-release-tag.commit(CI通过 tr -d '[:space:]' < .github/vllm-release-tag.commit 读取同一文件),当前为 v0.24.0;开发/CI实际安装的是 .github/vllm-main-verified.commit 指向的主分支验证提交(该提交包含后续调度器演进)。旧补丁复制了一个比 v0.24.0 更旧的 vLLM 中的 schedule(),因此整体已经过时。本轮将 schedule() 副本逐字对齐到发布标签的 Scheduler.schedule(),仅保留3处平衡增量(禁用路径的提前返回、balance_flag 门控、if request_queue is None: break);run_busy_loop() / run_engine_core() 的副本已在 Phase 1 中删除。注意:本文档中任何具体的 v0.24.0 只是固定版本文件当前值的快照——随着固定版本推进它会过时,绝不能用作版本权威依据;任何需要此标签的代码/测试必须在运行时读取该文件。

为什么对齐到 v0.24.0 标签而不是已安装的 main-verified 提交? 两个原因:(a) 生产环境实际运行的是 v0.24.0 版本,因此将副本对齐到该版本可保持生产行为与运行时一致;(b) 固定的 git 标签在每次 CI 运行时都指向相同的源码,因此“对副本与上游进行逐字比较(仅允许 3 处差异)”成为可重现的漂移检查——而移动的 main-verified 哈希会随着每次提交向前漂移,无法作为稳定的护栏。

代价与边界: 副本(v0.24.0 逻辑)与 main-verified 运行时的行为略有差异,但 balance 的实际调度路径仅在 NPU + DP + MoE 下触发,CPU 单元测试不会涉及(参见测试计划);这些差异不影响门控本身的语义。两个受支持的版本——发布标签 v0.24.0 和 main-verified 提交 e5588e49——都提供 schedule(self, throttle_prefills=False);因此重写与该共享签名一致,禁用路径也会直接将 throttle_prefills 传给 super()。即:主体对齐发布标签;签名匹配两个受支持版本;禁用路径直接委托。

  1. 它违反了 AGENTS.md 补丁策略。 该策略要求补丁“最小且聚焦”,并“有长期计划向上游贡献”。一个 500 行的逐字副本是无法审查的(不对比上游差异就无法看到真正的改动),并且每次 vLLM 升级都必须手动重新同步。

  2. 静默且未记录的偏差会累积。 例如,重写悄悄地将上游的 assert request_queue is not None 改成了 if request_queue is None: break。此类偏差会使未来的差异变得不可信。

本轮经验:移除 v0.23 支持后,两个受支持版本提供相同的 Scheduler.schedule(self, throttle_prefills=False) 契约。因此兼容性自省和版本字符串分支已无价值,应当删除。单元测试现在要求重写签名在每条 CI 路径中都与已安装的上游签名一致。

设计

原则

移除副本,而非功能。将收集逻辑拉入调度器本身(这样两个 EngineCore 副本就可以删除),并在未来通过一个最小的上游接缝注入门控(这样 schedule() 副本就可以删除)。balance_flag 语义严格保持不变。

实现状态与修正(本轮落地了阶段 1 + 2A + 3)

在实现过程中,我们验证了上游的实际结构,发现两个草案假设不成立;两者均已在此修正:

  • 上游 Scheduler 没有 new_step_starts() 生命周期钩子(这是 kv_cache_manager 的方法),也没有可覆盖的“每步调度开始”接缝。因此 gather 不能放在 schedule() 内部(参见步骤 1 — 将 gather 挂接到 _has_global_unfinished_reqs 并删除 EngineCore 副本中的死锁教训);而是放在引擎核心的 _has_global_unfinished_reqs 上。
  • 调度器无法延迟获取 DP 组: dp_group_init_data_parallel 中生成(早于调度器创建),且没有全局注册表。因此 BalanceDPEngineCoreProc 不会被删除,而是精简为一个覆盖 _has_global_unfinished_reqs 的子类(注入 dp_group 并调用一次 balance_gather);run_engine_core 的副本被替换为修补模块级 DPEngineCoreProc 名称(上游的 run_engine_core 在调用时通过模块全局名称解析此类),且此替换仅在启用 balance 时发生(条件激活)。

因此,阶段1的描述和下面的“重构后文件结构”均已重写以匹配实际实现。阶段3将配置探测简化为两个回退(AscendConfig → additional_config),并移除了直接的环境变量读取——VLLM_ASCEND_BALANCE_SCHEDULING 仍由 AscendConfig 集中解析(作为 additional_config 的已弃用回退),但 _balance_scheduling_enabled 不再自行读取它,从而绕过了 AscendConfig。详情请参见分阶段推出

步骤 1 — 将 gather 挂接到 _has_global_unfinished_reqs 并删除 EngineCore 副本

旧的 BalanceDPEngineCoreProc.run_busy_loop()run_engine_core() 是上游方法的逐字副本,并且已经过时偏离:上游的 run_busy_loop 切换为 while self._handle_shutdown(),添加了 eep_scaling_state / is_sleeping 保护以及尾随的 raise SystemExit,而补丁仍然使用 while True 和手写的信号处理器;run_engine_core 同样增加了 SignalCallback、numa、tracer 逻辑。目标是删除这两个副本。

实现过程中出现了三个约束条件:

  1. 调度器无法自行获取 DP 组。 dp_groupDPEngineCoreProc._init_data_parallel 内部的 parallel_config.stateless_init_dp_group() 创建,并存储在引擎核心上;该方法在 EngineCoreProc.__init__ 中运行,早于 super().__init__()(后者创建调度器),并且没有用于延迟查找的注册表。因此,引擎核心必须将 dp_group 传递给调度器。
  2. balance_gather 必须在每个活跃(非空闲)wave 的每次迭代中的每个 rank 上运行(参见下面的死锁教训)。schedule() 不满足此要求——一个本地已排空的 rank 会获取一个虚拟批次,并且永远不会进入 schedule()
  3. Balance 不得侵入未启用它的配置(例如,PD 分离的重计算 / AsyncRecomputeScheduler),因此 BalanceDPEngineCoreProc 的替换必须是有条件的。

因此,最终的落地方案是:

  • balance_gather 被拉入 BalanceScheduler(无参数签名,使用 self.dp_group),但不是从 schedule() 中调用——引擎核心每步触发它(见下文)。
  • BalanceDPEngineCoreProc 不会被删除,而是精简为一个覆盖:它挂接 _has_global_unfinished_reqs,首先调用 super()._has_global_unfinished_reqs()(每 32 步运行一次跨 rank 的 all-reduce),然后在同一调用内部注入 dp_group 并调用一次 balance_gather()。上游的 run_busy_loop 在每次迭代的每个非空闲路径上恰好调用一次 _has_global_unfinished_reqs(包括已排空 rank 永远不会进入 schedule() 的虚拟批次迭代),因此每个 rank 在每个活跃步骤都参与 gather。run_busy_loop 的主体不再被复制。
  • run_engine_core 的副本被完全删除。 上游的 run_engine_core(一个静态方法)在其主体内部通过模块全局名称解析 DPEngineCoreProcengine_core = DPEngineCoreProc(*args, **kwargs),参见 vllm/v1/engine/core.py)。因此,一个薄包装器包装了 run_engine_core:在其入口处(vllm_config 可用时),它通过 _balance_scheduling_enabled 决定是将模块级的 DPEngineCoreProc 替换为 BalanceDPEngineCoreProc 还是恢复上游原始版本,然后调用原始的 run_engine_core。这是条件激活——关闭 balance 时,逐字使用上游的实现;信号处理、SignalCallback、numa 和 tracer 都保持上游正确。

教训 A — 死锁(gather 不能放在 schedule() 中)。 早期版本将 balance_gather 放在 BalanceScheduler.schedule() 的顶部,并声称“在两步之间 self.running 只在 schedule() / update_from_output() 内变化,因此快照是等价的,并且每步仍有一个 all_gather — 安全”。这个论点只对了一半:门控看到的值确实等价,但它忽略了 all_gather 是一个集合操作,每个 rank 必须同步参与。在 DP MoE 下,一个已排空本地请求的 rank(has_requests() 为 False)会运行 execute_dummy_batch() 并且从不进入 schedule(),因此它跳过了那个 all_gather,而一个仍然繁忙的 rank 调用它并永远等待 — 集合不匹配,死锁_has_global_unfinished_reqs 实际上每 32 步才真正进行 all-reduce,并且 engines_running 在此期间是粘性的,这扩大了这一窗口。

教训 B — 死锁(gather 也不能放在 _process_engine_step 中;它必须紧跟在 _has_global_unfinished_reqs all-reduce 之后)。 后来的迭代通过将 gather 移入 _process_engine_step(每次迭代调用,在同步之前和空闲 continue 门控之前)“修复”了教训 A。这重新引入了另一种死锁_has_global_unfinished_reqs 是繁忙循环中唯一在 wave/空闲状态上重新同步 rank 的点。将每步的 all_gather 放在该同步之前(以及空闲 continue 之前)会使 gather 与 wave 协调解耦。在 wave 边界 — 请求在不同时间完成于每个 rank,_process_input_queue 阻塞在下一个 wave / 新请求上,engines_running 粘性长达 32 步 — 一个 rank 可能到达 gather,而另一个 rank 仍阻塞在 _process_input_queuefuture.result() 中。然后 all_gather 死锁;卡住的 EngineCore 无法再排空其 worker 共享内存广播通道,worker 的 sample_tokens 响应无处安放,60 秒后引擎因 RPC call to sample_tokens timed out 而死亡(间歇性观察到 — “5 次 GPQA 运行正常,第 6 次挂起” — 因为触发取决于每次运行的完成时序)。这与专家并行是否跨越 DP rank 无关:失败机制是 EngineCore↔worker 共享内存耗尽,而不是 worker 前向传播。结论:balance_gather 必须紧跟在 super()._has_global_unfinished_reqs() 之后,这是每次迭代唯一的跨 rank 同步点,因此 rank 在进入 all-gather 时刚刚就 engines_running 达成一致。 由于 _has_global_unfinished_reqs 只在采取空闲 continue 的迭代中被调用,当所有 rank 都空闲时,gather 会被每个 rank 一致地跳过(没有 rank 执行额外的 gather)— 与重构前复制的 run_busy_loop 相同,其中 gather 紧跟在 all-reduce 之后。这一教训由单元测试中的 _has_global_unfinished_reqs 接缝保护所锁定。

时序——与重构前逐位一致。 Gather现在运行在_has_global_unfinished_reqs的尾部,即在schedule() + 执行 + update_from_output()之后,并紧跟在跨rank all-reduce之后——正是重构前由engine-core驱动的gather所在的位置。Gate在下一步的schedule()中消费该值;行为不变。

步骤2 — 用最小接缝替换schedule()副本

balance_flag gate被上游内联在schedule()中间;目前没有可覆盖的接缝。这分两个阶段解决。

阶段2A — 过渡期(无上游依赖):

保留schedule()覆盖,但:

  • 重写匹配受支持版本的共享签名: def schedule(self, throttle_prefills: bool = False)。v0.24.0 和 e5588e49 都提供此签名,禁用路径通过 super().schedule(throttle_prefills) 直接委托。旧的 v0.23 兼容分支和签名自省已不再需要。
  • 将平衡变更压缩为3个带有清晰注释的差异点:(1) 禁用路径的早期返回委托给 super();(2) WAITING 循环内的 balance_flag 门控;(3) if request_queue is None: break(上游使用 assert)。由于上游没有更细粒度的钩子,函数体仍需复制。
  • 逐字比较现在可重现: schedule() 的副本与发布标签对齐(仅3个平衡差异点不同),因此固定的标签使得每次 CI 运行时“与上游的逐字比较”都能产生相同的基线。“意图锁定”测试(签名相等、存在3个差异行、上游接缝仍然存在)继续作为 CPU 可及的护栏,并新增了一个“与发布标签的逐字比较(仅允许3个差异点)”的漂移测试(参见测试计划)。漂移测试在运行时从 .github/vllm-release-tag.commit 读取标签(与 CI 同源)——它不硬编码版本也不读取设计文档;当固定点前进时,测试自动与新标签比较并变红以指示“副本需要重新同步”。
  • “在固定点前进时重新对齐副本”现在是常规维护:每次发布标签前进时,将3个差异点重新应用到新标签的 schedule() 上(持续到阶段2B删除该副本)。

阶段 2B — 目标(随上游贡献一起落地):

贡献一个最小的上游重构,将 WAITING 循环的停止条件提取为一个可重写的方法:

# upstream vllm/v1/core/sched/scheduler.py
def _should_stop_admitting_waiting(self) -> bool:
    return len(self.running) >= self.max_num_running_reqs

一旦上游暴露了该接缝,Ascend 补丁将简化为:

class BalanceScheduler(Scheduler):
    def _should_stop_admitting_waiting(self) -> bool:
        if super()._should_stop_admitting_waiting():
            return True
        return self._balance_enabled and (
            max(t.item() for t in self.balance_queue) >= self.max_num_running_reqs
        )

(此处 >=== 等价,因为任何 rank 的 len(running) 都不会超过 max_num_running_reqs;下面的契约将语义固定为 ==,以明确“领导者达到上限 ⇒ 冻结”,并拒绝“追赶领导者”的重新解释。)

结果: 约520行的 schedule() 副本被永久删除;该文件不再因上游对 schedule() 的编辑而产生漂移。这是 AGENTS.md 所要求的“向上游贡献的长期计划”。

关于 >===,以及被拒绝的“临时降低上限”想法。 一个早期的想法是通过临时设置 self.max_num_running_reqs = min(cap, max(balance_queue)) 来重用上游现有的零拷贝中断条件。这将产生一个不同的语义(“让落后的 rank 追赶领导者”),并且被明确拒绝——请参见下面的契约。

步骤 3 — 规范化配置探测

_balance_scheduling_enabled() 简化为两个回退选项(AscendConfig → additional_config)。删除 run_engine_core 副本后,唯一的调用者是 BalanceScheduler.__init__,但此时 AscendConfig 是否已初始化仍无法保证(这是旧文件顶部 TODO 的起源),因此保留 additional_config 作为启动窗口期的回退,否则函数返回 False。本轮相对于旧实现收紧了一件事:

  • 移除了直接的环境变量读取。 旧实现回退到裸的 os.getenv("VLLM_ASCEND_BALANCE_SCHEDULING"),违反了 AGENTS.md 的“无散落 os.getenv”原则。此函数不再自行读取环境变量——VLLM_ASCEND_BALANCE_SCHEDULINGAscendConfig 集中解析(作为 additional_config 的已弃用回退),并通过主路径 get_ascend_config().enable_balance_scheduling 生效,避免了多个入口点。
  • 文件顶部的 TODO 更新为“一旦 AscendConfig 初始化被提前,这可以简化为单个 get_ascend_config().enable_balance_scheduling 读取”。

稍后(一旦 AscendConfig 的时机问题解决):将两个回退合并为单个读取。

行为保持契约

此重构必须严格保持以下不变性;任何偏离都是错误。

  1. 领导者达到容量上限 ⇒ 全局冻结。 balance_flagmax(balance_queue) == max_num_running_reqs,根据上一步收集到的各 rank 的 len(running) 计算。当条件为真时,所有 rank 都不再接受新的 WAITING 请求。比较操作是 == 与配置的 max_num_running_reqs 进行比较——不是 >=也不是“追赶领导者”。
  2. 相同输入 ⇒ 相同输出。 给定相同的 self.runningself.waitingself.skipped_waitingbalance_queue 和 token 预算,重构后的 schedule() 产生的 SchedulerOutput 与当前实现完全相同(相同的已调度/已抢占/已恢复集合,相同的 num_scheduled_tokens,相同的连接器元数据)。
  3. 收集节奏不变。 每个活跃引擎步骤恰好执行一次 all_gather,在相同的 DP 组上,负载仍然是 len(self.running),当所有 rank 都空闲时,所有 rank 一致地跳过。仅调用位置发生了变化。
  4. 禁用路径不变。enable_balance_scheduling 为 false 时,_balance_run_engine_core 将模块级别的 DPEngineCoreProc 恢复为上游原始实现,并且引擎核心逐字运行上游的实现;BalanceScheduler_balance_enabled=False 时将 schedule(throttle_prefills) 委托给 super().schedule(throttle_prefills),不分配 balance_queue,并且不执行任何集合通信。即,当平衡功能关闭时,它不会触及任何配置(包括 PD 分离重计算 / AsyncRecomputeScheduler,后者已通过 platform.py 与平衡功能互斥;这是第二层防御)。
  5. 现有约束仍然适用。 profiling_chunk_config 互斥锁(参见 vllm_ascend/ascend_config.py)和 PD 混合模式限制(参见 vllm_ascend/platform.py)仍在原处强制执行。

重构后的文件结构

经过本轮(阶段1 + 2A + 3)后,关键结构如下。schedule() 函数体是发布标签 v0.24.0 的逐字副本(在阶段2B之前不能删除),包含三个已记录的差异(禁用路径的提前返回 + WAITING循环中的 balance_flag 门控 + if request_queue is None: break):

# vllm_ascend/patch/platform/patch_balance_schedule.py
import torch
import torch.distributed as dist
import vllm.v1.core.sched.scheduler as _sched_mod
import vllm.v1.engine.core as _engine_core_mod
from vllm.v1.core.sched.scheduler import Scheduler
from vllm.v1.engine.core import DPEngineCoreProc, EngineCoreProc
# ... other vllm imports ...


def _balance_scheduling_enabled(vllm_config) -> bool:
    try:
        from vllm_ascend.ascend_config import get_ascend_config
        return bool(get_ascend_config().enable_balance_scheduling)
    except Exception:
        pass
    additional_config = getattr(vllm_config, "additional_config", None) or {}
    if "enable_balance_scheduling" in additional_config:
        return bool(additional_config["enable_balance_scheduling"])
    return False  # no longer reads the env var itself; VLLM_ASCEND_BALANCE_SCHEDULING is parsed by AscendConfig


class BalanceScheduler(Scheduler):
    def __init__(self, ...):
        super().__init__(...)
        self._balance_enabled = _balance_scheduling_enabled(vllm_config)
        self.dp_group = None  # injected by BalanceDPEngineCoreProc before the first gather
        if self._balance_enabled:
            self.balance_queue = [torch.tensor([0], ...) for _ in range(dp_size)]

    def balance_gather(self):  # uses self.dp_group; no-op when disabled / not injected
        if not self._balance_enabled or self.dp_group is None:
            return
        running_tensor = torch.tensor([len(self.running)], dtype=torch.int, device="cpu")
        dist.all_gather(self.balance_queue, running_tensor, group=self.dp_group)

    def schedule(self, throttle_prefills: bool = False) -> SchedulerOutput:  # shared by v0.24.0 and e5588e49
        if not self._balance_enabled:  # delta 1: disabled-path early return
            return super().schedule(throttle_prefills)
        # NOTE: balance_gather is NOT called here -- see BalanceDPEngineCoreProc.
        # ... upstream schedule() body (verbatim-aligned to the v0.24.0 tag) ...
        #   # inside the WAITING loop (deltas 2, 3):
        #   if max(t.item() for t in self.balance_queue) == self.max_num_running_reqs:  # delta 2: leader-at-cap => global freeze
        #       break
        #   request_queue = self._select_waiting_queue_for_scheduling()
        #   if request_queue is None:  # delta 3: keep if-break (upstream has assert)
        #       break
        # ...


class BalanceDPEngineCoreProc(DPEngineCoreProc):
    """Hook _has_global_unfinished_reqs: inject dp_group + one balance_gather per
    active step. Gather MUST sit immediately after super()._has_global_unfinished_reqs()
    (the only per-iteration cross-rank sync) -- NOT inside _process_engine_step
    (which runs before that sync and before the idle continue gate, and would
    deadlock at wave boundaries -> sample_tokens timeout), and NOT inside
    schedule() (drained ranks skip schedule() and would miss the all_gather)."""

    def _has_global_unfinished_reqs(self, local_unfinished: bool) -> bool:
        result = super()._has_global_unfinished_reqs(local_unfinished)
        self.scheduler.dp_group = self.dp_group
        self.scheduler.balance_gather()
        return result


_OriginalDPEngineCoreProc = _engine_core_mod.DPEngineCoreProc
_OriginalRunEngineCore = EngineCoreProc.run_engine_core


def _balance_run_engine_core(*args, dp_rank=0, local_dp_rank=0, **kwargs):
    # Conditional activation: swap the module-level DPEngineCoreProc only when balance is on.
    if _balance_scheduling_enabled(kwargs.get("vllm_config")):
        _engine_core_mod.DPEngineCoreProc = BalanceDPEngineCoreProc
    else:
        _engine_core_mod.DPEngineCoreProc = _OriginalDPEngineCoreProc
    return _OriginalRunEngineCore(*args, dp_rank=dp_rank, local_dp_rank=local_dp_rank, **kwargs)


# Scheduler is constructed by module-global name when scheduler_cls is unset
# (the PD-mixed balance path); recompute / dynamic-batch / profiling schedulers
# set scheduler_cls and bypass this name, which is correct.
_sched_mod.Scheduler = BalanceScheduler
EngineCoreProc.run_engine_core = staticmethod(_balance_run_engine_core)

本轮删除了约 95 行的 run_engine_core + run_busy_loop 副本及其无效导入,并添加了模块文档字符串、注释和 _balance_run_engine_core 条件激活包装器;净行数几乎没有减少,但它消除的是陈旧漂移风险(旧副本已落后于上游的 _handle_shutdown / eep_scaling_state / SignalCallback 演进),并修复了启用平衡时的死锁(先在 schedule() 内部收集,然后在 _process_engine_step 内部收集;现在在 _has_global_unfinished_reqs 之后)。

阶段 2B(上游提供 _should_stop_admitting_waiting)将删除约 520 行的 schedule() 副本,并将文件缩减至大约 60–80 行,且不包含任何逐字上游副本

测试计划

  1. 签名 + 意图锁 + 逐字漂移测试(阶段2A)。 断言:(a) BalanceScheduler.schedule 的签名可被两种引擎调用形状绑定schedule()schedule(throttle_prefills=...)并且是已安装 Scheduler.schedule 参数集的超集——注意,这不是“签名行等于已安装”,因为在双版本CI下,两条通道的已安装签名不同,相等性断言只能在一个通道上通过(这锁定了“双版本 ⇒ 取并集”的经验教训);(b)3个平衡增量行必须存在于主体中(禁用路径 super().schedule() 委托、WAITING循环中的 balance_flag 门控、if request_queue is None: break);(c)_balance_run_engine_core 包装器已安装,且 DPEngineCoreProc 在导入时被交换(延迟到包装器,在调用时条件性交换);(d)上游 DPEngineCoreProc._has_global_unfinished_reqs 仍然存在(收集注入点——它必须在每个非空闲迭代中被调用,否则 all_gather 会死锁);(e)上游 Scheduler 接缝方法(包括 _build_kv_connector_meta_inflight_prefill_reserved_blocks)仍然存在;(f)逐字漂移检测——首先从 .github/vllm-release-tag.commit 读取发布标签(与CI相同的来源,非硬编码,非从设计文档读取),然后 git show <tag>:vllm/v1/core/sched/scheduler.py 获取该标签的 schedule(),剥离相同的3个增量,并通过AST逐字比较 BalanceScheduler.schedule 的源代码;两者必须相同。读取锁定文件意味着锁定推进自动翻转测试以比较新标签并变红,表示“副本需要重新同步”——这正是我们想要的维护信号;如果锁定文件或标签不可达(例如,vLLM不是源代码检出,在 vllm-ascend 树外运行),则跳过测试,而非失败。
  2. 行为等价测试。 使用一个假的DP组和手动设置的 balance_queue 构建 BalanceScheduler,在几种代表性状态下驱动 schedule()(领导者达到容量冻结、落后排名未满、禁用、空等待),并断言 SchedulerOutput 相同(合同项2)。重用 tests/ut/test_platform.py 中的平衡测试框架。
  3. 收集节奏测试。 模拟 torch.distributed.all_gather(注意:在 balance_gather 内部,dist = torch.distributed,因此模拟目标必须是 torch.distributed.all_gather,而非 vllm.distributed.all_gather);断言每个 balance_gather() 恰好执行一次 all_gather,负载为 len(self.running) 和注入的 dp_group(合同项3)。
  4. 禁用路径测试。 在标志关闭的情况下,断言 balance_queue 未被分配,all_gather 未被调用,且 schedule() 委托给 super().schedule()(合同项4)。
  5. NPU性能检查。 根据 AGENTS.md 的 NPU 指南,max(t.item() for t in self.balance_queue) 每次步骤触发一次主机同步(不可避免,因为此值驱动主机端控制流)。通过性能分析确认重构引入超出当前同步的额外同步。

分阶段推出

阶段 范围 风险 依赖项 状态
1 Hook gather onto _has_global_unfinished_reqs (after the cross-rank all-reduce — avoids both the schedule()-skip deadlock and the _process_engine_step wave-boundary deadlock); slim BalanceDPEngineCoreProc to that hook; delete the run_engine_core/run_busy_loop copies; run_engine_core wrapper conditionally activates DPEngineCoreProc; module-level Scheduler swap Low none ✅ Done
2A 覆盖签名采用双版本并集schedule(self, throttle_prefills=False),CI同时运行 v0.23.0 + 1f486d96);主体与发布标签逐字对齐(仅3个平衡增量);禁用路径通过签名内省_SUPER_SCHEDULE_HAS_THROTTLE)委托给 super();签名可调用性 + 意图锁 + 发布标签逐字漂移测试 ✅ 已完成
3 Collapse config probing to two fallbacks (AscendConfig → additional_config); remove the direct env-var read (still parsed centrally by AscendConfig) Low Phase 1 ✅ Done
2B 上游 _should_stop_admitting_waiting PR;删除 schedule() 副本 上游审查 ⏳ 待办
测试 漂移回归 / 行为等价 / 收集节奏 / 禁用路径 / NPU性能检查 阶段1 + 2A ⏳ 待办(需要NPU)

每个阶段可以独立发布和回滚。阶段1、2A和3可以在同一个发布中落地;阶段2B在上游PR合并时落地。