Prhub

#49877 [Bugfix][KV Offload][P2P] Scope serve state to fetch rounds

原始 PR 作者 Etelis 合并时间 2026-07-28 23:01 文件变更 6 提交数 16 评论 16 代码增减 +573 / -393

执行摘要

修复 P2P KV offload 多轮 fetch 竞态条件

修复Issue #49820:对称P2P producer在持续负载下,单个kv_request_id因前缀增长而产生多轮lookup→fetch。server只保留单一outbound slot,当轮次重叠时,完成一轮会抹除下一轮已固定的supply,导致下一轮fetch要么等待30秒的_LOAD_TIMEOUT_S超时,要么因主动重复fetch检测而摧毁整个session。

值得精读。该PR展示了如何通过引入显式轮次标识(round_seq)将共享状态的竞态转化为确定性隔离,是协议设计与状态机重构的经典案例。review中的讨论——尤其是从序列化fetch到round_seq隔离、从枚举到布尔标志的简化——反映了重要的设计取舍过程。建议关注协议扩展的兼容性处理、状态隔离的粒度选择以及测试的多场景覆盖。

讨论亮点

讨论1:序列化fetch是否必须

  • liranschour质疑早期版本强制序列化fetch的方案,建议参考orozery在issue中的提议。
  • Etelis随后改用round_seq隔离loads,允许多个fetch并发,消除了序列化需求。
  • 结论:采用并行loads + round_seq匹配,而非串行队列,设计更优。

讨论2:round_seq是否允许None

  • orozery提议强制非None以简化代码,liranschour同意。
  • 最终版本中round_seq成为所有消息的必要字段(PD客户端传0),向后兼容通过版本协商而非可空字段实现。

讨论3:wire_id是否需要req_id后缀

  • liranschour指出kv_request_id本身已由orchestrator分配唯一ID,无需追加req_id。
  • Etelis移除了此前添加的wire_id后缀。

讨论4:用peer_lookup_open替代ClientPhase

  • liranschour引用Claude建议:ClientPhase已成为单标量替代问题,可用布尔标志表示peer端是否需要terminal empty。
  • orozery赞同,Etelis实施。该简化消除了finish()中因phase检查遗漏terminal empty的bug。

讨论5:inflight断言与验证

  • orozery建议在inflight递减时添加assert >=0,以及对称P2P fetch的keys必须都是confirmed probes的assert。
  • Etelis已采纳,增加了防御性检查。

实现拆解

步骤1:协议扩展——所有消息携带round_seq

protocol.py中为FetchMsgLookupMsgLookupRespMsgTransferDoneMsgAbortFetchMsgAbortAckMsg添加必需的ROUND_SEQ字段(PD客户端使用单轮0)。新增_require_non_neg_int验证函数,保证字段类型正确。

步骤2:Server端状态按轮次隔离

server.py中,_OutboundRequestState从表示对整个请求的serve状态变为仅代表一个fetch轮次的状态。新增lookup_supplied布尔值区分对称P2P与PD的supply来源。_ServerRequestState新增outbound字典(key = round_seq)管理多个并行轮次。_InflightXfer增加roundround_key字段,使TransferDone/AbortAck能够回退到对应的轮次。on_fetch()on_abort_fetch()等处理函数均按round_seq分发到正确的状态对象。

步骤3:Client端状态支持并发多轮loads

client.py中,_ClientRequestStateloads: dict[int, _InboundLoadState]替代单字段load,支持同一kv_request_id下多个in-flight loads并存。移除ClientPhase枚举,改用peer_lookup_open: bool标记peer端是否仍持有未closed的lookup状态。finish()方法根据此标志和loads状态决定是否需要发送terminal empty FetchMsg。request_blocks()在发送FetchMsg时附带当前round_seq,并将loads注册到对应键。

步骤4:会话分发层适配

session.py_dispatch_message中,从msg解析round_seq并传给server/client的对应方法。

步骤5:测试覆盖

更新test_sessions.pytest_manager.py:现有测试均携带round_seq字段;新增_client_load等辅助函数;新增多轮serve回归测试,覆盖跨轮次supply隔离、空fetch僵尸、未服务demand快速失败、abort隔离、fetch序列化避免泄漏等场景。

文件 模块 状态 重要度
vllm/v1/kv_offload/tiering/p2p/session/server.py KV Offload modified 8.84
vllm/v1/kv_offload/tiering/p2p/session/client.py KV Offload modified 8.84
vllm/v1/kv_offload/tiering/p2p/session/protocol.py KV Offload modified 6.73
tests/v1/kv_offload/tiering/p2p/test_sessions.py 测试 modified 6.29
vllm/v1/kv_offload/tiering/p2p/session/session.py KV Offload modified 5.89
tests/v1/kv_offload/tiering/p2p/test_manager.py 测试 modified 4.35

关键符号

ClientRole.request_blocks ClientRole.finish ClientRole.on_transfer_done ClientRole.on_abort_ack ClientRole._on_load_terminal ServerRole.on_fetch ServerRole.on_abort_fetch ServerRole._fail_round_jobs ServerRole._drain_abort ServerRole._finalize_abort ServerRole._submit_transfer Session._dispatch_message

关键源码片段

vllm/v1/kv_offload/tiering/p2p/session/server.py core-logic

核心服务端状态机重构:_OutboundRequestState 改为单轮次状态,_ServerRequestState 新增 outbound 字典支持多轮;_InflightXfer 增加 round 引用;abort/finish 隔离到具体轮次。影响整个 server-role 的消息处理逻辑。

# vllm/v1/kv_offload/tiering/p2p/session/server.py
# 服务端按轮次隔离的核心数据结构@dataclass
class _OutboundRequestState:
    """单个 fetch 轮次的 serve 状态。    每个 kv_request_id 可能经历多轮 lookup→fetch;轮次状态保存在
    ``_ServerRequestState.outbound`` 字典中,key 为 wire 上的 round_seq。
    """
​
    # Supply 来自 inbound lookup pin(对称 P2P):后续不会有 submit_store
    # 到达,因此未匹配的 fetch demand 立即失败。PD 轮次则等待 store 填充。
    lookup_supplied: bool = False
    demand_received: bool = False
    # key -> (job_id, local_block_idx):已有 blocks,等待 peer fetch。
    available: dict[OffloadKey, tuple[int, int]] = field(default_factory=dict)
    # key -> remote_block_idx:peer 想要但尚未就绪的 blocks。
    demanded: dict[OffloadKey, int] = field(default_factory=dict)
    remaining: int = 0 # 还需传输的 blocks 数量
    finishing: bool = False # 是否应尽快结束该轮次
    inflight: int = 0 # 本轮已提交但尚未 poll 的 transfer 数
    # 向本轮 submit_store 的 job ID 集合,用于在终态清理时 drain。
    pending_job_ids: set[int] = field(default_factory=set)
​
    def add_stored_blocks(
        self, keys: Sequence[OffloadKey], block_ids: Sequence[int], job_id: int
    ) -> _MatchResult:
        # ... (省略方法体,保持 focus 在数据结构)
​
    def add_fetch_demand(
        self, keys: Sequence[OffloadKey], block_indexes: Sequence[int]
    ) -> _MatchResult:
        # ...
​
​
@dataclass
class _InflightXfer:
    """单次 inflight RDMA transfer 的元数据,keyed by transfer_id。"""
​
    kv_request_id: str
    block_count: int
    # 贡献了 blocks 的 store job ID 集合。
    job_ids: set[int]
    # 该 transfer 属于哪个轮次及其在 ``st.outbound`` 中的 key。
    round: _OutboundRequestState = field(default_factory=_OutboundRequestState)
    round_key: int = 0
vllm/v1/kv_offload/tiering/p2p/session/client.py core-logic

核心客户端状态机重构:_ClientRequestState 改用 round_seq 和 loads 字典支持并发多轮 load;移除 ClientPhase 枚举,引入 peer_lookup_open 标志简化 finish 逻辑;request_blocks 发送 FetchMsg 时附带 round_seq。

# vllm/v1/kv_offload/tiering/p2p/session/client.py
# 客户端按轮次隔离的核心数据结构@dataclass
class _InboundLoadState:
    """单个 inflight load 的客户端状态。    存放在 ``_ClientRequestState.loads`` 字典中,key 为 round_seq。
    """
    job_id: int
    submitted_at: float
    aborted_at: float | None = None
​
​
@dataclass
class _ClientRequestState:
    """Per-kv_request_id 客户端状态。    对称 P2P 使用 lookup 阶段字段(probes/unsent);PD 模式
    仅使用 loads 字典和 round_seq=0。一条记录在所有字段空闲时
    被回收(参见 ``ClientRole._maybe_prune``)。
    """
​
    # -- Lookup 阶段(仅对称 P2P;PD 不动) --
    probes: dict[OffloadKey, bool | None] = field(default_factory=dict)
    unsent: list[OffloadKey] = field(default_factory=list)
    # 当前 lookup 轮次编号。LookupMsg 携带它,每个 fetch 关闭该轮次
    # 并递增它,从而在 wire 上隔离每轮的 supply/demand/completion。
    round_seq: int = 0
    # 该 id 是否已执行过对称 lookup 阶段(register_lookup)。
    # 若是,则带 keys 的 fetch 必须保证每个 key 都被 probe 确认。
    probed: bool = False
​
    # Peer 端尚持有未被 FetchMsg 关闭的 lookup 状态。
    # finish() 在该标志为 True 时需发送空的 terminal empty FetchMsg。
    peer_lookup_open: bool = False
    # In-flight loads,keyed by 它们所属 fetch 的 round_seq。
    loads: dict[int, _InboundLoadState] = field(default_factory=dict)
# request_blocks 方法中发送 FetchMsg 的关键片段(client.py)
# 当发送 fetch 时,携带当前 round_seq,并将 load 注册到对应 round
send(
    FetchMsg,
    {
        FetchMsg.KV_REQUEST_ID: kv_request_id,
        FetchMsg.KEYS: list(keys),
        FetchMsg.BLOCK_INDEXES: [int(idx) for idx in block_ids],
        FetchMsg.ROUND_SEQ: round_seq, # 携带当前轮次
    },
)
# 关闭 peer 端的 lookup 状态标记
st.peer_lookup_open = False
# 将 load 注册到 loads 字典,key = round_seq
st.loads[round_seq] = _InboundLoadState(job_id=job_id, submitted_at=time.monotonic())

评论区精华

序列化 fetch 的必要性 设计

liranschour 质疑早期版本强制序列化 fetch 的方案,建议参考 orozery 在 issue 中的提议。Etelis 随后改用 round_seq 隔离 loads,允许多个 fetch 并发。

结论:采用并行 loads + round_seq 匹配,而非串行队列。 · 已解决

round_seq 是否允许 None 设计

orozery 提议强制非 None 以简化代码,liranschour 同意。

结论:round_seq 成为所有消息的必要字段,PD 客户端传 0。 · 已解决

wire_id 是否需要 req_id 后缀 设计

liranschour 指出 kv_request_id 本身已唯一,无需追加 req_id。Etelis 移除了此前添加的 wire_id 后缀。

结论:移除 wire_id suffix。 · 已解决

用 peer_lookup_open 替代 ClientPhase 设计

liranschour 引用 Claude 建议:ClientPhase 已成为单标量替代问题,可用布尔标志表示 peer 端是否需要 terminal empty。orozery 赞同。

结论:移除 ClientPhase 枚举,改用 peer_lookup_open 布尔标志。 · 已解决

finish() 遗漏 terminal empty fetch bug

liranschour 指出 finish() 在有 load inflight 时跳过 terminal empty fetch,导致后续轮次 supply 泄漏。

结论:通过 peer_lookup_open 标志确保 finish() 在需要时发送 terminal empty fetch。 · 已解决

inflight 计数断言 正确性

orozery 建议在 inflight 递减时添加 assert >=0。

结论:已添加 assert xfer.round.inflight >= 0。 · 已解决

对称 P2P fetch 的 key 必须为 confirmed probe 正确性

orozery 建议 assert 确认 symmetric fetch 的 keys 都是 confirmed probes。

结论:已添加相应的 assert。 · 已解决

风险与影响

协议兼容性

round_seq字段被标记为必需,但PD客户端(非对称P2P)仅使用单轮0,且协议版本未变更。升级时需确保所有节点同步部署,否则可能因字段缺失导致连接失败。

状态复杂度

引入多轮状态管理后,server/client状态机的状态空间显著增大(outbound字典、多个loads、abort隔离)。任何遗漏更新(如未从pending_aborts中清理)都可能导致内存泄漏或僵尸状态。

性能开销

每轮fetch额外携带round_seq字段,消息体增加几个字节,影响可忽略。但多轮并行可能增加server端状态内存消耗,在极端大量并发请求下需关注GC压力。

测试覆盖

虽然新增了多轮回归测试,但模拟场景是否完全覆盖生产中的复杂时序仍有风险。

用户影响

启用对称P2P offloading的用户将避免请求超时30秒和session崩溃的严重问题。对于PD模式用户无影响,因为PD一直使用round 0,行为不变。

系统影响

修复后,server和client的状态机更加健壮,能够正确处理同一kv_request_id下的多轮lookup/fetch。由于round_seq隔离,abort和finish不会错误地影响其他轮次。

团队影响

后续开发者在P2P session模块中必须考虑轮次隔离,新增消息必须携带round_seq。减少了因状态泄漏导致的难以调试的竞态。

协议兼容性 状态复杂度 多轮回归测试覆盖

关联 Issue

#49820 [Bug][KV Offload][P2P] Symmetric fetch can remain non-terminal after confirmed lookup hits; consumer stays deferred for 30s

完整报告

参与讨论