Prhub

#56033 [Bugfix][Mooncake] Fix heterogeneous PP transfer completion

原始 PR 作者 wangyicong52 合并时间 2026-09-11 10:57 文件变更 2 提交数 2 评论 6 代码增减 +85 / -3

执行摘要

修复 Mooncake 异构 PP 传输:放宽层匹配并修正完成计数

PR body 明确了两条并存的故障链:一是「The existing receiver contacts every producer PP stage when the PP sizes differ. Producer-side layer matching rejects peers that own different layers」,即消费端在 PP 不一致时会 connect 每一个生产端 PP stage,而生产端按注册层匹配时把「对端本来就不持有该层」当成配置错误直接拒绝;二是「completion accounting only counts consumer TP ranks. Allowing those pulls without correcting the count would release producer KV before all consumer PP stages finish」,即若只放宽匹配而不修计数,反而会让生产端在最后一个真正搬数据的 consumer 完成时就提前释放 KV。修复目标是让 P-PP4 → D-PP2 这类部署可用:作者在 H20 上以 DeepSeek-V4-Pro-0813(两节点 Prefill TP4/PP4 → 两节点 Decode TP8/PP2,Mooncake RDMA)复现到修复前 PD 请求超时且报 producer/consumer layer-alignment 错误,修复后 200 条 GSM8K 通过且准确率 97.5%。PR body 同时说明设计约束:不改 all-peer 路由(不假设 PP rank 比例蕴含层边界对齐),共享层内部缺失 occurrence 仍然报错。

值得精读,属于「改动很小但语义很关键」的分布式正确性修复。建议重点关注三处:_align_transfer_regions 中 allow_partial_layers 与 (layer_name, 0) not in remote_by_key 的组合如何把「整层缺席」和「occurrence 缺失」区分开;resolve_need_send 把 need_send 乘以 consumer PP 规模这一完成计数语义,以及它在对端漏发 pull 时的退化行为;新增测试 test_heterogeneous_pp_waits_for_consumer_without_shared_layers 如何用单测复刻 P4/D2 的两个 stage 顺序。若要在生产启用异构 PP,建议额外验证「某个 consumer PP stage 失败/过期」的路径,并确认 P/D 版本同步策略。

讨论亮点

本 PR 共 6 条评论,其中 4 条是 CI 触发与 bot 回执(zhewenl、ZJY0516 各执行一次 /ci run,对应 Buildkite #88211 / #88226),行级 review 评论为 0,没有出现技术分歧。

  • ZJY0516:APPROVED,无文字意见,随后合入。
  • zhewenl:「looks good, thanks for the fix!」——维护者只确认修复方向,没有追问 need_send *= remote_pp_size 在某个 consumer stage 不发 pull 时是否会永久不满足。
  • claude[bot]:「This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.」——自动评审未运行,读者看不到独立的行级核查。

因此本 PR 的技术论证主要来自 PR body 自述与作者在 H20 上的手工验证(DeepSeek-V4-Pro-0813、Qwen3.5-2B、Falcon-H1-0.5B-Instruct),而不是评论区的对抗性讨论。

实现拆解

  1. 元数据扩容(MooncakeXferMetadata.remote_pp_size):在 vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py 的 msgspec 结构体中新增 remote_pp_size: int = 1,并在 receive_kv_from_single_worker 组装 pull 元数据时填入 self.pp_size。这让生产端第一次能感知消费端 PP 布局,是后两步判定的前提;默认值 1 保证单 stage/旧语义不变。
  2. 层匹配放宽(_align_transfer_regions 新增 keyword-only allow_partial_layers):函数仍以 (layer_name, occurrence) 为 key 做对齐;当某 producer region 在 remote 侧找不到对应 key 时,新增分支判断 allow_partial_layers and (local_region.layer_name, 0) not in remote_by_key 才 continue 跳过,否则维持原来的报错返回。这里刻意区分「对端整层缺席」(预期内,跳过)与「对端注册了该层但缺少第 k 个 occurrence」(真错配,仍失败),保留了共享层内部的严格校验。
  3. 完成计数修正(resolve_need_send + send_kv_to_decode 调用点):send_kv_to_decode 在调用对齐函数时传入 allow_partial_layers=(meta.remote_pp_size > 1 and meta.remote_pp_size != self.pp_size),即只在异构 PP 下放宽;在首次建立完成计数时把 meta.remote_pp_size 透传给 resolve_need_send。resolve_need_send 保留 need_send = len(remote_tp_ranks) 的 TP fanout 基线,并在异构 PP 下再 need_send *= remote_pp_size,使「没有共享层的 no-op pull」也占一个完成名额,避免 KV 提前释放;日志措辞从 consumer TP workers 调整为 consumer workers。
  4. 路由策略保持不变:没有引入「按 PP rank 比例推断层边界」的优化,仍让每个 consumer PP stage 向每个 producer stage 发起 pull,把正确性建立在计数而非路由裁剪上,降低了跨配置组合的推理复杂度。
  5. 测试与可观测性配套:tests/v1/kv_connector/unit/test_mooncake_connector.py 新增 test_heterogeneous_pp_waits_for_consumer_without_shared_layers,构造 producer pp_size=4 且只注册 layer 0、consumer remote_pp_size=2 且两个 stage 分别持有 layer 0 / layer 1 的 P4/D2 场景:第一次 pull 后断言 transfer 仍在 reqs_need_send 且 finished_sending_reqs 为空,第二次 no-op pull 后才完成并移出,同时断言 _send_blocks 只被调用一次("consumer:1234", [0x1100], [0x2200], [256]),确保 no-op pull 不产生多余 RDMA。
文件 模块 状态 重要度
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/mooncake_connector.py KV 连接器 modified 6.8
tests/v1/kv_connector/unit/test_mooncake_connector.py 连接器测试 modified 6.07

关键符号

_align_transfer_regions resolve_need_send send_kv_to_decode receive_kv_from_single_worker MooncakeXferMetadata test_heterogeneous_pp_waits_for_consumer_without_shared_layers

关键源码片段

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

唯一源码改动点:新增 remote_pp_size 元数据、_align_transfer_regions 的 allow_partial_layers 放宽、resolve_need_send 的完成计数修正,是修复 KV 提前释放与层匹配误报的核心。

_align_transfer_regions 增加 allow_partial_layers,把「对端整层缺席」和「共享层内 occurrence 缺失」分开处理:

def _align_transfer_regions(
    local_regions: list[TransferRegion],
    remote_regions: list[TransferRegion],
    *,
    # 新增开关:异构 PP 布局(如 P-PP4 -> D-PP2)下,对端 PP stage 不持有
    # 本端某些层属于预期情况,允许该 peer 参与匹配而不是直接判定配置错误
    allow_partial_layers: bool = False,
) -> tuple[list[TransferRegion], list[TransferRegion], str | None]:
    """Align KV transfer regions by registered layer-name occurrence.
​
    PP shard 各自持有不同的层子集,因此 producer / consumer 的 PP 布局
    不一致时按位置匹配是错的;这里统一用 (layer_name, occurrence) 作为 key,
    同一层的多个 transfer buffer 由出现次序区分。
    """
​
    def keyed_regions(
        regions: list[TransferRegion],
    ) -> list[tuple[tuple[str, int], TransferRegion]]:
        counts: dict[str, int] = defaultdict(int)
        keyed: list[tuple[tuple[str, int], TransferRegion]] = []
        for region in regions:
            occurrence = counts[region.layer_name]
            counts[region.layer_name] += 1
            keyed.append(((region.layer_name, occurrence), region))
        return keyed
​
    local_keyed = keyed_regions(local_regions)
    remote_keyed = keyed_regions(remote_regions)
    remote_by_key = dict(remote_keyed)
    aligned_local: list[TransferRegion] = []
    aligned_remote: list[TransferRegion] = []
    for key, local_region in local_keyed:
        remote_region = remote_by_key.get(key)
        if remote_region is None:
            if (
                allow_partial_layers
                # 只有对端连这一层的 occurrence 0 都没有(该层完全不属于它的
                # PP 分片)时才跳过;若对端注册了同层却缺少第 k 个 occurrence,
                # 说明布局真的错配,仍然走下面的报错分支
                and (local_region.layer_name, 0) not in remote_by_key
            ):
                continue
            return (
                [],
                [],
                (
                    "Mooncake producer registered layer has no matching "
                    f"consumer occurrence: {key[0]} occurrence {key[1]}."
                ),
            )
        # 共享层内部的严格校验保持不变:layer_index / group_index 必须一致
        if local_region.layer_index != remote_region.layer_index:
            return (
                [],
                [],
                (
                    "Mooncake registered layer index mismatch for "
                    f"{local_region.layer_name}: producer="
                    f"{local_region.layer_index}, consumer="
                    f"{remote_region.layer_index}."
                ),
            )
        if local_region.group_index != remote_region.group_index:
            return (
                [],
                [],
                (
                    "Mooncake registered group index mismatch for "
                    f"{local_region.layer_name}: producer="
                    f"{local_region.group_index}, consumer="
                    f"{remote_region.group_index}."
                ),
            )
        aligned_local.append(local_region)
        aligned_remote.append(remote_region)
​
    return aligned_local, aligned_remote, None

元数据字段与完成计数:remote_pp_size 随 pull 请求带到生产端,resolve_need_send 在异构 PP 下为每个 consumer PP stage 预留一个完成名额:

class MooncakeXferMetadata(msgspec.Struct, omit_defaults=True):
    remote_hostname: str
    remote_port: int
    remote_tp_size: int
    remote_tp_rank: int
    req_blocks: dict[ReqId, tuple[TransferId, list[list[int]]]]
    kv_caches_base_addr: list[int]
    block_lens: list[int]
    kv_block_lens: list[int]
    registered_layer_names: list[str] = msgspec.field(default_factory=list)
    registered_layer_indices: list[int] = msgspec.field(default_factory=list)
    registered_group_indices: list[int] = msgspec.field(default_factory=list)
    # 新增:consumer 侧 PP 规模,默认 1 保证单 stage / 旧版本语义不变
    remote_pp_size: int = 1
​
​
async def send_kv_to_decode(self, identity, sock, meta: MooncakeXferMetadata):
    # ...
    local_regions, remote_regions, align_err = _align_transfer_regions(
        local_regions,
        remote_regions,
        # 仅在 PP 布局异构时放宽:两个 PP stage 各拉一次的 no-op pull 是合法的
        allow_partial_layers=(
            meta.remote_pp_size > 1 and meta.remote_pp_size != self.pp_size
        ),
    )
    # ...
    if not send_meta.need_send:
        # 首次建立该 request 的完成计数时,把 consumer 的 PP 规模一并传入
        self.resolve_need_send(send_meta, remote_tp_ranks, meta.remote_pp_size)
​
​
def resolve_need_send(
    self,
    send_meta: SendBlockMeta,
    remote_tp_ranks: list[int],
    remote_pp_size: int = 1,
):
    # 同构场景基线语义:一个 P 要给每个配对的 D TP rank 各发送一次
    send_meta.need_send = len(remote_tp_ranks)
    if remote_pp_size > 1 and remote_pp_size != self.pp_size:
        # 异构 PP 下每个 consumer PP stage 都会亲自向每个 producer stage 发 pull,
        # 即使它与当前 producer stage 没有共享层(no-op pull)也必须计数;
        # 否则最后一个真正搬数据的 consumer 完成时就会误判完成、提前释放 KV
        send_meta.need_send *= remote_pp_size
    logger.debug(
        "Mooncake request %s will be served by %d consumer workers: TP ranks=%s",
        send_meta.transfer_id,
        send_meta.need_send,
        remote_tp_ranks,
    )
tests/v1/kv_connector/unit/test_mooncake_connector.py test-coverage

新增 P4/D2 回归用例,锁定「第一个 consumer PP stage 完成后仍保留 KV,no-op pull 到达后才置位 finished_sending_reqs」这一核心不变量。

新测试用单测复刻 P-PP4 / D-PP2 的两个 consumer stage:

@pytest.mark.asyncio
async def test_heterogeneous_pp_waits_for_consumer_without_shared_layers():
    """P4/D2 must retain KV until both D stages finish, including a no-op pull."""
​
    config = create_vllm_config(kv_connector="MooncakeConnector", kv_role="kv_producer")
    with (
        set_current_vllm_config(config),
        patch_worker_dependencies(),
        patch.object(MooncakeConnectorWorker, "_sync_block_size_with_kernel"),
    ):
        worker = MooncakeConnector(
            config, KVConnectorRole.WORKER, _make_test_kv_cache_config()
        ).connector_worker
        try:
            # producer 处于 PP4,只注册 layer 0;consumer 处于 PP2,
            # stage 0 持有 layer 0(共享层),stage 1 持有 layer 1(无共享层)
            worker.pp_size = 4
            worker.kv_caches_base_addr = [0x1000]
            worker.block_len_per_layer = [256]
            worker.kv_block_len_per_layer = [256]
            worker.registered_layer_names = ["model.layers.0.self_attn"]
            worker.registered_layer_indices = [0]
            send_meta = SendBlockMeta(
                p_req_id="p-req",
                transfer_id="transfer",
                local_block_ids=[[1]],
                ready=asyncio.Event(),
            )
            send_meta.ready.set()
            worker.reqs_need_send["transfer"] = send_meta
            sock = AsyncMock(spec=zmq.asyncio.Socket, send_multipart=AsyncMock())
            with (
                patch.object(worker, "sender_loop", asyncio.get_running_loop()),
                patch.object(worker, "_send_blocks", return_value=0) as send,
            ):
                for pp_rank in range(2):
                    metadata = MooncakeXferMetadata(
                        remote_hostname="consumer",
                        remote_port=1234 + pp_rank,
                        remote_tp_size=1,
                        remote_tp_rank=0,
                        # 关键输入:consumer 上报 PP2,触发异构 PP 计数分支
                        remote_pp_size=2,
                        req_blocks={"d-req": ("transfer", [[2]])},
                        kv_caches_base_addr=[0x2000],
                        block_lens=[256],
                        kv_block_lens=[256],
                        registered_layer_names=[f"model.layers.{pp_rank}.self_attn"],
                        registered_layer_indices=[pp_rank],
                    )
                    await worker.send_kv_to_decode(b"consumer", sock, metadata)
                    response = worker._xfer_resp_decoder.decode(
                        sock.send_multipart.call_args.args[0][1]
                    )
                    # 两个 stage 都收到 FINISH + ok_reqs,但「完成」必须等到最后一个
                    assert response.status == MooncakeXferResponseStatus.FINISH
                    assert response.ok_reqs == ["d-req"]
                    # stage 0 之后仍保留待发送状态,stage 1 的 no-op pull 到达才置位
                    assert ("transfer" in worker.reqs_need_send) == (pp_rank == 0)
                    assert worker.finished_sending_reqs == (
                        set() if pp_rank == 0 else {"p-req"}
                    )
                # 只有共享层的 stage 0 真正触发 RDMA,no-op pull 不产生搬运
                send.assert_called_once_with("consumer:1234", [0x1100], [0x2200], [256])
        finally:
            worker.shutdown()
            worker.is_kv_consumer = True

评论区精华

异构 PP 下完成计数是否必须计入 no-op pull 设计

PR body 明确交代了这个权衡:「Allowing those pulls without correcting the count would release producer KV before all consumer PP stages finish」,因此选择让每个 consumer PP stage 都计一个完成名额,同时保留 all-peer 路由而不假设 PP rank 比例蕴含层边界对齐。评论区没有对这个设计提出追问。

结论:采纳「放宽匹配 + 修正计数」的组合方案,并用单测固定「第一个 stage 完成后仍保留 KV」的不变量。 · 已解决

维护者对修复方向的确认 other

zhewenl 在触发 CI 后回复「looks good, thanks for the fix!」,ZJY0516 直接 APPROVED,均未提出条件或反对意见;两条 `/ci run` 分别对应 Buildkite #88211 与 #88226。

结论:维护者快速认可并合入,无技术争议,但也意味着 `need_send *= remote_pp_size` 的假设未经对抗性审查。 · 已解决

fork PR 的自动评审缺失 测试

claude[bot] 说明「This pull request is from a fork — automated review is disabled. A repository maintainer can comment `@claude review` to run a one-time review.」本 PR 未触发一次性评审,因此没有行级 review 评论。

结论:技术论证依赖 PR body 自述与 H20 手工验证结果;若后续修改此计数逻辑,建议显式要求人工或自动深审。 · 已解决

风险与影响

  1. 完成计数的强假设(resolve_need_send):send_meta.need_send *= remote_pp_size 假定每个 consumer PP stage 一定会发起一次 pull。若某个 D stage 因异常、连接器 abort 或请求过期而未 pull,生产端 need_send 永远无法满足,请求只能等到 expire_time / VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT 被回收——故障表现从「提前释放」变成「迟迟不释放」,而新增测试只覆盖两个 stage 都成功 pull 的理想路径。
  2. 匹配放宽的判定精度(_align_transfer_regions):跳过条件限定为 (layer_name, 0) not in remote_by_key,即仅当对端连 occurrence 0 都没有时才跳过;若对端注册了该层却缺少第 k 个 occurrence(乱序或 buffer 数量不一致),仍按错误返回。这偏向保守,但对「PP 规模相同、只是部分 stage 未注册某层」的场景不会放宽,行为与修复前一致。
  3. 协议兼容性:remote_pp_size 带默认值 1,混部时旧 consumer 不发送该字段,生产端会退回「严格匹配 + 只按 TP 计数」的旧语义,异构 PP 仍会失败;修复只在 P/D 两端都升级后生效,代码里没有版本协商或显式告警。
  4. 测试强度:新增用例是单元级(mock _send_blocks、手工构造 MooncakeXferMetadata),没有多 stage、多 TP、e2e 或 CI 内的异构 PP 覆盖;PR body 里 Qwen3.5-2B / Falcon-H1 的验证还依赖 #51718 的修复,属于外部依赖,未进入自动化回归。
  5. 回归面:改动被 meta.remote_pp_size > 1 and meta.remote_pp_size != self.pp_size 严格门控,同构 PP/TP 路径(默认 remote_pp_size=1 或等于 self.pp_size)行为不变,回归风险主要集中在异构 PP 这一目标场景。

对用户而言,使用 Mooncake PD 分离且 P/D 的 PP 布局异构(如 P-PP4 → D-PP2)的部署从「请求超时、层对齐报错」变为「正常完成且资源回收」,PR body 中的 DSv4-Pro、Qwen3.5-2B、Falcon-H1 三组配置均验证通过。对系统而言,这是一次生产端 KV 释放时机的正确性修复:既不再误报错,也不会在 consumer PP stage 未跑完时释放 KV;异构 TP fanout 的既有语义被保留,性能和 RDMA 行为不退化(测试断言 no-op pull 不触发 _send_blocks)。对团队而言,Mooncake pull 协议新增了 remote_pp_size 这一必守契约,后续任何改动完成计数、路由或层对齐的 PR 都需要同时考虑 PP 维度;同时 P/D 两端需同步升级才能获得修复。

KV 传输完成语义变更 完成计数依赖对端必达 仅单元测试覆盖 元数据新增字段的跨版本兼容

关联 Issue

#51718 [6/N][KV-Cache Layout Refactor] Standardize KV cache layout

完整报告

参与讨论