平衡调度重构¶
摘要 现有的 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.py 和 vllm_ascend/ascend_config.py 中。
实际逻辑(仅两处)¶
整个功能归结为两个操作:
- 运行计数的跨秩同步 — 每个引擎步骤执行一次
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)
- 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 |
这种“复制整个单元”的方法有三个具体危害:
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()。即:主体对齐发布标签;签名匹配两个受支持版本;禁用路径直接委托。
-
它违反了
AGENTS.md补丁策略。 该策略要求补丁“最小且聚焦”,并“有长期计划向上游贡献”。一个 500 行的逐字副本是无法审查的(不对比上游差异就无法看到真正的改动),并且每次 vLLM 升级都必须手动重新同步。 -
静默且未记录的偏差会累积。 例如,重写悄悄地将上游的
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 逻辑。目标是删除这两个副本。
实现过程中出现了三个约束条件:
- 调度器无法自行获取 DP 组。
dp_group由DPEngineCoreProc._init_data_parallel内部的parallel_config.stateless_init_dp_group()创建,并存储在引擎核心上;该方法在EngineCoreProc.__init__中运行,早于super().__init__()(后者创建调度器),并且没有用于延迟查找的注册表。因此,引擎核心必须将dp_group传递给调度器。 balance_gather必须在每个活跃(非空闲)wave 的每次迭代中的每个 rank 上运行(参见下面的死锁教训)。schedule()不满足此要求——一个本地已排空的 rank 会获取一个虚拟批次,并且永远不会进入schedule()。- 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(一个静态方法)在其主体内部通过模块全局名称解析DPEngineCoreProc(engine_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_reqsall-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_queue或future.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_SCHEDULING由AscendConfig集中解析(作为additional_config的已弃用回退),并通过主路径get_ascend_config().enable_balance_scheduling生效,避免了多个入口点。 - 文件顶部的 TODO 更新为“一旦 AscendConfig 初始化被提前,这可以简化为单个
get_ascend_config().enable_balance_scheduling读取”。
稍后(一旦 AscendConfig 的时机问题解决):将两个回退合并为单个读取。
行为保持契约¶
此重构必须严格保持以下不变性;任何偏离都是错误。
- 领导者达到容量上限 ⇒ 全局冻结。
balance_flag为max(balance_queue) == max_num_running_reqs,根据上一步收集到的各 rank 的len(running)计算。当条件为真时,所有 rank 都不再接受新的 WAITING 请求。比较操作是==与配置的max_num_running_reqs进行比较——不是>=,也不是“追赶领导者”。 - 相同输入 ⇒ 相同输出。 给定相同的
self.running、self.waiting、self.skipped_waiting、balance_queue和 token 预算,重构后的schedule()产生的SchedulerOutput与当前实现完全相同(相同的已调度/已抢占/已恢复集合,相同的num_scheduled_tokens,相同的连接器元数据)。 - 收集节奏不变。 每个活跃引擎步骤恰好执行一次
all_gather,在相同的 DP 组上,负载仍然是len(self.running),当所有 rank 都空闲时,所有 rank 一致地跳过。仅调用位置发生了变化。 - 禁用路径不变。 当
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与平衡功能互斥;这是第二层防御)。 - 现有约束仍然适用。
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 行,且不包含任何逐字上游副本。
测试计划¶
- 签名 + 意图锁 + 逐字漂移测试(阶段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 树外运行),则跳过测试,而非失败。 - 行为等价测试。 使用一个假的DP组和手动设置的
balance_queue构建BalanceScheduler,在几种代表性状态下驱动schedule()(领导者达到容量冻结、落后排名未满、禁用、空等待),并断言SchedulerOutput相同(合同项2)。重用tests/ut/test_platform.py中的平衡测试框架。 - 收集节奏测试。 模拟
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)。 - 禁用路径测试。 在标志关闭的情况下,断言
balance_queue未被分配,all_gather未被调用,且schedule()委托给super().schedule()(合同项4)。 - 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合并时落地。