Prhub

#47495 [Bugfix][KV-transfer] MoRIIO: retry RDMA send-queue-full backpressure instead of failing the read

原始 PR 作者 edwinlim0919 合并时间 2026-07-17 04:02 文件变更 4 提交数 5 评论 8 代码增减 +91 / -14

执行摘要

修复 MoRIIO RDMA 发送队列满时重试读请求

在高并发下,每个 QP 的 RDMA 发送队列是硬件限制的(如 bnxt max_qp_wr=4351),read_remote_data 同步提交 READ 请求时如果 SQ 满则立即返回 Failed 状态,此前该状态被当作传输失败,导致预填充释放 block、丢弃请求,最终因超时中止。实际上 CQ 轮询线程会在几毫秒内释放 SQ 深度,请求本可成功。

值得精读,尤其关注瞬态错误重试策略和 ACK 缓冲机制的设计权衡。_is_sq_full_status 的实现依赖字符串匹配,建议未来考虑更健壮的检测方式(如 mori 库的错误码)。

讨论亮点

CI 中的预提交检查因 pip-compile-xpu 从 pytorch xpu wheel 镜像获取 pyzmq 时出现 503 Service Unavailable 而失败,与代码本身无关,作者请求 maintainer 重跑 CI。另外,V1 Core + KV + Metrics 单元测试失败的原因是手工构造的 worker 未初始化 _pending_unmapped_acks 属性,作者快速提交补丁修复(0ca33d79)。

实现拆解

  1. 缓冲未映射的 ACK:在 MoRIIOConnectorWorker.__init__ 中新增 _pending_unmapped_acks 列表,get_finished 先从该列表取 ACK,再合并 pop_finished_req_ids 的新 ACK,对于 transfer_id 不在 transfer_id_to_request_id 中的 ACK 不再直接警告丢弃,而是追加到缓冲列表等待下次轮询重试,解决通知先于 start_load_kv 到达导致的竞争。

  2. SQ full 重试逻辑:在 _read_blocks 中,当 status.Failed() 且消息包含 SQ full(通过 _is_sq_full_status 静态方法检测)时,不直接标记失败,而是退避重试 READ,重试总时间受 transfer_timeout 限制。若超时仍未成功,则将失败状态存储,由 get_finished 按非致命方式处理。非 SQ full 的失败行为不变。

  3. 放宽 block ID 校验moriio_layout.pycompute_block_transfer_offsets 原要求 local_block_ids 长度严格等于 remote_block_ids,现改为只拒绝 local_block_ids 长于 remote_block_ids,允许短/空列表(对应 READ 模式的纯释放路径)。

  4. 测试适配:更新 test_moriio_kv_layout.py 中的测试函数名为 test_local_block_ids_longer_than_remote_raises_value_error,新增 test_empty_local_block_ids_is_free_only_noop;在 test_moriio_tp_ack.pytest_worker_get_finished_counts_structured_release_fan_in 中为手工构造的 worker 设置 _pending_unmapped_acks = [],避免属性不存在错误。

文件 模块 状态 重要度
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_connector.py KV 传输 modified 7.67
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_layout.py KV 传输 modified 5.6
tests/v1/kv_connector/unit/test_moriio_kv_layout.py 测试 modified 6.01
tests/v1/kv_connector/unit/test_moriio_tp_ack.py 测试 modified 3.48

关键符号

_is_sq_full_status get_finished compute_block_transfer_offsets _read_blocks

关键源码片段

vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_connector.py core-logic

核心变更文件:实现 SQ full 重试逻辑(_is_sq_full_status + _read_blocks 修改)和未映射 ACK 缓冲(_pending_unmapped_acks + get_finished 修改),+70/-9。

# vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_connector.py# 在 __init__ 中新增:缓冲 READ 完成 ACK 的列表
# 当 decode 的 release ACK 在 transfer_id_to_request_id 建立之前到达时,
# 先缓冲在此,每次 get_finished 轮询重试,避免丢失导致 block 泄漏。
self._pending_unmapped_acks: list = []# 静态方法:检测 RDMA 发送队列满的瞬态错误
@staticmethod
def _is_sq_full_status(status) -> bool:
    """True if a MoRIIO transfer status is a transient RDMA send-queue-full
    rejection (retryable backpressure), not a terminal failure."""
    try:
        # mori 将 SQ full 表示为 Failed() 且消息包含 "SQ full"
        return bool(status.Failed()) and "SQ full" in (status.Message() or "")
    except Exception:
        return Falsedef get_finished(self) -> tuple[set[str], set[str]]:
    # 合并缓冲区和新 ACK,然后清空缓冲区
    finished_acks = self._pending_unmapped_acks + list(
        self.moriio_wrapper.pop_finished_req_ids()
    )
    self._pending_unmapped_acks = []
    for ack in finished_acks:
        transfer_id = ack if isinstance(ack, str) else ack.transfer_id
        if transfer_id not in self.transfer_id_to_request_id:
            # 映射未就绪,缓冲并留待下次轮询(不丢弃)
            self._pending_unmapped_acks.append(ack)
            continue
        # ... 原有 resolve 逻辑def _read_blocks(self, ...):
    # ... 发送 READ 后
    if not status.Succeeded():
        if self._is_sq_full_status(status):
            # SQ full:退避重试,由 transfer_timeout 限制总时间
            if self._retry_read(...):
                return
        # 其他失败或超时:走原有失败处理
    # ...
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_layout.py core-logic

放宽 compute_block_transfer_offsets 的校验,允许 local_block_ids 短于 remote_block_ids,支持 READ 模式纯释放路径。

# vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_layout.pydef compute_block_transfer_offsets(...):
    # 之前要求长度严格相等,现在允许 local 更短
    # 因为 READ 模式下 decode 只需释放部分 block
    if len(local_block_ids) > len(remote_block_ids):
        raise ValueError(
            "local_block_ids longer than remote_block_ids: "
            f"{len(local_block_ids)} > {len(remote_block_ids)}"
        )
    # ... 后续逻辑基于 len(local) 循环,自动处理短列表

评论区精华

单元测试失败:缺少 _pending_unmapped_acks 属性 测试

CI 中 V1 Core + KV + Metrics 测试失败,原因是 test_worker_get_finished_counts_structured_release_fan_in 手工构造的 MoRIIOConnectorWorker 未设置 _pending_unmapped_acks,而 get_finished 现在会读取该属性。

结论:提交 0ca33d79 修复,在测试中初始化该属性。 · 已解决

预提交检查失败:pytorch xpu wheel 镜像 503 other

pip-compile-xpu 因 pytorch xpu wheel 镜像返回 503 Service Unavailable 而失败,作者请求 maintainer 重跑 CI。

结论:由 maintainer 重跑后通过。 · 已解决

风险与影响

  1. 重试循环可能增加延迟:如果持续 SQ full 直到 transfer_timeout 超限,会占用更多时间,但这是合理退避,不会无限阻塞。
  2. SQ full 判断依赖字符串匹配_is_sq_full_status 通过检查 status.Message() 是否包含 SQ full 来判断,该字符串依赖 mori 库的异常消息格式,未来库升级可能变更,缺少防御性适配。
  3. 缓冲 ACK 带来内存风险:极端情况下如果映射持续不建立,_pending_unmapped_acks 可能无限增长,但 transfer_timeout 最终会失败并清理映射。
  4. 放宽 length 校验:允许 local_block_ids 短于 remote_block_ids 可能隐藏真正的问题(如广播 bug),但通过只允许短于(不允许长于)来保留对异常情况的报错。

直接影响使用 MoRIIO KV 传输的离散预填充/解码(P/D)部署,尤其是高并发多解码/MTP 场景。此前 SQ full 会导致请求超时、KV block 泄漏、预填充缓存死锁,本修复消除了这些故障,使系统在高并发下稳定运行。对不使用 MoRIIO 的用户无影响。

SQ full 重试依赖字符串匹配 缓冲 ACK 潜在无限增长 放宽长度校验可能掩盖异常

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论