执行摘要
- 一句话:修复 DP>1 权重同步 drain 的提交竞态超时
- 推荐动作:值得精读。该 PR 清晰展示了一个异步引擎边界上的竞态:vLLM 的 pause 是“停止调度但接受请求”,verl 必须自行在客户端侧收敛提交。值得关注的设计点包括:单事件循环上“无 await 的检查 + 计数”、barrier 超时降级而非死锁、resume 开门放在 node_rank 守卫之前。建议结合 vllm-project/vllm#51481/#51488 一起阅读,理解上游为何选择 EnginePausedError 语义。
功能与动机
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 是同一问题。
实现拆解
- 状态字段与常量:在 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 超时上限。
- generate() 挂起与计数:在调用 self.engine.generate(...) 之前加入 park 循环,门关闭时等待 _resume_event;门开启后 _admitting += 1。随后消费生成器时,首次拿到输出即 _admitting -= 1,并通过 finally 保证未产出就退出(被 abort 或异常)也递减,避免计数泄漏。关键设计是 gate 检查与计数增加之间没有 await,借助 actor 的单事件循环保证不会观察到“门已关且计数为 0”的中间状态。
- abort_all_requests() 关门顺序:先置 _submission_paused = True 并 clear() 事件,再以 10ms 间隔轮询 _admitting 直到归零;超过 60 秒则告警并继续(降级而非死锁)。之后才快照 request_ids 并执行原有的 pause_generation 路径,确保暂停时不存在未覆盖的提交。
- resume_generation() 重开 gate:在 node_rank != 0 的早期返回之前先把 _submission_paused 置回 False 并 set() 事件,保证每个 server 都放行 park 中的请求,只有 rank 0 继续驱动引擎恢复。
- 测试配套:新增 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(模块 推理服务;类别 source;类型 core-logic;符号 vLLMHttpServer.init, vLLMHttpServer.generate, vLLMHttpServer.abort_all_requests, vLLMHttpServer.resume_generation): 核心源码修改:新增提交门、在途计数与唤醒事件,重构 abort_all_requests 的关门顺序和 resume_generation 的门控位置,直接修复 DP>1 权重同步 drain 超时竞态。
tests/workers/rollout/rollout_vllm/test_submitter_gate_on_cpu.py(模块 推理服务;类别 test;类型 test-coverage;符号 _FakeEngine, _make_server, test_abort_does_not_pause_until_inflight_admissions_land, test_submission_parks_while_gate_closed_and_wakes_on_resume): 新增 5 个 GPU-free 单测,用 _FakeEngine 验证 gate 顺序、park/唤醒、barrier 超时,是本次修复正确性的直接保障。
关键符号:vLLMHttpServer.init, vLLMHttpServer.generate, vLLMHttpServer.abort_all_requests, vLLMHttpServer.resume_generation
关键源码片段
verl/workers/rollout/vllm_rollout/vllm_async_server.py
核心源码修改:新增提交门、在途计数与唤醒事件,重构 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 -= 1
async 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
新增 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())
评论区精华
关键讨论集中在 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”。
- 与 vLLM 上游修复(#51481/#51488)的取舍 (design): wuxibin89 决定先在 verl 侧修复,vLLM 版本升级后再移除该 gate。
- 是否采用 #7393 删除 drain 的替代路线 (design): 未获得进一步回应,最终合并采用本 PR 的保序等待方案。
风险与影响
- 风险:
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 演进, 模拟测试未覆盖真实竞态
关联脉络
- PR #7393 (PR body 中提及,未提供标题): PR body 明确讨论 #7393 删除 drain 的替代方案,与本 PR 是同一问题上的两种处理路线。
- PR #7227 [ckpt, rollout, vllm] feat: add vLLM consumer for delta-sharded weight sync: 同改 vllm_async_server.py,且 delta 权重同步是本 PR 所保护的 drain 等待阶段的上游动作。
- PR #7565 [rollout] fix: surface vLLM prefix-cache hit counts in TokenOutput: 同文件维护,关注 vLLM 服务器内部状态暴露与引擎边界行为。
参与讨论