Prhub

#53453 [KVConnector][P2P] Configurable unbound-store timeout and one-RTT rejection of a late fetch

原始 PR 作者 liranschour 合并时间 2026-09-15 10:38 文件变更 3 提交数 4 评论 10 代码增减 +193 / -14

执行摘要

P2P 卸载层超时可配,晚到 fetch 一 RTT 内被拒

Issue #53128 报告:PD Multi Tier 的生产者从「第一个 stored chunk」起算 60 s 就丢弃 park 的 KV,而消费者的 fetch 要等剩余 prefill、生产者 HTTP 返回、代理分发、解码准入之后才发出,实测 98 K token 的 prefill 在空闲 H200 上就吃掉 6.7 s、轻载下 13.3 s,长 prompt 真实并发下总和突破 60 s。后果是消费者打满 _LOAD_TIMEOUT_S = 30.0(实测 control 组 0.30 s / 580 MB,late 组 38.31 s / 0 字节)再回退本地重算,而重算本身又推高负载。PR body 明确指出两点:「The 60 s ceiling was not configurable」以及「A fetch arriving after the reap was never rejected — this is the user-visible damage」。此外 PR 还指出回收路径里的 self._failed_req_ids.add(kid) 只在文档里声称能让晚到 fetch 短路,实际 fetch 路径没有任何读取者,且 on_request_finished 是唯一的 discard 点而它早已执行过,于是该集合在进程生命周期内只增不减——本次一并修掉。

值得精读,尤其是负责 KV offload / tiering / PD 分离部署的工程师。三个值得关注的设计点:一是用带剪枝的墓碑表 _reaped_stores 承接跨实例失败通知,复用既有 finish_request → _finalize_outbound 终态路径而不改协议、不加 session API,是把「超时」变成「可立即观测的失败」的干净做法;二是配置入口「先 float() 强转、再以 NaN 兜底统一报错」的模式,对任何从 kv_connector_extra_config JSON 原样 splat 进来的 tier 参数都适用,可直接照搬到其它 tier;三是 _REAPED_ID_RETENTION_S 与消费者 _LOAD_TIMEOUT_S 的隐式耦合是唯一需要持续盯住的设计点,建议后续要么加断言/测试,要么把二者关联起来。测试方面,recording_drain 断言「线上从未出现 abort_fetch」这种验证路径而非仅验证结果的手法,也值得其它失败路径测试借鉴。

讨论亮点

讨论集中三条,均由 reviewer orozery 提出(第一条是转述 Claude 的分析),作者 liranschour 三条回复分别为 Done / Fixed / No need,bnellnm 与 orozery 最终均 APPROVED。

  1. 「回收后仍继续 park」的漏洞:orozery 引用「submit_store never consults _reaped_stores,so a producer whose prefill outruns the timeout keeps parking new batches under the reaped id. Fix: also drain/fail self._unbound_stores.pop(kv_request_id, ()) in the reject branch, and short-circuit submit_store on a reaped id.」作者回「Done」,以独立 commit 28b92ec 落地了两处改动,并同步把 _poll_once 注释里「keeps taking the unbound path」改成「reject path」。

  2. 配置值类型:orozery 指出「Probably worth converting to float first (with a try)」,作者回「Fixed」。最终 commit ee79456 的说明解释得最清楚:值是从用户 JSON 原样 splat 进来的,quoted "180" 会先死在与 0 的比较上抛 TypeError,而不可转换的值连键名都报不出来,所以改成先强转、再统一报错。

  3. 两个超时是否应互相约束:orozery 建议「consider also rejecting values below _STORE_TIMEOUT_S」,作者明确拒绝:「No need. These are 2 unrelated timeouts.」——即未绑定回收时限与单 job 的 store 超时被视为正交,不加入交叉校验。

另有一条流程噪音:claude[bot] 因该 PR 来自 fork 而自动 review 被禁用,需要维护者手动 @claude review。

实现拆解

  1. 引入 per-tier 配置键并做先行类型强转:P2PSecondaryTierManager.__init__ 新增形参 unbound_store_timeout_s: float = _UNBOUND_STORE_TIMEOUT_S,形状与既有 host/port/backends/num_threads 对齐(由 SecondaryTierFactory.create_secondary_tier 以 **config 原样 splat 传入,因此不能假设类型已是 float)。先 float() 再用 if not timeout_s > 0 判范围,TypeError/ValueError 统一转成 float('nan') 兜底,保证 "180" 这类被模板渲染成字符串的 JSON 数值能通过、而不可转换的值会得到带配置键名的 ValueError。结果存入 self._unbound_store_timeout_s。

  2. 用墓碑表替代有缺陷的失败集合:新增 self._reaped_stores: dict[str, float](kv_request_id → 回收时刻)。_reap_unbound_stores 把原来的 self._failed_req_ids.add(kid) 换成 self._reaped_stores[kid] = now,并在函数开头按新增常量 _REAPED_ID_RETENTION_S = 60.0 剪枝过期墓碑——注释明确它必须大于消费者侧的 session.client._LOAD_TIMEOUT_S,否则消费者早已自行放弃、没有人能收到拒绝。剪枝放在 if not self._unbound_stores: return 之前,保证无 park 批次时墓碑仍会过期。_failed_req_ids 语义随之收窄为「传输/会话失败」,仍供 lookup() 拒绝使用。

  3. _poll_once 对晚到 fetch 一个 RTT 终结:在 for kv_request_id in result.new_fetch_ids 循环顶部插入 _reaped_stores 命中分支,调用既有的 session.finish_request(kv_request_id) 走 finish_request → _finalize_outbound 终态路径(无协议改动、无新 session API),对端同 tick 收到 TransferDoneMsg(success=False);同时 pop 掉该 id 下可能被重新 park 的批次并以失败上报,随后 continue 而不写入 _kv_to_session,让后续 submit_store 继续走拒绝路径。

  4. submit_store 短路已回收 id:新增 if kv_request_id in self._reaped_stores: 分支,记一条 warning 并直接 JobResult(success=False) 返回,避免「prefill 超出超时」的生产者把没人能 fetch 的块再 park 一整个超时周期、持续钉住 primary tier 的 CPU 槽位。这一步是在 review 之后以独立 commit 28b92ec 补上的。

  5. 测试与文档配套:tests/v1/kv_offload/tiering/p2p/test_manager.py 的 _make_manager 桩补上 _reaped_stores 与 _unbound_store_timeout_s,test_unbound_store_reaped_after_timeout 的断言从 _failed_req_ids 迁到 _reaped_stores;新增 test_late_fetch_after_reap_fails_immediately,通过包装 mgr_a._control._drain_outbound_to 的 recording_drain 记录消费者所有出站消息类型,断言失败结果来自 TransferDoneMsg、线上从未出现 abort_fetch、墙钟远小于 _LOAD_TIMEOUT_S。docs/features/kv_offloading_usage.md 在 secondary_tiers 配置表新增 unbound_store_timeout_s 行(默认 60)并给出 "unbound_store_timeout_s": 180 的完整 --kv-transfer-config 示例,说明调大它会延长 primary-tier 槽位占用。

变更点 文件 / 符号 说明
配置键 manager.py → __init__、_unbound_store_timeout_s 先 float() 再校验,NaN 兜底无效值
墓碑表 manager.py → _reaped_stores、_REAPED_ID_RETENTION_S 取代 _failed_req_ids 记录回收,带 60 s 剪枝
回收逻辑 manager.py → _reap_unbound_stores 使用实例超时;剪枝墓碑;写墓碑而非失败集合
拒绝路径 manager.py → _poll_once 命中墓碑即 finish_request + 清空重 park 批次
生产侧短路 manager.py → submit_store 已回收 id 立即失败,不再 park
测试 test_manager.py 桩补字段、断言迁移、新增路径级断言用例
文档 docs/features/kv_offloading_usage.md 配置表 + 示例 + 槽位占用权衡说明
文件 模块 状态 重要度
vllm/v1/kv_offload/tiering/p2p/manager.py P2P 卸载层 modified 7.45
tests/v1/kv_offload/tiering/p2p/test_manager.py P2P 卸载测试 modified 6.18
docs/features/kv_offloading_usage.md 配置文档 modified 3.17

关键符号

P2PSecondaryTierManager.__init__ P2PSecondaryTierManager.submit_store P2PSecondaryTierManager._reap_unbound_stores P2PSecondaryTierManager._poll_once P2PSecondaryTierManager.on_request_finished test_late_fetch_after_reap_fails_immediately recording_drain _make_manager

关键源码片段

vllm/v1/kv_offload/tiering/p2p/manager.py core-logic

全部核心逻辑所在:新增 per-tier 配置键 `unbound_store_timeout_s` 及其强转校验、新增墓碑表 `_reaped_stores` 与 `_REAPED_ID_RETENTION_S`、重写 `_reap_unbound_stores` 的记账方式、在 `_poll_once` 中加入一 RTT 拒绝分支、在 `submit_store` 中加入已回收 id 的短路。同时移除了 `_failed_req_ids` 在回收路径上的无界增长。

# vllm/v1/kv_offload/tiering/p2p/manager.py
​
# 未绑定 store 的默认回收时限:生产者 park 的 KV 块在这段时间内没有等到
# FetchMsg 把它绑定到 session,就会被回收,以免继续占住 primary tier 的 CPU 槽位。
# 该值现在可由 tier 配置键 unbound_store_timeout_s 逐层覆盖。
_UNBOUND_STORE_TIMEOUT_S = 60.0
​
# 被回收的 kv_request_id 的“墓碑”保留时长:晚到的 FetchMsg 靠它被一次性拒绝。
# 必须大于消费者侧的 session.client._LOAD_TIMEOUT_S(30 s)——超过之后消费者
# 已经自行放弃,再保留墓碑也没有人能收到这个拒绝。
_REAPED_ID_RETENTION_S = 60.0
​
​
class P2PSecondaryTierManager(SecondaryTierManager):
    def __init__(
        self,
        offloading_spec: OffloadingSpec,
        primary_kv_view: memoryview,
        tier_type: str = "p2p",
        host: str | None = None,
        port: int | None = None,
        backends: list[str] | None = None,
        num_threads: int = 4,
        # 新增 per-tier 配置键,形状与 host/port/backends/num_threads 一致;
        # SecondaryTierFactory.create_secondary_tier 以 **config 原样 splat 传入,
        # 所以不能假设类型已经是 float——部署模板常把 JSON 数值渲染成字符串 "180"。
        unbound_store_timeout_s: float = _UNBOUND_STORE_TIMEOUT_S,
        **kwargs: Any,
    ) -> None:
        super().__init__(offloading_spec, primary_kv_view, tier_type)
​
        # 先 float() 再判范围:否则 "180" 会死在与 0 的比较上抛 TypeError,
        # 而完全不可转换的值也拿不到带配置键名的报错。NaN 作为“转不动”的哨兵,
        # 因为任何 NaN 比较都为 False,会自然落到下面的 ValueError。
        try:
            timeout_s = float(unbound_store_timeout_s)
        except (TypeError, ValueError):
            timeout_s = float("nan")
        if not timeout_s > 0:
            raise ValueError(
                f"unbound_store_timeout_s must be a positive number, got "
                f"{unbound_store_timeout_s!r}"
            )
        self._unbound_store_timeout_s = timeout_s
​
        self._unbound_stores: dict[str, list[_UnboundStoreBatch]] = {}
        # kv_request_id → 其 park 批次被回收的时刻(墓碑表)。命中这里意味着该 id 下
        # 已经没有任何可服务的块,于是:
        # 1. _poll_once 收到它的 FetchMsg 时当场结束这一轮,不再 park 需求;
        # 2. submit_store 直接失败,不再把没人能取的块重新 park 一个完整超时。
        # 条目由 _reap_unbound_stores 在 _REAPED_ID_RETENTION_S 之后剪掉。
        self._reaped_stores: dict[str, float] = {}
    def _reap_unbound_stores(self) -> None:
        """回收从未被任何 peer fetch 过的 submit_store 批次。
​
        遍历 _unbound_stores,找出最早批次已超过 _unbound_store_timeout_s 的条目:
        丢弃该 kv_request_id、把每个批次以失败上报,并把 id 记入 _reaped_stores,
        这样晚到的 FetchMsg 在一个 RTT 内被拒绝、晚到的 submit_store 也会直接失败。
        同时顺带过期消费者不可能还在等待的墓碑条目——这正是该函数必须放在
        “无待回收批次”提前返回之前的原因。
        """
        now = time.monotonic()
        if self._reaped_stores:
            retention_deadline = now - _REAPED_ID_RETENTION_S
            for kid in [
                kid
                for kid, reaped_at in self._reaped_stores.items()
                if reaped_at <= retention_deadline
            ]:
                del self._reaped_stores[kid]
        if not self._unbound_stores:
            return
        deadline = now - self._unbound_store_timeout_s
        expired: list[str] = []
        for kid, batches in self._unbound_stores.items():
            # 批次按到达顺序追加,队首即最老的一批,回收时钟从首 chunk 起算。
            if batches[0].submitted_at <= deadline:
                expired.append(kid)
        if not expired:
            return
        for kid in expired:
            batches = self._unbound_stores.pop(kid)
            # 关键语义变更:不再写 _failed_req_ids(fetch 路径没有读取者,且
            # on_request_finished 早已跑过,唯一 discard 点不会再命中,集合会
            # 随进程生命周期无界增长),改为写带时间的墓碑。
            self._reaped_stores[kid] = now
            for batch in batches:
                self._finished_jobs.append(
                    JobResult(job_id=batch.job_id, success=False)
                )
            logger.warning(
                "P2P %s: unbound store kv_request_id=%s timed out after %s "
                "without a fetch — failing %d job(s)",
                self._local_id,
                kid,
                self._unbound_store_timeout_s,
                len(batches),
            )
​
    def submit_store(self, job_metadata: TransferJob) -> None:
        job_id = job_metadata.job_id
        # ... 省略 dest / kv_request_id 解析与既有失败分支 ...
        # 该 id 的早期批次已被回收,消费者要么已经放弃、要么会在 fetch 路径上被
        # 拒绝;此时继续 park 只会让没人能取的块再占住 primary-tier 槽位一个完整
        # 超时周期,所以立刻失败——无论哪种情况,解码端都会回退到本地重算。
        if kv_request_id in self._reaped_stores:
            logger.warning(
                "P2P %s: submit_store for reaped kv_request_id=%s job_id=%d "
                "— failing without parking",
                self._local_id,
                kv_request_id,
                job_id,
            )
            self._finished_jobs.append(JobResult(job_id=job_id, success=False))
            return
        # Fast path: a session has already received FetchMsg for this id,
        # so we can route the batch straight into its ServerRole.
        session = self._kv_to_session.get(kv_request_id)
    def _poll_once(self) -> None:
        # ... 省略 transport / session 的既有轮询 ...
        for kv_request_id in result.new_fetch_ids:
            if kv_request_id in self._reaped_stores:
                # 这个 id 的块已经被回收,将来也不会有任何 submit_store 能满足
                # on_fetch 刚记录下的需求。就在本 tick 结束这一轮:对端会在同一次
                # poll 内收到 TransferDoneMsg(success=False) 并立即回退本地 prefill,
                # 而不是 park 到自己的 load timeout 才放弃。
                # id 故意不绑定到 session,这样后续 submit_store 继续走拒绝路径;
                # 回收与墓碑剪枝之间被重新 park 的批次在这里一并失败,避免它们
                # 占住槽位再等一个完整超时。
                session.finish_request(kv_request_id)
                for batch in self._unbound_stores.pop(kv_request_id, ()):
                    self._finished_jobs.append(
                        JobResult(job_id=batch.job_id, success=False)
                    )
                continue
            self._kv_to_session[kv_request_id] = session
tests/v1/kv_offload/tiering/p2p/test_manager.py test-coverage

新增路径级测试 `test_late_fetch_after_reap_fails_immediately`,用 `recording_drain` 包装出站通道记录消费者发出的每条消息类型,断言晚到 fetch 走的是 TransferDoneMsg 而非超时后 abort;同时把 `_make_manager` 桩补齐 `_reaped_stores` / `_unbound_store_timeout_s`,并把 `test_unbound_store_reaped_after_timeout` 的断言从 `_failed_req_ids` 迁到 `_reaped_stores`。

    def test_late_fetch_after_reap_fails_immediately(self):
        """针对已被回收的 kv_request_id,fetch 在一个往返内失败。
​
        生产者一旦越过 unbound_store_timeout_s 就丢弃 park 的块;此后才到达的
        fetch 必须在下一个 tick 通过 TransferDoneMsg(success=False) 得知结果,
        而不是先卡满 _LOAD_TIMEOUT_S 再走 abort 路径。
        """
        from vllm.v1.kv_offload.tiering.p2p.session.client import _LOAD_TIMEOUT_S
​
        mgr_a, mgr_b = _build_paired_managers()
        kv_id = "req-late"
​
        # 记录消费者发出的每一条消息类型,后面的断言用它证明从未需要 abort。
        sent_from_a: list[str] = []
        drain = mgr_a._control._drain_outbound_to
​
        def recording_drain(peer_local_id: str):
            out = drain(peer_local_id)
            sent_from_a.extend(msg.get("type") for _, msg in out)
            return out
​
        mgr_a._control._drain_outbound_to = recording_drain # type: ignore[method-assign]
​
        # 生产者先 park,再在任何 fetch 到达之前把块回收掉。
        mgr_b.submit_store(
            _job_metadata(
                job_id=200,
                keys=[b"b-block"],
                chunk_ids=[0],
                kv_params={"remote_decoder": {"kv_request_id": kv_id}},
            )
        )
        mgr_b._unbound_store_timeout_s = 0.0
        reap_results = list(mgr_b.get_finished_jobs())
        assert JobResult(job_id=200, success=False) in reap_results
        assert kv_id in mgr_b._reaped_stores
​
        # 消费者这时才来取已经不存在的块。
        consumer_params = {
            "remote_prefiller": {
                "kv_request_id": kv_id,
                "remote_host": "B",
                "remote_port": 2,
            },
        }
        mgr_a.on_new_request(_req_context(consumer_params))
        mgr_a.submit_load(
            _job_metadata(
                job_id=101,
                keys=[b"b-block"],
                chunk_ids=[0],
                kv_params=consumer_params,
            )
        )
​
        started = time.monotonic()
        all_a: list[JobResult] = []
        for _ in range(8):
            all_a.extend(list(mgr_a.get_finished_jobs()))
            list(mgr_b.get_finished_jobs())
        elapsed = time.monotonic() - started
​
        # 断言的是路径而非仅结果:失败来自 TransferDoneMsg,线上从未出现
        # abort_fetch,且墙钟远小于消费者的 _LOAD_TIMEOUT_S。
        assert JobResult(job_id=101, success=False) in all_a, all_a
        assert kv_id in mgr_a._failed_req_ids
        assert "abort_fetch" not in sent_from_a, sent_from_a
        assert elapsed < _LOAD_TIMEOUT_S

评论区精华

回收分支需清空重新 park 的批次 正确性

orozery 在 `_poll_once` 的新增分支上转述分析:「also drain/fail `self._unbound_stores.pop(kv_request_id, ())` in the reject branch」,指出仅凭 `finish_request` 终结这一轮还不够,回收与墓碑剪枝之间可能又积累了批次。

结论:作者回复「Done」,最终实现中该分支除 `session.finish_request(kv_request_id)` 外,还会 `pop` 并以 `JobResult(success=False)` 上报这些批次,随后 `continue` 保持 id 未绑定。 · 已解决

submit_store 需短路已回收的 kv_request_id 正确性

同一评论指出:「`submit_store` never consults `_reaped_stores`, so a producer whose prefill outruns the timeout keeps parking new batches under the reaped id」,会让没人能 fetch 的块持续钉住 primary tier 槽位。

结论:作者回复「Done」,以独立 commit `28b92ec` 加入短路分支:命中 `_reaped_stores` 时记 warning 并直接返回失败结果,`_poll_once` 注释里的「unbound path」也相应改为「reject path」。 · 已解决

配置值应先 float() 强转再校验 正确性

orozery 建议「Probably worth converting to `float` first (with a try)」。因为该参数由 `SecondaryTierFactory.create_secondary_tier` 以 `**config` 原样传入,类型注解不做任何强制,quoted `"180"` 会先在 `<= 0` 比较上抛 `TypeError`,不可转换的值也报不出配置键名。

结论:作者回复「Fixed」,最终 commit `ee79456` 先 `float()`、以 `float('nan')` 兜底不可转换值,再由 `if not timeout_s > 0` 统一抛带键名的 `ValueError`。 · 已解决

是否应拒绝小于 _STORE_TIMEOUT_S 的配置值 设计

orozery 提出「consider also rejecting values below `_STORE_TIMEOUT_S`」,希望把未绑定回收时限与单 job 的 store 超时绑定起来校验。

结论:作者明确拒绝:「No need. These are 2 unrelated timeouts.」两个超时被视为正交,未加入交叉校验;注释中只保留了「墓碑保留期必须大于消费者 load timeout」这一条依赖说明。 · 未采纳

风险与影响

  1. 墓碑保留期与消费者 load timeout 的隐式耦合:_REAPED_ID_RETENTION_S = 60.0 只靠注释保证「必须大于」session.client._LOAD_TIMEOUT_S = 30.0,两者是不同模块里的独立常量。若未来消费者侧超时被调大(或网络延迟让 fetch 晚于 reap + 60 s 到达),墓碑已被剪枝,_poll_once 会退回旧行为——绑定 id、park 一个永远无人满足的需求,消费者再次打满自己的超时。新增的 test_late_fetch_after_reap_fails_immediately 只覆盖「reap 后 8 次 poll 内到达」,没有覆盖墓碑过期后的边界。

  2. 生产者侧失败面与日志量扩大:submit_store 对已回收 id 每个 job 记一条 warning,并产出 JobResult(success=False)。长 prefill + 高并发下,同一被回收 id 会持续触发该分支,warning 日志与失败计数显著增多,依赖这些 JobResult 做统计或告警的上游需要重新校准预期。

  3. 配置校验仍偏宽松:float() 接受 bool(float(True) == 1.0 且 > 0 成立),会让 "unbound_store_timeout_s": true 静默变成 1 s 的极端短超时;也没有对极大的值设上限,而文档已说明窗口越长 primary-tier CPU 槽位被钉得越久。

  4. 回收计时起点未变:仍按 batches[0].submitted_at(首个 park 批次)起算,PR body 明确将其列为有意不改——实测 idle 下 first-chunk 与 completion 只差约 6 s、负载下约 13 s。这意味着真实预算依然是「首 chunk → fetch < 超时」,长 prefill 部署必须主动调大配置,默认 60 s 在某些场景仍可能偏紧。

  5. 测试桩维护成本:_make_manager() 通过 __new__ 绕过 __init__,新增的 _reaped_stores / _unbound_store_timeout_s 依赖手工补齐;后续再往 __init__ 加实例属性而忘记同步桩,会以 AttributeError 的形式暴露在无关用例上。

用户侧:使用 OffloadingConnector + TieringOffloadingSpec(CPU primary + p2p secondary)的 PD 分离部署,在晚到 fetch 场景下不再每次都多付约 30 s 停顿加上整个 prompt 的重算,失败在同一个 tick 内可见;同时获得 unbound_store_timeout_s 这一可调旋钮,长 prefill 部署可以从 60 s 上调(文档给出 180 的示例)。系统侧:失败路径语义更清晰(墓碑拒绝 vs 打满超时后 abort),生产者的 _failed_req_ids 不再随进程生命周期无界增长;未绑定批次的语义从「静默 park 到超时」变为「超时即墓碑、后续 store 直接失败」。团队侧:新增一个需要理解相对关系的运维参数(与 _STORE_TIMEOUT_S、消费者 load timeout 并列),文档已补充但调优经验仍需积累;对 KV offload/tiering 代码所有者而言,这次改动把「跨实例失败通知」的实现范式确立为可复用模式。影响范围局限于 p2p 次级层,不影响协议、不影响生成结果(PR 声明未做模型评测,因为只改失败路径时序与配置默认值)。

失败路径语义变更 跨组件超时耦合无测试覆盖 配置校验偏宽松 生产者侧失败日志量上升

关联 Issue

#53128 [Bug][KVOffload] PD Multi Tier discards parked KV after a fixed 60s, so a queued consumer recomputes

完整报告

参与讨论