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

Signed-off-by: Edwin Lim <edwin.lim@mangoboost.io> Signed-off-by: Edwin Lim <edwinlim0919@gmail.com> Signed-off-by: harishk-mangoboost <harish.kambhampaty@mangoboost.io> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: harishk-mangoboost <harish.kambhampaty@mangoboost.io>

limeward committed Jul 16, 2026 at 13:02 UTC 971dac2caa827cb34dc682791ac6e15f6dbdab84
4 files changed +91 -14
tests/v1/kv_connector/unit/test_moriio_kv_layout.py
+11 -2
@@ -670,16 +670,25 @@ def test_moriio_wrapper_rejects_invalid_messages(role, payload, match):
670 wrapper._handle_message(payload)
671
672
673 -def test_block_id_length_mismatch_raises_value_error():
673 +def test_local_block_ids_longer_than_remote_raises_value_error():
674 cache = torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16)
675 worker = _worker({"layer": cache}, {"layer": _full_spec()})
676
677 - with pytest.raises(ValueError, match="must have the same length"):
677 + with pytest.raises(ValueError, match="longer than remote_block_ids"):
678 moriio_layout.compute_block_transfer_offsets(
679 "layer", cache, worker.layer_to_spec, [1, 3], [4], _remote_meta().num_blocks
680 )
681
682
683 +def test_empty_local_block_ids_is_free_only_noop():
684 + cache = torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16)
685 + worker = _worker({"layer": cache}, {"layer": _full_spec()})
686 +
687 + assert moriio_layout.compute_block_transfer_offsets(
688 + "layer", cache, worker.layer_to_spec, [], [4, 5], _remote_meta().num_blocks
689 + ) == ([], [], [])
690 +
691 +
692 def test_registration_regions_do_not_split_interleaved_or_mla_cache():
693 separated = torch.empty((2, 8, 4, 2, 3), dtype=torch.bfloat16)
694 interleaved = torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16)
tests/v1/kv_connector/unit/test_moriio_tp_ack.py
+1
@@ -253,6 +253,7 @@ def test_worker_get_finished_counts_structured_release_fan_in():
253 worker.transfer_id_to_request_id = {"tx-fanin": "req-fanin"}
254 worker._consumer_notification_counts = {}
255 worker._completed_consumer_notifications = set()
256 + worker._pending_unmapped_acks = []
257
258 assert worker.get_finished() == (set(), set())
259 assert worker._consumer_notification_counts == {"tx-fanin": 1}
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_connector.py
+70 -9
@@ -1030,6 +1030,16 @@ class MoRIIOConnectorWorker:
1030 use_mla=self.use_mla,
1031 )
1032 self.transfer_id_to_request_id: dict[TransferId, ReqId] = {}
1033 + # READ-mode producer: a decode release-ACK can arrive BEFORE
1034 + # start_load_kv populates transfer_id_to_request_id (the notify races
1035 + # ahead of the scheduler->worker sync). Buffer such ACKs and retry them
1036 + # next get_finished tick instead of dropping them -- dropping loses the
1037 + # completion, so the request is never marked done_sending, its KV blocks
1038 + # leak, and the prefill KV cache wedges at high concurrency. Buffered
1039 + # BEFORE resolve_moriio_transfer_ack, so each ACK is counted exactly once
1040 + # (on the tick its mapping exists) -- the heterogeneous-TP ack-counting
1041 + # is preserved.
1042 + self._pending_unmapped_acks: list = []
1043
1044 # TODO: consider the integration of flashinfer or other backends.
1045 self.backend_name = backend.get_name()
@@ -1542,16 +1552,22 @@ class MoRIIOConnectorWorker:
1552 # pop_finished_req_ids returns release ACKs sent by decode. Keep
1553 # duplicate ACKs because heterogeneous TP can fan multiple decode
1554 # ranks into one prefill rank for the same transfer_id.
1545 - finished_acks = self.moriio_wrapper.pop_finished_req_ids()
1555 + # Combine freshly-arrived ACKs with any buffered from prior ticks
1556 + # whose transfer_id wasn't mapped yet (notify raced ahead of
1557 + # start_load_kv); retry the lookup every tick. Buffered before
1558 + # resolve_moriio_transfer_ack so each ACK is counted exactly once.
1559 + finished_acks = self._pending_unmapped_acks + list(
1560 + self.moriio_wrapper.pop_finished_req_ids()
1561 + )
1562 + self._pending_unmapped_acks = []
1563 resolved_transfer_ids: set[TransferId] = set()
1564 for ack in finished_acks:
1565 transfer_id = ack if isinstance(ack, str) else ack.transfer_id
1566 if transfer_id not in self.transfer_id_to_request_id:
1550 - logger.warning(
1551 - "Could not find %s in transfer_id_to_request_id "
1552 - "lookup table. This could lead to a possible hang.",
1553 - transfer_id,
1554 - )
1567 + # Mapping not populated yet -- buffer and retry next tick,
1568 + # do NOT drop (dropping leaks producer KV at high conc and
1569 + # wedges the prefill).
1570 + self._pending_unmapped_acks.append(ack)
1571 continue
1572 resolved_transfer_id = resolve_moriio_transfer_ack(
1573 ack,
@@ -1933,6 +1949,23 @@ class MoRIIOConnectorWorker:
1949 ),
1950 )
1951
1952 + @staticmethod
1953 + def _is_sq_full_status(status) -> bool:
1954 + """True if a MoRIIO transfer status is a transient RDMA send-queue-full
1955 + rejection (retryable backpressure), not a terminal failure.
1956 +
1957 + read_remote_data posts the RDMA READ synchronously (the mori executor
1958 + joins its worker before returning and marks the status on the calling
1959 + thread), so a send-queue-full rejection is a Failed() status the moment
1960 + the call returns. mori surfaces it as a generic ERR_RDMA_OP carrying
1961 + "SQ full" in the message (no distinct code), so we match the message.
1962 + Only meaningful once status.Failed() is True.
1963 + """
1964 + try:
1965 + return bool(status.Failed()) and "SQ full" in (status.Message() or "")
1966 + except Exception:
1967 + return False
1968 +
1969 def _read_blocks(
1970 self,
1971 local_block_ids: list[int],
@@ -1950,6 +1983,8 @@ class MoRIIOConnectorWorker:
1983 dp0_engine_id = self.get_engine_name_with_dp(dst_engine_id, 0)
1984 sessions, remote_moriio_meta = self._get_built_session(dp0_engine_id)
1985
1986 + # SQ-full backpressure deadline, shared across this request's layers.
1987 + _sq_deadline = time.monotonic() + self.moriio_config.transfer_timeout
1988 for layer_name in self.layer_name_to_local_kv_cache_metadata:
1989 sess_idx = list(self.layer_name_to_local_kv_cache_metadata.keys()).index(
1990 layer_name
@@ -1962,9 +1997,35 @@ class MoRIIOConnectorWorker:
1997 remote_tp_size=remote_tp_size,
1998 )
1999 # TODO : apply multi-session batch-read when moriio support it
1965 - transfer_status = self.moriio_wrapper.read_remote_data(
1966 - offs[2], offs[0], offs[1], sessions[sess_idx]
1967 - )
2000 + #
2001 + # SQ-full backpressure: read_remote_data posts the RDMA READ
2002 + # SYNCHRONOUSLY, so a send-queue-full rejection (per-QP HW cap) comes
2003 + # back as a Failed() status right here. A SEPARATE CQ-poll thread
2004 + # drains completions and frees SQ depth, so back off and RE-POST
2005 + # rather than let a transient rejection abort the request. No
2006 + # self-deadlock (the drain is off-thread); the reserve is
2007 + # all-or-nothing (nothing posted on a rejected attempt). Bounded by
2008 + # transfer_timeout; on sustained overload store the failed status and
2009 + # let get_finished handle it non-fatally (notify prefill + drop).
2010 + _backoff = 0.001
2011 + while True:
2012 + transfer_status = self.moriio_wrapper.read_remote_data(
2013 + offs[2], offs[0], offs[1], sessions[sess_idx]
2014 + )
2015 + if not self._is_sq_full_status(transfer_status):
2016 + break
2017 + if time.monotonic() > _sq_deadline:
2018 + logger.warning(
2019 + "MoRIIO READ send queue stayed full past "
2020 + "transfer_timeout for req %s layer %s; storing failed "
2021 + "status (get_finished notifies prefill and drops the "
2022 + "request). Raise qp_per_transfer if frequent.",
2023 + request_id,
2024 + layer_name,
2025 + )
2026 + break
2027 + time.sleep(_backoff)
2028 + _backoff = min(_backoff * 2, 0.05)
2029 with self.moriio_wrapper.lock:
2030 self._recving_transfers[request_id].append(transfer_status)
2031 self._recving_transfers_callback_addr[request_id] = (
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_layout.py
+9 -3
@@ -312,10 +312,16 @@ def compute_block_transfer_offsets(
312 [list[int], list[int], list[int]], tuple[list[int], list[int], list[int]]
313 ] = merge_contiguous_offsets,
314 ) -> tuple[list[int], list[int], list[int]]:
315 - if len(local_block_ids) != len(remote_block_ids):
315 + # A shorter (or empty) local list is the READ-mode "drop the transfer, just
316 + # free the prefill blocks" case (full-prefix-hit / aborted-before-scheduled):
317 + # decode pulls fewer blocks than the prefill holds. The zip loop below pairs
318 + # local[i]<->remote[i] and sizes by len(local), so a short local transfers
319 + # only what decode allocated and an empty local is a no-op. A longer local
320 + # list is a genuine bug and still fails loudly.
321 + if len(local_block_ids) > len(remote_block_ids):
322 raise ValueError(
317 - "local_block_ids and remote_block_ids must have the same length: "
318 - f"{len(local_block_ids)} != {len(remote_block_ids)}"
323 + "local_block_ids longer than remote_block_ids: "
324 + f"{len(local_block_ids)} > {len(remote_block_ids)}"
325 )
326 geometry = get_layer_transfer_geometry(
327 layer_name, kv_cache, layer_to_spec, remote_num_blocks