Prhub

#41357 [Bugfix] Prevent stale multiproc RPC deadlines from becoming unbounded waits

原始 PR 作者 bugkeep 合并时间 2026-07-30 07:41 文件变更 3 提交数 1 评论 13 代码增减 +175 / -2

执行摘要

修复 multiproc RPC 死线过期导致负超时无限等待

关联 Issue #40926 报告 V1 引擎 + MTP + GLM-5.1 上线挂死,sample_tokens RPC 超时导致 EngineDeadError。根因是 collective_rpc 在 enqueue 时计算绝对死线,但后续 drain 其他 future 消耗了时间,导致死线过期后在 dequeue 时 deadline - time.monotonic() 为负,ZMQ 对负超时行为未定义(常表现为无限等待)。

该 PR 值得精读,尤其是根因分析过程:从用户报告的 EngineDeadError 到 ZMQ 负超时的完整追踪。虽然最终代码变更极小,但防御性设计思路(在多个层级 clamp)值得借鉴。测试设计采用 FakeClock 模拟时间变化,是测试超时逻辑的良好模式。

讨论亮点

Review 中主要讨论了三点:

  • 辅助函数命名与精简:Dao007forever 建议将 _remaining_timeout 函数名改为 _remaining_timeout_seconds,但最终 njhill 认为不需要额外函数,直接内联 max(0.0, ...) 即可,作者采纳了直接内联。
  • 同步路径 FutureWrapper 绕过:作者最初尝试在 non_block=False 时绕过 FutureWrapper 直接 drain,njhill 指出这是无关联的不必要变更并要求 revert,作者已回退。
  • recv 注释精炼:njhill 建议缩短 recv 中注释,作者采纳。最终 PR 只保留两处 clamp 和测试文件。

实现拆解

步骤

  1. multiproc_executor.pyget_response 中加入超时钳位:将 dequeue_timeout 计算由 deadline - time.monotonic() 改为 max(0.0, deadline - time.monotonic())。这是一个最小改动,确保负超时变为 0.0,从而立即超时而非挂起。
  2. shm_broadcast.pyrecv 中加入防御性钳位:将 timeout_ms = None if timeout is None else int(timeout * 1000) 改为 int(timeout * 1000) 后加上 max(0, ...)。作为深层防御,即使上层遗漏钳位,也确保负值不会到达 ZMQ poll。
  3. 新增测试文件 test_multiproc_executor_timeout.py:包含 25 个单元测试,使用 FakeClock 模拟时间旅行,验证死线过期、非过期、None 死线等场景,并通过 FakeResponseMQ 断言 dequeue 超时值从未为负。测试覆盖 _dequeue_timeout 辅助函数、FutureWrapper 行为以及集成场景。
文件 模块 状态 重要度
tests/v1/executor/test_multiproc_executor_timeout.py 超时测试 added 7.64
vllm/distributed/device_communicators/shm_broadcast.py 消息队列 modified 5.16
vllm/v1/executor/multiproc_executor.py 异步执行器 modified 5.29

关键符号

_dequeue_timeout FutureWrapper.result MessageQueue.recv collective_rpc.get_response

关键源码片段

tests/v1/executor/test_multiproc_executor_timeout.py test-coverage

新增的回归测试文件,包含 25 个单元测试,覆盖死线钳位的核心逻辑和 FutureWrapper 行为,是验证修复正确性的关键。

# SPDX-License-Identifier: Apache-2.0
"""Regression tests for stale multiproc RPC deadlines."""import time
from collections import deque
from concurrent.futures import Future, InvalidStateError
from contextlib import suppress
from unittest.mock import patch# 核心辅助函数:将绝对死线转换为剩余秒数,负值钳位为 0.0
def _dequeue_timeout(deadline: float | None) -> float | None:
    return None if deadline is None else max(0.0, deadline - time.monotonic())# FutureWrapper 模拟实际执行器中的行为:
# 在调用 result() 时先 drain 队列中已排队的其他 future
class FutureWrapper(Future):
    def __init__(self, futures_queue, get_response, aggregate=lambda x: x):
        self.futures_queue = futures_queue
        self.get_response = get_response
        self.aggregate = aggregate
        super().__init__()
        self.futures_queue.appendleft(self)
​
    def result(self, timeout=None):
        if timeout is not None:
            raise RuntimeError("timeout not implemented")
        while not self.done():
            future = self.futures_queue.pop()
            future._wait_for_response()
        return super().result()
​
    def _wait_for_response(self):
        try:
            response = self.aggregate(self.get_response())
            with suppress(InvalidStateError):
                self.set_result(response)
        except Exception as e:
            with suppress(InvalidStateError):
                self.set_exception(e)# FakeClock 提供可控制的时间源,避免依赖 real time.monotonic()
class FakeClock:
    def __init__(self, start: float = 0.0) -> None:
        self._now = start
​
    def monotonic(self) -> float:
        return self._now
​
    def advance(self, seconds: float) -> None:
        self._now += seconds# FakeResponseMQ 记录收到的 timeout 值,并断言从未为负
class FakeResponseMQ:
    def __init__(self, response=("SUCCESS", "dummy")):
        self.response = response
        self.timeouts: list[float | None] = []
​
    def dequeue(self, timeout=None):
        assert timeout is None or timeout >= 0.0, (
            f"dequeue received negative timeout: {timeout}"
        )
        self.timeouts.append(timeout)
        return self.response# 核心回归测试:死线过去 5 秒,断言 dequeue 收到 0.0 而非负值
def test_future_wrapper_stale_deadline_never_passes_negative_timeout():
    clock = FakeClock(100.0)
    futures_queue = deque()
    response_mq = FakeResponseMQ()
    deadline = clock.monotonic() + 1.0
​
    def get_response():
        return response_mq.dequeue(timeout=_dequeue_timeout(deadline))
​
    future = FutureWrapper(futures_queue, get_response=get_response)
    clock.advance(5.0) # 将时钟拨过死线
​
    with patch("time.monotonic", clock.monotonic):
        future.result()
​
    assert response_mq.timeouts == [0.0] # 验证超时值为 0.0 而非负数
vllm/v1/executor/multiproc_executor.py core-logic

核心修复处:在 `get_response` 中将对 `deadline` 的减法改为 `max(0.0, ...)`,直接解决负超时问题。

# 在 MultiprocExecutor.collective_rpc 内部的 get_response 函数中
# 修复前:dequeue_timeout = None if deadline is None else (deadline - time.monotonic())
# 修复后:dequeue_timeout = None if deadline is None else max(0.0, deadline - time.monotonic())
# 这样即使死线已过期,也会传递 0.0 而非负值给下面的 mq.dequeue()
def get_response():
    responses = []
    for mq in response_mqs:
        # 关键修复行:max(0.0, ...) 确保负值被钳位为 0.0
        dequeue_timeout = (
            None if deadline is None else max(0.0, deadline - time.monotonic())
        )
        try:
            status, result = mq.dequeue(timeout=dequeue_timeout)
        except TimeoutError as e:
            raise TimeoutError(f"RPC call to {method} timed out.") from e
        if status != WorkerProc.ResponseStatus.SUCCESS:
            raise RuntimeError(
                f"Worker failed with error '{result}', please check the"
                " stack trace above for the root cause"
            )
        responses.append(result)
    return responses[0] if output_rank is not None else responses

评论区精华

辅助函数 _remaining_timeout 是否必要 设计

Dao007forever 建议将 _remaining_timeout 函数名改为 _remaining_timeout_seconds,但 njhill 认为不需要额外函数,直接内联 max(0.0, ...) 即可,作者采纳内联方案。

结论:取消辅助函数,直接在 dequeue_timeout 计算式中内联 max(0.0, ...)。 · 已解决

同步路径 FutureWrapper 绕过是否必要 设计

作者最初在 non_block=False 路径中直接 drain 队列而不使用 FutureWrapper,njhill 认为这是无关联的不必要变更,并要求 revert。作者同意并回退。

结论:回退同步路径变化,保持原有 FutureWrapper 使用方式。 · 已解决

recv 中注释简洁性 style

njhill 建议缩短 shm_broadcast.py recv 中关于钳位的注释,从详细解释改为更简洁的 “Ensure non-negative timeout passed to zmq poll”。

结论:接受缩短的注释。 · 已解决

风险与影响

变更极小(+2/-1 行核心逻辑),风险可控。主要风险在于:

  • deadline 本身是 None,逻辑不变(None 分支未触)。
  • max(0.0, ...) 只会在死线过期时改变行为,可能导致过早超时,但这是预期的恢复行为。
  • recv 中的 max(0, int(timeout * 1000)) 只有当 timeout 为负时才改变行为,正常情况不变。
  • 没有公共 API 变动,不影响外部用户。
  • 用户影响:修复了 V1 引擎 + MTP + 密集流量下潜在的 EngineDeadError,提高系统稳定性。影响所有使用 collective_rpc 的场景。
  • 系统影响:减少了进程挂起风险,降低运维成本。
  • 团队影响:极低风险的小改动,只需合并。
极小变更 深层防御

关联 Issue

#40926 [Bug]: V1 engine + MTP + GLM-5.1 (DSA + MoE + MLA) — workers hang under sustained traffic, sample_tokens RPC timeout, EngineDeadError

完整报告

参与讨论