[ROCm][P/D] Fix MoRIIO WRITE mode for mixed KV layouts (#46290)

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>

Tan Pin Siang committed Jun 23, 2026 at 12:12 UTC 7e47fb72b568bd18062ca14e71271dd32edb372c
6 files changed +1104 -195
tests/v1/kv_connector/unit/test_moriio_connector.py
+75 -2
@@ -1,6 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import importlib.util
4 +import socket
5 import uuid
6 from unittest.mock import MagicMock, patch
7
@@ -9,7 +10,6 @@ import pytest
10 import torch
11 import zmq
12
12 -from tests.conftest import _find_free_port
13 from vllm.config import (
14 CacheConfig,
15 DeviceConfig,
@@ -23,12 +23,14 @@ from vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_common import (
23 MoRIIOAgentMetadata,
24 MoRIIOConnectorMetadata,
25 MoRIIOConstants,
26 + MoRIIOMode,
27 resolve_host_ip,
28 zmq_ctx,
29 )
30 from vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_connector import (
31 KVConnectorRole,
32 MoRIIOConnector,
33 + MoRIIOConnectorScheduler,
34 MoRIIOConnectorWorker,
35 )
36 from vllm.platforms import current_platform
@@ -46,6 +48,12 @@ from vllm.v1.kv_cache_interface import (
48 from .utils import create_request, create_scheduler
49
50
51 +def _find_free_port() -> int:
52 + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
53 + sock.bind(("", 0))
54 + return sock.getsockname()[1]
55 +
56 +
57 def _make_test_kv_cache_config() -> KVCacheConfig:
58 layer_names = ["layer0", "layer1", "layer2"]
59 return KVCacheConfig(
@@ -121,6 +129,16 @@ def _setup_kv_transfer_request(
129 return request
130
131
132 +def _write_consumer_scheduler_for_finished_request(tp_size: int = 2):
133 + scheduler = MoRIIOConnectorScheduler.__new__(MoRIIOConnectorScheduler)
134 + scheduler.is_producer = False
135 + scheduler.mode = MoRIIOMode.WRITE
136 + scheduler.tp_size = tp_size
137 + scheduler._reqs_need_recv = {}
138 + scheduler.unmap_request_id = MagicMock()
139 + return scheduler
140 +
141 +
142 class FakeMoRIIOWrapper:
143 # A fake MoRIIOWrapper for testing purposes
144 def __init__(self, *args, **kwargs):
@@ -177,7 +195,7 @@ class FakeMoRIIOWrapper:
195 def _handle_completion_message(self, msg: str):
196 pass
197
180 - def send_notify(self, req_ids, remote_ip, remote_port):
198 + def send_notify(self, req_ids, remote_ip, remote_port, message_type=None):
199 pass
200
201 def pop_finished_req_ids(self):
@@ -434,6 +452,61 @@ def test_read_mode_loads_remote_block_ids():
452 assert block_id == block.block_id, f"{block_id} != {block.block_id}"
453
454
455 +@pytest.mark.parametrize(
456 + ("transfer_id", "extra_params", "expected_notifications"),
457 + [
458 + pytest.param(
459 + "xfer-7",
460 + {"remote_host": "127.0.0.1", "remote_notify_port": 7000},
461 + [
462 + ("xfer-7", "127.0.0.1", 7000),
463 + ("xfer-7", "127.0.0.1", 7001),
464 + ],
465 + id="address-available",
466 + ),
467 + pytest.param("xfer-8", {}, [], id="address-unavailable-plain-id"),
468 + ],
469 +)
470 +def test_write_mode_finished_before_alloc_releases_prefill_blocks(
471 + transfer_id, extra_params, expected_notifications
472 +):
473 + scheduler = _write_consumer_scheduler_for_finished_request(tp_size=2)
474 + notifications = []
475 + scheduler._send_transfer_release = lambda transfer_id, host, port: (
476 + notifications.append((transfer_id, host, port))
477 + )
478 + request = create_request(request_id=7, do_remote_prefill=True)
479 + request.request_id = "plain-decode-id"
480 + request.kv_transfer_params = {
481 + "do_remote_prefill": True,
482 + "do_remote_decode": False,
483 + "transfer_id": transfer_id,
484 + } | extra_params
485 +
486 + delay_free, new_params = scheduler.request_finished(request, block_ids=[])
487 +
488 + assert not delay_free
489 + assert new_params is None
490 + assert request.kv_transfer_params["do_remote_prefill"] is False
491 + assert scheduler._reqs_need_recv == {}
492 + assert notifications == expected_notifications
493 +
494 +
495 +def test_send_transfer_release_sends_structured_release_message():
496 + scheduler = _write_consumer_scheduler_for_finished_request()
497 + path = make_zmq_path("tcp", "127.0.0.1", 7000)
498 + sock = MagicMock()
499 + scheduler.paths = {path: sock}
500 +
501 + scheduler._send_transfer_release("xfer-7", "127.0.0.1", 7000)
502 +
503 + payload = sock.send.call_args.args[0]
504 + assert msgspec.msgpack.decode(payload) == {
505 + "type": "release",
506 + "transfer_id": "xfer-7",
507 + }
508 +
509 +
510 @pytest.mark.skipif(
511 not aiter_available, reason="Requires aiter package for ROCm FlashAttention backend"
512 )
tests/v1/kv_connector/unit/test_moriio_kv_layout.py
+536 -41
@@ -2,7 +2,11 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 import importlib.util
5 +import threading
6 +from collections import OrderedDict, defaultdict
7 +from queue import Queue
8 from types import SimpleNamespace
9 +from typing import Any
10
11 import pytest
12 import torch
@@ -19,16 +23,33 @@ if not (current_platform.is_rocm() and mori_available):
23 allow_module_level=True,
24 )
25
26 +moriio_common = importlib.import_module(
27 + "vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_common"
28 +)
29 +moriio_engine = importlib.import_module(
30 + "vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_engine"
31 +)
32 moriio_layout = importlib.import_module(
33 "vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_layout"
34 )
35 +msgpack = importlib.import_module("msgpack")
36 +
37 +ROLE = moriio_common.ROLE
38 +MoRIIOError = moriio_common.MoRIIOError
39 +RemoteAllocInfo = moriio_common.RemoteAllocInfo
40 +WriteTask = moriio_common.WriteTask
41 +set_role = moriio_common.set_role
42 +MoRIIOWrapper = moriio_engine.MoRIIOWrapper
43 +MoRIIOWriter = moriio_engine.MoRIIOWriter
44
45
27 -def _full_spec(block_size: int = 4) -> FullAttentionSpec:
46 +def _full_spec(
47 + block_size: int = 4, num_kv_heads: int = 2, head_size: int = 3
48 +) -> FullAttentionSpec:
49 return FullAttentionSpec(
50 block_size=block_size,
30 - num_kv_heads=2,
31 - head_size=3,
51 + num_kv_heads=num_kv_heads,
52 + head_size=head_size,
53 dtype=torch.bfloat16,
54 )
55
@@ -59,55 +80,216 @@ def _remote_meta(num_blocks: int = 16) -> SimpleNamespace:
80 return SimpleNamespace(num_blocks=num_blocks)
81
82
62 -def test_separated_kv_layout_uses_kv_axis_zero_and_block_axis_one():
63 - cache = torch.empty((2, 8, 4, 2, 3), dtype=torch.bfloat16)
64 - worker = _worker({"layer": cache}, {"layer": _full_spec()})
65 -
66 - geometry = moriio_layout.get_layer_transfer_geometry(
67 - "layer", cache, worker.layer_to_spec, remote_num_blocks=16
83 +def _writer_with_fake_worker(fake_worker: Any) -> Any:
84 + writer = MoRIIOWriter.__new__(MoRIIOWriter)
85 + writer._worker_ref = lambda: fake_worker
86 + writer._write_task_q = Queue()
87 + writer._write_state_lock = threading.Lock()
88 + writer._scheduled_writes = defaultdict(int)
89 + writer._scheduled_layers = defaultdict(set)
90 + writer._sealed_writes = {}
91 + writer.ensure_worker_started = lambda: None
92 + return writer
93 +
94 +
95 +def _wrapper_for_messages() -> Any:
96 + wrapper = MoRIIOWrapper.__new__(MoRIIOWrapper)
97 + wrapper.lock = threading.Lock()
98 + wrapper.done_remote_allocate_req_dict = {}
99 + wrapper.done_req_ids = []
100 + wrapper.done_write_cache_req_ids = []
101 + wrapper._terminal_transfer_ids = OrderedDict()
102 + return wrapper
103 +
104 +
105 +def _write_task(layer_name: str, transfer_id: str = "xfer") -> Any:
106 + return WriteTask(
107 + request_id="req",
108 + transfer_id=transfer_id,
109 + dst_engine_id="remote-engine",
110 + local_block_ids=[1, 3],
111 + remote_block_ids_hint=None,
112 + layer_name=layer_name,
113 + event=None,
114 + remote_notify_port=7000,
115 + remote_ip="127.0.0.1",
116 )
69 - assert geometry.block_stride == 24
70 - assert geometry.local_kv_stride == 192
71 - assert geometry.remote_kv_stride == 384
72 - assert geometry.split_kv_regions
117
74 - assert moriio_layout.compute_block_transfer_offsets(
75 - "layer", cache, worker.layer_to_spec, [1, 3], [4, 5], _remote_meta().num_blocks
76 - ) == ([48, 144, 432, 528], [192, 240, 960, 1008], [48, 48, 48, 48])
118
78 -
79 -def test_interleaved_kv_layout_uses_block_axis_zero_and_kv_axis_one():
80 - cache = torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16)
81 - worker = _worker({"layer": cache}, {"layer": _full_spec()})
119 +@pytest.mark.parametrize(
120 + ("shape", "spec", "remote_num_blocks", "expected_geometry", "expected_offsets"),
121 + [
122 + pytest.param(
123 + (2, 8, 4, 2, 3),
124 + _full_spec(),
125 + 16,
126 + {
127 + "block_stride": 24,
128 + "local_kv_stride": 192,
129 + "remote_kv_stride": 384,
130 + "split_kv_regions": True,
131 + },
132 + ([48, 144, 432, 528], [192, 240, 960, 1008], [48, 48, 48, 48]),
133 + id="separated",
134 + ),
135 + pytest.param(
136 + (8, 2, 4, 2, 3),
137 + _full_spec(),
138 + 16,
139 + {
140 + "block_stride": 48,
141 + "local_kv_stride": 24,
142 + "remote_kv_stride": 24,
143 + "split_kv_regions": False,
144 + },
145 + ([96, 288], [384, 480], [96, 96]),
146 + id="interleaved",
147 + ),
148 + pytest.param(
149 + (2, 8, 2, 4, 3),
150 + _full_spec(),
151 + 16,
152 + {
153 + "block_size": 4,
154 + "block_stride": 24,
155 + "local_kv_stride": 192,
156 + "remote_kv_stride": 384,
157 + "split_kv_regions": True,
158 + },
159 + ([48, 144, 432, 528], [192, 240, 960, 1008], [48, 48, 48, 48]),
160 + id="shuffled-separated",
161 + ),
162 + pytest.param(
163 + (8, 2, 2, 4, 3),
164 + _full_spec(),
165 + 16,
166 + {
167 + "block_size": 4,
168 + "block_stride": 48,
169 + "local_kv_stride": 24,
170 + "remote_kv_stride": 24,
171 + "split_kv_regions": False,
172 + },
173 + ([96, 288], [384, 480], [96, 96]),
174 + id="shuffled-interleaved",
175 + ),
176 + pytest.param(
177 + (2, 16, 2, 2, 3),
178 + _full_spec(),
179 + 16,
180 + {
181 + "num_blocks": 8,
182 + "block_size": 4,
183 + "block_stride": 24,
184 + "local_kv_stride": 192,
185 + "remote_kv_stride": 384,
186 + "split_kv_regions": True,
187 + },
188 + ([48, 144, 432, 528], [192, 240, 960, 1008], [48, 48, 48, 48]),
189 + id="separated-kernel-blocks",
190 + ),
191 + pytest.param(
192 + (16, 2, 2, 2, 3),
193 + _full_spec(),
194 + 16,
195 + {
196 + "num_blocks": 8,
197 + "block_size": 4,
198 + "block_stride": 48,
199 + "local_kv_stride": None,
200 + "remote_kv_stride": None,
201 + "transfers_per_block": 1,
202 + },
203 + ([96, 288], [384, 480], [96, 96]),
204 + id="interleaved-kernel-blocks",
205 + ),
206 + pytest.param(
207 + (2, 32, 8, 2, 3),
208 + _full_spec(block_size=16, num_kv_heads=8),
209 + 8,
210 + {
211 + "num_blocks": 4,
212 + "block_size": 16,
213 + "block_len": 768,
214 + "block_stride": 384,
215 + "local_kv_stride": 1536,
216 + "remote_kv_stride": 3072,
217 + "split_kv_regions": True,
218 + },
219 + (
220 + [768, 2304, 3840, 5376],
221 + [3072, 3840, 9216, 9984],
222 + [768, 768, 768, 768],
223 + ),
224 + id="separated-kernel-axis-from-spec",
225 + ),
226 + pytest.param(
227 + (32, 2, 8, 2, 3),
228 + _full_spec(block_size=16, num_kv_heads=8),
229 + 8,
230 + {
231 + "num_blocks": 4,
232 + "block_size": 16,
233 + "block_len": 1536,
234 + "block_stride": 768,
235 + "local_kv_stride": None,
236 + "remote_kv_stride": None,
237 + "transfers_per_block": 1,
238 + },
239 + ([1536, 4608], [6144, 7680], [1536, 1536]),
240 + id="interleaved-kernel-axis-from-spec",
241 + ),
242 + pytest.param(
243 + (8, 4, 3),
244 + _mla_spec(),
245 + 16,
246 + {
247 + "block_stride": 12,
248 + "local_kv_stride": None,
249 + "remote_kv_stride": None,
250 + "transfers_per_block": 1,
251 + },
252 + ([24, 72], [96, 120], [24, 24]),
253 + id="mla-key-only",
254 + ),
255 + ],
256 +)
257 +def test_supported_layouts_compute_expected_geometry_and_offsets(
258 + shape, spec, remote_num_blocks, expected_geometry, expected_offsets
259 +):
260 + cache = torch.empty(shape, dtype=torch.bfloat16)
261 + worker = _worker({"layer": cache}, {"layer": spec})
262
263 geometry = moriio_layout.get_layer_transfer_geometry(
84 - "layer", cache, worker.layer_to_spec, remote_num_blocks=16
264 + "layer", cache, worker.layer_to_spec, remote_num_blocks=remote_num_blocks
265 )
86 - assert geometry.block_stride == 48
87 - assert geometry.local_kv_stride == 24
88 - assert geometry.remote_kv_stride == 24
89 - assert not geometry.split_kv_regions
90 -
91 - assert moriio_layout.compute_block_transfer_offsets(
92 - "layer", cache, worker.layer_to_spec, [1, 3], [4, 5], _remote_meta().num_blocks
93 - ) == ([96, 288], [384, 480], [96, 96])
266 + for field, expected in expected_geometry.items():
267 + assert getattr(geometry, field) == expected
268
269 + assert (
270 + moriio_layout.compute_block_transfer_offsets(
271 + "layer",
272 + cache,
273 + worker.layer_to_spec,
274 + [1, 3],
275 + [4, 5],
276 + remote_num_blocks,
277 + )
278 + == expected_offsets
279 + )
280
96 -def test_mla_key_only_layout_transfers_one_slab_per_block():
97 - cache = torch.empty((8, 4, 3), dtype=torch.bfloat16)
98 - worker = _worker({"layer": cache}, {"layer": _mla_spec()})
281
100 - geometry = moriio_layout.get_layer_transfer_geometry(
101 - "layer", cache, worker.layer_to_spec, remote_num_blocks=16
282 +def test_kernel_block_layout_without_spec_dimensions_rejects_ambiguous_axes():
283 + cache = torch.empty((2, 32, 8, 2, 3), dtype=torch.bfloat16)
284 + worker = _worker(
285 + {"layer": cache},
286 + {"layer": SimpleNamespace(block_size=16)},
287 )
103 - assert geometry.block_stride == 12
104 - assert geometry.local_kv_stride is None
105 - assert geometry.remote_kv_stride is None
106 - assert geometry.transfers_per_block == 1
288
108 - assert moriio_layout.compute_block_transfer_offsets(
109 - "layer", cache, worker.layer_to_spec, [1, 3], [4, 5], _remote_meta().num_blocks
110 - ) == ([24, 72], [96, 120], [24, 24])
289 + with pytest.raises(ValueError, match="Ambiguous MoRIIO kernel-block"):
290 + moriio_layout.get_layer_transfer_geometry(
291 + "layer", cache, worker.layer_to_spec, remote_num_blocks=8
292 + )
293
294
295 def test_mixed_layers_compute_distinct_offsets_per_layer():
@@ -155,6 +337,319 @@ def test_mixed_layers_compute_distinct_offsets_per_layer():
337 assert interleaved != indexer
338
339
340 +def test_write_transfer_plan_caches_offsets_per_geometry():
341 + kv_caches = {
342 + "dense0": torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16),
343 + "dense1": torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16),
344 + "indexer": torch.empty((8, 4, 3), dtype=torch.bfloat16),
345 + }
346 + calls: list[str] = []
347 +
348 + class FakeWorker:
349 + kv_caches: dict[str, torch.Tensor]
350 + layer_name_to_local_kv_cache_metadata: dict[str, list[Any]]
351 +
352 + def _compute_block_transfer_offsets(
353 + self, layer_name, local_block_ids, remote_block_ids, remote_moriio_meta
354 + ):
355 + calls.append(layer_name)
356 + call_id = len(calls)
357 + return ([call_id], [call_id + 10], [call_id + 20])
358 +
359 + fake_worker = FakeWorker()
360 + fake_worker.kv_caches = kv_caches
361 + fake_worker.layer_name_to_local_kv_cache_metadata = {name: [] for name in kv_caches}
362 + writer = MoRIIOWriter.__new__(MoRIIOWriter)
363 + writer._worker_ref = lambda: fake_worker
364 + request_info = RemoteAllocInfo(block_ids=[4, 5])
365 + remote_meta = _remote_meta()
366 +
367 + dense0_plan = writer._prepare_transfer_plan(
368 + SimpleNamespace(
369 + layer_name="dense0",
370 + local_block_ids=[1, 3],
371 + request_id="req",
372 + transfer_id="xfer",
373 + ),
374 + request_info,
375 + remote_meta,
376 + )
377 + dense1_plan = writer._prepare_transfer_plan(
378 + SimpleNamespace(
379 + layer_name="dense1",
380 + local_block_ids=[1, 3],
381 + request_id="req",
382 + transfer_id="xfer",
383 + ),
384 + request_info,
385 + remote_meta,
386 + )
387 + indexer_plan = writer._prepare_transfer_plan(
388 + SimpleNamespace(
389 + layer_name="indexer",
390 + local_block_ids=[1, 3],
391 + request_id="req",
392 + transfer_id="xfer",
393 + ),
394 + request_info,
395 + remote_meta,
396 + )
397 +
398 + assert calls == ["dense0", "indexer"]
399 + assert dense0_plan.transfer_local_offsets == [1]
400 + assert dense1_plan.transfer_local_offsets == [1]
401 + assert indexer_plan.transfer_local_offsets == [2]
402 + assert len(request_info.transfer_offsets) == 2
403 +
404 +
405 +def test_write_scheduler_deduplicates_layers_and_seals_expected_count():
406 + request_info = RemoteAllocInfo(block_ids=[4, 5])
407 + wrapper = _wrapper_for_messages()
408 + wrapper.done_remote_allocate_req_dict["xfer"] = request_info
409 + writer = _writer_with_fake_worker(SimpleNamespace(moriio_wrapper=wrapper))
410 +
411 + assert writer.schedule_write(_write_task("dense0"))
412 + assert not writer.schedule_write(_write_task("dense0"))
413 + assert writer.schedule_write(_write_task("indexer"))
414 +
415 + assert writer._write_task_q.qsize() == 2
416 + writer.seal_pending_transfers()
417 +
418 + assert request_info.writes_expected == 2
419 + assert writer._sealed_writes["xfer"] == 2
420 +
421 +
422 +def test_write_completion_notifies_once_after_all_sealed_writes_finish():
423 + class FakeWrapper:
424 + def __init__(self):
425 + self.done_remote_allocate_req_dict = {}
426 + self.done_req_ids = []
427 + self.lock = threading.Lock()
428 + self.notifications = []
429 + self.wait_count = 0
430 + self.waited_statuses = []
431 + self._terminal_transfer_ids = OrderedDict()
432 +
433 + def waiting_for_transfer_complete(self, transfer_statuses=None):
434 + self.wait_count += 1
435 + self.waited_statuses.append(list(transfer_statuses or []))
436 +
437 + def _is_transfer_terminal_locked(self, transfer_id):
438 + return transfer_id in self._terminal_transfer_ids
439 +
440 + def _mark_transfer_terminal_locked(self, transfer_id):
441 + self._terminal_transfer_ids[transfer_id] = None
442 +
443 + def send_notify(self, transfer_id, remote_ip, remote_port, message_type=None):
444 + self.notifications.append(
445 + (transfer_id, remote_ip, remote_port, message_type)
446 + )
447 +
448 + wrapper = FakeWrapper()
449 + request_info = RemoteAllocInfo(block_ids=[4, 5], writes_expected=2)
450 + request_info.transfer_statuses.extend(["status-a", "status-b"])
451 + request_info.completion_request_id = "req"
452 + request_info.completion_remote_notify_port = 7000
453 + request_info.completion_remote_ip = "127.0.0.1"
454 + wrapper.done_remote_allocate_req_dict["xfer"] = request_info
455 + writer = _writer_with_fake_worker(
456 + SimpleNamespace(moriio_wrapper=wrapper, tp_rank=2)
457 + )
458 + writer._scheduled_writes["xfer"] = 2
459 + writer._scheduled_layers["xfer"] = {"dense0", "indexer"}
460 + writer._sealed_writes["xfer"] = 2
461 +
462 + writer._mark_write_done("xfer", request_info)
463 + assert wrapper.notifications == []
464 + writer._mark_write_done("xfer", request_info)
465 + writer._finalize_if_complete("xfer", request_info)
466 +
467 + assert wrapper.notifications == [("xfer", "127.0.0.1", 7002, "write_done")]
468 + assert wrapper.done_req_ids == ["xfer"]
469 + assert wrapper.done_remote_allocate_req_dict == {}
470 + assert wrapper.wait_count == 1
471 + assert wrapper.waited_statuses == [["status-a", "status-b"]]
472 + assert request_info.transfer_statuses == []
473 + assert wrapper._is_transfer_terminal_locked("xfer")
474 +
475 +
476 +def test_moriio_wrapper_waits_scoped_statuses_without_global_drain():
477 + class FakeStatus:
478 + def __init__(self):
479 + self.checked = 0
480 +
481 + def Succeeded(self):
482 + self.checked += 1
483 + return True
484 +
485 + def Failed(self):
486 + return False
487 +
488 + wrapper = MoRIIOWrapper.__new__(MoRIIOWrapper)
489 + wrapper.lock = threading.Lock()
490 + wrapper._transfer_timeout = 1
491 + global_status = FakeStatus()
492 + scoped_status = FakeStatus()
493 + wrapper.transfer_status = [global_status]
494 +
495 + wrapper.waiting_for_transfer_complete([scoped_status])
496 +
497 + assert scoped_status.checked == 1
498 + assert global_status.checked == 0
499 + assert wrapper.transfer_status == [global_status]
500 +
501 +
502 +def test_write_failure_marks_terminal_and_clears_scheduled_state():
503 + wrapper = _wrapper_for_messages()
504 + wrapper.done_remote_allocate_req_dict["xfer"] = RemoteAllocInfo(block_ids=[4, 5])
505 + writer = _writer_with_fake_worker(SimpleNamespace(moriio_wrapper=wrapper))
506 + writer._scheduled_writes["xfer"] = 2
507 + writer._scheduled_layers["xfer"] = {"dense0", "indexer"}
508 + writer._sealed_writes["xfer"] = 2
509 +
510 + writer._mark_request_done("xfer")
511 +
512 + assert wrapper.done_req_ids == ["xfer"]
513 + assert wrapper.done_remote_allocate_req_dict == {}
514 + assert wrapper._is_transfer_terminal_locked("xfer")
515 + assert "xfer" not in writer._scheduled_writes
516 + assert "xfer" not in writer._scheduled_layers
517 + assert "xfer" not in writer._sealed_writes
518 +
519 +
520 +def test_schedule_write_rejects_terminal_transfer_without_recreating_state():
521 + wrapper = _wrapper_for_messages()
522 + wrapper.done_remote_allocate_req_dict["xfer"] = RemoteAllocInfo(block_ids=[4, 5])
523 + writer = _writer_with_fake_worker(SimpleNamespace(moriio_wrapper=wrapper))
524 + writer._scheduled_writes["xfer"] = 1
525 + writer._scheduled_layers["xfer"] = {"dense0"}
526 + writer._sealed_writes["xfer"] = 1
527 +
528 + writer._mark_request_done("xfer")
529 +
530 + assert not writer.schedule_write(_write_task("indexer"))
531 + assert writer._write_task_q.empty()
532 + assert "xfer" not in writer._scheduled_writes
533 + assert "xfer" not in writer._scheduled_layers
534 + assert "xfer" not in writer._sealed_writes
535 +
536 +
537 +def test_late_remote_blocks_message_is_ignored_after_transfer_done():
538 + set_role(ROLE.PRODUCER)
539 + wrapper = _wrapper_for_messages()
540 + with wrapper.lock:
541 + wrapper._mark_transfer_terminal_locked("xfer")
542 +
543 + wrapper._handle_message(
544 + msgpack.dumps(
545 + {
546 + "type": "remote_blocks",
547 + "req_id": "req",
548 + "transfer_id": "xfer",
549 + "block_notify_list": [4, 5],
550 + "decode_rank": 3,
551 + }
552 + )
553 + )
554 +
555 + assert "xfer" not in wrapper.done_remote_allocate_req_dict
556 +
557 +
558 +@pytest.mark.parametrize(
559 + ("role", "payload", "expected"),
560 + [
561 + pytest.param(
562 + ROLE.PRODUCER,
563 + msgpack.dumps(
564 + {
565 + "type": "remote_blocks",
566 + "req_id": "req",
567 + "transfer_id": "xfer",
568 + "block_notify_list": [4, 5],
569 + "decode_rank": 3,
570 + }
571 + ),
572 + "remote_blocks",
573 + id="remote-blocks",
574 + ),
575 + pytest.param(
576 + ROLE.CONSUMER,
577 + msgpack.dumps({"type": "write_done", "transfer_id": "xfer"}),
578 + "write_done",
579 + id="write-done",
580 + ),
581 + pytest.param(
582 + ROLE.PRODUCER,
583 + msgpack.dumps({"type": "release", "transfer_id": "xfer"}),
584 + "release",
585 + id="release",
586 + ),
587 + pytest.param(None, b"xfer", "plain", id="plain-string"),
588 + ],
589 +)
590 +def test_moriio_wrapper_routes_valid_messages(role, payload, expected):
591 + wrapper = _wrapper_for_messages()
592 + completions: list[str] = []
593 + if role is not None:
594 + set_role(role)
595 + if expected == "plain":
596 + wrapper._handle_completion_message = completions.append
597 +
598 + wrapper._handle_message(payload)
599 +
600 + if expected == "remote_blocks":
601 + request_info = wrapper.done_remote_allocate_req_dict["xfer"]
602 + assert request_info.block_ids == [4, 5]
603 + assert request_info.decode_dp_rank == 3
604 + elif expected == "write_done":
605 + 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:
610 + assert completions == ["xfer"]
611 +
612 +
613 +@pytest.mark.parametrize(
614 + ("role", "payload", "match"),
615 + [
616 + pytest.param(
617 + None,
618 + msgpack.dumps({"type": "unknown", "transfer_id": "xfer"}),
619 + "Unhandled structured message type",
620 + id="unknown-structured-type",
621 + ),
622 + pytest.param(
623 + ROLE.PRODUCER,
624 + msgpack.dumps(
625 + {
626 + "type": "remote_blocks",
627 + "req_id": "req",
628 + "transfer_id": "xfer",
629 + "block_notify_list": [],
630 + }
631 + ),
632 + "block_notify_list cannot be empty",
633 + id="empty-remote-blocks",
634 + ),
635 + pytest.param(
636 + None,
637 + b"",
638 + "Unhandled message format",
639 + id="empty-completion",
640 + ),
641 + ],
642 +)
643 +def test_moriio_wrapper_rejects_invalid_messages(role, payload, match):
644 + wrapper = _wrapper_for_messages()
645 + if role is not None:
646 + set_role(role)
647 + wrapper._handle_completion_message = lambda msg: None
648 +
649 + with pytest.raises(MoRIIOError, match=match):
650 + wrapper._handle_message(payload)
651 +
652 +
653 def test_block_id_length_mismatch_raises_value_error():
654 cache = torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16)
655 worker = _worker({"layer": cache}, {"layer": _full_spec()})
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_common.py
+28 -10
@@ -78,8 +78,17 @@ class RemoteAllocInfo:
78
79 block_ids: list[int]
80 writes_done: int = 0
81 + writes_expected: int | None = None
82 decode_dp_rank: int = 0
82 - transfer_offset: tuple[list[int], list[int], list[int]] | None = None
83 + completion_request_id: str | None = None
84 + completion_remote_notify_port: int | None = None
85 + completion_remote_ip: str | None = None
86 + completion_notified: bool = False
87 + transfer_statuses: list[Any] = field(default_factory=list)
88 + transfer_offsets: dict[
89 + tuple[tuple[int, ...], tuple[int, ...], torch.dtype],
90 + tuple[list[int], list[int], list[int]],
91 + ] = field(default_factory=dict)
92
93
94 class ROLE(Enum):
@@ -434,12 +443,21 @@ class MoRIIOConnectorMetadata(KVConnectorMetadata):
443 ):
444 transfer_id = kv_transfer_params["transfer_id"]
445
437 - # Parse host/ports from the request_id. The router embeds both zmq_addresses
438 - # in the request_id
439 - peer_zmq = get_peer_zmq_from_request_id(request_id, is_producer=write_mode)
440 - remote_host, remote_handshake_port, remote_notify_port = (
441 - parse_moriio_zmq_address(peer_zmq)
442 - )
446 + remote_host = kv_transfer_params.get("remote_host")
447 + remote_handshake_port = kv_transfer_params.get("remote_handshake_port")
448 + remote_notify_port = kv_transfer_params.get("remote_notify_port")
449 + if (
450 + remote_host is None
451 + or remote_handshake_port is None
452 + or remote_notify_port is None
453 + ):
454 + # Parse host/ports from the request_id. The router embeds both
455 + # zmq_addresses in PD request IDs, but WRITE decode requests may carry
456 + # a plain request ID and get the remote address via kv_transfer_params.
457 + peer_zmq = get_peer_zmq_from_request_id(request_id, is_producer=write_mode)
458 + remote_host, remote_handshake_port, remote_notify_port = (
459 + parse_moriio_zmq_address(peer_zmq)
460 + )
461
462 _req = ReqMeta(
463 transfer_id=transfer_id,
@@ -447,9 +465,9 @@ class MoRIIOConnectorMetadata(KVConnectorMetadata):
465 remote_block_ids=kv_transfer_params["remote_block_ids"],
466 remote_engine_id=kv_transfer_params["remote_engine_id"],
467 remote_host=remote_host,
450 - remote_port=remote_handshake_port,
451 - remote_handshake_port=remote_handshake_port,
452 - remote_notify_port=remote_notify_port,
468 + remote_port=int(remote_handshake_port),
469 + remote_handshake_port=int(remote_handshake_port),
470 + remote_notify_port=int(remote_notify_port),
471 tp_size=kv_transfer_params.get("tp_size", 1),
472 remote_dp_size=kv_transfer_params.get("remote_dp_size", 1),
473 )
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_connector.py
+109 -64
@@ -234,7 +234,13 @@ class MoRIIOConnector(KVConnectorBase_V1):
234 return None
235
236 def wait_for_save(self):
237 - pass
237 + if self.mode != MoRIIOMode.WRITE or get_role() != ROLE.PRODUCER:
238 + return
239 + assert self.connector_worker is not None
240 + assert isinstance(self._connector_metadata, MoRIIOConnectorMetadata), (
241 + "Connector metadata not initialized yet"
242 + )
243 + self.connector_worker.wait_for_save(self._connector_metadata)
244
245 def shutdown(self):
246 if self.connector_worker is not None:
@@ -390,6 +396,49 @@ class MoRIIOConnectorScheduler:
396 serialized_data = msgpack.dumps(data)
397 self.paths[path].send(serialized_data)
398
399 + def _send_transfer_release(self, transfer_id: TransferId, host: str, port: int):
400 + path = make_zmq_path("tcp", host, port)
401 + if path not in self.paths:
402 + ctx = zmq.Context.instance()
403 + sock = make_zmq_socket(
404 + ctx=ctx, path=path, socket_type=zmq.DEALER, bind=False
405 + )
406 + self.paths[path] = sock
407 +
408 + self.paths[path].send(
409 + msgpack.dumps({"type": "release", "transfer_id": transfer_id})
410 + )
411 +
412 + def _release_write_prefill_blocks(self, request_id: ReqId, params: dict[str, Any]):
413 + transfer_id = params.get("transfer_id")
414 + if transfer_id is None:
415 + logger.warning(
416 + "Cannot release WRITE prefill blocks for request %s: "
417 + "missing transfer_id",
418 + request_id,
419 + )
420 + return
421 +
422 + remote_dp_rank = params.get("remote_dp_rank", 0)
423 + remote_host = params.get("remote_host")
424 + remote_notify_port = params.get("remote_notify_port")
425 + if remote_host is None or remote_notify_port is None:
426 + try:
427 + peer_zmq = get_peer_zmq_from_request_id(request_id, is_producer=False)
428 + remote_host, _, remote_notify_port = parse_moriio_zmq_address(peer_zmq)
429 + except ValueError:
430 + logger.warning(
431 + "Cannot release WRITE prefill blocks for request %s: "
432 + "missing remote notify address",
433 + request_id,
434 + )
435 + return
436 +
437 + remote_notify_port = int(remote_notify_port)
438 + for tp_index in range(self.tp_size):
439 + target_port = remote_notify_port + get_port_offset(remote_dp_rank, tp_index)
440 + self._send_transfer_release(transfer_id, remote_host, target_port)
441 +
442 def update_state_after_alloc(
443 self,
444 request: "Request",
@@ -443,11 +492,20 @@ class MoRIIOConnectorScheduler:
492 )
493
494 remote_dp_rank = request.kv_transfer_params.get("remote_dp_rank", 0)
446 -
447 - peer_zmq = get_peer_zmq_from_request_id(
448 - request.request_id, is_producer=False
495 + remote_host = request.kv_transfer_params.get("remote_host")
496 + remote_notify_port = request.kv_transfer_params.get(
497 + "remote_notify_port"
498 )
450 - remote_host, _, remote_notify_port = parse_moriio_zmq_address(peer_zmq)
499 + if remote_host is None or remote_notify_port is None:
500 + peer_zmq = get_peer_zmq_from_request_id(
501 + request.request_id, is_producer=False
502 + )
503 + remote_host, _, remote_notify_port = parse_moriio_zmq_address(
504 + peer_zmq
505 + )
506 + remote_notify_port = int(remote_notify_port)
507 +
508 + block_ids = blocks.get_block_ids()[0]
509
510 for tp_index in range(self.tp_size):
511 target_port = remote_notify_port + get_port_offset(
@@ -457,7 +515,7 @@ class MoRIIOConnectorScheduler:
515 self.send_notify_block(
516 req_id=request.request_id,
517 transfer_id=request.kv_transfer_params["transfer_id"],
460 - block_notify_list=blocks.get_block_ids()[0],
518 + block_notify_list=block_ids,
519 host=remote_host,
520 port=target_port,
521 )
@@ -473,60 +531,31 @@ class MoRIIOConnectorScheduler:
531 meta = MoRIIOConnectorMetadata()
532 meta.transfer_id_to_request_id = self.transfer_id_to_request_id
533
476 - if self.mode == MoRIIOMode.WRITE:
477 - # when async_load_kv finished,
478 - # new reqs will be added to scheduler_output.scheduled_new_reqs
479 -
480 - if get_role() == ROLE.CONSUMER:
481 - for new_req in scheduler_output.scheduled_new_reqs:
482 - red_id = new_req.req_id
483 - local_block_ids = list(new_req.block_ids)[0]
484 - assert new_req.sampling_params is not None, (
485 - f"sampling_params is None for req {new_req.req_id}"
486 - )
487 - assert hasattr(new_req.sampling_params, "extra_args"), (
488 - f"sampling_params missing extra_args for req {new_req.req_id}"
489 - )
490 - kv_transfer_params = (
491 - new_req.sampling_params.extra_args.get("kv_transfer_params", {})
492 - if new_req.sampling_params.extra_args
493 - else {}
494 - )
495 - meta.add_new_req(
496 - red_id,
497 - local_block_ids,
498 - kv_transfer_params,
499 - )
500 - if get_role() == ROLE.PRODUCER:
501 - # This is the logic for checking against chunked prefill.
502 - # When the last chunk is identified,
503 - # It places the request metadata into the saving queue.
504 -
505 - for i, req_id in enumerate(
506 - scheduler_output.scheduled_cached_reqs.req_ids
507 - ):
508 - new_block_ids = (
509 - scheduler_output.scheduled_cached_reqs.new_block_ids[i]
510 - )
511 -
512 - if new_block_ids is not None:
513 - block_ids = new_block_ids[0]
514 - # TODO : hybrid attn, etc
515 - req, existing_blocks = self._reqs_need_pending_save[req_id]
516 - updated_blocks = list(existing_blocks) + (block_ids)
517 - self._reqs_need_pending_save[req_id] = (req, updated_blocks)
518 - if (
519 - len(self._reqs_need_pending_save[req_id][1])
520 - * self.block_size
521 - >= req.num_prompt_tokens
522 - ):
523 - meta.add_new_req(
524 - request_id=req_id,
525 - local_block_ids=self._reqs_need_pending_save[req_id][1],
526 - kv_transfer_params=req.kv_transfer_params or {},
527 - write_mode=True,
528 - )
529 - del self._reqs_need_pending_save[req_id]
534 + if self.mode == MoRIIOMode.WRITE and get_role() == ROLE.PRODUCER:
535 + # This is the logic for checking against chunked prefill.
536 + # When the last chunk is identified,
537 + # It places the request metadata into the saving queue.
538 +
539 + for i, req_id in enumerate(scheduler_output.scheduled_cached_reqs.req_ids):
540 + new_block_ids = scheduler_output.scheduled_cached_reqs.new_block_ids[i]
541 +
542 + if new_block_ids is not None:
543 + block_ids = new_block_ids[0]
544 + # TODO : hybrid attn, etc
545 + req, existing_blocks = self._reqs_need_pending_save[req_id]
546 + updated_blocks = list(existing_blocks) + (block_ids)
547 + self._reqs_need_pending_save[req_id] = (req, updated_blocks)
548 + if (
549 + len(self._reqs_need_pending_save[req_id][1]) * self.block_size
550 + >= req.num_prompt_tokens
551 + ):
552 + meta.add_new_req(
553 + request_id=req_id,
554 + local_block_ids=self._reqs_need_pending_save[req_id][1],
555 + kv_transfer_params=req.kv_transfer_params or {},
556 + write_mode=True,
557 + )
558 + del self._reqs_need_pending_save[req_id]
559
560 # Loop through scheduled reqs and convert to ReqMeta.
561 for req_id, (req, block_ids) in self._reqs_need_recv.items():
@@ -601,9 +630,15 @@ class MoRIIOConnectorScheduler:
630 # update_state_after_alloc must not have been called (the request
631 # must have been aborted before it was scheduled).
632 # To avoid stranding the prefill blocks in the prefill instance,
604 - # we must add empty block_ids to _reqs_need_recv so that our
605 - # worker side will notify and free blocks in the prefill instance.
606 - self._reqs_need_recv[request.request_id] = (request, [])
633 + # READ mode adds empty block_ids to _reqs_need_recv so the worker
634 + # side notifies the prefill instance. WRITE mode should notify the
635 + # producer directly: there is no decode allocation for the producer
636 + # to write into, and a plain request_id may not contain router-
637 + # embedded MoRIIO ZMQ addresses.
638 + if self.mode == MoRIIOMode.WRITE:
639 + self._release_write_prefill_blocks(request.request_id, params)
640 + else:
641 + self._reqs_need_recv[request.request_id] = (request, [])
642 params["do_remote_prefill"] = False
643 return False, None
644
@@ -635,6 +670,10 @@ class MoRIIOConnectorScheduler:
670 do_remote_decode=False,
671 remote_block_ids=computed_block_ids,
672 remote_engine_id=self.engine_id,
673 + remote_host=self.host_ip,
674 + remote_handshake_port=self.handshake_port,
675 + remote_notify_port=self.side_notify_port,
676 + remote_dp_size=self.vllm_config.parallel_config.data_parallel_size,
677 tp_size=self.vllm_config.parallel_config.tensor_parallel_size,
678 transfer_id=params["transfer_id"],
679 )
@@ -1480,7 +1519,7 @@ class MoRIIOConnectorWorker:
1519 metadata: MoRIIOConnectorMetadata,
1520 layer_name: str,
1521 kv_layer: torch.Tensor,
1483 - attn_metadata: "AttentionMetadata",
1522 + attn_metadata: "AttentionMetadata | None",
1523 **kwargs,
1524 ):
1525 if not self.is_producer:
@@ -1604,6 +1643,12 @@ class MoRIIOConnectorWorker:
1643
1644 self._reqs_to_send.update(metadata.reqs_to_send)
1645
1646 + def wait_for_save(self, metadata: MoRIIOConnectorMetadata):
1647 + if self.mode == MoRIIOMode.WRITE and self.is_producer:
1648 + for layer_name, kv_layer in self.kv_caches.items():
1649 + self.save_kv_layer(metadata, layer_name, kv_layer, None)
1650 + self._writer.seal_pending_transfers()
1651 +
1652 def _read_blocks_for_req(self, req_id: str, meta: ReqMeta):
1653 logger.debug(
1654 "Remote agent %s available, calling _read_blocks for req %s",
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_engine.py
+247 -73
@@ -2,6 +2,8 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import threading
4 import time
5 +from collections import OrderedDict, defaultdict
6 +from queue import Empty, Queue
7 from typing import TYPE_CHECKING, Any
8 from weakref import ref as weakref_ref
9
@@ -18,8 +20,6 @@ from vllm.utils.network_utils import (
20 if TYPE_CHECKING:
21 from mori.io import BackendType
22
21 -from queue import Empty, Queue
22 -
23 from vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_common import (
24 ROLE,
25 HandshakeError,
@@ -61,10 +61,25 @@ except ImportError:
61 """Write task execution logic for MoRIIO connector."""
62
63
64 +_MAX_TERMINAL_TRANSFER_IDS = 4096
65 +
66 +
67 +WriteGeometryKey = tuple[tuple[int, ...], tuple[int, ...], torch.dtype]
68 +
69 +
70 +def _get_write_geometry_key(kv_cache: torch.Tensor) -> WriteGeometryKey:
71 + return (tuple(kv_cache.shape), tuple(kv_cache.stride()), kv_cache.dtype)
72 +
73 +
74 class MoRIIOWriter:
75 """Handles write operations for KV cache transfers.
66 - Implements distributed KV cache transfer using the MoRIIO library
67 - for RDMA-based communication between prefill and decode instances."""
76 +
77 + WRITE mode state machine:
78 + D sends destination block allocation, P schedules one write per layer
79 + after the layer CUDA event, P seals the scheduled write count after
80 + forward, then P notifies D and releases P blocks after all scheduled
81 + writes complete.
82 + """
83
84 def __init__(self, worker: "MoRIIOConnectorWorker"):
85 """Initialize the writer.
@@ -76,7 +91,11 @@ class MoRIIOWriter:
91 self._write_task_q: Queue[WriteTask] = Queue()
92 self._write_worker_started = False
93 self._write_worker_lock = threading.Lock()
94 + self._write_state_lock = threading.Lock()
95 self._deferred_tasks: list[WriteTask] = []
96 + self._scheduled_writes: dict[TransferId, int] = defaultdict(int)
97 + self._scheduled_layers: dict[TransferId, set[str]] = defaultdict(set)
98 + self._sealed_writes: dict[TransferId, int] = {}
99 self._defer_timeout = worker.moriio_config.defer_timeout
100
101 @property
@@ -106,14 +125,55 @@ class MoRIIOWriter:
125 thread.start()
126 logger.info("Started MoRIIO write worker thread")
127
109 - def schedule_write(self, task: WriteTask) -> None:
128 + def schedule_write(self, task: WriteTask) -> bool:
129 """Schedule a write task.
130
131 Args:
132 task: The write task to schedule
133 """
134 self.ensure_worker_started()
135 + if self._is_transfer_terminal(task.transfer_id):
136 + return False
137 +
138 + with self._write_state_lock:
139 + if self._is_transfer_terminal(task.transfer_id):
140 + return False
141 + if task.layer_name in self._scheduled_layers[task.transfer_id]:
142 + return False
143 + self._scheduled_layers[task.transfer_id].add(task.layer_name)
144 + self._scheduled_writes[task.transfer_id] += 1
145 self._write_task_q.put(task)
146 + return True
147 +
148 + def is_scheduled(self, transfer_id: TransferId, layer_name: str) -> bool:
149 + with self._write_state_lock:
150 + return layer_name in self._scheduled_layers.get(transfer_id, set())
151 +
152 + def seal_pending_transfers(self) -> None:
153 + """Seal expected WRITE counts after the model forward has run.
154 +
155 + `save_kv_layer` is only invoked for attention layers whose backend uses
156 + the standard KV connector hook. Hybrid models can register more KV
157 + cache tensors than the number of hooks that fire in a forward, so WRITE
158 + completion must be based on the tasks actually queued for the transfer.
159 + """
160 + pending: list[tuple[TransferId, RemoteAllocInfo]] = []
161 + with self._write_state_lock:
162 + for transfer_id, write_count in self._scheduled_writes.items():
163 + if transfer_id in self._sealed_writes:
164 + continue
165 + self._sealed_writes[transfer_id] = write_count
166 + request_info = (
167 + self.worker.moriio_wrapper.done_remote_allocate_req_dict.get(
168 + transfer_id
169 + )
170 + )
171 + if request_info is not None:
172 + request_info.writes_expected = write_count
173 + pending.append((transfer_id, request_info))
174 +
175 + for transfer_id, request_info in pending:
176 + self._finalize_if_complete(transfer_id, request_info)
177
178 def _write_worker_loop(self) -> None:
179 """Main loop for the write worker thread."""
@@ -128,6 +188,9 @@ class MoRIIOWriter:
188 except Empty:
189 continue
190
191 + if self._is_transfer_terminal(task.transfer_id):
192 + continue
193 +
194 # Check if remote blocks are ready
195 if not self._is_remote_ready(task):
196 # task.retry_count += 1
@@ -158,6 +221,8 @@ class MoRIIOWriter:
221 still_deferred: list[WriteTask] = []
222
223 for task in self._deferred_tasks:
224 + if self._is_transfer_terminal(task.transfer_id):
225 + continue
226 if now - task.enqueue_time > defer_timeout:
227 logger.error(
228 "Deferred write task for request %s expired after %.1fs "
@@ -181,12 +246,25 @@ class MoRIIOWriter:
246
247 self._deferred_tasks = still_deferred
248
249 + def _clear_transfer_state(self, transfer_id: TransferId) -> None:
250 + with self._write_state_lock:
251 + self._scheduled_writes.pop(transfer_id, None)
252 + self._scheduled_layers.pop(transfer_id, None)
253 + self._sealed_writes.pop(transfer_id, None)
254 +
255 + def _is_transfer_terminal(self, transfer_id: TransferId) -> bool:
256 + wrapper = self.worker.moriio_wrapper
257 + with wrapper.lock:
258 + return wrapper._is_transfer_terminal_locked(transfer_id)
259 +
260 def _mark_request_done(self, transfer_id: str) -> None:
261 """Mark a request done so its blocks are freed, even on transfer failure."""
262 wrapper = self.worker.moriio_wrapper
263 with wrapper.lock:
264 wrapper.done_req_ids.append(transfer_id)
189 - wrapper.done_remote_allocate_req_dict.pop(transfer_id, None)
265 + wrapper.done_remote_allocate_req_dict.pop(transfer_id, None)
266 + wrapper._mark_transfer_terminal_locked(transfer_id)
267 + self._clear_transfer_state(transfer_id)
268
269 def _is_remote_ready(self, task: WriteTask) -> bool:
270 """Check if remote blocks are allocated for this task.
@@ -229,6 +307,12 @@ class MoRIIOWriter:
307 """
308 # Get remote allocation info
309 request_info = self._get_remote_alloc_info(task.transfer_id)
310 + with self._write_state_lock:
311 + request_info.completion_request_id = task.request_id
312 + request_info.completion_remote_notify_port = task.remote_notify_port
313 + request_info.completion_remote_ip = task.remote_ip
314 + if task.transfer_id in self._sealed_writes:
315 + request_info.writes_expected = self._sealed_writes[task.transfer_id]
316
317 if request_info.block_ids is None:
318 logger.debug(
@@ -259,10 +343,12 @@ class MoRIIOWriter:
343 plan = self._prepare_transfer_plan(task, request_info, remote_moriio_meta)
344
345 # Execute transfer
262 - self._do_layer_write(plan, sessions)
346 + transfer_statuses = self._do_layer_write(plan, sessions)
347 + with self._write_state_lock:
348 + request_info.transfer_statuses.extend(transfer_statuses)
349
350 # Finalize if all layers complete
265 - self._finalize_if_complete(task, request_info)
351 + self._mark_write_done(task.transfer_id, request_info)
352
353 def _prepare_transfer_plan(
354 self,
@@ -279,21 +365,23 @@ class MoRIIOWriter:
365 Returns:
366 The transfer plan
367 """
282 - # Compute offsets if not cached
283 - if request_info.transfer_offset is None:
368 + layer_cache = self.worker.kv_caches[task.layer_name]
369 + geometry_key = _get_write_geometry_key(layer_cache)
370 + offsets = request_info.transfer_offsets.get(geometry_key)
371 + if offsets is None:
372 offsets = self.worker._compute_block_transfer_offsets(
373 task.layer_name,
374 task.local_block_ids,
375 request_info.block_ids,
376 remote_moriio_meta,
377 )
290 - request_info.transfer_offset = offsets
378 + request_info.transfer_offsets[geometry_key] = offsets
379
380 # Get session index
381 layer_names = list(self.worker.layer_name_to_local_kv_cache_metadata.keys())
382 sess_idx = layer_names.index(task.layer_name)
383
296 - local_off, remote_off, sizes = request_info.transfer_offset
384 + local_off, remote_off, sizes = offsets
385
386 return LayerTransferPlan(
387 request_id=task.request_id,
@@ -306,7 +394,7 @@ class MoRIIOWriter:
394 use_batch=True,
395 )
396
309 - def _do_layer_write(self, plan: LayerTransferPlan, sessions: list) -> None:
397 + def _do_layer_write(self, plan: LayerTransferPlan, sessions: list) -> list[Any]:
398 """Perform the actual layer write.
399
400 Args:
@@ -314,59 +402,82 @@ class MoRIIOWriter:
402 sessions: List of transfer sessions
403 """
404 if plan.use_batch:
317 - self.worker.moriio_wrapper.write_remote_data(
318 - plan.transfer_sizes,
319 - plan.transfer_local_offsets,
320 - plan.transfer_remote_offsets,
321 - sessions[plan.sess_idx],
322 - )
323 - else:
324 - for i in range(len(plan.transfer_local_offsets)):
405 + return [
406 + self.worker.moriio_wrapper.write_remote_data(
407 + plan.transfer_sizes,
408 + plan.transfer_local_offsets,
409 + plan.transfer_remote_offsets,
410 + sessions[plan.sess_idx],
411 + )
412 + ]
413 +
414 + transfer_statuses: list[Any] = []
415 + for i in range(len(plan.transfer_local_offsets)):
416 + transfer_statuses.append(
417 self.worker.moriio_wrapper.write_remote_data_single(
418 plan.transfer_sizes[i],
419 plan.transfer_local_offsets[i],
420 plan.transfer_remote_offsets[i],
421 plan.sess_idx,
422 )
423 + )
424 + return transfer_statuses
425
332 - def _finalize_if_complete(
333 - self, task: WriteTask, request_info: RemoteAllocInfo
426 + def _mark_write_done(
427 + self, transfer_id: TransferId, request_info: RemoteAllocInfo
428 ) -> None:
335 - """Finalize transfer if all layers are complete.
429 + """Record one completed WRITE task and finalize if sealed."""
430 + with self._write_state_lock:
431 + request_info.writes_done += 1
432 + self._finalize_if_complete(transfer_id, request_info)
433
337 - Args:
338 - task: The write task
339 - request_info: Remote allocation information
340 - """
341 - request_info.writes_done += 1
434 + def _finalize_if_complete(
435 + self, transfer_id: TransferId, request_info: RemoteAllocInfo
436 + ) -> None:
437 + """Finalize transfer if all scheduled writes are complete."""
438 + with self._write_state_lock:
439 + expected = request_info.writes_expected
440 + if expected is None or request_info.writes_done < expected:
441 + return
442 + if request_info.completion_notified:
443 + return
444 + request_id = request_info.completion_request_id
445 + remote_notify_port = request_info.completion_remote_notify_port
446 + remote_ip = request_info.completion_remote_ip
447 + if request_id is None or remote_notify_port is None or remote_ip is None:
448 + return
449 + transfer_statuses = list(request_info.transfer_statuses)
450 + request_info.transfer_statuses.clear()
451 + request_info.completion_notified = True
452
343 - if request_info.writes_done >= self.worker.num_layers:
344 - # Wait for transfer to complete
345 - self.worker.moriio_wrapper.waiting_for_transfer_complete()
453 + # Wait for this request's transfers to complete.
454 + self.worker.moriio_wrapper.waiting_for_transfer_complete(transfer_statuses)
455
347 - remote_port = task.remote_notify_port + get_port_offset(
348 - request_info.decode_dp_rank, self.worker.tp_rank
349 - )
350 - # Consider using RDMA immediate data in decode side
351 - # to eliminate the need for this notification.
352 - # Consider including the first gen token from prefill in the notification
456 + remote_port = remote_notify_port + get_port_offset(
457 + request_info.decode_dp_rank, self.worker.tp_rank
458 + )
459 + # Consider using RDMA immediate data in decode side
460 + # to eliminate the need for this notification.
461 + # Consider including the first gen token from prefill in the notification
462
354 - # Send completion notification
355 - self.worker.moriio_wrapper.send_notify(
356 - task.transfer_id, task.remote_ip, remote_port
357 - )
358 - # mark request as done, then we can free the blocks
359 - with self.worker.moriio_wrapper.lock:
360 - self.worker.moriio_wrapper.done_req_ids.append(task.transfer_id)
361 - del self.worker.moriio_wrapper.done_remote_allocate_req_dict[
362 - task.transfer_id
363 - ]
364 - logger.debug(
365 - "Completed transfer for (request, transfer) %s, %s, notified port %d",
366 - task.request_id,
367 - task.transfer_id,
368 - remote_port,
463 + # Send completion notification
464 + self.worker.moriio_wrapper.send_notify(
465 + transfer_id, remote_ip, remote_port, message_type="write_done"
466 + )
467 + # mark request as done, then we can free the blocks
468 + with self.worker.moriio_wrapper.lock:
469 + self.worker.moriio_wrapper.done_req_ids.append(transfer_id)
470 + self.worker.moriio_wrapper.done_remote_allocate_req_dict.pop(
471 + transfer_id, None
472 )
473 + self.worker.moriio_wrapper._mark_transfer_terminal_locked(transfer_id)
474 + self._clear_transfer_state(transfer_id)
475 + logger.debug(
476 + "Completed transfer for (request, transfer) %s, %s, notified port %d",
477 + request_id,
478 + transfer_id,
479 + remote_port,
480 + )
481
482
483 class MoRIIOWrapper:
@@ -400,6 +511,7 @@ class MoRIIOWrapper:
511 self.done_req_ids: list[str] = []
512 self.done_remote_allocate_req_dict: dict[TransferId, RemoteAllocInfo] = {}
513 self.done_write_cache_req_ids: list[str] = []
514 + self._terminal_transfer_ids: OrderedDict[TransferId, None] = OrderedDict()
515 self._transfer_timeout = transfer_timeout
516 self.notify_thread: threading.Thread | None = None
517 self.sessions: list[IOEngine.Session] = []
@@ -506,8 +618,7 @@ class MoRIIOWrapper:
618 transfer_status = session.batch_write(
619 local_offset, remote_offset, transfer_size_byte, write_uid
620 )
509 - with self.lock:
510 - self.transfer_status.append(transfer_status)
621 + return transfer_status
622
623 def write_remote_data_single(
624 self, transfer_size_byte, local_offset=0, remote_offset=0, sess_idx=0
@@ -520,16 +631,18 @@ class MoRIIOWrapper:
631 transfer_size_byte,
632 self.moriio_engine.allocate_transfer_uid(),
633 )
523 - with self.lock:
524 - self.transfer_status.append(transfer_status)
634 + return transfer_status
635
526 - def waiting_for_transfer_complete(self):
527 - if not self.transfer_status:
528 - return
636 + def waiting_for_transfer_complete(self, transfer_statuses: list[Any] | None = None):
637 + if transfer_statuses is None:
638 + with self.lock:
639 + transfers_to_wait = self.transfer_status[:]
640 + self.transfer_status.clear()
641 + else:
642 + transfers_to_wait = list(transfer_statuses)
643
530 - with self.lock:
531 - transfers_to_wait = self.transfer_status[:]
532 - self.transfer_status.clear()
644 + if not transfers_to_wait:
645 + return
646
647 timeout = self._transfer_timeout
648 deadline = time.monotonic() + timeout
@@ -598,49 +711,105 @@ class MoRIIOWrapper:
711 # [read] mode: receives block release messages from decode side
712 # Decode Role:
713 # [write] mode: receives KV cache write completion notifications
714 + msg_str = repr(msg)
715 handled = False
716 try:
717 data = msgpack.loads(msg)
604 - if isinstance(data, dict) and "req_id" in data:
718 + if isinstance(data, dict):
719 self._handle_structured_message(data)
606 -
720 return
608 - except (msgpack.exceptions.ExtraData, msgpack.exceptions.UnpackException):
721 + except (
722 + msgpack.exceptions.ExtraData,
723 + msgpack.exceptions.UnpackException,
724 + ValueError,
725 + ):
726 logger.debug("Failed to decode msgpack message, will try as string")
727 pass
728
729 try:
730 msg_str = msg.decode("UTF-8")
614 - if msg_str.startswith(MoRIIOConstants.TRANSFER_PREFIX):
731 + if msg_str:
732 self._handle_completion_message(msg_str)
733 handled = True
734 except UnicodeDecodeError:
618 - logger.warning("Received non-UTF8 message: %s", msg_str)
735 + logger.warning("Received non-UTF8 message: %r", msg)
736 if not handled:
737 raise MoRIIOError(f"Unhandled message format: {msg_str}")
738
739 def _handle_structured_message(self, data: dict):
740 + message_type = data.get("type")
741 + if message_type is None and "req_id" in data:
742 + message_type = "remote_blocks"
743 +
744 + if message_type == "remote_blocks":
745 + self._handle_remote_blocks_message(data)
746 + elif message_type == "write_done":
747 + self._handle_write_done_message(data)
748 + elif message_type == "release":
749 + self._handle_release_message(data)
750 + else:
751 + raise MoRIIOError(f"Unhandled structured message type: {message_type}")
752 +
753 + def _handle_remote_blocks_message(self, data: dict):
754 assert get_role() == ROLE.PRODUCER, "Only prefill can get block messages"
755 transfer_id = data["transfer_id"]
756 block_notify_list = data.get("block_notify_list", [])
757 decode_dp_rank = data.get("decode_rank", 0)
627 - assert len(block_notify_list) > 0, (
628 - "block_notify_list cannot be empty in remote allocate message"
629 - )
758 + if not block_notify_list:
759 + raise MoRIIOError(
760 + "block_notify_list cannot be empty in remote allocate message"
761 + )
762
763 with self.lock:
764 + if self._is_transfer_terminal_locked(transfer_id):
765 + logger.debug(
766 + "Ignoring remote allocation for terminal transfer %s",
767 + transfer_id,
768 + )
769 + return
770 self.done_remote_allocate_req_dict[transfer_id] = RemoteAllocInfo(
771 block_ids=block_notify_list, decode_dp_rank=decode_dp_rank
772 )
773
774 + def _handle_write_done_message(self, data: dict):
775 + assert get_role() != ROLE.PRODUCER, (
776 + "Only decode can get WRITE completion messages"
777 + )
778 + transfer_id = data["transfer_id"]
779 + with self.lock:
780 + self.done_write_cache_req_ids.append(transfer_id)
781 +
782 + def _handle_release_message(self, data: dict):
783 + assert get_role() == ROLE.PRODUCER, (
784 + "Only prefill can get transfer release messages"
785 + )
786 + transfer_id = data["transfer_id"]
787 + with self.lock:
788 + self.done_req_ids.append(transfer_id)
789 + self.done_remote_allocate_req_dict.pop(transfer_id, None)
790 + self._mark_transfer_terminal_locked(transfer_id)
791 +
792 def _handle_completion_message(self, msg: str):
793 with self.lock:
794 if get_role() == ROLE.PRODUCER:
795 self.done_req_ids.append(msg)
796 + self.done_remote_allocate_req_dict.pop(msg, None)
797 + self._mark_transfer_terminal_locked(msg)
798 else:
799 self.done_write_cache_req_ids.append(msg)
800
643 - def send_notify(self, req_ids, remote_ip, remote_port):
801 + def _is_transfer_terminal_locked(self, transfer_id: TransferId) -> bool:
802 + return transfer_id in self._terminal_transfer_ids
803 +
804 + def _mark_transfer_terminal_locked(self, transfer_id: TransferId) -> None:
805 + self._terminal_transfer_ids[transfer_id] = None
806 + self._terminal_transfer_ids.move_to_end(transfer_id)
807 + while len(self._terminal_transfer_ids) > _MAX_TERMINAL_TRANSFER_IDS:
808 + self._terminal_transfer_ids.popitem(last=False)
809 +
810 + def send_notify(
811 + self, req_ids, remote_ip, remote_port, message_type: str | None = None
812 + ):
813 if not remote_ip or not remote_port:
814 logger.warning("Missing remote_ip or remote_port for notification")
815 return
@@ -664,7 +833,12 @@ class MoRIIOWrapper:
833 "Invalid req_id type: %s, expected str", type(req_id)
834 )
835 continue
667 - sock.send(req_id.encode("utf-8"))
836 + if message_type is None:
837 + sock.send(req_id.encode("utf-8"))
838 + else:
839 + sock.send(
840 + msgpack.dumps({"type": message_type, "transfer_id": req_id})
841 + )
842 except Exception as e:
843 logger.error("Failed to send notification to %s: %s", path, e)
844 self.paths.pop(path, None)
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_layout.py
+109 -5
@@ -56,6 +56,42 @@ def is_mla_cache_layer(
56 return isinstance(spec, (MLAAttentionSpec, SlidingWindowMLASpec))
57
58
59 +def _spec_dim_matches(value: int, expected: int | None) -> bool:
60 + return expected is None or value == expected
61 +
62 +
63 +def _kernel_layout_matches(
64 + spec: KVCacheSpec, kernel_block_size: int, num_kv_heads: int, head_dim: int
65 +) -> bool:
66 + if kernel_block_size <= 0 or spec.block_size % kernel_block_size != 0:
67 + return False
68 + return _spec_dim_matches(
69 + num_kv_heads, getattr(spec, "num_kv_heads", None)
70 + ) and _spec_dim_matches(head_dim, getattr(spec, "head_size", None))
71 +
72 +
73 +def _select_kernel_block_layout(
74 + layer_name: str, shape: torch.Size, spec: KVCacheSpec
75 +) -> tuple[int, int, int]:
76 + axis2_matches = _kernel_layout_matches(spec, shape[2], shape[3], shape[4])
77 + axis3_matches = _kernel_layout_matches(spec, shape[3], shape[2], shape[4])
78 +
79 + if axis2_matches and axis3_matches and shape[2] != shape[3]:
80 + raise ValueError(
81 + f"Ambiguous MoRIIO kernel-block K/V cache shape for layer "
82 + f"{layer_name}: {tuple(shape)}"
83 + )
84 + if axis2_matches:
85 + return shape[2], shape[3], shape[4]
86 + if axis3_matches:
87 + return shape[3], shape[2], shape[4]
88 +
89 + raise ValueError(
90 + f"Unsupported MoRIIO K/V cache shape for layer {layer_name}: "
91 + f"{tuple(shape)} does not contain block size {spec.block_size}"
92 + )
93 +
94 +
95 def get_layer_transfer_geometry(
96 layer_name: str,
97 kv_cache: torch.Tensor,
@@ -65,6 +101,7 @@ def get_layer_transfer_geometry(
101 shape = kv_cache.shape
102 stride = kv_cache.stride()
103 element_size = kv_cache.element_size()
104 + spec = layer_to_spec[layer_name]
105 is_mla_cache = is_mla_cache_layer(layer_to_spec, layer_name)
106
107 if is_mla_cache and len(shape) == 3:
@@ -85,25 +122,92 @@ def get_layer_transfer_geometry(
122 )
123
124 if not is_mla_cache and len(shape) == 5 and shape[0] == 2:
88 - _, num_blocks, block_size, num_kv_heads, head_dim = shape
125 + _, num_blocks = shape[:2]
126 + kernel_blocks_per_block = 1
127 + if shape[2] == spec.block_size:
128 + block_size, num_kv_heads, head_dim = shape[2:]
129 + elif shape[3] == spec.block_size:
130 + num_kv_heads, block_size, head_dim = shape[2:]
131 + else:
132 + kernel_num_blocks = num_blocks
133 + kernel_block_size, num_kv_heads, head_dim = _select_kernel_block_layout(
134 + layer_name, shape, spec
135 + )
136 + kernel_blocks_per_block = spec.block_size // kernel_block_size
137 + if kernel_num_blocks % kernel_blocks_per_block != 0:
138 + raise ValueError(
139 + f"Unsupported MoRIIO K/V cache shape for layer {layer_name}: "
140 + f"{tuple(shape)} has {kernel_num_blocks} kernel blocks, "
141 + f"not divisible by {kernel_blocks_per_block}"
142 + )
143 + num_blocks = kernel_num_blocks // kernel_blocks_per_block
144 + block_size = spec.block_size
145 slot_size_bytes = num_kv_heads * head_dim * element_size
146 block_len = block_size * slot_size_bytes
91 - remote_kv_stride = stride[1] * (remote_num_blocks or num_blocks)
147 return LayerTransferGeometry(
148 num_blocks=num_blocks,
149 block_size=block_size,
150 block_len=block_len,
151 slot_size_bytes=slot_size_bytes,
97 - block_stride=stride[1],
152 + block_stride=stride[1] * kernel_blocks_per_block,
153 local_kv_stride=stride[0],
99 - remote_kv_stride=remote_kv_stride,
154 + remote_kv_stride=(
155 + stride[1] * kernel_blocks_per_block * (remote_num_blocks or num_blocks)
156 + ),
157 transfers_per_block=2,
158 regions_per_block=1,
159 split_kv_regions=True,
160 )
161
162 if not is_mla_cache and len(shape) == 5 and shape[1] == 2:
106 - num_blocks, _, block_size, num_kv_heads, head_dim = shape
163 + num_blocks = shape[0]
164 + if shape[2] == spec.block_size:
165 + block_size, num_kv_heads, head_dim = shape[2:]
166 + slot_size_bytes = num_kv_heads * head_dim * element_size
167 + block_len = block_size * slot_size_bytes
168 + return LayerTransferGeometry(
169 + num_blocks=num_blocks,
170 + block_size=block_size,
171 + block_len=block_len,
172 + slot_size_bytes=slot_size_bytes,
173 + block_stride=stride[0],
174 + local_kv_stride=stride[1],
175 + remote_kv_stride=stride[1],
176 + transfers_per_block=2,
177 + regions_per_block=2,
178 + split_kv_regions=False,
179 + )
180 + elif shape[3] == spec.block_size:
181 + num_kv_heads, block_size, head_dim = shape[2:]
182 + else:
183 + kernel_num_blocks = num_blocks
184 + kernel_block_size, _, _ = _select_kernel_block_layout(
185 + layer_name, shape, spec
186 + )
187 + kernel_blocks_per_block = spec.block_size // kernel_block_size
188 + if kernel_num_blocks % kernel_blocks_per_block != 0:
189 + raise ValueError(
190 + f"Unsupported MoRIIO K/V cache shape for layer {layer_name}: "
191 + f"{tuple(shape)} has {kernel_num_blocks} kernel blocks, "
192 + f"not divisible by {kernel_blocks_per_block}"
193 + )
194 + num_blocks = kernel_num_blocks // kernel_blocks_per_block
195 + block_size = spec.block_size
196 + block_stride = stride[0] * kernel_blocks_per_block
197 + block_len = block_stride * element_size
198 + slot_size_bytes = block_len // block_size
199 + return LayerTransferGeometry(
200 + num_blocks=num_blocks,
201 + block_size=block_size,
202 + block_len=block_len,
203 + slot_size_bytes=slot_size_bytes,
204 + block_stride=block_stride,
205 + local_kv_stride=None,
206 + remote_kv_stride=None,
207 + transfers_per_block=1,
208 + regions_per_block=1,
209 + split_kv_regions=False,
210 + )
211 slot_size_bytes = num_kv_heads * head_dim * element_size
212 block_len = block_size * slot_size_bytes
213 return LayerTransferGeometry(