Prhub

#50984 [Bugfix][Mooncake] Report failed remote KV loads to the scheduler

原始 PR 作者 BruceLoveDecimal 合并时间 2026-09-14 17:55 文件变更 1 提交数 7 评论 12 代码增减 +66 / -14

执行摘要

Mooncake 解码侧上报失败 KV 加载,解除请求永久滞留

Issue#50719 描述生产 P/D 分离场景(GLM-5.2-NVFP4 + MooncakeConnector + kv_load_failure_policy='fail')中 D 节点系统性 stall:日志里 pulling kv_caches ... failed: Timeout waiting for P side ready 在一次抓取中出现 516 次,同时 deferred(即 num_skipped_waiting_reqs)持续累积,请求既不完成也不释放 KV。PR body 指出根因:请求只能通过 finished_recving_reqs 离开 WAITING_FOR_REMOTE_KVS,而失败路径只写了日志;并且 kv_load_failure_policy 是 no-op,因为 MooncakeConnector 从未实现 get_block_ids_with_load_errors()。作者明确对齐 KVConnectorBase_V1 的约定与 NixlConnectorWorker._handle_failed_transfer() 的既有做法。

值得精读,尤其是负责 P/D 分离、KV 连接器与调度器失败策略的工程师。重点关注三个设计决策:(1) 把「失败」也写入 finished_recving_reqs,用同一出口解除 WAITING_FOR_REMOTE_KVS 等待,这是最小侵入的解法;(2) 用 PullReqMeta.failed + queue.Queue 解决多 P 回报顺序与跨线程传递,而非引入锁; (3) 对空 local_block_ids 的显式跳过,避免触发调度器 assert req_id in self.requests,这是只有在理解 notify_kv_transfer_request_rejected 路径后才会想到的边界。阅读时应把它与 NIXL 的 _handle_failed_transfer() 对照看,并注意本 PR 未新增单测:如果需要在此基础上改动,建议先为 _handle_failed_recv 的幂等、空 block 与 process_pulling_result 的 failed 分支补测试。

讨论亮点

本次 review 的高价值信息几乎全部来自 PR body 而非行级评论(review_comments_count = 0):

  • 作者主动交代与 #36014 的重叠并列出本版本的差异(无 D 侧额外过期定时器、额外处理 MooncakeXferResponseStatus.ERROR、bootstrap 失败路径、空 block 保护、local_block_ids 展平、_invalid_block_ids 用 queue.Queue),并明确表态「Happy to fold this into #36014 instead if @machov or @dtcccc prefer」。
  • @dtcccc 直接给出 "LGTM! @NickLucche please review and trigger ci when you are ready.",未要求折叠到 #36014。
  • 作者连续 ping 了 NickLucche、ivanium、orozery、zhewenl、ZJY0516;最终 @ivanium 以 APPROVED 收尾,@zhewenl 与 @ZJY0516 先后 /ci run,CI 通过后由 ZJY0516 合入。
  • claude[bot] 说明「This pull request is from a fork — automated review is disabled」,cudarabbitai[bot] 也报告 no actionable comments,因此这条核心解码路径的改动实际没有行级评审记录。
  • 代码内注释承载了关键设计理由(空 block 跳过是为了避免触发调度器 assert req_id in self.requests;失败也要写入 finished_recving_reqs 才能离开 WAITING_FOR_REMOTE_KVS),值得作为阅读入口。

实现拆解

  1. 补齐调度器契约(入口):在 MooncakeConnector 上新增 get_block_ids_with_load_errors(),断言 connector_worker 存在后转发到 worker。这是整个修复的开关——此前缺失该方法使 kv_load_failure_policy(fail / recompute)完全失效,调度器拿不到失败 block。
  2. 建立失败收集通道:MooncakeConnectorWorker.__init__ 新增 self._invalid_block_ids: queue.Queue[set[int]]。注释写明「Written from the receiver loop, drained from the worker thread」,选 queue.Queue 而非 set 是因为写入方是 receiver 协程/线程、读取方是 EngineCore 的 worker 线程,随后 get_block_ids_with_load_errors() 用 get_nowait() + queue.Empty 循环排空。
  3. 新增统一失败处理 _handle_failed_recv(pull_metas, req_ids, reason):遍历请求,跳过 pull_meta is None 或已置 failed 的项;置位 PullReqMeta.failed(新增字段,默认 False);调用 self.xfer_stats.record_failed_recv();把 local_block_ids(#40860 之后为 list[list[int]])展平为无效 block 集合后入队;并把 pull_meta.d_req_id 加入 finished_recving_reqs——后者是请求离开 WAITING_FOR_REMOTE_KVS 的唯一途径,也是本次修复能解 stall 的关键。
  4. 收敛所有失败来源:receive_kv_from_single_worker() 中 MooncakeXferResponseStatus.ERROR 分支与 except Exception 分支由「logger.error + record_failed_recv + return」改为调用 _handle_failed_recv;process_pulling_result() 中 response.err_reqs 分支同样改走该方法;handle_new_engine_id() 的 bootstrap 失败(找不到 remote_engine_id)也改为上报,而不是只打日志。作者的取舍是:不再额外加 D 侧过期定时器,因为丢响应已由既有的 zmq.RCVTIMEO(VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT + 60 秒)覆盖。
  5. 两处防误伤保护:其一,展平后 invalid 为空时跳过上报,注释解释了这类 pull 只是请 P 释放 block(对应 AsyncLLM.notify_kv_transfer_request_rejected 提交的 abort_immediately 请求),上报会触发调度器的 assert req_id in self.requests;其二,process_pulling_result() 处理 ok_reqs 时若 pull_meta.failed 为真则 continue,避免「一个 P 失败、另一个 P 成功」把请求重新算作接收完成。
  6. 测试与配套:changed_files_count 为 1,没有新增或修改测试文件;PR body 说明本地 pytest tests/v1/kv_connector/unit/test_mooncake_connector.py 26 项通过、ruff-check/ruff-format 通过,并在 2× RTX 5090、Qwen3-0.6B、1P1D、VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT=30 上做了对照 E2E(deferred 由 30s 时 2 个降为 0,KV block 释放,客户端收到 HTTP 500)。无模型精度评估,也没有配置、schema 或部署脚本改动。
文件 模块 状态 重要度
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py KV 传输 modified 7.81

关键符号

MooncakeConnectorWorker._handle_failed_recv MooncakeConnectorWorker.get_block_ids_with_load_errors MooncakeConnector.get_block_ids_with_load_errors MooncakeConnectorWorker.process_pulling_result MooncakeConnectorWorker.receive_kv_from_single_worker MooncakeConnectorWorker.handle_new_engine_id

关键源码片段

vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py core-logic

唯一改动文件。新增 `_handle_failed_recv()` 与 `get_block_ids_with_load_errors()`,给 `PullReqMeta` 加 `failed` 去重位,把 `MooncakeConnector` 的失败 block 上报接口补全,并把 ERROR 响应、ZMQ 异常、`err_reqs`、bootstrap 失败四条路径统一接入,使调度器能执行 `kv_load_failure_policy`。

# vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py
import queue
from collections.abc import Collection
​
​
@dataclass
class PullReqMeta:
    d_req_id: ReqId
    transfer_id: TransferId
    # 按 KV cache group 分组,上报给调度器前需要展平。
    local_block_ids: list[list[int]]
    remote_engine_id: EngineId
    remote_bootstrap_addr: str
    expire_time: float = float("inf")
    # 一个 D 可能同时对接多个 P,用计数跟踪未完成的 pull 任务。
    pull_tasks_count: int = 0
    # 新增:任一 worker 上报失败即置位,避免同一请求后续的「成功」回报覆盖失败状态。
    failed: bool = False
​
​
class MooncakeConnectorWorker:
    def __init__(self, ...):
        ...
        self.finished_sending_reqs: set[ReqId] = set()
        self.finished_recving_reqs: set[ReqId] = set()
        # 新增:由 receiver 协程写入、由 worker 线程排空,必须使用线程安全的 queue.Queue。
        self._invalid_block_ids: queue.Queue[set[int]] = queue.Queue()
​
    def _handle_failed_recv(
        self,
        pull_metas: dict[ReqId, PullReqMeta],
        req_ids: Collection[ReqId],
        reason: str,
    ) -> None:
        """Report a failed remote KV load so the scheduler can fail or recompute it."""
        failed: list[ReqId] = []
        for req_id in req_ids:
            pull_meta = pull_metas.get(req_id)
            # 幂等:已标记失败的请求直接跳过,避免重复上报、重复计数。
            if pull_meta is None or pull_meta.failed:
                continue
            pull_meta.failed = True
            failed.append(req_id)
            self.xfer_stats.record_failed_recv()
​
            # local_block_ids 现在是 list[list[int]](#40860 之后多 KV cache group),统一展平。
            invalid = {b for group in pull_meta.local_block_ids for b in group}
            if not invalid:
                # 空 block 的 pull 只是请 P 释放 block(请求从未进入调度器,见
                # AsyncLLM.notify_kv_transfer_request_rejected 提交的 abort_immediately 请求)。
                # 此时 D 侧没有请求在等 KV 加载,上报反而会触发调度器的
                # assert req_id in self.requests。
                continue
            self._invalid_block_ids.put(invalid)
            # 关键:失败也算「接收结束」,请求才有可能离开 WAITING_FOR_REMOTE_KVS。
            self.finished_recving_reqs.add(pull_meta.d_req_id)
​
        if failed:
            logger.error("pulling kv_caches for %s failed: %s", failed, reason)
​
    def get_block_ids_with_load_errors(self) -> set[int]:
        """Drain the blocks whose remote KV load failed since the last call."""
        result: set[int] = set()
        while True:
            try:
                result.update(self._invalid_block_ids.get_nowait())
            except queue.Empty:
                break
        return result
​
    def process_pulling_result(self, response: MooncakeXferResponse, pull_metas) -> None:
        ok_reqs: list[ReqId] = response.ok_reqs or []
        for req_id in ok_reqs:
            pull_meta = pull_metas[req_id]
            if pull_meta.failed:
                # 该请求已被判定失败,成功回报不再参与 pull_tasks_count 归零。
                continue
            pull_meta.pull_tasks_count -= 1
            if pull_meta.pull_tasks_count == 0:
                self.finished_recving_reqs.add(pull_meta.d_req_id)
​
        if response.err_reqs:
            # 原先只打日志;现在与 ZMQ、异常路径统一走失败上报。
            self._handle_failed_recv(
                pull_metas, response.err_reqs, response.err_msg or "unknown error"
            )
class MooncakeConnector(KVConnectorBase_V1, SupportsHMA):
    # 本 PR 补上的关键接口:调度器靠它拿到失败 block 并套用 kv_load_failure_policy
    # (fail / recompute)。此前 MooncakeConnector 未实现该方法,导致该配置项一直是 no-op。
    def get_block_ids_with_load_errors(self) -> set[int]:
        """Get block IDs whose remote KV load failed."""
        assert self.connector_worker is not None
        return self.connector_worker.get_block_ids_with_load_errors()
​
    async def receive_kv_from_single_worker(self, pull_metas, req_ids, worker_addr) -> None:
        try:
            with make_zmq_socket(
                self.async_zmq_ctx, worker_addr, zmq.DEALER, bind=False, linger=0
            ) as sock:
                # 丢响应由既有的 RCVTIMEO 兜底,因此本 PR 不再额外加 D 侧过期定时器。
                sock.setsockopt(
                    zmq.RCVTIMEO, (envs.VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT + 60) * 1000
                )
                await sock.send(encoded_data)
                while True:
                    ret_msg = await sock.recv()
                    response = self._xfer_resp_decoder.decode(ret_msg)
                    if response.status == MooncakeXferResponseStatus.ERROR:
                        # ERROR 响应不携带 err_reqs,因此直接以本批次 req_ids 作为失败集合。
                        self._handle_failed_recv(
                            pull_metas, req_ids, response.err_msg or "transfer error"
                        )
                        return
                    self.process_pulling_result(response, pull_metas)
                    if response.status == MooncakeXferResponseStatus.FINISH:
                        break
        except zmq.ContextTerminated:
            logger.debug("ZMQ context terminated, exiting Mooncake receiver thread.")
        except Exception as e:
            # 原来只记录日志并 return,请求会永远停在 WAITING_FOR_REMOTE_KVS。
            self._handle_failed_recv(pull_metas, req_ids, f"transfer failed: {e}")
            return

评论区精华

与 #36014 的重叠及本版本的实现取舍 设计

PR body 主动说明与 #36014(2026-03 起开放)重叠,并列出本版本差异:不额外加 D 侧过期定时器(丢响应由既有 zmq.RCVTIMEO 覆盖)、额外处理 MooncakeXferResponseStatus.ERROR(@dtcccc 曾指出其不携带 err_reqs)、覆盖 bootstrap 失败路径、保护空 block pull、展平 #40860 之后变为 list[list[int]] 的 local_block_ids、_invalid_block_ids 改用 queue.Queue;作者表态「Happy to fold this into #36014 instead if @machov or @dtcccc prefer」。

结论:@dtcccc 回复 LGTM,未要求折叠进 #36014;PR 独立合入 main。 · 已解决

空 block pull 不得上报,否则触发调度器断言 正确性

`_handle_failed_recv` 在 `local_block_ids` 展平为空时跳过上报并入队。代码注释解释:这类 pull 只是请 P 释放 block,对应 AsyncLLM.notify_kv_transfer_request_rejected 提交的 abort_immediately 请求,D 侧没有请求在等加载,上报会触发调度器的 assert req_id in self.requests。

结论:以代码内注释固化该边界,review 中无人提出异议。 · 已解决

跨线程数据结构与多 P 回报去重 设计

`_invalid_block_ids` 由 receiver 协程写入、worker 线程排空,因此用 queue.Queue 而非 set;新增 `PullReqMeta.failed` 位,配合 `process_pulling_result` 中的 `if pull_meta.failed: continue`,保证一个 P 失败后另一个 P 的成功回报不会把请求重新标记为接收完成。

结论:随 PR 一并合入,未引发争议。 · 已解决

评审覆盖度与 CI 流程 other

claude[bot] 因 PR 来自 fork 而禁用自动评审(This pull request is from a fork — automated review is disabled),coderabbitai 也报告无 actionable comments;作者先后 ping NickLucche、ivanium、orozery、zhewenl、ZJY0516,最终 @ivanium 以 APPROVED 通过,@zhewenl 与 @ZJY0516 分别触发 /ci run,CI 通过后由 ZJY0516 合入。review_comments_count 为 0。

结论:以人工批准 + CI 全绿方式合入,核心解码路径缺少逐行评审记录。 · 已解决

风险与影响

  1. 行为语义变更(用户可见):get_block_ids_with_load_errors() 补上后 kv_load_failure_policy 从 no-op 变为真正生效,原先「静默卡死」的请求现在会变成失败(作者 E2E 观察到 HTTP 500)。对已经接受旧行为的部署,错误率指标会出现阶跃式上升,需要运维侧同步预期。
  2. 失败粒度是整请求:_handle_failed_recv 一旦处理某 req_id 就置 failed=True 并把该请求全部 local_block_ids 展平上报,即使只是某个 P/tp rank 的瞬时失败。在 kv_load_failure_policy=recompute 下会整请求重算,代价高于理想的分 block 恢复;反之不漏报,优先保证不 stall。
  3. 线程安全与队列排空:_invalid_block_ids 由 receiver 协程写入、worker 线程排空。若某些调用路径不再周期性调用 get_block_ids_with_load_errors(),队列会随失败持续累积(长尾内存增长)。提供的材料未包含调度器侧调用点,建议确认每步都会 drain。
  4. 缺少针对性单测:changed_files_count=1,没有测试文件变更。新增的幂等去重、空 block 跳过、队列 drain、failed 与 pull_tasks_count 的交互都只靠既有 26 个单测 + 手工 E2E 覆盖;process_pulling_result 中 continue 会让 pull_tasks_count 不再递减,属于容易回归的细节分支。
  5. 统计口径变化:record_failed_recv() 从「每次 worker 级失败记一次」变为「每个失败请求在首次标记时记一次」,同批次多请求失败会放大计数,依赖 xfer_stats 做告警阈值的看板需要重新标定。
  6. 多 P 状态竞争:if pull_meta.failed: continue 解决了「先失败后成功」的覆盖问题,但依赖 failed 只在接收协程内单线程置位;材料中未见对并发写入的额外保护说明。

影响面集中在 P/D 分离部署中 kv_connector='MooncakeConnector' 的解码侧:用户侧,故障请求从「连接悬挂、迟迟不返回」变为明确失败(HTTP 500)或按策略重算,D 节点的 deferred/WAITING_FOR_REMOTE_KVS 不再无界累积,KV block 能及时释放,因此是高流量下避免整机 stall 的可用性修复。系统侧,改动是 Mooncake 连接器内部的失败上报接线,不涉及 API、序列化协议、模型计算与配置项,MooncakeXferResponse/MooncakeXferResponseStatus 契约不变,与 NixlConnectorWorker._handle_failed_transfer() 的语义对齐降低了多连接器维护成本。团队侧,这个修复把「失败上报接口必须实现」隐性契约显性化,为其他尚未实现 get_block_ids_with_load_errors() 的连接器提供了参考实现;同时它补齐了 #36014 长期未落地的能力,后续若 #36014 合入需要做实现归并而非功能重复。

核心解码路径变更 行为语义变更:失败策略由 no-op 转为生效 缺少针对性单测覆盖 跨线程队列依赖调度器排空 失败统计口径变化

关联 Issue

#50719 [Bug][PD][Mooncake] Decode requests stuck in WAITING_FOR_REMOTE_KVS and fail with Timeout waiting for P side ready

完整报告

参与讨论