[ROCm][P/D] Support MoRIIO heterogeneous TP fan-in (#46332)

Signed-off-by: Tan Pin Siang <tanpinsiang@gmail.com> Co-authored-by: vllmellm <vllm.ellm@embeddedllm.com> Co-authored-by: Hongxia Yang <hongxia.yang@amd.com> Co-authored-by: Jun Kang Chow <junkangchow@gmail.com> Co-authored-by: Chun Fang <chun.fang@amd.com> Co-authored-by: TianDi101 <ditian12@amd.com> Co-authored-by: functionstackx <47992694+functionstackx@users.noreply.github.com> Co-authored-by: tjtanaa <tunjian.tan@embeddedllm.com>

Tan Pin Siang committed Jun 23, 2026 at 18:33 UTC d32575a2d2d83362a69d8bcf4544589cdbc11a9c
6 files changed +544 -33
tests/v1/kv_connector/unit/test_moriio_connector.py
+8 -1
@@ -195,7 +195,14 @@ class FakeMoRIIOWrapper:
195 def _handle_completion_message(self, msg: str):
196 pass
197
198 - def send_notify(self, req_ids, remote_ip, remote_port, message_type=None):
198 + def send_notify(
199 + self,
200 + req_ids,
201 + remote_ip,
202 + remote_port,
203 + message_type=None,
204 + message_fields=None,
205 + ):
206 pass
207
208 def pop_finished_req_ids(self):
tests/v1/kv_connector/unit/test_moriio_kv_layout.py
+30 -10
@@ -36,6 +36,7 @@ msgpack = importlib.import_module("msgpack")
36
37 ROLE = moriio_common.ROLE
38 MoRIIOError = moriio_common.MoRIIOError
39 +MoRIIOTransferAck = moriio_common.MoRIIOTransferAck
40 RemoteAllocInfo = moriio_common.RemoteAllocInfo
41 WriteTask = moriio_common.WriteTask
42 set_role = moriio_common.set_role
@@ -440,9 +441,16 @@ def test_write_completion_notifies_once_after_all_sealed_writes_finish():
441 def _mark_transfer_terminal_locked(self, transfer_id):
442 self._terminal_transfer_ids[transfer_id] = None
443
443 - def send_notify(self, transfer_id, remote_ip, remote_port, message_type=None):
444 + def send_notify(
445 + self,
446 + transfer_id,
447 + remote_ip,
448 + remote_port,
449 + message_type=None,
450 + message_fields=None,
451 + ):
452 self.notifications.append(
445 - (transfer_id, remote_ip, remote_port, message_type)
453 + (transfer_id, remote_ip, remote_port, message_type, message_fields)
454 )
455
456 wrapper = FakeWrapper()
@@ -464,8 +472,8 @@ def test_write_completion_notifies_once_after_all_sealed_writes_finish():
472 writer._mark_write_done("xfer", request_info)
473 writer._finalize_if_complete("xfer", request_info)
474
467 - assert wrapper.notifications == [("xfer", "127.0.0.1", 7002, "write_done")]
468 - assert wrapper.done_req_ids == ["xfer"]
475 + assert wrapper.notifications == [("xfer", "127.0.0.1", 7002, "write_done", None)]
476 + assert wrapper.done_req_ids == [MoRIIOTransferAck("xfer")]
477 assert wrapper.done_remote_allocate_req_dict == {}
478 assert wrapper.wait_count == 1
479 assert wrapper.waited_statuses == [["status-a", "status-b"]]
@@ -509,7 +517,7 @@ def test_write_failure_marks_terminal_and_clears_scheduled_state():
517
518 writer._mark_request_done("xfer")
519
512 - assert wrapper.done_req_ids == ["xfer"]
520 + assert wrapper.done_req_ids == [MoRIIOTransferAck("xfer")]
521 assert wrapper.done_remote_allocate_req_dict == {}
522 assert wrapper._is_transfer_terminal_locked("xfer")
523 assert "xfer" not in writer._scheduled_writes
@@ -581,9 +589,21 @@ def test_late_remote_blocks_message_is_ignored_after_transfer_done():
589 pytest.param(
590 ROLE.PRODUCER,
591 msgpack.dumps({"type": "release", "transfer_id": "xfer"}),
584 - "release",
592 + MoRIIOTransferAck("xfer"),
593 id="release",
594 ),
595 + pytest.param(
596 + ROLE.PRODUCER,
597 + msgpack.dumps(
598 + {
599 + "type": "release",
600 + "transfer_id": "xfer",
601 + "consumer_tp_size": 8,
602 + }
603 + ),
604 + MoRIIOTransferAck("xfer", 8),
605 + id="release-consumer-tp-size",
606 + ),
607 pytest.param(None, b"xfer", "plain", id="plain-string"),
608 ],
609 )
@@ -603,11 +623,11 @@ def test_moriio_wrapper_routes_valid_messages(role, payload, expected):
623 assert request_info.decode_dp_rank == 3
624 elif expected == "write_done":
625 assert wrapper.done_write_cache_req_ids == ["xfer"]
606 - elif expected == "release":
607 - assert wrapper.done_req_ids == ["xfer"]
608 - assert wrapper._is_transfer_terminal_locked("xfer")
609 - else:
626 + elif expected == "plain":
627 assert completions == ["xfer"]
628 + else:
629 + assert wrapper.done_req_ids == [expected]
630 + assert wrapper._is_transfer_terminal_locked("xfer")
631
632
633 @pytest.mark.parametrize(
tests/v1/kv_connector/unit/test_moriio_tp_ack.py new
+310
@@ -0,0 +1,310 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import threading
5 +
6 +import pytest
7 +
8 +from vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_common import (
9 + MoRIIOMode,
10 + MoRIIOTransferAck,
11 +)
12 +from vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_connector import (
13 + MoRIIOConnectorWorker,
14 + get_moriio_expected_ack_count,
15 + get_moriio_remote_tp_rank,
16 + resolve_moriio_transfer_ack,
17 + validate_moriio_heterogeneous_tp_kv_heads,
18 +)
19 +
20 +
21 +def test_remote_tp_rank_same_tp_maps_to_self():
22 + assert [get_moriio_remote_tp_rank(rank, 4, 4) for rank in range(4)] == [
23 + 0,
24 + 1,
25 + 2,
26 + 3,
27 + ]
28 +
29 +
30 +def test_remote_tp_rank_p4_d8_floor_maps_decode_to_prefill():
31 + assert [get_moriio_remote_tp_rank(rank, 8, 4) for rank in range(8)] == [
32 + 0,
33 + 0,
34 + 1,
35 + 1,
36 + 2,
37 + 2,
38 + 3,
39 + 3,
40 + ]
41 +
42 +
43 +def test_remote_tp_rank_p8_d4_maps_to_first_prefill_rank_per_pair():
44 + assert [get_moriio_remote_tp_rank(rank, 4, 8) for rank in range(4)] == [
45 + 0,
46 + 2,
47 + 4,
48 + 6,
49 + ]
50 +
51 +
52 +@pytest.mark.parametrize(
53 + ("local_tp_rank", "local_tp_size", "remote_tp_size"),
54 + [
55 + (0, 6, 4),
56 + (0, 4, 6),
57 + ],
58 +)
59 +def test_remote_tp_rank_invalid_non_multiple_tp_raises(
60 + local_tp_rank: int, local_tp_size: int, remote_tp_size: int
61 +):
62 + with pytest.raises(ValueError, match="multiple"):
63 + get_moriio_remote_tp_rank(local_tp_rank, local_tp_size, remote_tp_size)
64 +
65 +
66 +@pytest.mark.parametrize(
67 + ("local_tp_size", "remote_tp_size", "total_num_kv_heads"),
68 + [
69 + (4, 4, 8),
70 + (8, 4, 4),
71 + (4, 8, 4),
72 + ],
73 +)
74 +def test_heterogeneous_tp_head_guard_allows_supported_layouts(
75 + local_tp_size: int, remote_tp_size: int, total_num_kv_heads: int
76 +):
77 + validate_moriio_heterogeneous_tp_kv_heads(
78 + local_tp_size,
79 + remote_tp_size,
80 + total_num_kv_heads,
81 + is_mla=False,
82 + )
83 +
84 +
85 +def test_heterogeneous_tp_head_guard_allows_mla_layouts():
86 + validate_moriio_heterogeneous_tp_kv_heads(
87 + local_tp_size=2,
88 + remote_tp_size=4,
89 + total_num_kv_heads=4,
90 + is_mla=True,
91 + )
92 +
93 +
94 +@pytest.mark.parametrize(
95 + ("local_tp_size", "remote_tp_size", "total_num_kv_heads"),
96 + [
97 + (4, 2, 4),
98 + (2, 4, 4),
99 + ],
100 +)
101 +def test_heterogeneous_tp_head_guard_rejects_split_kv_heads(
102 + local_tp_size: int, remote_tp_size: int, total_num_kv_heads: int
103 +):
104 + with pytest.raises(NotImplementedError, match="replicated KV heads"):
105 + validate_moriio_heterogeneous_tp_kv_heads(
106 + local_tp_size,
107 + remote_tp_size,
108 + total_num_kv_heads,
109 + is_mla=False,
110 + )
111 +
112 +
113 +def test_expected_ack_count_for_homogeneous_or_smaller_consumer_tp_is_one():
114 + assert get_moriio_expected_ack_count(4, 4) == 1
115 + assert get_moriio_expected_ack_count(8, 4) == 1
116 +
117 +
118 +def test_expected_ack_count_for_decode_fan_in():
119 + assert get_moriio_expected_ack_count(4, 8) == 2
120 +
121 +
122 +def test_expected_ack_count_rejects_non_multiple_fan_in():
123 + with pytest.raises(ValueError, match="multiple"):
124 + get_moriio_expected_ack_count(4, 6)
125 +
126 +
127 +def test_plain_string_ack_is_backward_compatible_single_ack():
128 + notification_counts: dict[str, int] = {}
129 + completed_transfer_ids: set[str] = set()
130 +
131 + assert (
132 + resolve_moriio_transfer_ack(
133 + "tx-plain",
134 + producer_tp_size=4,
135 + live_transfer_ids={"tx-plain"},
136 + notification_counts=notification_counts,
137 + completed_transfer_ids=completed_transfer_ids,
138 + )
139 + == "tx-plain"
140 + )
141 + assert notification_counts == {}
142 + assert completed_transfer_ids == {"tx-plain"}
143 +
144 +
145 +def test_structured_release_ack_waits_for_all_expected_acks():
146 + ack = MoRIIOTransferAck("tx-fanin", consumer_tp_size=8)
147 + notification_counts: dict[str, int] = {}
148 + completed_transfer_ids: set[str] = set()
149 +
150 + assert (
151 + resolve_moriio_transfer_ack(
152 + ack,
153 + producer_tp_size=4,
154 + live_transfer_ids={"tx-fanin"},
155 + notification_counts=notification_counts,
156 + completed_transfer_ids=completed_transfer_ids,
157 + )
158 + is None
159 + )
160 + assert notification_counts == {"tx-fanin": 1}
161 + assert completed_transfer_ids == set()
162 +
163 + assert (
164 + resolve_moriio_transfer_ack(
165 + ack,
166 + producer_tp_size=4,
167 + live_transfer_ids={"tx-fanin"},
168 + notification_counts=notification_counts,
169 + completed_transfer_ids=completed_transfer_ids,
170 + )
171 + == "tx-fanin"
172 + )
173 + assert notification_counts == {}
174 + assert completed_transfer_ids == {"tx-fanin"}
175 +
176 +
177 +def test_duplicate_ack_after_completion_does_not_resolve_twice():
178 + ack = MoRIIOTransferAck("tx-dup", consumer_tp_size=8)
179 + notification_counts: dict[str, int] = {}
180 + completed_transfer_ids: set[str] = set()
181 +
182 + assert (
183 + resolve_moriio_transfer_ack(
184 + ack,
185 + producer_tp_size=4,
186 + live_transfer_ids={"tx-dup"},
187 + notification_counts=notification_counts,
188 + completed_transfer_ids=completed_transfer_ids,
189 + )
190 + is None
191 + )
192 + assert (
193 + resolve_moriio_transfer_ack(
194 + ack,
195 + producer_tp_size=4,
196 + live_transfer_ids={"tx-dup"},
197 + notification_counts=notification_counts,
198 + completed_transfer_ids=completed_transfer_ids,
199 + )
200 + == "tx-dup"
201 + )
202 + assert (
203 + resolve_moriio_transfer_ack(
204 + ack,
205 + producer_tp_size=4,
206 + live_transfer_ids={"tx-dup"},
207 + notification_counts=notification_counts,
208 + completed_transfer_ids=completed_transfer_ids,
209 + )
210 + is None
211 + )
212 + assert notification_counts == {}
213 + assert completed_transfer_ids == {"tx-dup"}
214 +
215 +
216 +def test_ack_for_non_live_transfer_is_ignored():
217 + notification_counts: dict[str, int] = {}
218 + completed_transfer_ids: set[str] = set()
219 +
220 + assert (
221 + resolve_moriio_transfer_ack(
222 + MoRIIOTransferAck("tx-stale", consumer_tp_size=8),
223 + producer_tp_size=4,
224 + live_transfer_ids={"tx-live"},
225 + notification_counts=notification_counts,
226 + completed_transfer_ids=completed_transfer_ids,
227 + )
228 + is None
229 + )
230 + assert notification_counts == {}
231 + assert completed_transfer_ids == set()
232 +
233 +
234 +def test_worker_get_finished_counts_structured_release_fan_in():
235 + class FakeWrapper:
236 + def __init__(self):
237 + self.batches = [
238 + [MoRIIOTransferAck("tx-fanin", consumer_tp_size=8)],
239 + [MoRIIOTransferAck("tx-fanin", consumer_tp_size=8)],
240 + ]
241 +
242 + def pop_finished_req_ids(self):
243 + return self.batches.pop(0)
244 +
245 + def shutdown(self):
246 + pass
247 +
248 + worker = MoRIIOConnectorWorker.__new__(MoRIIOConnectorWorker)
249 + worker.is_producer = True
250 + worker.mode = MoRIIOMode.READ
251 + worker.world_size = 4
252 + worker.moriio_wrapper = FakeWrapper()
253 + worker.transfer_id_to_request_id = {"tx-fanin": "req-fanin"}
254 + worker._consumer_notification_counts = {}
255 + worker._completed_consumer_notifications = set()
256 +
257 + assert worker.get_finished() == (set(), set())
258 + assert worker._consumer_notification_counts == {"tx-fanin": 1}
259 +
260 + assert worker.get_finished() == ({"req-fanin"}, set())
261 + assert worker._consumer_notification_counts == {}
262 + assert worker._completed_consumer_notifications == {"tx-fanin"}
263 +
264 +
265 +def test_read_completion_sends_structured_release_with_consumer_tp_size():
266 + class DoneStatus:
267 + def Succeeded(self):
268 + return True
269 +
270 + def Failed(self):
271 + return False
272 +
273 + class FakeWrapper:
274 + def __init__(self):
275 + self.lock = threading.Lock()
276 + self.sent = []
277 +
278 + def send_notify(
279 + self,
280 + transfer_id,
281 + host,
282 + port,
283 + message_type=None,
284 + message_fields=None,
285 + ):
286 + self.sent.append((transfer_id, host, port, message_type, message_fields))
287 +
288 + def shutdown(self):
289 + pass
290 +
291 + worker = MoRIIOConnectorWorker.__new__(MoRIIOConnectorWorker)
292 + worker.world_size = 8
293 + worker.moriio_wrapper = FakeWrapper()
294 + worker._recving_transfers = {"req": [DoneStatus()]}
295 + worker._recving_transfers_callback_addr = {
296 + "req": ("127.0.0.1", "7000", "tx-release")
297 + }
298 +
299 + assert worker._pop_done_transfers() == {"tx-release"}
300 + assert worker.moriio_wrapper.sent == [
301 + (
302 + "tx-release",
303 + "127.0.0.1",
304 + "7000",
305 + "release",
306 + {"consumer_tp_size": 8},
307 + )
308 + ]
309 + assert worker._recving_transfers == {}
310 + assert worker._recving_transfers_callback_addr == {}
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_common.py
+6 -1
@@ -6,7 +6,7 @@ import threading
6 import time
7 from collections.abc import Iterator
8 from dataclasses import dataclass
9 -from typing import TYPE_CHECKING, Any
9 +from typing import TYPE_CHECKING, Any, NamedTuple
10
11 import msgspec
12 import regex as re
@@ -43,6 +43,11 @@ ReqId = str
43 TransferId = str
44
45
46 +class MoRIIOTransferAck(NamedTuple):
47 + transfer_id: TransferId
48 + consumer_tp_size: int = 1
49 +
50 +
51 @dataclass
52 class WriteTask:
53 request_id: ReqId
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_connector.py
+163 -10
@@ -6,6 +6,7 @@ import queue
6 import threading
7 import time
8 from collections import defaultdict
9 +from collections.abc import Collection
10 from concurrent.futures import Future, ThreadPoolExecutor
11 from typing import TYPE_CHECKING, Any
12
@@ -30,6 +31,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_common import (
31 MoRIIOConnectorMetadata,
32 MoRIIOConstants,
33 MoRIIOMode,
34 + MoRIIOTransferAck,
35 ReqId,
36 ReqMeta,
37 TransferId,
@@ -98,6 +100,92 @@ def is_moriio_available() -> bool:
100 return MoRIIO_enabled
101
102
103 +def get_moriio_remote_tp_rank(
104 + local_tp_rank: int, local_tp_size: int, remote_tp_size: int
105 +) -> int:
106 + if local_tp_size <= 0 or remote_tp_size <= 0:
107 + raise ValueError("TP sizes must be positive")
108 + if local_tp_rank < 0 or local_tp_rank >= local_tp_size:
109 + raise ValueError(
110 + f"local_tp_rank {local_tp_rank} must be in [0, {local_tp_size})"
111 + )
112 + if remote_tp_size == local_tp_size:
113 + return local_tp_rank
114 + if remote_tp_size > local_tp_size:
115 + if remote_tp_size % local_tp_size != 0:
116 + raise ValueError(
117 + f"remote tp_size {remote_tp_size} must be a multiple of local "
118 + f"tp_size {local_tp_size} for heterogeneous-TP P/D"
119 + )
120 + return local_tp_rank * (remote_tp_size // local_tp_size)
121 + if local_tp_size % remote_tp_size != 0:
122 + raise ValueError(
123 + f"local tp_size {local_tp_size} must be a multiple of remote "
124 + f"tp_size {remote_tp_size} for heterogeneous-TP P/D"
125 + )
126 + return local_tp_rank // (local_tp_size // remote_tp_size)
127 +
128 +
129 +def validate_moriio_heterogeneous_tp_kv_heads(
130 + local_tp_size: int,
131 + remote_tp_size: int,
132 + total_num_kv_heads: int,
133 + is_mla: bool,
134 +) -> None:
135 + if is_mla or local_tp_size == remote_tp_size:
136 + return
137 + if local_tp_size <= 0 or remote_tp_size <= 0 or total_num_kv_heads <= 0:
138 + raise ValueError("TP sizes and total_num_kv_heads must be positive")
139 + if min(local_tp_size, remote_tp_size) >= total_num_kv_heads:
140 + return
141 + raise NotImplementedError(
142 + "MoRIIO heterogeneous TP requires replicated KV heads on both "
143 + f"prefill and decode. Got total_num_kv_heads={total_num_kv_heads}, "
144 + f"local_tp_size={local_tp_size}, remote_tp_size={remote_tp_size}."
145 + )
146 +
147 +
148 +def get_moriio_expected_ack_count(producer_tp_size: int, consumer_tp_size: int) -> int:
149 + if producer_tp_size <= 0 or consumer_tp_size <= 0:
150 + raise ValueError("TP sizes must be positive")
151 + if consumer_tp_size <= producer_tp_size:
152 + return 1
153 + if consumer_tp_size % producer_tp_size != 0:
154 + raise ValueError(
155 + f"consumer tp_size {consumer_tp_size} must be a multiple of "
156 + f"producer tp_size {producer_tp_size} for heterogeneous-TP P/D"
157 + )
158 + return consumer_tp_size // producer_tp_size
159 +
160 +
161 +def resolve_moriio_transfer_ack(
162 + ack: MoRIIOTransferAck | TransferId,
163 + producer_tp_size: int,
164 + live_transfer_ids: Collection[TransferId],
165 + notification_counts: dict[TransferId, int],
166 + completed_transfer_ids: set[TransferId],
167 +) -> TransferId | None:
168 + if isinstance(ack, str):
169 + ack = MoRIIOTransferAck(ack)
170 + transfer_id = ack.transfer_id
171 + if transfer_id not in live_transfer_ids:
172 + return None
173 + if transfer_id in completed_transfer_ids:
174 + return None
175 +
176 + expected_acks = get_moriio_expected_ack_count(
177 + producer_tp_size, ack.consumer_tp_size
178 + )
179 + count = notification_counts.get(transfer_id, 0) + 1
180 + if count < expected_acks:
181 + notification_counts[transfer_id] = count
182 + return None
183 +
184 + notification_counts.pop(transfer_id, None)
185 + completed_transfer_ids.add(transfer_id)
186 + return transfer_id
187 +
188 +
189 class MoRIIOConnector(KVConnectorBase_V1):
190 def __init__(
191 self,
@@ -798,6 +886,11 @@ class MoRIIOConnectorWorker:
886 # Completions that arrived before transfer_id_to_request_id was populated.
887 # Retried each step until the mapping is established.
888 self._unmatched_write_completions: set[str] = set()
889 + # Producer-side READ-mode ACK fan-in. When decode TP is larger than
890 + # prefill TP, multiple decode ranks can read from one prefill rank and
891 + # notify the same transfer_id. Blocks are reusable only after all ACKs.
892 + self._consumer_notification_counts: dict[TransferId, int] = {}
893 + self._completed_consumer_notifications: set[TransferId] = set()
894
895 role = "producer" if self.is_producer else "consumer"
896 engine_suffix = (
@@ -1161,7 +1254,9 @@ class MoRIIOConnectorWorker:
1254 # a hack to keep us moving. We will switch when moving to etcd
1255 # or where we have a single ZMQ socket in the scheduler.
1256
1164 - port_offset = get_port_offset(remote_dp_rank, self.tp_rank)
1257 + port_offset = get_port_offset(
1258 + remote_dp_rank, self._remote_tp_rank(remote_tp_size)
1259 + )
1260 path = make_zmq_path("tcp", host, port + port_offset)
1261 logger.debug("handshake Querying metadata on path: %s", path)
1262
@@ -1226,6 +1321,9 @@ class MoRIIOConnectorWorker:
1321
1322 return {remote_agent_name}
1323
1324 + def _remote_tp_rank(self, remote_tp_size: int) -> int:
1325 + return get_moriio_remote_tp_rank(self.tp_rank, self.world_size, remote_tp_size)
1326 +
1327 def _background_moriio_handshake(
1328 self, req_id: ReqId, remote_engine_id: EngineId, meta: ReqMeta
1329 ):
@@ -1435,13 +1533,32 @@ class MoRIIOConnectorWorker:
1533 done_sending, done_recving = set(), set()
1534
1535 if self.is_producer:
1438 - # pop_finished_req_ids returns transfer_ids (the ZMQ payload sent
1439 - # by decode via send_notify); map back to req_ids for the scheduler.
1440 - finished_transfer_ids = self.moriio_wrapper.pop_finished_req_ids()
1536 + # pop_finished_req_ids returns release ACKs sent by decode. Keep
1537 + # duplicate ACKs because heterogeneous TP can fan multiple decode
1538 + # ranks into one prefill rank for the same transfer_id.
1539 + finished_acks = self.moriio_wrapper.pop_finished_req_ids()
1540 + resolved_transfer_ids: set[TransferId] = set()
1541 + for ack in finished_acks:
1542 + transfer_id = ack if isinstance(ack, str) else ack.transfer_id
1543 + if transfer_id not in self.transfer_id_to_request_id:
1544 + logger.warning(
1545 + "Could not find %s in transfer_id_to_request_id "
1546 + "lookup table. This could lead to a possible hang.",
1547 + transfer_id,
1548 + )
1549 + continue
1550 + resolved_transfer_id = resolve_moriio_transfer_ack(
1551 + ack,
1552 + producer_tp_size=self.world_size,
1553 + live_transfer_ids=self.transfer_id_to_request_id.keys(),
1554 + notification_counts=self._consumer_notification_counts,
1555 + completed_transfer_ids=(self._completed_consumer_notifications),
1556 + )
1557 + if resolved_transfer_id is not None:
1558 + resolved_transfer_ids.add(resolved_transfer_id)
1559 done_sending = {
1560 self.transfer_id_to_request_id[xfer_id]
1443 - for xfer_id in finished_transfer_ids
1444 - if xfer_id in self.transfer_id_to_request_id
1561 + for xfer_id in resolved_transfer_ids
1562 }
1563 else:
1564 if self.mode == MoRIIOMode.WRITE:
@@ -1486,7 +1603,13 @@ class MoRIIOConnectorWorker:
1603 if last.Succeeded():
1604 host, port, xfer_id = self._recving_transfers_callback_addr[req_id]
1605 done_req_ids.add(xfer_id)
1489 - self.moriio_wrapper.send_notify(xfer_id, host, port)
1606 + self.moriio_wrapper.send_notify(
1607 + xfer_id,
1608 + host,
1609 + port,
1610 + message_type="release",
1611 + message_fields={"consumer_tp_size": self.world_size},
1612 + )
1613 to_remove.append(req_id)
1614 elif last.Failed():
1615 logger.error(
@@ -1499,7 +1622,13 @@ class MoRIIOConnectorWorker:
1622 )
1623 host, port, xfer_id = self._recving_transfers_callback_addr[req_id]
1624 try:
1502 - self.moriio_wrapper.send_notify(xfer_id, host, port)
1625 + self.moriio_wrapper.send_notify(
1626 + xfer_id,
1627 + host,
1628 + port,
1629 + message_type="release",
1630 + message_fields={"consumer_tp_size": self.world_size},
1631 + )
1632 except Exception:
1633 logger.exception(
1634 "Failed to send error notification for request %s",
@@ -1585,6 +1714,15 @@ class MoRIIOConnectorWorker:
1714 """
1715 self.transfer_id_to_request_id = metadata.transfer_id_to_request_id
1716 if self.is_producer:
1717 + live_transfer_ids = set(self.transfer_id_to_request_id)
1718 + self._consumer_notification_counts = {
1719 + transfer_id: count
1720 + for transfer_id, count in self._consumer_notification_counts.items()
1721 + if transfer_id in live_transfer_ids
1722 + }
1723 + self._completed_consumer_notifications.intersection_update(
1724 + live_transfer_ids
1725 + )
1726 self.moriio_wrapper.async_wait_reqid()
1727 return
1728 if self.mode == MoRIIOMode.WRITE:
@@ -1663,6 +1801,7 @@ class MoRIIOConnectorWorker:
1801 remote_block_ids=meta.remote_block_ids,
1802 remote_host=meta.remote_host,
1803 remote_notify_port=meta.remote_notify_port,
1804 + remote_tp_size=meta.tp_size,
1805 )
1806
1807 def _write_blocks_for_req(self, req_id: ReqId, meta: ReqMeta, layer_name, kv_layer):
@@ -1756,6 +1895,7 @@ class MoRIIOConnectorWorker:
1895 local_block_ids: list[int],
1896 remote_block_ids: list[int],
1897 remote_moriio_meta: MoRIIOAgentMetadata,
1898 + remote_tp_size: int | None = None,
1899 ) -> tuple[list[int], list[int], list[int]]:
1900 """Compute transfer offsets for block data.
1901
@@ -1767,6 +1907,14 @@ class MoRIIOConnectorWorker:
1907 Returns:
1908 Tuple of (local_offsets, remote_offsets, transfer_sizes)
1909 """
1910 + validate_moriio_heterogeneous_tp_kv_heads(
1911 + local_tp_size=self.world_size,
1912 + remote_tp_size=(
1913 + remote_tp_size if remote_tp_size is not None else self.world_size
1914 + ),
1915 + total_num_kv_heads=self.model_config.get_total_num_kv_heads(),
1916 + is_mla=self._is_mla_cache_layer(layer_name),
1917 + )
1918 return compute_block_transfer_offsets(
1919 layer_name=layer_name,
1920 kv_cache=self.kv_caches[layer_name],
@@ -1788,6 +1936,7 @@ class MoRIIOConnectorWorker:
1936 transfer_id: str,
1937 remote_host: str,
1938 remote_notify_port: int,
1939 + remote_tp_size: int,
1940 ) -> None:
1941 if self.mode == MoRIIOMode.WRITE:
1942 return
@@ -1800,7 +1949,11 @@ class MoRIIOConnectorWorker:
1949 layer_name
1950 )
1951 offs = self._compute_block_transfer_offsets(
1803 - layer_name, local_block_ids, remote_block_ids, remote_moriio_meta
1952 + layer_name,
1953 + local_block_ids,
1954 + remote_block_ids,
1955 + remote_moriio_meta,
1956 + remote_tp_size=remote_tp_size,
1957 )
1958 # TODO : apply multi-session batch-read when moriio support it
1959 transfer_status = self.moriio_wrapper.read_remote_data(
@@ -1810,6 +1963,6 @@ class MoRIIOConnectorWorker:
1963 self._recving_transfers[request_id].append(transfer_status)
1964 self._recving_transfers_callback_addr[request_id] = (
1965 remote_host,
1813 - str(remote_notify_port + self.tp_rank),
1966 + str(remote_notify_port + self._remote_tp_rank(remote_tp_size)),
1967 transfer_id,
1968 )
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_engine.py
+27 -11
@@ -27,6 +27,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_common import (
27 MoRIIOAgentMetadata,
28 MoRIIOConstants,
29 MoRIIOError,
30 + MoRIIOTransferAck,
31 RemoteAllocInfo,
32 TransferError,
33 TransferId,
@@ -261,7 +262,7 @@ class MoRIIOWriter:
262 """Mark a request done so its blocks are freed, even on transfer failure."""
263 wrapper = self.worker.moriio_wrapper
264 with wrapper.lock:
264 - wrapper.done_req_ids.append(transfer_id)
265 + wrapper.done_req_ids.append(MoRIIOTransferAck(transfer_id))
266 wrapper.done_remote_allocate_req_dict.pop(transfer_id, None)
267 wrapper._mark_transfer_terminal_locked(transfer_id)
268 self._clear_transfer_state(transfer_id)
@@ -466,7 +467,9 @@ class MoRIIOWriter:
467 )
468 # mark request as done, then we can free the blocks
469 with self.worker.moriio_wrapper.lock:
469 - self.worker.moriio_wrapper.done_req_ids.append(transfer_id)
470 + self.worker.moriio_wrapper.done_req_ids.append(
471 + MoRIIOTransferAck(transfer_id)
472 + )
473 self.worker.moriio_wrapper.done_remote_allocate_req_dict.pop(
474 transfer_id, None
475 )
@@ -508,7 +511,7 @@ class MoRIIOWrapper:
511 self.remote_engine_ip: str | None = None
512 self.notify_port: int | None = None
513 self.lock = threading.Lock()
511 - self.done_req_ids: list[str] = []
514 + self.done_req_ids: list[MoRIIOTransferAck] = []
515 self.done_remote_allocate_req_dict: dict[TransferId, RemoteAllocInfo] = {}
516 self.done_write_cache_req_ids: list[str] = []
517 self._terminal_transfer_ids: OrderedDict[TransferId, None] = OrderedDict()
@@ -784,15 +787,20 @@ class MoRIIOWrapper:
787 "Only prefill can get transfer release messages"
788 )
789 transfer_id = data["transfer_id"]
790 + consumer_tp_size = int(data.get("consumer_tp_size", 1))
791 + if consumer_tp_size <= 0:
792 + raise MoRIIOError(
793 + f"Invalid consumer_tp_size in release message: {consumer_tp_size}"
794 + )
795 with self.lock:
788 - self.done_req_ids.append(transfer_id)
796 + self.done_req_ids.append(MoRIIOTransferAck(transfer_id, consumer_tp_size))
797 self.done_remote_allocate_req_dict.pop(transfer_id, None)
798 self._mark_transfer_terminal_locked(transfer_id)
799
800 def _handle_completion_message(self, msg: str):
801 with self.lock:
802 if get_role() == ROLE.PRODUCER:
795 - self.done_req_ids.append(msg)
803 + self.done_req_ids.append(MoRIIOTransferAck(msg))
804 self.done_remote_allocate_req_dict.pop(msg, None)
805 self._mark_transfer_terminal_locked(msg)
806 else:
@@ -808,7 +816,12 @@ class MoRIIOWrapper:
816 self._terminal_transfer_ids.popitem(last=False)
817
818 def send_notify(
811 - self, req_ids, remote_ip, remote_port, message_type: str | None = None
819 + self,
820 + req_ids,
821 + remote_ip,
822 + remote_port,
823 + message_type: str | None = None,
824 + message_fields: dict[str, Any] | None = None,
825 ):
826 if not remote_ip or not remote_port:
827 logger.warning("Missing remote_ip or remote_port for notification")
@@ -836,18 +849,21 @@ class MoRIIOWrapper:
849 if message_type is None:
850 sock.send(req_id.encode("utf-8"))
851 else:
839 - sock.send(
840 - msgpack.dumps({"type": message_type, "transfer_id": req_id})
841 - )
852 + payload = {"type": message_type, "transfer_id": req_id}
853 + if message_fields:
854 + payload.update(message_fields)
855 + sock.send(msgpack.dumps(payload))
856 except Exception as e:
857 logger.error("Failed to send notification to %s: %s", path, e)
858 self.paths.pop(path, None)
859 raise
860
861 def pop_finished_req_ids(self):
848 - # producer invocation: get the set of completed requests at the decode
862 + # Producer invocation: return every completion message since the last
863 + # call. Do not dedupe: heterogeneous TP can produce multiple release
864 + # ACKs for the same transfer_id and the caller must count each one.
865 with self.lock:
850 - done_send = set(self.done_req_ids)
866 + done_send = list(self.done_req_ids)
867 self.done_req_ids = []
868 return done_send
869