[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>
This commit is contained in:
limeward
2026-07-16 20:02:27 +00:00
committed by GitHub
co-authored by Claude harishk-mangoboost
parent efa2e424f6
commit 971dac2caa
4 changed files with 91 additions and 14 deletions
@@ -670,16 +670,25 @@ def test_moriio_wrapper_rejects_invalid_messages(role, payload, match):
wrapper._handle_message(payload)
def test_block_id_length_mismatch_raises_value_error():
def test_local_block_ids_longer_than_remote_raises_value_error():
cache = torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16)
worker = _worker({"layer": cache}, {"layer": _full_spec()})
with pytest.raises(ValueError, match="must have the same length"):
with pytest.raises(ValueError, match="longer than remote_block_ids"):
moriio_layout.compute_block_transfer_offsets(
"layer", cache, worker.layer_to_spec, [1, 3], [4], _remote_meta().num_blocks
)
def test_empty_local_block_ids_is_free_only_noop():
cache = torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16)
worker = _worker({"layer": cache}, {"layer": _full_spec()})
assert moriio_layout.compute_block_transfer_offsets(
"layer", cache, worker.layer_to_spec, [], [4, 5], _remote_meta().num_blocks
) == ([], [], [])
def test_registration_regions_do_not_split_interleaved_or_mla_cache():
separated = torch.empty((2, 8, 4, 2, 3), dtype=torch.bfloat16)
interleaved = torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16)
@@ -253,6 +253,7 @@ def test_worker_get_finished_counts_structured_release_fan_in():
worker.transfer_id_to_request_id = {"tx-fanin": "req-fanin"}
worker._consumer_notification_counts = {}
worker._completed_consumer_notifications = set()
worker._pending_unmapped_acks = []
assert worker.get_finished() == (set(), set())
assert worker._consumer_notification_counts == {"tx-fanin": 1}
@@ -1030,6 +1030,16 @@ class MoRIIOConnectorWorker:
use_mla=self.use_mla,
)
self.transfer_id_to_request_id: dict[TransferId, ReqId] = {}
# READ-mode producer: a decode release-ACK can arrive BEFORE
# start_load_kv populates transfer_id_to_request_id (the notify races
# ahead of the scheduler->worker sync). Buffer such ACKs and retry them
# next get_finished tick instead of dropping them -- dropping loses the
# completion, so the request is never marked done_sending, its KV blocks
# leak, and the prefill KV cache wedges at high concurrency. Buffered
# BEFORE resolve_moriio_transfer_ack, so each ACK is counted exactly once
# (on the tick its mapping exists) -- the heterogeneous-TP ack-counting
# is preserved.
self._pending_unmapped_acks: list = []
# TODO: consider the integration of flashinfer or other backends.
self.backend_name = backend.get_name()
@@ -1542,16 +1552,22 @@ class MoRIIOConnectorWorker:
# pop_finished_req_ids returns release ACKs sent by decode. Keep
# duplicate ACKs because heterogeneous TP can fan multiple decode
# ranks into one prefill rank for the same transfer_id.
finished_acks = self.moriio_wrapper.pop_finished_req_ids()
# Combine freshly-arrived ACKs with any buffered from prior ticks
# whose transfer_id wasn't mapped yet (notify raced ahead of
# start_load_kv); retry the lookup every tick. Buffered before
# resolve_moriio_transfer_ack so each ACK is counted exactly once.
finished_acks = self._pending_unmapped_acks + list(
self.moriio_wrapper.pop_finished_req_ids()
)
self._pending_unmapped_acks = []
resolved_transfer_ids: set[TransferId] = set()
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:
logger.warning(
"Could not find %s in transfer_id_to_request_id "
"lookup table. This could lead to a possible hang.",
transfer_id,
)
# Mapping not populated yet -- buffer and retry next tick,
# do NOT drop (dropping leaks producer KV at high conc and
# wedges the prefill).
self._pending_unmapped_acks.append(ack)
continue
resolved_transfer_id = resolve_moriio_transfer_ack(
ack,
@@ -1933,6 +1949,23 @@ class MoRIIOConnectorWorker:
),
)
@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.
read_remote_data posts the RDMA READ synchronously (the mori executor
joins its worker before returning and marks the status on the calling
thread), so a send-queue-full rejection is a Failed() status the moment
the call returns. mori surfaces it as a generic ERR_RDMA_OP carrying
"SQ full" in the message (no distinct code), so we match the message.
Only meaningful once status.Failed() is True.
"""
try:
return bool(status.Failed()) and "SQ full" in (status.Message() or "")
except Exception:
return False
def _read_blocks(
self,
local_block_ids: list[int],
@@ -1950,6 +1983,8 @@ class MoRIIOConnectorWorker:
dp0_engine_id = self.get_engine_name_with_dp(dst_engine_id, 0)
sessions, remote_moriio_meta = self._get_built_session(dp0_engine_id)
# SQ-full backpressure deadline, shared across this request's layers.
_sq_deadline = time.monotonic() + self.moriio_config.transfer_timeout
for layer_name in self.layer_name_to_local_kv_cache_metadata:
sess_idx = list(self.layer_name_to_local_kv_cache_metadata.keys()).index(
layer_name
@@ -1962,9 +1997,35 @@ class MoRIIOConnectorWorker:
remote_tp_size=remote_tp_size,
)
# TODO : apply multi-session batch-read when moriio support it
transfer_status = self.moriio_wrapper.read_remote_data(
offs[2], offs[0], offs[1], sessions[sess_idx]
)
#
# SQ-full backpressure: read_remote_data posts the RDMA READ
# SYNCHRONOUSLY, so a send-queue-full rejection (per-QP HW cap) comes
# back as a Failed() status right here. A SEPARATE CQ-poll thread
# drains completions and frees SQ depth, so back off and RE-POST
# rather than let a transient rejection abort the request. No
# self-deadlock (the drain is off-thread); the reserve is
# all-or-nothing (nothing posted on a rejected attempt). Bounded by
# transfer_timeout; on sustained overload store the failed status and
# let get_finished handle it non-fatally (notify prefill + drop).
_backoff = 0.001
while True:
transfer_status = self.moriio_wrapper.read_remote_data(
offs[2], offs[0], offs[1], sessions[sess_idx]
)
if not self._is_sq_full_status(transfer_status):
break
if time.monotonic() > _sq_deadline:
logger.warning(
"MoRIIO READ send queue stayed full past "
"transfer_timeout for req %s layer %s; storing failed "
"status (get_finished notifies prefill and drops the "
"request). Raise qp_per_transfer if frequent.",
request_id,
layer_name,
)
break
time.sleep(_backoff)
_backoff = min(_backoff * 2, 0.05)
with self.moriio_wrapper.lock:
self._recving_transfers[request_id].append(transfer_status)
self._recving_transfers_callback_addr[request_id] = (
@@ -312,10 +312,16 @@ def compute_block_transfer_offsets(
[list[int], list[int], list[int]], tuple[list[int], list[int], list[int]]
] = merge_contiguous_offsets,
) -> tuple[list[int], list[int], list[int]]:
if len(local_block_ids) != len(remote_block_ids):
# A shorter (or empty) local list is the READ-mode "drop the transfer, just
# free the prefill blocks" case (full-prefix-hit / aborted-before-scheduled):
# decode pulls fewer blocks than the prefill holds. The zip loop below pairs
# local[i]<->remote[i] and sizes by len(local), so a short local transfers
# only what decode allocated and an empty local is a no-op. A longer local
# list is a genuine bug and still fails loudly.
if len(local_block_ids) > len(remote_block_ids):
raise ValueError(
"local_block_ids and remote_block_ids must have the same length: "
f"{len(local_block_ids)} != {len(remote_block_ids)}"
"local_block_ids longer than remote_block_ids: "
f"{len(local_block_ids)} > {len(remote_block_ids)}"
)
geometry = get_layer_transfer_geometry(
layer_name, kv_cache, layer_to_spec, remote_num_blocks