Prhub

#52702 [Bugfix][Elastic EP] Reject scale below the minimum data parallel size

原始 PR 作者 Etelis 合并时间 2026-08-19 09:37 文件变更 2 提交数 2 评论 4 代码增减 +16 / -2

执行摘要

提前拒绝低于最小 DP 的弹性 EP 缩容,避免引擎崩溃

Issue #30660 报告弹性 EP 从 DP4 缩容到 DP2 时在 replicate_experts 中触发 AssertionError。PR body 指出根因:缩放保持每个引擎的物理专家数固定,任何低于 ceil(num_experts * cur_dp / num_physical_experts) 的目标都会使 num_redundant_experts 为负,该请求会被接受并提交,随后在 eplb/policy/default.py 的裸 assert num_redundant >= 0 处失败,直接杀死 engine core,服务器不可用。

值得精读,尤其建议关注「校验时机前置」的设计:在 prepare 阶段同步校验、在 commit 阶段不再假设输入合法,这种把异步多阶段流程的失败点尽量提前的做法是弹性系统的通用模式。数学公式部分建议配合注释阅读,并考虑后续补一个针对边界条件的单元测试。

讨论亮点

本次 review 几乎没有技术交锋:

  • claude[bot] 自动说明该 PR 来自 fork,自动审查被禁用,需要维护者评论触发。
  • itayalroy(疑似 EP 相关模块负责人)直接批准并留言 Looks good, thanks!,同时 cc 了 tlrmchlsmth。
  • tlrmchlsmth 批准并触发 /ci run,CI 在 commit b7dfd20a45d6 上通过。

没有遗留的未解决疑虑或设计争议。

实现拆解

本 PR 的修复思路是把非法缩容的拦截点从「commit 之后的 EPLB 策略 assert」提前到「prepare 阶段」,共分两步:

  1. 核心校验逻辑(vllm/v1/engine/core_client.py 的 prepare_elastic_ep):先把冗余专家计算拆成两步,先算出当前物理专家总数 num_physical_experts,再按比例推导目标 DP 下的 num_redundant_experts。推导结果为负时立即抛出 ValueError,并在错误信息里用 -(-num_experts * cur_data_parallel_size // num_physical_experts)(等价于向上取整 ceil)给出可缩容的最小 DP 数。该校验发生在任何 standby group 创建、异步重配置提交之前,因此不会造成资源浪费或进程崩溃。
  2. HTTP 入口异常映射(vllm/entrypoints/serve/elastic_ep/api_router.py 的 scale_elastic_ep):在原有 TimeoutError 500 分支之前新增 except ValueError 分支,把合法性校验错误映射为 HTTP 400 并透出具体错误信息。这样客户端能直接看到 Cannot scale to data_parallel_size 1, minimum is 2 这类明确提示,而不是笼统的 500 Scale failed。
  3. 测试与验证:本 PR 未新增自动化测试文件,作者通过手工 curl 验证了 DP2→DP1(被拒绝)与 DP2→DP4(成功)两条路径,并跑过 pre-commit。CI 已通过 Buildkite #84508 触发,未引入回归。
文件 模块 状态 重要度
vllm/v1/engine/core_client.py 引擎客户端 modified 6.73
vllm/entrypoints/serve/elastic_ep/api_router.py API 路由 modified 5.27

关键符号

prepare_elastic_ep commit_elastic_ep scale_elastic_ep

关键源码片段

vllm/v1/engine/core_client.py core-logic

核心修复所在:在 prepare_elastic_ep 中引入 num_physical_experts 并校验 num_redundant_experts 非负,提前拦截非法缩容请求,从源头避免 engine core 崩溃。

async def prepare_elastic_ep(self, new_data_parallel_size: int) -> None:
    """Prepare elastic EP scaling without routing requests to new engines."""
    # 若已有未提交的缩放准备,先处理重复请求
    if (prepared := self._prepared_elastic_ep) is not None:
        if prepared[0] == new_data_parallel_size:
            return
        raise RuntimeError("Elastic EP scaling is already prepared")
​
    cur_data_parallel_size = len(self.core_engines)
    assert self.vllm_config.parallel_config.data_parallel_backend == "ray", (
        "Only ray DP backend supports scaling elastic EP"
    )
​
    parallel_config = self.vllm_config.parallel_config
    num_experts = self.vllm_config.model_config.get_num_experts()
    # 缩放时每个引擎的物理专家数保持不变,先算出当前物理专家总数
    num_physical_experts = (
        num_experts + parallel_config.eplb_config.num_redundant_experts
    )
    # 按比例推导目标 DP 下的冗余专家数;
    # 若结果为负数,说明逻辑专家数已无法被物理专家数覆盖
    num_redundant_experts = (
        num_physical_experts * new_data_parallel_size // cur_data_parallel_size
        - num_experts
    )
    if num_redundant_experts < 0:
        # 提前在 prepare 阶段拒绝,避免 commit 后才在 EPLB 策略里触发
        # 裸 assert num_redundant >= 0 而杀死 engine core
        raise ValueError(
            f"Cannot scale to data_parallel_size {new_data_parallel_size}, "
            f"minimum is "
            f"{-(-num_experts * cur_data_parallel_size // num_physical_experts)}"
        )
​
    if new_data_parallel_size < cur_data_parallel_size:
        await self._prepare_scale_down_elastic_ep(new_data_parallel_size)
    else:
        await self._prepare_scale_up_elastic_ep(
            new_data_parallel_size, num_redundant_experts
        )
    self._prepared_elastic_ep = new_data_parallel_size, num_redundant_experts
vllm/entrypoints/serve/elastic_ep/api_router.py entrypoint

HTTP 入口层把 ValueError 映射为 400,让调用方直接看到具体错误信息而不是笼统的 500。

    client = engine_client(raw_request)
    try:
        await client.scale_elastic_ep(new_data_parallel_size, drain_timeout)
        return JSONResponse(
            {
                "message": f"Scaled to {new_data_parallel_size} data parallel engines",
            }
        )
    except TimeoutError as e:
        raise HTTPException(
            status_code=408,
            detail="Scale failed due to request drain timeout "
            f"after {drain_timeout} seconds",
        ) from e
    except ValueError as e:
        # 将合法性校验错误映射为 400,让调用方直接看到
        # "Cannot scale to data_parallel_size ..." 这类明确信息
        raise HTTPException(status_code=400, detail=str(e)) from e
    except Exception as e:
        logger.error("Scale failed: %s", e)
        raise HTTPException(status_code=500, detail="Scale failed") from e

评论区精华

Fork PR 自动审查被禁用 other

claude[bot] 说明该 PR 来自 fork,自动化审查默认关闭,维护者可通过 @claude review 触发一次性审查。

结论:无需人工处理,属于 bot 的常规提示。 · 已解决

维护者审批与 CI 触发 other

itayalroy 直接批准并留言 Looks good, thanks!,tlrmchlsmth 批准并执行 /ci run 触发 Buildkite CI #84508。

结论:无技术争议,维护者认可修复方案并完成 CI 验证。 · 已解决

风险与影响

  1. 缺少自动化测试覆盖:本次改动没有新增测试文件,边界条件(例如 num_redundant_experts 恰好为 0、无冗余配置下的缩容)仅靠手工验证,后续维护者改动这段整数运算时容易引入 off-by-one 回归。
  2. 整数数学公式的隐式技巧num_physical_experts * new_data_parallel_size // cur_data_parallel_size - num_experts 依赖 Python 向下取整语义,最小 DP 的计算 -(-num_experts * cur_data_parallel_size // num_physical_experts) 则是向上取整技巧,没有注释说明推导过程时,后续维护容易看错。
  3. prepare 阶段抛错的行为变化:此前 prepare_elastic_ep 对非法请求不会抛错,现在会抛 ValueError;若存在其他调用方(如 Rust 前端、内部 scheduler 脚本)未捕获该异常,可能从「延迟崩溃」变成「提前报错」,需要确认所有调用路径。

影响范围集中在弹性专家并行(Elastic EP)的缩容路径:

  • 用户侧:非法缩容请求从「引擎核心进程崩溃、服务不可用」变成「HTTP 400 明确提示」,可用性显著提升,且错误信息可执行(直接给出最小 DP 值)。
  • 系统侧:避免创建 standby group 后再回滚的资源浪费,避免 assert 直接杀死 engine core 进程,重配置流程的失败路径变得更加可控。
  • 团队侧:该修复是弹性 EP 走向自动化扩缩容(如 Kubernetes HPA 驱动)的必要前置保障,与 PR#51885 降低重配置停机时间的目标互补。
核心路径变更 缺少测试覆盖 整数运算边界易错

关联 Issue

#30660 [Bug]: [Elastic EP]: AssertionError in replicate_experts during DP4 → DP2 scale-down

完整报告

参与讨论