Prhub

#7511 [vllm, rollout] fix: gate rollout submission during weight-sync drain for DP>1

原始 PR 作者 EricMarcus-ai 合并时间 2026-08-31 22:28 文件变更 2 提交数 1 评论 5 代码增减 +199 / -3

执行摘要

修复 DP>1 权重同步 drain 的提交竞态超时

vLLM 的 pause 语义是“停止调度新请求,但仍然接受请求”,且该行为是有意设计、跨版本稳定(“all pause modes queue new adds”)。verl 把暂停当作“不会再有新请求到达”,但没有任何 verl 侧机制阻止请求在暂停期间被提交。若某个请求在 drain 的第一次检查前到达并置位 engines_running,而只有 rank 0 的 wave_complete 才能清除该标志,暂停期间该事件永远不会到来,drain 只能超时抛出 TimeoutError。该问题只在 DP>1 时出现,因为 vLLM 的 drain 等待 dp_engines_running(),且模型规模越大、单步执行时间越长,pause 与首次 dp engines running 检查之间的窗口越宽。issue 评论中 EricMarcus-ai 强调“It will arise in any large-scale training currently.”,aoshen02 确认 vllm-project/vllm#51481 是同一问题。

值得精读。该 PR 清晰展示了一个异步引擎边界上的竞态:vLLM 的 pause 是“停止调度但接受请求”,verl 必须自行在客户端侧收敛提交。值得关注的设计点包括:单事件循环上“无 await 的检查 + 计数”、barrier 超时降级而非死锁、resume 开门放在 node_rank 守卫之前。建议结合 vllm-project/vllm#51481/#51488 一起阅读,理解上游为何选择 EnginePausedError 语义。

讨论亮点

关键讨论集中在 issue 评论中:aoshen02 表示 vLLM 团队会尽快合入上游修复(vllm-project/vllm#51488,并关联 #51481);EricMarcus-ai 确认 #51481 与本次是同一根源,若上游修复合入会得到 EnginePausedError 语义,只需再配合 catch 即可,但“任何未包含这两个 PR 的 vLLM 版本仍会触发,目前甚至没进任何 release”;wuxibin89 最终拍板“先在 verl 侧修复,升级到修复版 vLLM 后再移除”。PR body 中作者还与 #7393 删除 drain 的路线进行了权衡:删除能避免崩溃,但会允许“孤儿请求”并跳过引擎静默等待,作者认为“might come back to bite us later”。

实现拆解

  1. 状态字段与常量:在 verl/workers/rollout/vllm_rollout/vllm_async_server.py 的 vLLMHttpServer.init 中新增 _submission_paused(提交门开关)、_admitting(已过门但未达引擎的在途请求计数)、_resume_event(唤醒 park 提交者的 asyncio.Event,初始为 set);模块级新增 _GATE_BARRIER_TIMEOUT_S = 60.0 作为 barrier 超时上限。
  2. generate() 挂起与计数:在调用 self.engine.generate(...) 之前加入 park 循环,门关闭时等待 _resume_event;门开启后 _admitting += 1。随后消费生成器时,首次拿到输出即 _admitting -= 1,并通过 finally 保证未产出就退出(被 abort 或异常)也递减,避免计数泄漏。关键设计是 gate 检查与计数增加之间没有 await,借助 actor 的单事件循环保证不会观察到“门已关且计数为 0”的中间状态。
  3. abort_all_requests() 关门顺序:先置 _submission_paused = True 并 clear() 事件,再以 10ms 间隔轮询 _admitting 直到归零;超过 60 秒则告警并继续(降级而非死锁)。之后才快照 request_ids 并执行原有的 pause_generation 路径,确保暂停时不存在未覆盖的提交。
  4. resume_generation() 重开 gate:在 node_rank != 0 的早期返回之前先把 _submission_paused 置回 False 并 set() 事件,保证每个 server 都放行 park 中的请求,只有 rank 0 继续驱动引擎恢复。
  5. 测试配套:新增 tests/workers/rollout/rollout_vllm/test_submitter_gate_on_cpu.py,用 _FakeEngine 记录暂停瞬间的 _admitting,5 个用例覆盖“abort 等待在途请求落地”“提交 park 与唤醒”“非 head server 重开 gate”“head server 同时恢复引擎”“barrier 超时降级”,全部可在 CPU 上运行。
文件 模块 状态 重要度
verl/workers/rollout/vllm_rollout/vllm_async_server.py 推理服务 modified 7.19
tests/workers/rollout/rollout_vllm/test_submitter_gate_on_cpu.py 推理服务 added 7.31

关键符号

vLLMHttpServer.__init__ vLLMHttpServer.generate vLLMHttpServer.abort_all_requests vLLMHttpServer.resume_generation

关键源码片段

verl/workers/rollout/vllm_rollout/vllm_async_server.py core-logic

核心源码修改:新增提交门、在途计数与唤醒事件,重构 abort_all_requests 的关门顺序和 resume_generation 的门控位置,直接修复 DP>1 权重同步 drain 超时竞态。

# vLLM 的 pause 只停止调度、不拒绝接收,pause 之后到达的请求会滞留在
# scheduler 等待队列,且对 drain 的活跃检查不可见,导致
# wait_for_requests_to_drain() 无法收敛。verl 必须在暂停前先关门。# __init__ 中新增的 gate 状态:
# self._submission_paused = False # 门是否关闭
# self._admitting = 0 # 已过门但未达引擎的在途请求数
# self._resume_event = asyncio.Event() # 唤醒 park 中的提交者
# self._resume_event.set()async def generate(self, request_id, prompt, sampling_params, ...):
    # ... 前置的 prompt / sampling 预处理 ...
    # 门检查与 _admitting += 1 之间没有 await;在 actor 单事件循环上,
    # 这保证不会出现“门已关但计数尚未更新”的可观察中间态。
    while self._submission_paused:
        logger.debug('parking request %s until weight sync completes', request_id)
        await self._resume_event.wait()
    self._admitting += 1
​
    with RLInsightLogger.trace_state('vllm_generate', state_lane_id=f'replica_{self.replica_rank}'):
        generator = self.engine.generate(...)
        final_res = None
        admitted = False
        try:
            async for output in generator:
                # 第一个输出说明请求已被引擎接纳,抵消放行时的计数。
                if not admitted:
                    admitted = True
                    self._admitting -= 1
                final_res = output
        finally:
            # 尚未接纳就退出(被 abort 或异常)也要递减,避免 barrier 卡死。
            if not admitted:
                self._admitting -= 1async def abort_all_requests(self, reset_prefix_cache: bool = True) -> dict[str, Any]:
    try:
        # 1) 先关门:新提交在 generate 里开始 park,不再流入引擎。
        self._submission_paused = True
        self._resume_event.clear()
        # 2) 等在途请求落地,最长 _GATE_BARRIER_TIMEOUT_S(60 秒);
        # 超时则告警继续,保证 pause 不会永久挂起。
        deadline = time.monotonic() + _GATE_BARRIER_TIMEOUT_S
        while self._admitting > 0:
            if time.monotonic() > deadline:
                logger.warning(
                    'Submission gate barrier timed out with %d admission(s) in flight, proceeding',
                    self._admitting,
                )
                break
            await asyncio.sleep(0.01)
        # 3) 快照 request_ids 后执行原有的 pause_generation 逻辑(未在本
        # 片段展开),此刻不再有新的请求能进入引擎。
        request_ids = list(self.engine.output_processor.request_states.keys())
        ...async def resume_generation(self):
    # 开门必须放在 node_rank 守卫之前:每个 server 都关过门,
    # 因此每个 server 都必须重开,否则非 head 节点会一直 park。
    self._submission_paused = False
    self._resume_event.set()
    if self.node_rank != 0:
        return
    await self.engine.resume_generation()
tests/workers/rollout/rollout_vllm/test_submitter_gate_on_cpu.py test-coverage

新增 5 个 GPU-free 单测,用 _FakeEngine 验证 gate 顺序、park/ 唤醒、barrier 超时,是本次修复正确性的直接保障。

class _FakeEngine:
    """记录引擎被暂停瞬间的 gate 状态,用来断言操作顺序。"""
​
    def __init__(self):
        # 需要提供 output_processor.request_states 供 abort 快照。
        self.output_processor = SimpleNamespace(request_states={})
        self.server = None
        self.pause_calls = 0
        self.resume_calls = 0
        self.admitting_at_pause = None
​
    async def pause_generation(self, **kwargs):
        # 记录暂停时还有多少在途请求,验证“先落地、后暂停”的顺序。
        self.pause_calls += 1
        self.admitting_at_pause = self.server._admitting
​
    async def resume_generation(self):
        self.resume_calls += 1
​
​
def test_abort_does_not_pause_until_inflight_admissions_land():
    async def main():
        server = _make_server()
        server._admitting = 1 # 一个请求已过门但尚未到达引擎
        abort = asyncio.create_task(server.abort_all_requests())
        await asyncio.sleep(0.05)
        # 门必须立即关闭,但 abort 必须等请求落地,不能提前暂停引擎。
        assert server._submission_paused is True
        assert not abort.done()
        assert server.engine.pause_calls == 0
        server._admitting = 0 # 模拟在途请求被引擎接纳
        await asyncio.wait_for(abort, timeout=5)
        # 暂停发生时,在途计数必须已经归零。
        assert server.engine.pause_calls == 1
        assert server.engine.admitting_at_pause == 0
​
    asyncio.run(main())

评论区精华

与 vLLM 上游修复(#51481/#51488)的取舍 设计

aoshen02 确认 vLLM 团队将尽快合入 #51488,并关联 #51481 作为上下文;EricMarcus-ai 指出 #51481 是同一问题,若上游合并会得到 EnginePausedError 语义,只需 catch 即可,但旧版 vLLM 仍会触发。

结论:wuxibin89 决定先在 verl 侧修复,vLLM 版本升级后再移除该 gate。 · 已解决

是否采用 #7393 删除 drain 的替代路线 设计

PR body 指出 #7393 删除 drain 能避免崩溃,但会允许孤儿请求并跳过引擎静默等待,作者认为可能之后出问题,并征求 HollowMan6 意见。

结论:未获得进一步回应,最终合并采用本 PR 的保序等待方案。 · unresolved

风险与影响

1) 权重同步关键路径变更:abort_all_requests 现在最多等待 60 秒,若 _admitting 计数因异常路径泄漏,权重更新会被阻塞至超时降级。
2) park 依赖 resume_generation 被调用:若权重同步中途异常导致 resume 未执行,park 中的请求会一直等待,当前实现没有独立的自动恢复机制。
3) 单事件循环假设:generate 中“无 await 的检查 + 计数”依赖 Ray actor 的单事件循环,未来若引入多线程或其它并发模型需要重新审查。
4) 临时兼容技术债:与 vLLM 上游 #51481/#51488 的 EnginePausedError 方案并存,升级 vLLM 后需要移除 gate 并切换错误处理路径。
5) 集成验证不足:测试用 _FakeEngine 模拟,未在真实 vLLM 多副本、真实权重同步流程中验证。

影响所有使用 DP>1 且周期做权重同步的 vLLM rollout 训练(大模型场景更易触发),修复前这类训练可能随机中断;修复后权重同步期间的新提交会被挂起而非拒绝,请求延迟略有增加但正确性得到保证。对团队而言,引入了一份与 vLLM 上游修复并行的兼容逻辑,需要跟踪版本演进并计划移除。

核心路径变更 临时兼容修复 存在超时降级分支 依赖上游 vLLM 演进 模拟测试未覆盖真实竞态

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论