[KVConnector][MoRIIO] Transfer hybrid mamba/KDA recurrent state in READ mode (#51052)
Signed-off-by: Yichao Zhu <Yichao.Zhu@amd.com>
YukioZzz committed
Sep 21, 2026 at 05:30 UTC
96791737887224c8bfec0a791517759f62e4d93b
11 files changed
+2746
-184
examples/disaggregated/disaggregated_serving/moriio_toy_proxy_server.py
+2
@@ -367,6 +367,8 @@ async def handle_request(api: str, request: Request):
367
"remote_block_ids"
368
]
369
req_data["kv_transfer_params"]["transfer_id"] = prefill_kv["transfer_id"]
370
+ if prefill_kv.get("tp_size") is not None:
371
+ req_data["kv_transfer_params"]["tp_size"] = prefill_kv["tp_size"]
372
373
req_data["kv_transfer_params"]["remote_dp_size"] = prefill_instance_endpoint[
374
"dp_size"
tests/v1/kv_connector/unit/test_moriio_connector.py
+11
@@ -775,6 +775,17 @@ def test_is_hma_required(swa_enabled, disable_hma, expected_is_hma):
775
assert scheduler.get_exchange_clipped_blocks(blocks) == blocks
776
777
778
+def test_transfer_disabled_hybrid_group_is_ignored():
779
+ config = _make_hybrid_kv_cache_config()
780
+ config.kv_cache_groups[1].enable_kv_transfer = False
781
+ scheduler = _read_scheduler(config)
782
+
783
+ assert scheduler._is_hma_required is False
784
+ assert scheduler.get_exchange_clipped_blocks([[1, 2, 3], [20, 21, 22]]) == [
785
+ [1, 2, 3]
786
+ ]
787
+
788
+
789
def test_non_sliding_window_hybrid_is_rejected():
790
"""A hybrid group that is not sliding-window (e.g. chunked-local
791
attention) must fail closed rather than be silently mistransferred."""
tests/v1/kv_connector/unit/test_moriio_hma_scheduler.py
new
+809
@@ -0,0 +1,809 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Pure-CPU unit tests for the MoRIIO hybrid (attention + mamba/KDA) scheduler
4
+and the KDA offset-template cache.
5
+
6
+These exercise the highest-risk, otherwise-untested scheduler logic without a
7
+GPU or the ``mori`` runtime: per-group block-id splitting, the READ/WRITE
8
+``N-1`` token accounting, P-side prompt truncation, and offset-template cache
9
+wiring.
10
+
11
+Like ``test_moriio_kv_layout.py`` the whole module is skipped unless it is
12
+running on ROCm with ``mori`` installed (importing the connector pulls in
13
+``mori``). The authoritative run happens on the MIA recipe image.
14
+"""
15
+
16
+import importlib
17
+import importlib.util
18
+from types import SimpleNamespace
19
+
20
+import pytest
21
+import torch
22
+
23
+from vllm.platforms import current_platform
24
+
25
+mori_available = importlib.util.find_spec("mori") is not None
26
+
27
+if not (current_platform.is_rocm() and mori_available):
28
+ pytest.skip(
29
+ "MoRIIOs are only available on ROCm with mori package installed",
30
+ allow_module_level=True,
31
+ )
32
+
33
+moriio_connector = importlib.import_module(
34
+ "vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_connector"
35
+)
36
+moriio_layout = importlib.import_module(
37
+ "vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_layout"
38
+)
39
+ssm_conv_transfer_utils = importlib.import_module(
40
+ "vllm.distributed.kv_transfer.kv_connector.v1.ssm_conv_transfer_utils"
41
+)
42
+MambaConvSplitInfo = ssm_conv_transfer_utils.MambaConvSplitInfo
43
+MoRIIOMode = moriio_connector.MoRIIOMode
44
+moriio_common = importlib.import_module(
45
+ "vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_common"
46
+)
47
+
48
+
49
+class _FakeScheduler(moriio_connector.MoRIIOConnectorScheduler): # type: ignore[name-defined]
50
+ """Constructs a scheduler without the heavy real ``__init__``; only the
51
+ attributes read by the method under test are set."""
52
+
53
+ def __init__(self, **attrs):
54
+ self._mamba_group_ids: list[int] = []
55
+ self._attn_group_ids: list[int] = [0]
56
+ self._ssm_state_slots_are_positional = False
57
+ self._is_hma_required = False
58
+ self.kv_cache_config = SimpleNamespace(
59
+ kv_cache_groups=[None, None],
60
+ select_transfer_block_ids=lambda block_ids: tuple(block_ids),
61
+ )
62
+ for k, v in attrs.items():
63
+ setattr(self, k, v)
64
+
65
+
66
+class _FakeConnector(moriio_connector.MoRIIOConnector): # type: ignore[name-defined]
67
+ def __init__(self, connector_scheduler):
68
+ self.connector_scheduler = connector_scheduler
69
+
70
+
71
+class _FakeWorker(moriio_connector.MoRIIOConnectorWorker): # type: ignore[name-defined]
72
+ def __init__(self, **attrs):
73
+ for k, v in attrs.items():
74
+ setattr(self, k, v)
75
+
76
+
77
+class _FakeBlocks:
78
+ def __init__(self, all_groups):
79
+ self._all_groups = all_groups
80
+
81
+ def get_block_ids(self):
82
+ return self._all_groups
83
+
84
+
85
+def _slot_strided(num_slots, per_slot_shape, slot_stride, dtype=torch.bfloat16):
86
+ inner = 1
87
+ for d in per_slot_shape:
88
+ inner *= d
89
+ assert slot_stride >= inner
90
+ backing = torch.zeros(num_slots * slot_stride, dtype=dtype)
91
+ inner_strides: list[int] = []
92
+ acc = 1
93
+ for d in reversed(per_slot_shape):
94
+ inner_strides.insert(0, acc)
95
+ acc *= d
96
+ return backing.as_strided(
97
+ (num_slots, *per_slot_shape), (slot_stride, *inner_strides)
98
+ )
99
+
100
+
101
+def _gdn_split_info(conv_rows=3, key_dim=4, value_dim=8, dtype_size=2):
102
+ conv_dim = 2 * key_dim + value_dim
103
+ conv_state_bytes = conv_dim * conv_rows * dtype_size
104
+ ssm_state_bytes = 64
105
+ return MambaConvSplitInfo(
106
+ conv_rows=conv_rows,
107
+ local_proj_dims=(key_dim, key_dim, value_dim),
108
+ conv_dtype_size=dtype_size,
109
+ ssm_sizes=(conv_state_bytes, ssm_state_bytes),
110
+ )
111
+
112
+
113
+def _mamba_spec(num_states: int = 2):
114
+ shapes = tuple((1, 1) for _ in range(num_states))
115
+ return moriio_connector.MambaSpec(
116
+ block_size=16,
117
+ shapes=shapes,
118
+ dtypes=(torch.float32,) * num_states,
119
+ mamba_cache_mode="all",
120
+ )
121
+
122
+
123
+def _gate_vllm_config(*, read_mode: bool = True, speculative_config=None):
124
+ return SimpleNamespace(
125
+ kv_transfer_config=SimpleNamespace(
126
+ kv_connector_extra_config={"read_mode": read_mode}
127
+ ),
128
+ speculative_config=speculative_config,
129
+ )
130
+
131
+
132
+# --------------------------------------------------------------------------
133
+# split_block_groups
134
+# --------------------------------------------------------------------------
135
+def test_split_block_groups_separates_attention_and_mamba():
136
+ sched = _FakeScheduler(_has_mamba=True, _attn_group_ids=[0], _mamba_group_ids=[1])
137
+ block_ids = ([1, 2], [99])
138
+ attn, mamba = sched.split_block_groups(block_ids)
139
+ assert attn == [1, 2]
140
+ assert mamba == [[99]]
141
+
142
+
143
+def test_split_block_groups_ignores_transfer_disabled_group():
144
+ mamba_spec = moriio_connector.MambaSpec(
145
+ block_size=16,
146
+ shapes=((1, 1),),
147
+ dtypes=(torch.float32,),
148
+ mamba_cache_mode="all",
149
+ )
150
+ config = SimpleNamespace(
151
+ kv_cache_groups=[
152
+ SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=object()),
153
+ SimpleNamespace(enable_kv_transfer=False, kv_cache_spec=object()),
154
+ SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=mamba_spec),
155
+ ],
156
+ )
157
+ config.transfer_groups = tuple(
158
+ group for group in config.kv_cache_groups if group.enable_kv_transfer
159
+ )
160
+ config.select_transfer_block_ids = lambda block_ids: tuple(
161
+ block_ids[group_id]
162
+ for group_id, group in enumerate(config.kv_cache_groups)
163
+ if group.enable_kv_transfer
164
+ )
165
+ attn_groups, mamba_groups = moriio_connector._split_kv_cache_group_kinds(config)
166
+ assert attn_groups == [0]
167
+ assert mamba_groups == [1]
168
+
169
+ sched = _FakeScheduler(
170
+ _has_mamba=True,
171
+ _attn_group_ids=attn_groups,
172
+ _mamba_group_ids=mamba_groups,
173
+ kv_cache_config=config,
174
+ )
175
+ assert sched.split_block_groups(([1, 2], [70, 71], [99])) == (
176
+ [1, 2],
177
+ [[99]],
178
+ )
179
+
180
+
181
+def test_exchange_blocks_ignore_transfer_disabled_group():
182
+ config = SimpleNamespace(
183
+ kv_cache_groups=[None, None, None],
184
+ select_transfer_block_ids=lambda block_ids: (block_ids[0], block_ids[2]),
185
+ )
186
+ sched = _FakeScheduler(
187
+ kv_cache_config=config,
188
+ _is_hma_required=True,
189
+ blocks_per_sw=[0, 2],
190
+ )
191
+
192
+ assert sched.get_exchange_clipped_blocks(([1, 2], [70, 71], [90, 91, 92])) == [
193
+ [1, 2],
194
+ [91, 92],
195
+ ]
196
+
197
+
198
+def test_scheduler_rejects_multiple_attention_groups_with_mamba():
199
+ config = SimpleNamespace(
200
+ kv_cache_groups=[
201
+ SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=object()),
202
+ SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=object()),
203
+ SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=_mamba_spec()),
204
+ ],
205
+ )
206
+ config.transfer_groups = tuple(config.kv_cache_groups)
207
+
208
+ with pytest.raises(moriio_common.MoRIIOError, match="exactly one transferable"):
209
+ moriio_connector.MoRIIOConnectorScheduler(_gate_vllm_config(), "engine", config)
210
+
211
+
212
+def test_split_block_groups_preserves_multiple_mamba_groups():
213
+ sched = _FakeScheduler(
214
+ _has_mamba=True,
215
+ _attn_group_ids=[0],
216
+ _mamba_group_ids=[1, 2],
217
+ )
218
+
219
+ assert sched.split_block_groups(([1, 2], [40], [70])) == (
220
+ [1, 2],
221
+ [[40], [70]],
222
+ )
223
+
224
+
225
+def test_scheduler_rejects_incompatible_mamba_groups():
226
+ incompatible = _mamba_spec()
227
+ object.__setattr__(incompatible, "page_size_padded", 4096)
228
+ config = SimpleNamespace(
229
+ kv_cache_groups=[
230
+ SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=object()),
231
+ SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=_mamba_spec()),
232
+ SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=incompatible),
233
+ ]
234
+ )
235
+ config.transfer_groups = tuple(config.kv_cache_groups)
236
+
237
+ with pytest.raises(moriio_common.MoRIIOError, match="same cache spec"):
238
+ moriio_connector.MoRIIOConnectorScheduler(_gate_vllm_config(), "engine", config)
239
+
240
+
241
+def test_scheduler_rejects_mamba_group_without_two_states():
242
+ config = SimpleNamespace(
243
+ kv_cache_groups=[
244
+ SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=object()),
245
+ SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=_mamba_spec(1)),
246
+ ],
247
+ )
248
+ config.transfer_groups = tuple(config.kv_cache_groups)
249
+
250
+ with pytest.raises(moriio_common.MoRIIOError, match="exactly two Mamba states"):
251
+ moriio_connector.MoRIIOConnectorScheduler(_gate_vllm_config(), "engine", config)
252
+
253
+
254
+def test_scheduler_rejects_speculative_hybrid_read():
255
+ config = SimpleNamespace(
256
+ kv_cache_groups=[
257
+ SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=object()),
258
+ SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=_mamba_spec()),
259
+ ],
260
+ )
261
+ config.transfer_groups = tuple(config.kv_cache_groups)
262
+
263
+ with pytest.raises(moriio_common.MoRIIOError, match="speculative decoding"):
264
+ moriio_connector.MoRIIOConnectorScheduler(
265
+ _gate_vllm_config(speculative_config=object()), "engine", config
266
+ )
267
+
268
+
269
+def test_scheduler_rejects_hybrid_write():
270
+ config = SimpleNamespace(
271
+ kv_cache_groups=[
272
+ SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=object()),
273
+ SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=_mamba_spec()),
274
+ ],
275
+ )
276
+ config.transfer_groups = tuple(config.kv_cache_groups)
277
+
278
+ with pytest.raises(moriio_common.MoRIIOError, match="READ mode only"):
279
+ moriio_connector.MoRIIOConnectorScheduler(
280
+ _gate_vllm_config(read_mode=False), "engine", config
281
+ )
282
+
283
+
284
+def test_split_block_groups_rejects_non_hybrid_use():
285
+ sched = _FakeScheduler(_has_mamba=False)
286
+
287
+ with pytest.raises(moriio_common.MoRIIOError, match="non-hybrid"):
288
+ sched.split_block_groups(([5, 6, 7],))
289
+
290
+
291
+def test_split_block_groups_accepts_empty_abort_payload():
292
+ sched = _FakeScheduler(_has_mamba=True)
293
+
294
+ assert sched.split_block_groups(()) == ([], [])
295
+
296
+
297
+def test_split_block_groups_keeps_positional_slots_in_all_mode():
298
+ # mamba_cache_mode="all" keeps a state per block position.
299
+ sched = _FakeScheduler(
300
+ _has_mamba=True,
301
+ _attn_group_ids=[0],
302
+ _mamba_group_ids=[1],
303
+ _ssm_state_slots_are_positional=True,
304
+ )
305
+ attn, mamba = sched.split_block_groups(([1], [40, 41, 42]))
306
+ assert attn == [1]
307
+ assert mamba == [[40, 41, 42]]
308
+
309
+
310
+def test_split_block_groups_keeps_only_running_state_outside_all_mode():
311
+ sched = _FakeScheduler(
312
+ _has_mamba=True,
313
+ _attn_group_ids=[0],
314
+ _mamba_group_ids=[1],
315
+ )
316
+ assert sched.split_block_groups(([1], [40, 41])) == ([1], [[41]])
317
+
318
+
319
+# --------------------------------------------------------------------------
320
+# request_finished_all_groups
321
+# --------------------------------------------------------------------------
322
+def test_request_finished_all_groups_carries_attn_and_mamba_in_one_field():
323
+ seen = {}
324
+
325
+ def _fake_request_finished(request, attn_block_ids, mamba_block_groups):
326
+ seen["attn"] = list(attn_block_ids)
327
+ seen["mamba"] = [list(group) for group in mamba_block_groups]
328
+ return True, {
329
+ "do_remote_prefill": True,
330
+ "remote_block_ids": [attn_block_ids, *mamba_block_groups],
331
+ }
332
+
333
+ sched = _FakeScheduler(_has_mamba=True, _attn_group_ids=[0], _mamba_group_ids=[1])
334
+ sched.request_finished = _fake_request_finished
335
+ conn = _FakeConnector(sched)
336
+
337
+ request = SimpleNamespace(request_id="r0")
338
+ block_ids = ([10, 11], [42])
339
+ delay_free, params = conn.request_finished_all_groups(request, block_ids)
340
+
341
+ assert delay_free is True
342
+ # Attention ids drive request_finished; the mamba slot rides the SAME
343
+ # remote_block_ids channel as [attn, *mamba_groups], so the proxy/router
344
+ # needs no KDA-specific field.
345
+ assert seen["attn"] == [10, 11]
346
+ assert seen["mamba"] == [[42]]
347
+ assert params["remote_block_ids"] == [[10, 11], [42]]
348
+ assert "remote_mamba_block_ids" not in params
349
+
350
+
351
+def test_request_finished_all_groups_pure_attention_stays_grouped():
352
+ def _fake_request_finished(request, block_ids):
353
+ return True, {"remote_block_ids": block_ids}
354
+
355
+ sched = _FakeScheduler(_has_mamba=False, _mamba_group_ids=[])
356
+ sched.request_finished = _fake_request_finished
357
+ conn = _FakeConnector(sched)
358
+
359
+ delay_free, params = conn.request_finished_all_groups(
360
+ SimpleNamespace(request_id="r1"), ([7, 8],)
361
+ )
362
+ # Main's HMA contract keeps pure-attention block ids grouped.
363
+ assert params["remote_block_ids"] == ([7, 8],)
364
+ assert "remote_mamba_block_ids" not in params
365
+
366
+
367
+def test_connector_supports_divergent_local_hybrid_hits():
368
+ connector = _FakeConnector(None)
369
+ connector._transfers_mamba_state = True
370
+ connector.mode = MoRIIOMode.READ
371
+ assert connector.supports_divergent_local_hybrid_hits is True
372
+ connector.mode = MoRIIOMode.WRITE
373
+ assert connector.supports_divergent_local_hybrid_hits is False
374
+
375
+ connector.mode = MoRIIOMode.READ
376
+ connector._transfers_mamba_state = False
377
+ assert connector.supports_divergent_local_hybrid_hits is False
378
+
379
+
380
+def _make_read_scheduler():
381
+ return _FakeScheduler(
382
+ is_producer=False,
383
+ mode=MoRIIOMode.READ,
384
+ _has_mamba=True,
385
+ _attn_group_ids=[0],
386
+ _mamba_group_ids=[1],
387
+ _ssm_state_slots_are_positional=True,
388
+ request_id_to_transfer_id={},
389
+ transfer_id_to_request_id={},
390
+ _reqs_need_recv={},
391
+ _req_kv_params={},
392
+ _max_decode_tail_blocks=1,
393
+ )
394
+
395
+
396
+def _make_read_request(remote_block_ids):
397
+ return SimpleNamespace(
398
+ request_id="req",
399
+ kv_transfer_params={
400
+ "do_remote_prefill": True,
401
+ "transfer_id": "tx",
402
+ "remote_engine_id": "prefill",
403
+ "remote_block_ids": remote_block_ids,
404
+ },
405
+ )
406
+
407
+
408
+def test_update_state_drops_decode_recompute_tail_block():
409
+ sched = _make_read_scheduler()
410
+ request = _make_read_request([[10, 11], [90, 91]])
411
+ blocks = _FakeBlocks(
412
+ all_groups=([100, 101, 102], [200, 201, 202]),
413
+ )
414
+
415
+ sched.update_state_after_alloc(request, blocks, num_external_tokens=256)
416
+
417
+ assert sched._reqs_need_recv["req"][1] == [[100, 101], [200, 201]]
418
+ assert sched._req_kv_params["req"]["remote_block_ids"] == [
419
+ [10, 11],
420
+ [90, 91],
421
+ ]
422
+
423
+
424
+def test_update_state_pairs_shorter_local_blocks_with_remote_suffix():
425
+ sched = _make_read_scheduler()
426
+ request = _make_read_request([[10, 11, 12], [92]])
427
+ blocks = _FakeBlocks(
428
+ all_groups=([102], [202]),
429
+ )
430
+
431
+ sched.update_state_after_alloc(request, blocks, num_external_tokens=64)
432
+
433
+ assert sched._reqs_need_recv["req"][1] == [[102], [202]]
434
+ assert sched._req_kv_params["req"]["remote_block_ids"] == [[12], [92]]
435
+
436
+
437
+def test_update_state_full_attention_hit_still_carries_mamba_state():
438
+ sched = _make_read_scheduler()
439
+ request = _make_read_request([[10], [90]])
440
+
441
+ sched.update_state_after_alloc(
442
+ request,
443
+ _FakeBlocks(all_groups=([100], [200])),
444
+ num_external_tokens=0,
445
+ )
446
+
447
+ assert sched._reqs_need_recv["req"][1] == [[], [200]]
448
+
449
+
450
+def test_update_state_preserves_each_mamba_group():
451
+ sched = _make_read_scheduler()
452
+ sched._mamba_group_ids = [1, 2]
453
+ request = _make_read_request([[10], [90], [80]])
454
+
455
+ sched.update_state_after_alloc(
456
+ request,
457
+ _FakeBlocks(all_groups=([100], [200], [300])),
458
+ num_external_tokens=64,
459
+ )
460
+
461
+ assert sched._reqs_need_recv["req"][1] == [[100], [200], [300]]
462
+ assert sched._req_kv_params["req"]["remote_block_ids"] == [
463
+ [10],
464
+ [90],
465
+ [80],
466
+ ]
467
+
468
+
469
+def test_align_read_blocks_allows_configured_lookahead_tail():
470
+ assert moriio_connector.MoRIIOConnectorScheduler._align_read_blocks(
471
+ [100, 101, 102], [10], max_decode_tail_blocks=2
472
+ ) == ([100], [10])
473
+ with pytest.raises(ValueError, match="allowed local tail"):
474
+ moriio_connector.MoRIIOConnectorScheduler._align_read_blocks(
475
+ [100, 101, 102], [10], max_decode_tail_blocks=1
476
+ )
477
+
478
+
479
+def test_update_state_aligns_attention_only_decode_tail():
480
+ sched = _make_read_scheduler()
481
+ sched._has_mamba = False
482
+ sched._mamba_group_ids = []
483
+ request = _make_read_request([[10, 11]])
484
+
485
+ sched.update_state_after_alloc(
486
+ request,
487
+ _FakeBlocks(all_groups=([100, 101, 102],)),
488
+ num_external_tokens=256,
489
+ )
490
+
491
+ assert sched._reqs_need_recv["req"][1] == [[100, 101]]
492
+ assert sched._req_kv_params["req"]["remote_block_ids"] == [[10, 11]]
493
+
494
+
495
+def test_read_producer_does_not_queue_write_only_save_state():
496
+ sched = _FakeScheduler(
497
+ is_producer=True,
498
+ mode=MoRIIOMode.READ,
499
+ _has_mamba=True,
500
+ _attn_group_ids=[0],
501
+ _mamba_group_ids=[1],
502
+ request_id_to_transfer_id={},
503
+ transfer_id_to_request_id={},
504
+ _reqs_need_save={},
505
+ _req_kv_params={},
506
+ )
507
+ request = SimpleNamespace(
508
+ request_id="req",
509
+ kv_transfer_params={"do_remote_decode": True, "transfer_id": "tx"},
510
+ )
511
+
512
+ sched.update_state_after_alloc(
513
+ request,
514
+ _FakeBlocks(all_groups=([100, 101], [200])),
515
+ num_external_tokens=0,
516
+ )
517
+
518
+ assert sched._reqs_need_save == {}
519
+ assert sched._req_kv_params == {}
520
+
521
+
522
+def test_write_producer_queues_grouped_attention_blocks():
523
+ sched = _FakeScheduler(
524
+ is_producer=True,
525
+ mode=MoRIIOMode.WRITE,
526
+ _has_mamba=False,
527
+ _attn_group_ids=[0],
528
+ _mamba_group_ids=[],
529
+ request_id_to_transfer_id={},
530
+ transfer_id_to_request_id={},
531
+ _reqs_need_save={},
532
+ _req_kv_params={},
533
+ )
534
+ request = SimpleNamespace(
535
+ request_id="req",
536
+ kv_transfer_params={"do_remote_decode": True, "transfer_id": "tx"},
537
+ )
538
+
539
+ sched.update_state_after_alloc(
540
+ request,
541
+ _FakeBlocks(all_groups=([100, 101],)),
542
+ num_external_tokens=0,
543
+ )
544
+
545
+ assert sched._reqs_need_save["req"][1] == ([100, 101],)
546
+ assert sched._req_kv_params["req"] == request.kv_transfer_params
547
+
548
+
549
+def test_session_build_rejects_per_layer_region_count_mismatch():
550
+ worker = _FakeWorker(
551
+ built_write_session={},
552
+ layer_name_to_local_kv_cache_metadata={"kda.0": ["conv", "ssm"]},
553
+ layer_name_to_remote_kv_cache_metadata={"prefill": {"kda.0": ["conv"]}},
554
+ )
555
+
556
+ with pytest.raises(moriio_common.MoRIIOError, match="registered 1 region"):
557
+ worker._get_built_session("prefill")
558
+
559
+
560
+def test_register_kv_caches_rejects_hybrid_write_before_registration():
561
+ worker = _FakeWorker(mode=MoRIIOMode.WRITE, _transfer_layer_names={"kda.0"})
562
+ worker._is_mamba_layer = lambda _layer_name: True
563
+
564
+ with pytest.raises(moriio_common.MoRIIOError, match="READ mode only"):
565
+ worker.register_kv_caches({"kda.0": object()})
566
+
567
+
568
+def test_register_kv_caches_rejects_no_transfer_enabled_layers():
569
+ worker = _FakeWorker(mode=MoRIIOMode.READ, _transfer_layer_names=set())
570
+
571
+ with pytest.raises(moriio_common.MoRIIOError, match="no transfer-enabled"):
572
+ worker.register_kv_caches({"disabled.0": object()})
573
+
574
+
575
+def test_worker_rejects_incompatible_mamba_specs_before_registration():
576
+ incompatible = _mamba_spec()
577
+ object.__setattr__(incompatible, "page_size_padded", 4096)
578
+ worker = _FakeWorker(
579
+ mode=MoRIIOMode.READ,
580
+ world_size=1,
581
+ _transfer_layer_names={"kda.0", "kda.1"},
582
+ layer_to_spec={"kda.0": _mamba_spec(), "kda.1": incompatible},
583
+ )
584
+
585
+ with pytest.raises(moriio_common.MoRIIOError, match="same cache spec"):
586
+ worker.register_kv_caches({"kda.0": object(), "kda.1": object()})
587
+
588
+
589
+# --------------------------------------------------------------------------
590
+# _truncate_mamba_request_for_prefill
591
+# --------------------------------------------------------------------------
592
+def _mk_request(prompt, max_tokens=64, params=None):
593
+ return SimpleNamespace(
594
+ kv_transfer_params={} if params is None else params,
595
+ num_prompt_tokens=len(prompt),
596
+ prompt_token_ids=list(prompt),
597
+ prompt_embeds=None,
598
+ _all_token_ids=list(prompt),
599
+ max_tokens=max_tokens,
600
+ )
601
+
602
+
603
+def test_truncate_mamba_request_pops_last_token_and_caps_tokens():
604
+ sched = _FakeScheduler()
605
+ req = _mk_request([10, 11, 12, 13, 14])
606
+ sched._truncate_mamba_request_for_prefill(req)
607
+
608
+ assert req.prompt_token_ids == [10, 11, 12, 13] # last token popped
609
+ assert req._all_token_ids == [10, 11, 12, 13]
610
+ assert req.num_prompt_tokens == 4 # N-1 accounting
611
+ assert req.max_tokens == 1
612
+ assert req.kv_transfer_params["_p_side_truncated"] is True
613
+
614
+
615
+def test_truncate_mamba_request_is_idempotent_across_reschedule():
616
+ sched = _FakeScheduler()
617
+ req = _mk_request([10, 11, 12])
618
+ sched._truncate_mamba_request_for_prefill(req)
619
+ assert req.num_prompt_tokens == 2
620
+ # A second call (e.g. after a preemption) must not truncate again.
621
+ sched._truncate_mamba_request_for_prefill(req)
622
+ assert req.num_prompt_tokens == 2
623
+ assert req.prompt_token_ids == [10, 11]
624
+
625
+
626
+def test_truncate_mamba_request_noop_for_single_token_prompt():
627
+ sched = _FakeScheduler()
628
+ req = _mk_request([10])
629
+ sched._truncate_mamba_request_for_prefill(req)
630
+ assert req.num_prompt_tokens == 1
631
+ assert req.prompt_token_ids == [10]
632
+ assert "_p_side_truncated" not in req.kv_transfer_params
633
+
634
+
635
+# --------------------------------------------------------------------------
636
+# get_num_new_matched_tokens (hybrid N-1 accounting)
637
+# --------------------------------------------------------------------------
638
+def test_get_num_new_matched_tokens_read_recomputes_last_token():
639
+ sched = _FakeScheduler(is_producer=False, mode=MoRIIOMode.READ, _has_mamba=True)
640
+ req = SimpleNamespace(
641
+ num_prompt_tokens=10,
642
+ prompt_token_ids=list(range(10)),
643
+ kv_transfer_params=None,
644
+ )
645
+ n, is_async = sched.get_num_new_matched_tokens(req, num_computed_tokens=0)
646
+ # READ always recomputes the final token locally: N-1 - computed.
647
+ assert n == 9
648
+ assert is_async is False
649
+
650
+
651
+def test_get_num_new_matched_tokens_write_plain_keeps_all_tokens():
652
+ sched = _FakeScheduler(is_producer=False, mode=MoRIIOMode.WRITE, _has_mamba=False)
653
+ req = SimpleNamespace(
654
+ num_prompt_tokens=10,
655
+ prompt_token_ids=list(range(10)),
656
+ kv_transfer_params=None,
657
+ )
658
+ n, is_async = sched.get_num_new_matched_tokens(req, num_computed_tokens=2)
659
+ # Pure-attention WRITE: no N-1 drop; full length minus already-computed.
660
+ assert n == 8
661
+ assert is_async is True
662
+
663
+
664
+@pytest.mark.parametrize(
665
+ ("mode", "num_computed_tokens", "expected", "is_async"),
666
+ [
667
+ (MoRIIOMode.READ, 0, 9, False),
668
+ (MoRIIOMode.READ, 10, 0, False),
669
+ (MoRIIOMode.WRITE, 2, 8, True),
670
+ ],
671
+)
672
+def test_get_num_new_matched_tokens_supports_embeds_only_prompts(
673
+ mode, num_computed_tokens, expected, is_async
674
+):
675
+ sched = _FakeScheduler(is_producer=False, mode=mode, _has_mamba=True)
676
+ req = SimpleNamespace(
677
+ num_prompt_tokens=10,
678
+ prompt_token_ids=None,
679
+ prompt_embeds=object(),
680
+ kv_transfer_params=None,
681
+ )
682
+
683
+ assert sched.get_num_new_matched_tokens(req, num_computed_tokens) == (
684
+ expected,
685
+ is_async,
686
+ )
687
+
688
+
689
+def test_get_num_new_matched_tokens_producer_returns_zero():
690
+ sched = _FakeScheduler(is_producer=True, mode=MoRIIOMode.WRITE, _has_mamba=True)
691
+ req = _mk_request(list(range(5)), params={"do_remote_decode": True})
692
+ n, is_async = sched.get_num_new_matched_tokens(req, num_computed_tokens=0)
693
+ assert (n, is_async) == (0, False)
694
+ # The producer never truncates here: the scheduler has already measured the
695
+ # prefix-cache hit against the full prompt by this point.
696
+ assert req.num_prompt_tokens == 5
697
+
698
+
699
+def test_on_new_request_truncates_producer_prompt():
700
+ sched = _FakeScheduler(is_producer=True, mode=MoRIIOMode.WRITE, _has_mamba=True)
701
+ req = _mk_request(list(range(5)), params={"do_remote_decode": True})
702
+ sched.on_new_request(req)
703
+ # Producer stops at h(N-1): the last prompt token was dropped.
704
+ assert req.num_prompt_tokens == 4
705
+ assert req.max_tokens == 1
706
+ assert req.kv_transfer_params["_p_side_truncated"] is True
707
+
708
+
709
+def test_on_new_request_is_noop_without_mamba_or_on_decode():
710
+ plain = _FakeScheduler(is_producer=True, mode=MoRIIOMode.WRITE, _has_mamba=False)
711
+ req = _mk_request(list(range(5)), params={"do_remote_decode": True})
712
+ plain.on_new_request(req)
713
+ assert req.num_prompt_tokens == 5
714
+
715
+ decode_side = _FakeScheduler(
716
+ is_producer=False, mode=MoRIIOMode.WRITE, _has_mamba=True
717
+ )
718
+ req2 = _mk_request(list(range(5)), params={"do_remote_prefill": True})
719
+ decode_side.on_new_request(req2)
720
+ assert req2.num_prompt_tokens == 5
721
+
722
+
723
+# --------------------------------------------------------------------------
724
+# Offset-template cache
725
+# --------------------------------------------------------------------------
726
+_SAMPLE_SLOT_SETS = [
727
+ ([1, 2], [1, 2]),
728
+ ([0], [3]),
729
+ ([3, 0, 2], [3, 0, 2]),
730
+ ([2, 2], [5, 5]),
731
+ ([], []),
732
+]
733
+
734
+
735
+def test_worker_compute_mamba_offsets_caches_and_matches_template():
736
+ split = _gdn_split_info()
737
+ conv_dim = sum(split.local_proj_dims)
738
+ conv_shape = (conv_dim, split.conv_rows)
739
+ ssm_shape = (2, 4, 4)
740
+ page_bytes = 256
741
+ pages = torch.zeros((8, 1, 1, page_bytes), dtype=torch.uint8)
742
+ spec = moriio_connector.MambaSpec(
743
+ block_size=1,
744
+ shapes=(conv_shape, ssm_shape),
745
+ dtypes=(torch.bfloat16, torch.bfloat16),
746
+ page_size_padded=page_bytes,
747
+ )
748
+ conv, ssm = moriio_layout.kda_conv_ssm(pages, spec)
749
+
750
+ worker = _FakeWorker(
751
+ kv_caches={"kda.0": pages},
752
+ layer_to_spec={"kda.0": spec},
753
+ _conv_decomp=split,
754
+ _mamba_offset_templates={},
755
+ tp_rank=0,
756
+ world_size=1,
757
+ )
758
+
759
+ cached_template = None
760
+ for local_slots, remote_slots in _SAMPLE_SLOT_SETS:
761
+ lo, ro, sz, n_conv = worker._compute_mamba_transfer_offsets(
762
+ "kda.0", local_slots, remote_slots
763
+ )
764
+ template = moriio_layout.build_mamba_offset_template(
765
+ conv, ssm, split, tp_ratio=1
766
+ )
767
+ expected = moriio_layout.apply_mamba_offset_template(
768
+ template, local_slots, remote_slots
769
+ )
770
+ assert (lo, ro, sz) == expected
771
+ assert n_conv == moriio_layout.compute_mamba_conv_split_count(
772
+ local_slots, split
773
+ )
774
+
775
+ current_template = worker._mamba_offset_templates["kda.0"]
776
+ if cached_template is None:
777
+ cached_template = current_template
778
+ else:
779
+ assert current_template is cached_template
780
+
781
+ assert "kda.0" in worker._mamba_offset_templates
782
+
783
+
784
+@pytest.mark.parametrize("remote_tp_size", [1, None])
785
+def test_mamba_tp_ratio_accepts_only_equal_tp(remote_tp_size):
786
+ worker = _FakeWorker(world_size=1)
787
+ assert worker._mamba_tp_ratio(remote_tp_size) == 1
788
+
789
+
790
+@pytest.mark.parametrize("remote_tp_size", [2, 3])
791
+def test_mamba_tp_ratio_rejects_heterogeneous_tp(remote_tp_size):
792
+ worker = _FakeWorker(world_size=1)
793
+ with pytest.raises(NotImplementedError, match="heterogeneous-TP"):
794
+ worker._mamba_tp_ratio(remote_tp_size)
795
+
796
+
797
+def test_worker_selects_each_mamba_layers_own_block_group():
798
+ worker = _FakeWorker(_mamba_payload_index_by_layer={"kda.0": 1, "kda.1": 2})
799
+ groups = ([10], [40], [70])
800
+
801
+ assert worker._mamba_blocks_for_layer("kda.0", groups) == [40]
802
+ assert worker._mamba_blocks_for_layer("kda.1", groups) == [70]
803
+
804
+
805
+def test_worker_rejects_missing_mamba_block_group():
806
+ worker = _FakeWorker(_mamba_payload_index_by_layer={"kda.0": 2})
807
+
808
+ with pytest.raises(moriio_common.MoRIIOError, match="no block group"):
809
+ worker._mamba_blocks_for_layer("kda.0", ([10], [40]))
tests/v1/kv_connector/unit/test_moriio_kv_layout.py
+275
@@ -2,6 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
import importlib.util
5
+import math
6
import threading
7
from collections import OrderedDict, defaultdict
8
from queue import Queue
@@ -14,6 +15,7 @@ import torch
15
from vllm.platforms import current_platform
16
from vllm.v1.kv_cache_interface import (
17
FullAttentionSpec,
18
+ MambaSpec,
19
MLAAttentionSpec,
20
)
21
@@ -36,6 +38,10 @@ moriio_layout = importlib.import_module(
38
"vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_layout"
39
)
40
msgpack = importlib.import_module("msgpack")
41
+ssm_conv_transfer_utils = importlib.import_module(
42
+ "vllm.distributed.kv_transfer.kv_connector.v1.ssm_conv_transfer_utils"
43
+)
44
+MambaConvSplitInfo = ssm_conv_transfer_utils.MambaConvSplitInfo
45
46
ROLE = moriio_common.ROLE
47
MoRIIOError = moriio_common.MoRIIOError
@@ -353,6 +359,9 @@ def test_write_transfer_plan_caches_offsets_per_layer_geometry():
359
kv_caches: dict[str, torch.Tensor]
360
layer_name_to_local_kv_cache_metadata: dict[str, list[Any]]
361
362
+ def _region_session_indices(self, layer_name):
363
+ return [list(self.kv_caches).index(layer_name)]
364
+
365
def _compute_block_transfer_offsets(
366
self,
367
layer_name,
@@ -673,6 +682,30 @@ def test_local_block_ids_longer_than_remote_raises_value_error():
682
)
683
684
685
+@pytest.mark.parametrize(
686
+ ("local_block_ids", "remote_block_ids", "match"),
687
+ [
688
+ ([-1], [0], "local block id -1"),
689
+ ([0], [8], "remote block id 8"),
690
+ ],
691
+)
692
+def test_block_ids_must_fit_registered_regions(
693
+ local_block_ids, remote_block_ids, match
694
+):
695
+ cache = torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16)
696
+ worker = _worker({"layer": cache}, {"layer": _full_spec()})
697
+
698
+ with pytest.raises(ValueError, match=match):
699
+ moriio_layout.compute_block_transfer_offsets(
700
+ "layer",
701
+ cache,
702
+ worker.layer_to_spec,
703
+ local_block_ids,
704
+ remote_block_ids,
705
+ _remote_meta(num_blocks=8).num_blocks,
706
+ )
707
+
708
+
709
def test_empty_local_block_ids_is_free_only_noop():
710
cache = torch.empty((8, 2, 4, 2, 3), dtype=torch.bfloat16)
711
worker = _worker({"layer": cache}, {"layer": _full_spec()})
@@ -745,3 +778,245 @@ def test_unsupported_shape_raises_value_error():
778
779
with pytest.raises(ValueError, match="Unsupported MoRIIO K/V cache shape"):
780
moriio_layout.get_layer_transfer_geometry("layer", cache, worker.layer_to_spec)
781
+
782
+
783
+# ---------------------------------------------------------------------------
784
+# Mamba/KDA (conv + ssm) transfer geometry
785
+# ---------------------------------------------------------------------------
786
+
787
+
788
+def _slot_strided(num_slots, per_slot_shape, slot_stride, dtype=torch.bfloat16):
789
+ """Build a slot-strided view: shape (num_slots, *per_slot_shape) whose slot
790
+ stride (dim 0) exceeds the per-slot element count, mimicking the K3 KDA
791
+ conv/ssm cache tensors."""
792
+ inner = 1
793
+ for d in per_slot_shape:
794
+ inner *= d
795
+ assert slot_stride >= inner
796
+ backing = torch.zeros(num_slots * slot_stride, dtype=dtype)
797
+ inner_strides: list[int] = []
798
+ acc = 1
799
+ for d in reversed(per_slot_shape):
800
+ inner_strides.insert(0, acc)
801
+ acc *= d
802
+ return backing.as_strided(
803
+ (num_slots, *per_slot_shape), (slot_stride, *inner_strides)
804
+ )
805
+
806
+
807
+def _gdn_split_info(conv_rows=3, key_dim=4, value_dim=8, dtype_size=2):
808
+ """A GDN-style conv split: sub-projections (key, key, value)."""
809
+ conv_dim = 2 * key_dim + value_dim
810
+ conv_state_bytes = conv_dim * conv_rows * dtype_size
811
+ ssm_state_bytes = 64 # opaque here; ssm size is taken from the tensor
812
+ return MambaConvSplitInfo(
813
+ conv_rows=conv_rows,
814
+ local_proj_dims=(key_dim, key_dim, value_dim),
815
+ conv_dtype_size=dtype_size,
816
+ ssm_sizes=(conv_state_bytes, ssm_state_bytes),
817
+ )
818
+
819
+
820
+def test_mamba_transfer_geometry_is_slot_strided():
821
+ split = _gdn_split_info()
822
+ conv_dim = sum(split.local_proj_dims)
823
+ conv = _slot_strided(5, (conv_dim, split.conv_rows), slot_stride=200)
824
+ ssm = _slot_strided(5, (2, 4, 4), slot_stride=64)
825
+
826
+ geom = moriio_layout.get_mamba_transfer_geometry(conv, ssm)
827
+
828
+ assert geom.conv_slot_stride == 200
829
+ assert geom.ssm_slot_stride == 64
830
+ assert geom.ssm_slot_bytes == (2 * 4 * 4) * ssm.element_size()
831
+ # Region spans the full slot-strided extent (shape[0] * stride(0)), not just
832
+ # numel(), so the highest-slot transfers stay in-bounds.
833
+ assert geom.conv_region_len == conv.shape[0] * conv.stride(0) * conv.element_size()
834
+ assert geom.ssm_region_len == ssm.shape[0] * ssm.stride(0) * ssm.element_size()
835
+
836
+
837
+def test_kda_conv_ssm_unpacks_padded_pages_as_aliasing_strided_views():
838
+ num_blocks = 4
839
+ page_bytes = 128
840
+ conv_shape = (3, 4)
841
+ ssm_shape = (2, 2, 4)
842
+ conv_bytes = math.prod(conv_shape) * torch.bfloat16.itemsize
843
+ ssm_bytes = math.prod(ssm_shape) * torch.bfloat16.itemsize
844
+ assert conv_bytes + ssm_bytes < page_bytes
845
+
846
+ pages = torch.zeros((num_blocks, 1, 1, page_bytes), dtype=torch.uint8)
847
+ spec = MambaSpec(
848
+ block_size=1,
849
+ shapes=(conv_shape, ssm_shape),
850
+ dtypes=(torch.bfloat16, torch.bfloat16),
851
+ page_size_padded=page_bytes,
852
+ )
853
+
854
+ conv, ssm = moriio_layout.kda_conv_ssm(pages, spec)
855
+
856
+ assert conv.shape == (num_blocks, *conv_shape)
857
+ assert ssm.shape == (num_blocks, *ssm_shape)
858
+ assert conv.stride() == (page_bytes // 2, 4, 1)
859
+ assert ssm.stride() == (page_bytes // 2, 8, 4, 1)
860
+ assert conv.untyped_storage().data_ptr() == pages.untyped_storage().data_ptr()
861
+ assert ssm.untyped_storage().data_ptr() == pages.untyped_storage().data_ptr()
862
+ assert conv.storage_offset() == 0
863
+ assert ssm.storage_offset() * ssm.element_size() == conv_bytes
864
+
865
+ conv[1].fill_(1)
866
+ ssm[2].fill_(2)
867
+ assert torch.all(pages[1, 0, 0, :conv_bytes].view(torch.bfloat16) == 1)
868
+ assert torch.all(
869
+ pages[2, 0, 0, conv_bytes : conv_bytes + ssm_bytes].view(torch.bfloat16) == 2
870
+ )
871
+
872
+
873
+def test_kda_conv_ssm_accepts_strided_blocks_without_copying():
874
+ backing = torch.zeros(4 * 256, dtype=torch.uint8)
875
+ pages = backing.as_strided((4, 1, 1, 128), (256, 128, 128, 1))
876
+ spec = MambaSpec(
877
+ block_size=1,
878
+ shapes=((3, 4), (2, 2, 4)),
879
+ dtypes=(torch.bfloat16, torch.bfloat16),
880
+ page_size_padded=128,
881
+ )
882
+
883
+ conv, ssm = moriio_layout.kda_conv_ssm(pages, spec)
884
+
885
+ assert not pages.is_contiguous()
886
+ assert conv.stride(0) == 128
887
+ assert ssm.stride(0) == 128
888
+ assert conv.untyped_storage().data_ptr() == backing.data_ptr()
889
+ assert ssm.untyped_storage().data_ptr() == backing.data_ptr()
890
+
891
+
892
+def test_kda_conv_ssm_rejects_noncontiguous_page_bytes():
893
+ pages = torch.zeros((4, 1, 1, 256), dtype=torch.uint8)[..., ::2]
894
+ spec = MambaSpec(
895
+ block_size=1,
896
+ shapes=((3, 4), (2, 2, 4)),
897
+ dtypes=(torch.bfloat16, torch.bfloat16),
898
+ page_size_padded=128,
899
+ )
900
+
901
+ with pytest.raises(ValueError, match="contiguous byte dimension"):
902
+ moriio_layout.kda_conv_ssm(pages, spec)
903
+
904
+
905
+def test_kda_conv_ssm_rejects_non_byte_packed_cache():
906
+ pages = torch.zeros((4, 1, 1, 128), dtype=torch.bfloat16)
907
+ spec = MambaSpec(
908
+ block_size=1,
909
+ shapes=((3, 4), (2, 2, 4)),
910
+ dtypes=(torch.bfloat16, torch.bfloat16),
911
+ page_size_padded=128,
912
+ )
913
+
914
+ with pytest.raises(ValueError, match="byte-sized dtype"):
915
+ moriio_layout.kda_conv_ssm(pages, spec)
916
+
917
+
918
+def test_mamba_transfer_geometry_requires_matching_slot_counts():
919
+ conv = torch.empty((5, 4, 3), dtype=torch.bfloat16)
920
+ ssm = torch.empty((4, 2, 4, 4), dtype=torch.bfloat16)
921
+
922
+ with pytest.raises(ValueError, match="conv/ssm slot count mismatch"):
923
+ moriio_layout.get_mamba_transfer_geometry(conv, ssm)
924
+
925
+
926
+def test_mamba_transfer_geometry_rejects_empty_caches():
927
+ conv = torch.empty((0, 4, 3), dtype=torch.bfloat16)
928
+ ssm = torch.empty((0, 2, 4, 4), dtype=torch.bfloat16)
929
+
930
+ with pytest.raises(ValueError, match="at least one slot"):
931
+ moriio_layout.get_mamba_transfer_geometry(conv, ssm)
932
+
933
+
934
+def test_mamba_conv_ssm_offsets_homogeneous_tp():
935
+ split = _gdn_split_info()
936
+ conv_dim = sum(split.local_proj_dims)
937
+ conv = _slot_strided(6, (conv_dim, split.conv_rows), slot_stride=200)
938
+ ssm = _slot_strided(6, (2, 4, 4), slot_stride=64)
939
+ geom = moriio_layout.get_mamba_transfer_geometry(conv, ssm)
940
+
941
+ local_slots = [1, 2]
942
+ remote_slots = [1, 2]
943
+ template = moriio_layout.build_mamba_offset_template(conv, ssm, split, tp_ratio=1)
944
+ local_offs, remote_offs, sizes = moriio_layout.apply_mamba_offset_template(
945
+ template, local_slots, remote_slots
946
+ )
947
+
948
+ n_conv = moriio_layout.compute_mamba_conv_split_count(local_slots, split)
949
+ assert n_conv == len(local_slots) * len(split.local_proj_dims)
950
+ assert len(local_offs) == n_conv + len(local_slots)
951
+
952
+ # Homogeneous TP: remote conv slices equal local slices; since local==remote
953
+ # slots, conv local and remote offsets coincide.
954
+ loc_co = split.local_conv_offsets
955
+ rem_co = split.remote_conv_offsets(0, 1)
956
+ k = 0
957
+ esz = conv.element_size()
958
+ for ls, rs in zip(local_slots, remote_slots):
959
+ lbase = ls * geom.conv_slot_stride * esz
960
+ rbase = rs * geom.conv_slot_stride * esz
961
+ for (loff, _), (roff, rsz) in zip(loc_co, rem_co):
962
+ assert local_offs[k] == lbase + loff
963
+ assert remote_offs[k] == rbase + roff
964
+ assert sizes[k] == rsz
965
+ k += 1
966
+
967
+ # SSM: whole per-slot block, no head sharding at tp_ratio == 1.
968
+ essz = ssm.element_size()
969
+ for j, (ls, rs) in enumerate(zip(local_slots, remote_slots)):
970
+ assert local_offs[n_conv + j] == ls * geom.ssm_slot_stride * essz
971
+ assert remote_offs[n_conv + j] == rs * geom.ssm_slot_stride * essz
972
+ assert sizes[n_conv + j] == geom.ssm_slot_bytes
973
+
974
+
975
+def test_mamba_conv_ssm_offsets_heterogeneous_tp_is_gated():
976
+ # Heterogeneous-TP mamba transfer is a documented follow-up (remote slot
977
+ # stride + multi-rank fan-in gather). Until then the offset helper must fail
978
+ # loudly rather than silently transfer wrong bytes, since the mamba path
979
+ # bypasses the attention KV-head validator.
980
+ split = _gdn_split_info()
981
+ conv_dim = sum(split.local_proj_dims)
982
+ conv = _slot_strided(4, (conv_dim, split.conv_rows), slot_stride=200)
983
+ ssm = _slot_strided(4, (2, 4, 4), slot_stride=64)
984
+ with pytest.raises(NotImplementedError):
985
+ moriio_layout.build_mamba_offset_template(conv, ssm, split, tp_ratio=2)
986
+
987
+
988
+def test_local_mamba_slots_must_fit_registered_regions():
989
+ split = _gdn_split_info()
990
+ conv_dim = sum(split.local_proj_dims)
991
+ conv = _slot_strided(6, (conv_dim, split.conv_rows), slot_stride=200)
992
+ ssm = _slot_strided(6, (2, 4, 4), slot_stride=64)
993
+ template = moriio_layout.build_mamba_offset_template(conv, ssm, split, tp_ratio=1)
994
+
995
+ with pytest.raises(ValueError, match="local mamba slot 6"):
996
+ moriio_layout.apply_mamba_offset_template(template, [6], [0])
997
+
998
+
999
+@pytest.mark.parametrize(
1000
+ ("local_slots", "remote_slots"),
1001
+ [([1, 2], [3]), ([1], [2, 3])],
1002
+)
1003
+def test_mamba_slot_counts_must_match(local_slots, remote_slots):
1004
+ split = _gdn_split_info()
1005
+ conv_dim = sum(split.local_proj_dims)
1006
+ conv = _slot_strided(6, (conv_dim, split.conv_rows), slot_stride=200)
1007
+ ssm = _slot_strided(6, (2, 4, 4), slot_stride=64)
1008
+ template = moriio_layout.build_mamba_offset_template(conv, ssm, split, tp_ratio=1)
1009
+
1010
+ with pytest.raises(ValueError, match="slot counts must match"):
1011
+ moriio_layout.apply_mamba_offset_template(template, local_slots, remote_slots)
1012
+
1013
+
1014
+def test_remote_mamba_slots_must_be_non_negative():
1015
+ split = _gdn_split_info()
1016
+ conv_dim = sum(split.local_proj_dims)
1017
+ conv = _slot_strided(6, (conv_dim, split.conv_rows), slot_stride=200)
1018
+ ssm = _slot_strided(6, (2, 4, 4), slot_stride=64)
1019
+ template = moriio_layout.build_mamba_offset_template(conv, ssm, split, tp_ratio=1)
1020
+
1021
+ with pytest.raises(ValueError, match="must be non-negative"):
1022
+ moriio_layout.apply_mamba_offset_template(template, [1], [-1])
tests/v1/kv_connector/unit/test_moriio_routing.py
+1
@@ -28,6 +28,7 @@ def _producer(global_dp_rank: int, dp_size: int, dp_size_local: int):
28
s = SimpleNamespace(
29
is_producer=True,
30
_is_hma_required=False,
31
+ _has_mamba=False,
32
blocks_per_sw=[0],
33
_global_dp_rank=global_dp_rank,
34
engine_id="engine-abc",
tests/v1/kv_connector/unit/test_moriio_tp_ack.py
+141
-1
@@ -2,12 +2,14 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
import threading
5
+from types import SimpleNamespace
6
7
import pytest
8
9
from vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_common import (
10
MoRIIOMode,
11
MoRIIOTransferAck,
12
+ TransferError,
13
)
14
from vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_connector import (
15
MoRIIOConnector,
@@ -17,6 +19,9 @@ from vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_connector import
19
resolve_moriio_transfer_ack,
20
validate_moriio_heterogeneous_tp_kv_heads,
21
)
22
+from vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_engine import (
23
+ MoRIIOWrapper,
24
+)
25
26
27
def test_remote_tp_rank_same_tp_maps_to_self():
@@ -277,6 +282,9 @@ def test_read_completion_sends_structured_release_with_consumer_tp_size():
282
self.lock = threading.Lock()
283
self.sent = []
284
285
+ # Exercise the real batch verdict rather than restating it here.
286
+ poll_transfer_batch = MoRIIOWrapper.poll_transfer_batch
287
+
288
def send_notify(
289
self,
290
transfer_id,
@@ -292,13 +300,18 @@ def test_read_completion_sends_structured_release_with_consumer_tp_size():
300
301
worker = MoRIIOConnectorWorker.__new__(MoRIIOConnectorWorker)
302
worker.world_size = 8
303
+ worker.moriio_config = SimpleNamespace(recv_abort_timeout=600.0)
304
worker.moriio_wrapper = FakeWrapper()
296
- worker._recving_transfers = {"req": {"layer0": DoneStatus()}}
305
+ # A layer maps to the list of reads posted for it (a KDA layer posts two).
306
+ worker._recving_transfers = {"req": {"layer0": [DoneStatus(), DoneStatus()]}}
307
worker._recving_transfers_callback_addr = {
308
"req": ("127.0.0.1", "7000", "tx-release")
309
}
310
# Transfer-timeout reaping state consulted by _pop_done_transfers.
311
worker._recving_transfers_start = {}
312
+ # Load-error bookkeeping cleared alongside a completed transfer.
313
+ worker._recving_local_blocks = {}
314
+ worker._invalid_block_ids = set()
315
316
assert worker._pop_done_transfers() == {"tx-release"}
317
assert worker.moriio_wrapper.sent == [
@@ -314,6 +327,133 @@ def test_read_completion_sends_structured_release_with_consumer_tp_size():
327
assert worker._recving_transfers_callback_addr == {}
328
329
330
+def test_aborted_read_without_local_blocks_only_releases_remote_blocks():
331
+ class FakeWrapper:
332
+ def __init__(self):
333
+ self.sent = []
334
+
335
+ def send_notify(
336
+ self,
337
+ transfer_id,
338
+ host,
339
+ port,
340
+ message_type=None,
341
+ message_fields=None,
342
+ ):
343
+ self.sent.append((transfer_id, host, port, message_type, message_fields))
344
+
345
+ def shutdown(self):
346
+ pass
347
+
348
+ worker = MoRIIOConnectorWorker.__new__(MoRIIOConnectorWorker)
349
+ worker.mode = MoRIIOMode.READ
350
+ worker.world_size = 8
351
+ worker.moriio_wrapper = FakeWrapper()
352
+
353
+ worker._read_blocks(
354
+ local_block_ids=[],
355
+ remote_block_ids=[[10, 11], [90]],
356
+ dst_engine_id="prefill",
357
+ request_id="req-aborted",
358
+ transfer_id="tx-aborted",
359
+ remote_host="127.0.0.1",
360
+ remote_notify_port=7000,
361
+ remote_tp_size=8,
362
+ remote_dp_rank=0,
363
+ chosen_tp=2,
364
+ )
365
+
366
+ assert worker.moriio_wrapper.sent == [
367
+ (
368
+ "tx-aborted",
369
+ "127.0.0.1",
370
+ "7002",
371
+ "release",
372
+ {"consumer_tp_size": 8},
373
+ )
374
+ ]
375
+
376
+
377
+def test_read_completion_waits_for_every_posted_read():
378
+ class Status:
379
+ def __init__(self, done: bool):
380
+ self.done = done
381
+
382
+ def Succeeded(self):
383
+ return self.done
384
+
385
+ def Failed(self):
386
+ return False
387
+
388
+ class FakeWrapper:
389
+ def __init__(self):
390
+ self.lock = threading.Lock()
391
+ self.sent = []
392
+
393
+ poll_transfer_batch = MoRIIOWrapper.poll_transfer_batch
394
+
395
+ def send_notify(self, *args, **kwargs):
396
+ self.sent.append((args, kwargs))
397
+
398
+ def shutdown(self):
399
+ pass
400
+
401
+ worker = MoRIIOConnectorWorker.__new__(MoRIIOConnectorWorker)
402
+ worker.world_size = 8
403
+ worker.moriio_config = SimpleNamespace(recv_abort_timeout=600.0)
404
+ worker.moriio_wrapper = FakeWrapper()
405
+ worker._recving_transfers = {
406
+ "req": {"kda_layer": [Status(done=True), Status(done=False)]}
407
+ }
408
+ worker._recving_transfers_callback_addr = {
409
+ "req": ("127.0.0.1", "7000", "tx-pending")
410
+ }
411
+ worker._recving_transfers_start = {"req": float("inf")}
412
+ worker._recving_local_blocks = {"req": [1, 2]}
413
+ worker._invalid_block_ids = set()
414
+
415
+ assert worker._pop_done_transfers() == set()
416
+ assert "req" in worker._recving_transfers
417
+ assert worker.moriio_wrapper.sent == []
418
+
419
+
420
+@pytest.mark.parametrize(
421
+ ("has_mamba", "expected_invalid"),
422
+ [(False, {1, 2}), (True, set())],
423
+)
424
+def test_failed_read_reports_blocks_only_without_hma(has_mamba, expected_invalid):
425
+ worker = MoRIIOConnectorWorker.__new__(MoRIIOConnectorWorker)
426
+ worker._has_mamba = has_mamba
427
+ worker._recving_local_blocks = {"req": [1, 2]}
428
+ worker._invalid_block_ids = set()
429
+
430
+ worker._record_failed_recv("req")
431
+
432
+ assert worker.get_block_ids_with_load_errors() == expected_invalid
433
+
434
+
435
+def test_hybrid_step_barrier_fails_closed(monkeypatch):
436
+ class FailingWrapper:
437
+ def waiting_for_transfer_complete(self, _statuses):
438
+ raise TransferError("failed")
439
+
440
+ def shutdown(self):
441
+ pass
442
+
443
+ worker = MoRIIOConnectorWorker.__new__(MoRIIOConnectorWorker)
444
+ worker._has_mamba = True
445
+ worker._reads_issued_this_step = [object()]
446
+ worker._mamba_reads_this_step = [object()]
447
+ worker.moriio_wrapper = FailingWrapper()
448
+ monkeypatch.setattr(
449
+ "vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_connector.get_forward_context",
450
+ lambda: SimpleNamespace(cudagraph_runtime_mode=None),
451
+ )
452
+
453
+ with pytest.raises(TransferError, match="failed"):
454
+ worker._await_reads_issued_this_step()
455
+
456
+
457
def test_requested_cudagraph_mode_is_never_overridden():
458
# The configured cudagraph mode is always honored: the barrier fires when
459
# the operator sets cudagraph_mode=PIECEWISE, and READ mode with full
tests/v1/kv_connector/unit/test_moriio_transfer_completion.py
new
+129
@@ -0,0 +1,129 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Transfer-completion semantics of the MoRIIO wrapper.
4
+
5
+Covers the two behaviours the READ barrier depends on:
6
+ * poll_transfer_batch considers EVERY status, not just the newest one
7
+ * waiting_for_transfer_complete blocks until terminal and raises on failure.
8
+"""
9
+
10
+import importlib
11
+import importlib.util
12
+import threading
13
+import time
14
+
15
+import pytest
16
+
17
+from vllm.platforms import current_platform
18
+
19
+mori_available = importlib.util.find_spec("mori") is not None
20
+
21
+if not (current_platform.is_rocm() and mori_available):
22
+ pytest.skip(
23
+ "MoRIIOs are only available on ROCm with mori package installed",
24
+ allow_module_level=True,
25
+ )
26
+
27
+moriio_common = importlib.import_module(
28
+ "vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_common"
29
+)
30
+moriio_engine = importlib.import_module(
31
+ "vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_engine"
32
+)
33
+
34
+MoRIIOWrapper = moriio_engine.MoRIIOWrapper
35
+TransferBatchState = moriio_common.TransferBatchState
36
+TransferError = moriio_common.TransferError
37
+
38
+
39
+@pytest.mark.parametrize("value", [0, -1, float("inf"), float("nan")])
40
+def test_timeout_must_be_positive_and_finite(value):
41
+ with pytest.raises(ValueError, match="must be finite and greater than zero"):
42
+ moriio_common._positive_finite_timeout("test_timeout", value)
43
+
44
+
45
+def test_positive_finite_timeout_is_preserved():
46
+ assert moriio_common._positive_finite_timeout("test_timeout", "1.5") == 1.5
47
+
48
+
49
+class FakeStatus:
50
+ """Mirrors the mori TransferStatus surface the wrapper uses."""
51
+
52
+ def __init__(self, state: str = "in_progress", message: str = ""):
53
+ self.state = state
54
+ self.message = message
55
+
56
+ def Succeeded(self) -> bool:
57
+ return self.state == "success"
58
+
59
+ def Failed(self) -> bool:
60
+ return self.state == "failed"
61
+
62
+ def Message(self) -> str:
63
+ return self.message
64
+
65
+ def Code(self) -> str:
66
+ return self.state
67
+
68
+
69
+def _ok():
70
+ return FakeStatus("success")
71
+
72
+
73
+def _pending():
74
+ return FakeStatus("in_progress")
75
+
76
+
77
+def _bad():
78
+ return FakeStatus("failed", "SQ full")
79
+
80
+
81
+@pytest.mark.parametrize(
82
+ "statuses,expected",
83
+ [
84
+ ([], TransferBatchState.DONE),
85
+ ([_ok(), _ok()], TransferBatchState.DONE),
86
+ # Each case below puts the interesting status BEFORE a success, which a
87
+ # statuses[-1] check would have missed entirely.
88
+ ([_bad(), _ok()], TransferBatchState.FAILED),
89
+ ([_pending(), _ok()], TransferBatchState.PENDING),
90
+ ([_pending(), _bad(), _ok()], TransferBatchState.FAILED),
91
+ ],
92
+)
93
+def test_poll_transfer_batch_considers_every_status(statuses, expected):
94
+ wrapper = MoRIIOWrapper(moriio_engine=object(), transfer_timeout=0.05)
95
+ assert wrapper.poll_transfer_batch(statuses) is expected
96
+
97
+
98
+@pytest.mark.parametrize(
99
+ "statuses_factory,expected_message",
100
+ [
101
+ (lambda: [_ok(), _bad()], "SQ full"),
102
+ (lambda: [_ok(), _pending()], "timed out"),
103
+ ],
104
+ ids=["failed", "timeout"],
105
+)
106
+def test_wait_raises_with_detail(statuses_factory, expected_message):
107
+ wrapper = MoRIIOWrapper(moriio_engine=object(), transfer_timeout=0.05)
108
+ with pytest.raises(TransferError, match=expected_message):
109
+ wrapper.waiting_for_transfer_complete(statuses_factory())
110
+
111
+
112
+def test_wait_returns_immediately_when_nothing_pending():
113
+ wrapper = MoRIIOWrapper(moriio_engine=object(), transfer_timeout=0.05)
114
+ wrapper.waiting_for_transfer_complete([])
115
+ wrapper.waiting_for_transfer_complete([_ok(), _ok()])
116
+
117
+
118
+def test_wait_blocks_until_terminal():
119
+ """The barrier is only worth anything if it actually waits."""
120
+ wrapper = MoRIIOWrapper(moriio_engine=object(), transfer_timeout=5.0)
121
+ late = FakeStatus("in_progress")
122
+ timer = threading.Timer(0.2, lambda: setattr(late, "state", "success"))
123
+ timer.start()
124
+ try:
125
+ start = time.monotonic()
126
+ wrapper.waiting_for_transfer_complete([late])
127
+ assert time.monotonic() - start >= 0.15
128
+ finally:
129
+ timer.cancel()
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_common.py
+56
-7
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import contextlib
4
+import math
5
import os
6
import threading
7
import time
@@ -45,6 +46,13 @@ TransferId = str
46
TransferOffsetsKey = tuple[str, tuple[int, ...], tuple[int, ...], torch.dtype]
47
48
49
+def _positive_finite_timeout(name: str, value: Any) -> float:
50
+ timeout = float(value)
51
+ if not math.isfinite(timeout) or timeout <= 0:
52
+ raise ValueError(f"{name} must be finite and greater than zero")
53
+ return timeout
54
+
55
+
56
class MoRIIOTransferAck(NamedTuple):
57
transfer_id: TransferId
58
consumer_tp_size: int = 1
@@ -160,6 +168,20 @@ class MoRIIOMode(Enum):
168
WRITE = "write"
169
170
171
+class TransferBatchState(Enum):
172
+ """Verdict for a group of transfer statuses that belong to one request.
173
+
174
+ A request's KV transfer is spread over one status per layer (two for a KDA
175
+ layer), so a single status never decides the request: PENDING means at
176
+ least one is still in flight and none has failed, FAILED means at least
177
+ one failed, DONE means all succeeded.
178
+ """
179
+
180
+ DONE = "done"
181
+ FAILED = "failed"
182
+ PENDING = "pending"
183
+
184
+
185
class MoRIIOError(Exception):
186
"""Base exception for MoRIIO operations."""
187
@@ -260,6 +282,7 @@ class MoRIIOConfig:
282
tp_size: int
283
transfer_timeout: float
284
defer_timeout: float
285
+ recv_abort_timeout: float
286
read_mode: bool = False
287
qp_per_transfer: int = 1
288
post_batch_size: int = -1
@@ -284,6 +307,8 @@ class MoRIIOConfig:
307
# raising TransferError (sec).
308
# defer_timeout -> Timeout before a deferred send with no finished_sending
309
# notification is reaped and its blocks force-freed (sec).
310
+ # recv_abort_timeout -> Timeout before an in-flight recv whose RDMA
311
+ # completion never arrived is aborted (sec).
312
313
# Knobs for RDMA transfers, ignored if on xgmi backend
314
# qp_per_transfer -> Number of RDMA Queue Pairs per KV transfer.
@@ -315,13 +340,29 @@ class MoRIIOConfig:
340
"must be one of 'rdma' or 'xgmi'."
341
)
342
318
- transfer_timeout = float(
343
+ transfer_timeout = _positive_finite_timeout(
344
+ "kv_connector_extra_config.transfer_timeout",
345
extra_config.get(
346
"transfer_timeout", MoRIIOConstants.DEFAULT_TRANSFER_TIMEOUT
321
- )
347
+ ),
348
)
323
- defer_timeout = float(
324
- extra_config.get("defer_timeout", MoRIIOConstants.DEFAULT_DEFER_TIMEOUT)
349
+ defer_timeout = _positive_finite_timeout(
350
+ "kv_connector_extra_config.defer_timeout",
351
+ extra_config.get("defer_timeout", MoRIIOConstants.DEFAULT_DEFER_TIMEOUT),
352
+ )
353
+ legacy_recv_abort_timeout = os.environ.get("VLLM_MORIIO_TRANSFER_TIMEOUT_S")
354
+ if legacy_recv_abort_timeout is not None:
355
+ logger.warning_once(
356
+ "The environment variable VLLM_MORIIO_TRANSFER_TIMEOUT_S is "
357
+ "deprecated. Set 'recv_abort_timeout' inside "
358
+ "kv_transfer_config.kv_connector_extra_config instead."
359
+ )
360
+ recv_abort_timeout = _positive_finite_timeout(
361
+ "kv_connector_extra_config.recv_abort_timeout",
362
+ extra_config.get(
363
+ "recv_abort_timeout",
364
+ legacy_recv_abort_timeout or MoRIIOConstants.DEFAULT_RECV_ABORT_TIMEOUT,
365
+ ),
366
)
367
368
return cls(
@@ -347,6 +388,7 @@ class MoRIIOConfig:
388
backend=backend,
389
transfer_timeout=transfer_timeout,
390
defer_timeout=defer_timeout,
391
+ recv_abort_timeout=recv_abort_timeout,
392
)
393
394
@@ -374,6 +416,10 @@ class MoRIIOConstants:
416
# notification is reaped and its blocks force-freed.
417
# Overridable via kv_connector_extra_config["defer_timeout"].
418
DEFAULT_DEFER_TIMEOUT = 60.0
419
+ # Timeout (seconds) before an in-flight recv whose RDMA completion was lost
420
+ # is aborted, so the decode worker does not hang on it forever.
421
+ # Overridable via kv_connector_extra_config["recv_abort_timeout"].
422
+ DEFAULT_RECV_ABORT_TIMEOUT = 120.0
423
424
425
# The router embeds both zmq_addresses in the request_id:
@@ -567,9 +613,12 @@ class MoRIIOConnectorMetadata(KVConnectorMetadata):
613
remote_handshake_port=int(remote_handshake_port),
614
remote_notify_port=int(remote_notify_port),
615
# Remote peer TP degree (used as remote_tp_size downstream). The
570
- # proxy advertises it under "remote_tp_size"; #46332 read "tp_size"
571
- # which is absent on WRITE producer requests -> defaulted to 1 ->
572
- # rank collapse. Read the right key; 0 == unknown (== homogeneous).
616
+ # decode side must know the prefiller's TP degree to map producer
617
+ # ranks onto the correct decode rank (and vice versa). The old plain
618
+ # "tp_size" key (#46332) is absent on WRITE producer requests and
619
+ # defaulted to 1, collapsing every producer rank onto decode rank 0
620
+ # and corrupting the transfer; prefer "remote_tp_size". 0 == unknown
621
+ # (treated as homogeneous downstream).
622
tp_size=int(
623
kv_transfer_params.get("remote_tp_size")
624
or kv_transfer_params.get("tp_size")
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_connector.py
+1003
-167
@@ -2,14 +2,13 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import logging
4
import math
5
-import os
5
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
12
-from typing import TYPE_CHECKING, Any
11
+from typing import TYPE_CHECKING, Any, cast
12
13
import msgpack
14
import msgspec
@@ -33,10 +32,13 @@ from vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_common import (
32
MoRIIOConfig,
33
MoRIIOConnectorMetadata,
34
MoRIIOConstants,
35
+ MoRIIOError,
36
MoRIIOMode,
37
MoRIIOTransferAck,
38
ReqId,
39
ReqMeta,
40
+ TransferBatchState,
41
+ TransferError,
42
TransferId,
43
WriteTask,
44
fold_local_rank,
@@ -56,11 +58,20 @@ from vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_engine import (
58
)
59
from vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_layout import (
60
LayerTransferGeometry,
61
+ MambaOffsetTemplate,
62
+ apply_mamba_offset_template,
63
build_layer_to_spec,
64
+ build_mamba_offset_template,
65
compute_block_transfer_offsets,
66
+ compute_mamba_conv_split_count,
67
get_layer_transfer_geometry,
68
is_mla_cache_layer,
69
iter_layer_registration_regions,
70
+ kda_conv_ssm,
71
+)
72
+from vllm.distributed.kv_transfer.kv_connector.v1.ssm_conv_transfer_utils import (
73
+ MambaConvSplitInfo,
74
+ derive_mamba_conv_split,
75
)
76
from vllm.distributed.parallel_state import (
77
get_tensor_model_parallel_world_size,
@@ -79,6 +90,7 @@ from vllm.v1.core.sched.output import SchedulerOutput
90
from vllm.v1.kv_cache_interface import (
91
FullAttentionSpec,
92
KVCacheConfig,
93
+ MambaSpec,
94
SlidingWindowSpec,
95
)
96
from vllm.v1.outputs import KVConnectorOutput
@@ -91,6 +103,13 @@ if TYPE_CHECKING:
103
104
logger = init_logger(__name__)
105
106
+# Backoff bounds for re-posting an RDMA READ that the NIC rejected because the
107
+# send queue was full. A separate CQ-poll thread drains completions, so the
108
+# wait only needs to be long enough for it to make progress.
109
+_SQ_FULL_BACKOFF_INITIAL_S = 0.001
110
+_SQ_FULL_BACKOFF_MAX_S = 0.05
111
+_MAX_LOCAL_DECODE_TAIL_BLOCKS = 1
112
+
113
114
try:
115
from mori.io import (
@@ -197,6 +216,12 @@ def resolve_moriio_transfer_ack(
216
217
218
class MoRIIOConnector(KVConnectorBase_V1, SupportsHMA):
219
+ @property
220
+ def supports_divergent_local_hybrid_hits(self) -> bool:
221
+ # The READ path always transfers the recurrent-state slot, including
222
+ # when the attention prefix is already present locally.
223
+ return self.mode == MoRIIOMode.READ and self._transfers_mamba_state
224
+
225
def __init__(
226
self,
227
vllm_config: VllmConfig,
@@ -217,19 +242,10 @@ class MoRIIOConnector(KVConnectorBase_V1, SupportsHMA):
242
+ str(self.kv_transfer_config.kv_connector_extra_config["handshake_port"])
243
)
244
self.mode = get_moriio_mode(self.kv_transfer_config)
220
- if (
221
- self.mode == MoRIIOMode.READ
222
- and self.kv_transfer_config.is_kv_consumer
223
- and vllm_config.compilation_config.cudagraph_mode.has_full_cudagraphs()
224
- ):
225
- # warn only; kv-read barrier requires PIECEWISE cudagraph mode
226
- logger.warning_once(
227
- "MoRIIO READ mode is running with %s CUDA graphs: per-layer "
228
- "KV-read barrier can't fire inside full graph; accuracy may "
229
- "degrade at high concurrency. Set cudagraph_mode=PIECEWISE "
230
- "in --compilation-config.",
231
- vllm_config.compilation_config.cudagraph_mode.name,
232
- )
245
+ self._transfers_mamba_state = any(
246
+ group.enable_kv_transfer and isinstance(group.kv_cache_spec, MambaSpec)
247
+ for group in kv_cache_config.kv_cache_groups
248
+ )
249
if role == KVConnectorRole.SCHEDULER:
250
self.connector_scheduler: MoRIIOConnectorScheduler | None = (
251
MoRIIOConnectorScheduler(vllm_config, self.engine_id, kv_cache_config)
@@ -279,6 +295,11 @@ class MoRIIOConnector(KVConnectorBase_V1, SupportsHMA):
295
request, blocks, num_external_tokens, self.connector_worker
296
)
297
298
+ def on_new_request(self, request: "Request") -> None:
299
+ """Fired when the scheduler admits a request, before any allocation."""
300
+ assert self.connector_scheduler is not None
301
+ self.connector_scheduler.on_new_request(request)
302
+
303
def build_connector_meta(
304
self,
305
scheduler_output: SchedulerOutput,
@@ -300,6 +321,14 @@ class MoRIIOConnector(KVConnectorBase_V1, SupportsHMA):
321
block_ids: BlockIds,
322
) -> tuple[bool, dict[str, Any] | None]:
323
assert self.connector_scheduler is not None
324
+ if self.connector_scheduler._has_mamba:
325
+ attn_block_ids, mamba_block_groups = (
326
+ self.connector_scheduler.split_block_groups(block_ids)
327
+ )
328
+ delay_free, params = self.connector_scheduler.request_finished(
329
+ request, attn_block_ids, mamba_block_groups
330
+ )
331
+ return delay_free, params
332
return self.connector_scheduler.request_finished(request, block_ids)
333
334
def update_connector_output(self, connector_output: KVConnectorOutput) -> None:
@@ -318,6 +347,12 @@ class MoRIIOConnector(KVConnectorBase_V1, SupportsHMA):
347
assert self.connector_worker is not None
348
return self.connector_worker.get_finished()
349
350
+ def get_block_ids_with_load_errors(self) -> set[int]:
351
+ """Blocks whose MoRIIO read failed, for the scheduler to recompute."""
352
+ if self.connector_worker is None:
353
+ return set()
354
+ return self.connector_worker.get_block_ids_with_load_errors()
355
+
356
def start_load_kv(self, forward_context: "ForwardContext", **kwargs) -> None:
357
assert self.connector_worker is not None
358
if self.mode == MoRIIOMode.WRITE and get_role() == ROLE.CONSUMER:
@@ -381,6 +416,47 @@ class MoRIIOConnector(KVConnectorBase_V1, SupportsHMA):
416
return False
417
418
419
+def _split_kv_cache_group_kinds(
420
+ kv_cache_config: "KVCacheConfig",
421
+) -> tuple[list[int], list[int]]:
422
+ """Classify kv_cache_groups into (attention_group_indices,
423
+ mamba_group_indices) by whether each group's spec is a MambaSpec.
424
+
425
+ Groups that opt out of external transfer are absent from
426
+ ``KVCacheConfig.transfer_groups``. Returned indices address that filtered
427
+ sequence and therefore also the connector's transfer block payload.
428
+ """
429
+ attn: list[int] = []
430
+ mamba: list[int] = []
431
+ for gi, group in enumerate(kv_cache_config.transfer_groups):
432
+ if isinstance(group.kv_cache_spec, MambaSpec):
433
+ mamba.append(gi)
434
+ else:
435
+ attn.append(gi)
436
+ return attn, mamba
437
+
438
+
439
+def _validate_mamba_specs(specs: Collection[MambaSpec]) -> MambaSpec | None:
440
+ """Validate the common packed-state contract and return its representative."""
441
+ specs = tuple(specs)
442
+ if not specs:
443
+ return None
444
+ for spec in specs:
445
+ if len(spec.shapes) != 2 or len(spec.dtypes) != 2:
446
+ raise MoRIIOError(
447
+ "MoRIIO hybrid READ supports exactly two Mamba states "
448
+ "(conv and SSM), got "
449
+ f"{len(spec.shapes)} shapes and {len(spec.dtypes)} dtypes"
450
+ )
451
+ reference = specs[0]
452
+ if any(spec != reference for spec in specs[1:]):
453
+ raise MoRIIOError(
454
+ "MoRIIO hybrid READ requires all transferable Mamba groups "
455
+ "to use the same cache spec"
456
+ )
457
+ return reference
458
+
459
+
460
class MoRIIOConnectorScheduler:
461
"""Implementation of Scheduler side methods."""
462
@@ -391,35 +467,73 @@ class MoRIIOConnectorScheduler:
467
kv_cache_config: "KVCacheConfig",
468
):
469
self.vllm_config = vllm_config
394
-
470
+ self.kv_cache_config = kv_cache_config
471
assert vllm_config.kv_transfer_config is not None, (
472
"kv_transfer_config must be set for MoRIIOConnector"
473
)
474
self.kv_transfer_config = vllm_config.kv_transfer_config
475
+ self.mode = get_moriio_mode(self.kv_transfer_config)
476
+ self._attn_group_ids, self._mamba_group_ids = _split_kv_cache_group_kinds(
477
+ kv_cache_config
478
+ )
479
+ self._has_mamba = bool(self._mamba_group_ids)
480
+ if self._has_mamba:
481
+ if len(self._attn_group_ids) != 1:
482
+ raise MoRIIOError(
483
+ "MoRIIO hybrid READ requires exactly one transferable "
484
+ "attention group, got "
485
+ f"{len(self._attn_group_ids)} attention groups"
486
+ )
487
+ mamba_specs = [
488
+ cast(MambaSpec, kv_cache_config.transfer_groups[group_id].kv_cache_spec)
489
+ for group_id in self._mamba_group_ids
490
+ ]
491
+ _validate_mamba_specs(mamba_specs)
492
+ if vllm_config.speculative_config is not None:
493
+ raise MoRIIOError(
494
+ "MoRIIO hybrid READ does not support speculative decoding"
495
+ )
496
+ if self.mode != MoRIIOMode.READ:
497
+ raise MoRIIOError(
498
+ "MoRIIO hybrid (mamba/KDA) transfer is implemented for READ "
499
+ "mode only; set kv_connector_extra_config.read_mode=true"
500
+ )
501
+ # Only "all" mode keeps a state per block position; the other modes keep
502
+ # a single running state in the last slot.
503
+ self._ssm_state_slots_are_positional = (
504
+ vllm_config.cache_config.mamba_cache_mode == "all"
505
+ )
506
self.block_size = vllm_config.cache_config.block_size
507
+ self._max_decode_tail_blocks = (
508
+ _MAX_LOCAL_DECODE_TAIL_BLOCKS
509
+ if self._has_mamba
510
+ else math.ceil((vllm_config.num_lookahead_tokens + 1) / self.block_size)
511
+ )
512
self.engine_id: EngineId = engine_id
401
- self.mode = get_moriio_mode(self.kv_transfer_config)
513
514
self._is_hma_required = (
515
not vllm_config.scheduler_config.disable_hybrid_kv_cache_manager
516
and any(
517
not isinstance(g.kv_cache_spec, FullAttentionSpec)
407
- for g in kv_cache_config.kv_cache_groups
518
+ for g in kv_cache_config.transfer_groups
519
)
520
)
521
if self._is_hma_required:
411
- # TODO(simondanielsson): support non-sliding window hybrids
522
+ # MoRIIO handles sliding-window attention and mamba/KDA groups in
523
+ # READ mode. Other hybrid cache specs still fail closed.
524
unsupported = [
525
type(g.kv_cache_spec).__name__
414
- for g in kv_cache_config.kv_cache_groups
526
+ for g in kv_cache_config.transfer_groups
527
if not isinstance(
416
- g.kv_cache_spec, (FullAttentionSpec, SlidingWindowSpec)
528
+ g.kv_cache_spec,
529
+ (FullAttentionSpec, SlidingWindowSpec, MambaSpec),
530
)
531
]
532
if unsupported:
533
raise NotImplementedError(
534
"MoRIIO only supports sliding-window hybrid models (e.g. "
422
- "Gemma) under the hybrid KV cache manager. Unsupported KV "
535
+ "Gemma) and mamba/KDA groups under the hybrid KV cache "
536
+ "manager. Unsupported KV "
537
f"cache group spec(s): {sorted(set(unsupported))}. Pass "
538
"--disable-hybrid-kv-cache-manager to run without HMA."
539
)
@@ -435,7 +549,7 @@ class MoRIIOConnectorScheduler:
549
(g.kv_cache_spec.sliding_window, g.kv_cache_spec.block_size)
550
if isinstance(g.kv_cache_spec, SlidingWindowSpec)
551
else (0, self.block_size)
438
- for g in kv_cache_config.kv_cache_groups
552
+ for g in kv_cache_config.transfer_groups
553
]
554
# add 1 to conservatively account for boundary overlap eg window isn't fully
555
# aligned with blocks.
@@ -451,8 +565,7 @@ class MoRIIOConnectorScheduler:
565
self._full_attn_group_idx = 0
566
self._full_attn_block_size = self.block_size
567
for gi, group in enumerate(kv_cache_config.kv_cache_groups):
454
- is_full_attn = self.blocks_per_sw[gi] == 0
455
- if is_full_attn:
568
+ if isinstance(group.kv_cache_spec, FullAttentionSpec):
569
self._full_attn_group_idx = gi
570
self._full_attn_block_size = getattr(
571
group.kv_cache_spec, "block_size", self.block_size
@@ -533,7 +646,11 @@ class MoRIIOConnectorScheduler:
646
- sliding window: keep only the last window size blocks
647
- full attention: keep all blocks
648
"""
536
- if len(block_ids) == 0 or not self._is_hma_required:
649
+ if len(block_ids) == 0:
650
+ return []
651
+ if len(block_ids) == len(self.kv_cache_config.kv_cache_groups):
652
+ block_ids = self.kv_cache_config.select_transfer_block_ids(block_ids)
653
+ if not self._is_hma_required:
654
return [list(blocks) for blocks in block_ids]
655
return [
656
blocks[-self.blocks_per_sw[i] :] if self.blocks_per_sw[i] > 0 else blocks
@@ -595,21 +712,75 @@ class MoRIIOConnectorScheduler:
712
713
"""
714
if self.is_producer:
715
+ # The P-side prompt truncation for hybrid models happens in
716
+ # on_new_request, before the scheduler looks the request up in the
717
+ # prefix cache.
718
return 0, False
719
600
- token_ids = request.prompt_token_ids or []
720
+ num_prompt_tokens = request.num_prompt_tokens
721
+ num_external_tokens = max(num_prompt_tokens - num_computed_tokens, 0)
722
if self.mode == MoRIIOMode.WRITE:
602
- # MoriiO in write mode, no remote prefill
723
+ # MoriiO in write mode, no remote prefill. Hybrid models never get
724
+ # here: register_kv_caches refuses WRITE mode for them, so there is
725
+ # no recurrent-state accounting to do.
726
+ return num_external_tokens, True
727
604
- return len(token_ids) - num_computed_tokens, True
728
+ # READ mode always recomputes the last token locally on the decoder.
729
+ #
730
+ # The second element declares who waits for the KV, not whether it has
731
+ # arrived. False keeps the wait on this side: the forward runs in the
732
+ # same step, and the connector blocks in wait_for_layer_load (or in
733
+ # start_load_kv under full-graph capture) until the KV is really there.
734
+ return max(num_external_tokens - 1, 0), False
735
+
736
+ def on_new_request(self, request: "Request") -> None:
737
+ """Scheduler hook fired when a request is admitted.
738
+
739
+ The hybrid P-side truncation must land here rather than in
740
+ ``get_num_new_matched_tokens``: the scheduler runs the local prefix
741
+ cache lookup first and only then asks the connector, so truncating
742
+ there shortens a prompt whose cache hit was already measured against
743
+ the full length. On a full hit that leaves ``num_computed_tokens ==
744
+ num_tokens`` and trips the scheduler's ``num_new_tokens > 0`` assert.
745
+ """
746
+ if not (self.is_producer and self._has_mamba):
747
+ return
748
+ params = request.kv_transfer_params
749
+ if params is not None and params.get("do_remote_decode"):
750
+ self._truncate_mamba_request_for_prefill(request)
751
606
- return len(token_ids) - 1 - num_computed_tokens, False
752
+ def _truncate_mamba_request_for_prefill(self, request: "Request") -> None:
753
+ """P-side only: drop the last prompt token so the prefiller computes
754
+ h(N-1) instead of h(N).
755
+
756
+ The decoder recomputes the last token locally to derive h(N) correctly
757
+ (see the READ len-1 accounting in get_num_new_matched_tokens). Guarded
758
+ by ``_p_side_truncated`` to avoid repeated truncation across a
759
+ preemption/reschedule. Mirrors the NIXL scheduler.
760
+ """
761
+ params = request.kv_transfer_params
762
+ if (
763
+ params is not None
764
+ and not params.get("_p_side_truncated")
765
+ and request.num_prompt_tokens > 1
766
+ ):
767
+ if request.prompt_token_ids is not None:
768
+ request.prompt_token_ids.pop()
769
+ elif request.prompt_embeds is not None:
770
+ request.prompt_embeds = request.prompt_embeds[:-1]
771
+ else:
772
+ return
773
+
774
+ request._all_token_ids.pop()
775
+ request.num_prompt_tokens -= 1
776
+ request.max_tokens = 1
777
+ params["_p_side_truncated"] = True
778
779
def send_notify_block(
780
self,
781
req_id: ReqId,
782
transfer_id: TransferId,
612
- block_notify_list: list[int],
783
+ block_notify_list: list,
784
host=None,
785
port=None,
786
):
@@ -739,7 +910,7 @@ class MoRIIOConnectorScheduler:
910
params.setdefault("transfer_id", transfer_id)
911
request_id = request.request_id
912
self.map_request_id(request_id, transfer_id)
742
- if params.get("do_remote_decode"):
913
+ if params.get("do_remote_decode") and self.mode == MoRIIOMode.WRITE:
914
local_block_ids: BlockIds = blocks.get_block_ids()
915
self._reqs_need_save[request.request_id] = (request, local_block_ids)
916
# Snapshot params now so chunked-prefill build_connector_meta
@@ -753,24 +924,88 @@ class MoRIIOConnectorScheduler:
924
# remote_engine_id is returned by the prefill's request_finished.
925
# host/ports come from the request_id (parsed in add_new_req).
926
if "remote_engine_id" in params:
927
+ adjusted_remote_block_ids = remote_block_ids
928
if num_external_tokens > 0:
757
- # Get unhashed blocks to pull from remote.
758
- local_block_ids = self.get_exchange_clipped_blocks(
759
- blocks.get_block_ids()
760
- )
761
- # Partial cache hits can lead to fewer local blocks vs
762
- # remote blocks, but never more
763
- assert all(
764
- len(local) <= len(remote)
765
- for local, remote in zip(
766
- local_block_ids, remote_block_ids, strict=True
929
+ if self._has_mamba:
930
+ # READ is synchronous, so allocate_slots may hash
931
+ # destination blocks before this hook runs. Pair
932
+ # the attention and recurrent-state suffixes
933
+ # independently.
934
+ remote_attn = list(remote_block_ids[0])
935
+ remote_mamba_groups = [
936
+ list(group) for group in remote_block_ids[1:]
937
+ ]
938
+ attn_block_ids, mamba_block_groups = (
939
+ self.split_block_groups(blocks.get_block_ids())
940
)
768
- )
941
+ if len(mamba_block_groups) != len(remote_mamba_groups):
942
+ raise MoRIIOError(
943
+ "MoRIIO hybrid READ Mamba group "
944
+ "count mismatch: "
945
+ f"local={len(mamba_block_groups)}, "
946
+ f"remote={len(remote_mamba_groups)}"
947
+ )
948
+ local_attn = attn_block_ids
949
+ local_attn, remote_attn = self._align_read_blocks(
950
+ local_attn,
951
+ remote_attn,
952
+ self._max_decode_tail_blocks,
953
+ )
954
+ aligned_local_mamba: list[list[int]] = []
955
+ aligned_remote_mamba: list[list[int]] = []
956
+ for local_group, remote_group in zip(
957
+ mamba_block_groups,
958
+ remote_mamba_groups,
959
+ strict=True,
960
+ ):
961
+ aligned_local, aligned_remote = (
962
+ self._align_read_blocks(
963
+ local_group, remote_group, 1
964
+ )
965
+ )
966
+ aligned_local_mamba.append(aligned_local)
967
+ aligned_remote_mamba.append(aligned_remote)
968
+ local_block_ids = [local_attn, *aligned_local_mamba]
969
+ adjusted_remote_block_ids = [
970
+ remote_attn,
971
+ *aligned_remote_mamba,
972
+ ]
973
+ else:
974
+ # Preserve main's per-group HMA representation
975
+ # for attention-only hybrid models while
976
+ # accounting for the locally recomputed tail.
977
+ local_block_ids = self.get_exchange_clipped_blocks(
978
+ blocks.get_block_ids()
979
+ )
980
+ adjusted_remote_block_ids = []
981
+ aligned_local_block_ids: list[list[int]] = []
982
+ for local_group, remote_group in zip(
983
+ local_block_ids,
984
+ remote_block_ids,
985
+ strict=True,
986
+ ):
987
+ aligned_local, aligned_remote = (
988
+ self._align_read_blocks(
989
+ local_group,
990
+ remote_group,
991
+ self._max_decode_tail_blocks,
992
+ )
993
+ )
994
+ aligned_local_block_ids.append(aligned_local)
995
+ adjusted_remote_block_ids.append(aligned_remote)
996
+ local_block_ids = aligned_local_block_ids
997
else:
770
- # If remote_blocks and num_external_tokens = 0, we have
771
- # a full prefix cache hit on the D worker. We need to call
772
- # send_notify in _read_blocks to free the memory on the P.
773
- local_block_ids = [[] for _ in remote_block_ids]
998
+ if self._has_mamba:
999
+ # Attention can be a complete local hit, but the
1000
+ # recurrent state is never prefix-cacheable.
1001
+ _, mamba_block_groups = self.split_block_groups(
1002
+ blocks.get_block_ids()
1003
+ )
1004
+ local_block_ids = [[], *mamba_block_groups]
1005
+ else:
1006
+ # Nothing to pull, but retain one empty list per
1007
+ # cache group so the worker can notify P.
1008
+ local_block_ids = [[] for _ in remote_block_ids]
1009
1010
self._reqs_need_recv[request.request_id] = (
1011
request,
@@ -778,7 +1013,10 @@ class MoRIIOConnectorScheduler:
1013
)
1014
# Snapshot params for chunked prefill consumption
1015
# in build_connector_meta (see comment above).
781
- self._req_kv_params[request.request_id] = dict(params)
1016
+ saved_params = dict(params)
1017
+ if num_external_tokens > 0:
1018
+ saved_params["remote_block_ids"] = adjusted_remote_block_ids
1019
+ self._req_kv_params[request.request_id] = saved_params
1020
else:
1021
logger.warning(
1022
"Got invalid KVTransferParams: %s. This "
@@ -1003,13 +1241,78 @@ class MoRIIOConnectorScheduler:
1241
logger.warning("Error closing ZMQ socket for path %s: %s", path, e)
1242
self.paths.clear()
1243
1244
+ def split_block_groups(
1245
+ self, block_ids: list[list[int]] | tuple[list[int], ...]
1246
+ ) -> tuple[list[int], list[list[int]]]:
1247
+ """Select transferable attention and Mamba block groups.
1248
+
1249
+ The wire payload stores the attention group first, followed by every
1250
+ Mamba group in transfer-group order. Outside ``mamba_cache_mode="all"``,
1251
+ only each group's running-state slot is transferred.
1252
+ """
1253
+ if not block_ids:
1254
+ return [], []
1255
+ if not self._has_mamba:
1256
+ raise MoRIIOError("Mamba block groups requested for a non-hybrid model")
1257
+ transfer_block_ids = self.kv_cache_config.select_transfer_block_ids(block_ids)
1258
+ attn = list(transfer_block_ids[self._attn_group_ids[0]])
1259
+ mamba_groups = [
1260
+ self._clip_mamba_group(list(transfer_block_ids[group_id]))
1261
+ for group_id in self._mamba_group_ids
1262
+ ]
1263
+ return attn, mamba_groups
1264
+
1265
+ def _clip_mamba_group(self, blocks: list[int]) -> list[int]:
1266
+ """Keep only the state-bearing slots of one mamba kv cache group."""
1267
+ if not blocks:
1268
+ return blocks
1269
+ if not self._ssm_state_slots_are_positional:
1270
+ # Single running state: everything before it is a null placeholder
1271
+ # or the previous step's superseded state.
1272
+ blocks = blocks[-1:]
1273
+ return blocks
1274
+
1275
+ @staticmethod
1276
+ def _align_read_blocks(
1277
+ local_block_ids: list[int],
1278
+ remote_block_ids: list[int],
1279
+ max_decode_tail_blocks: int,
1280
+ ) -> tuple[list[int], list[int]]:
1281
+ """Pair a decode suffix with the corresponding prefill blocks.
1282
+
1283
+ Prefix hits can make the decode's unhashed suffix shorter than the
1284
+ prefill's full list, so read from the matching remote suffix. The
1285
+ decoder can instead have one extra tail block for the locally
1286
+ recomputed final token; that block has no remote source and is not
1287
+ transferred.
1288
+ """
1289
+ if len(local_block_ids) > len(remote_block_ids):
1290
+ extra_decode_blocks = len(local_block_ids) - len(remote_block_ids)
1291
+ if extra_decode_blocks > max_decode_tail_blocks:
1292
+ raise ValueError(
1293
+ "decode block ids exceed prefill block ids beyond the "
1294
+ f"allowed local tail ({max_decode_tail_blocks} blocks): "
1295
+ f"{len(local_block_ids)} > {len(remote_block_ids)}"
1296
+ )
1297
+ local_block_ids = local_block_ids[: len(remote_block_ids)]
1298
+ elif len(local_block_ids) < len(remote_block_ids):
1299
+ remote_block_ids = (
1300
+ remote_block_ids[-len(local_block_ids) :] if local_block_ids else []
1301
+ )
1302
+ return local_block_ids, remote_block_ids
1303
+
1304
def request_finished(
1305
self,
1306
request: "Request",
1009
- block_ids: BlockIds,
1307
+ block_ids: BlockIds | list[int],
1308
+ mamba_block_groups: list[list[int]] | None = None,
1309
) -> tuple[bool, dict[str, Any] | None]:
1310
"""Once a request is finished, determine whether request blocks
1311
should be freed now or will be sent asynchronously and freed later.
1312
+
1313
+ ``block_ids`` normally contains all cache groups. Hybrid callers from
1314
+ ``request_finished_all_groups`` pass a flat attention list and the
1315
+ recurrent-state groups separately in ``mamba_block_groups``.
1316
"""
1317
request_id = request.request_id
1318
params = request.kv_transfer_params
@@ -1042,8 +1345,25 @@ class MoRIIOConnectorScheduler:
1345
if self.mode == MoRIIOMode.WRITE:
1346
self._release_write_prefill_blocks(request.request_id, params)
1347
else:
1045
- empty_groups: BlockIds = [[] for _ in self.blocks_per_sw]
1046
- self._reqs_need_recv[request.request_id] = (request, empty_groups)
1348
+ # Carry the actually-allocated blocks (incl. the per-request KDA
1349
+ # mamba slot) instead of []: a do_remote_prefill request finishing
1350
+ # before update_state_after_alloc still has blocks, and dropping the
1351
+ # mamba slot here would re-trip the KDA guard in _read_blocks.
1352
+ if mamba_block_groups:
1353
+ recv_blocks: BlockIds = [
1354
+ list(cast(list[int], block_ids)),
1355
+ *[list(group) for group in mamba_block_groups],
1356
+ ]
1357
+ elif block_ids and isinstance(block_ids[0], int):
1358
+ recv_blocks = [list(cast(list[int], block_ids))]
1359
+ else:
1360
+ grouped_block_ids = cast(BlockIds, block_ids)
1361
+ recv_blocks = (
1362
+ [list(group) for group in grouped_block_ids]
1363
+ if self._has_mamba
1364
+ else [[] for _ in self.blocks_per_sw]
1365
+ )
1366
+ self._reqs_need_recv[request.request_id] = (request, recv_blocks)
1367
params["do_remote_prefill"] = False
1368
return False, None
1369
@@ -1054,7 +1374,17 @@ class MoRIIOConnectorScheduler:
1374
return False, None
1375
1376
# On producer
1057
- computed_block_ids = self.get_exchange_clipped_blocks(block_ids)
1377
+ if mamba_block_groups is not None:
1378
+ computed_block_ids: BlockIds = [
1379
+ list(cast(list[int], block_ids)),
1380
+ *[list(group) for group in mamba_block_groups],
1381
+ ]
1382
+ elif self._has_mamba:
1383
+ raise MoRIIOError("Hybrid request completion requires all KV cache groups")
1384
+ else:
1385
+ computed_block_ids = self.get_exchange_clipped_blocks(
1386
+ cast(BlockIds, block_ids)
1387
+ )
1388
delay_free_blocks = any(len(group) > 0 for group in computed_block_ids)
1389
1390
if delay_free_blocks:
@@ -1209,10 +1539,14 @@ class MoRIIOConnectorWorker:
1539
self.is_producer = self.kv_transfer_config.is_kv_producer
1540
self.layer_to_spec = build_layer_to_spec(kv_cache_config)
1541
# we transfer by layer, but store by kv cache group
1212
- self.layer_to_group: dict[str, int] = {
1213
- layer_name: group_idx
1214
- for group_idx, group in enumerate(kv_cache_config.kv_cache_groups)
1215
- for layer_name in group.layer_names
1542
+ self.layer_to_group = dict(kv_cache_config.transfer_group_index_by_layer)
1543
+ self._transfer_layer_names = set(self.layer_to_group)
1544
+ _, mamba_transfer_group_ids = _split_kv_cache_group_kinds(kv_cache_config)
1545
+ self._num_mamba_transfer_groups = len(mamba_transfer_group_ids)
1546
+ self._mamba_payload_index_by_layer = {
1547
+ layer_name: payload_index
1548
+ for payload_index, group_id in enumerate(mamba_transfer_group_ids, start=1)
1549
+ for layer_name in kv_cache_config.transfer_groups[group_id].layer_names
1550
}
1551
1552
if self.is_producer:
@@ -1353,7 +1687,8 @@ class MoRIIOConnectorWorker:
1687
# Map of engine_id -> num_blocks. All ranks in the same deployment will
1688
# have the same number of blocks.
1689
self.dst_num_blocks: dict[EngineId, int] = {}
1356
- # In-progress READ transfers: req_id -> {layer_name: status}.
1690
+ # In-progress READ transfers: req_id -> {layer_name: [statuses]}.
1691
+ # A KDA layer posts two reads (conv + ssm), so a layer maps to a list.
1692
self._recving_transfers: defaultdict[ReqId, dict] = defaultdict(dict)
1693
# Values are (remote_host, remote_notify_port, transfer_id).
1694
self._recving_transfers_callback_addr: dict[ReqId, tuple[str, str, str]] = {}
@@ -1361,6 +1696,18 @@ class MoRIIOConnectorWorker:
1696
# Used by _pop_done_transfers to abort transfers whose RDMA
1697
# completion is lost, instead of hanging forever.
1698
self._recving_transfers_start: dict[str, float] = {}
1699
+ # Destination attention blocks per in-flight recv. Failed non-HMA reads
1700
+ # report these to the scheduler's single-group recovery path.
1701
+ self._recving_local_blocks: dict[ReqId, list[int]] = {}
1702
+ # Drained by get_block_ids_with_load_errors.
1703
+ self._invalid_block_ids: set[int] = set()
1704
+ # This step's posted reads, awaited before the forward when the
1705
+ # per-layer barrier cannot run.
1706
+ self._reads_issued_this_step: list = []
1707
+ # This step's posted recurrent-state reads. Mamba layers are not
1708
+ # wrapped by the attention KV-transfer decorator, so wait_for_layer_load
1709
+ # never fires for them and these must be awaited in start_load_kv.
1710
+ self._mamba_reads_this_step: list = []
1711
1712
# Track the expiration time of requests that are waiting to be sent.
1713
self._reqs_to_send: dict[ReqId, float] = {}
@@ -1386,6 +1733,14 @@ class MoRIIOConnectorWorker:
1733
1734
self.block_window_per_layer: list[int | None] = []
1735
self.use_mla = self.model_config.use_mla
1736
+ # Hybrid (mamba/KDA) recurrent-state transfer. Populated in
1737
+ # register_kv_caches when the model has a MambaSpec kv_cache group.
1738
+ self._has_mamba = False
1739
+ self._conv_decomp: MambaConvSplitInfo | None = None
1740
+ self._mamba_offset_templates: dict[str, MambaOffsetTemplate] = {}
1741
+ # layer_name -> flat session indices (one per registered region:
1742
+ # 1 for attention, 2 (conv, ssm) for a KDA layer). Built once lazily.
1743
+ self._region_session_index: dict[str, list[int]] | None = None
1744
self.built_session = False
1745
self.built_write_session: defaultdict[str, list] = defaultdict(list)
1746
backend = get_attn_backend(
@@ -1460,23 +1815,39 @@ class MoRIIOConnectorWorker:
1815
1816
def _get_built_session(self, remote_engine_id):
1817
if remote_engine_id not in self.built_write_session:
1818
+ remote_layer_map = self.layer_name_to_remote_kv_cache_metadata[
1819
+ remote_engine_id
1820
+ ]
1821
cur_remote_engine_sessions = []
1464
- for ln, local_meta in self.layer_name_to_local_kv_cache_metadata.items():
1465
- unpacked_local_memory_meta = (
1466
- self.moriio_wrapper.get_unpack_memory_metadata(local_meta[0])
1467
- )
1468
- unpacked_remote_memory_meta = (
1469
- self.moriio_wrapper.get_unpack_memory_metadata(
1470
- self.layer_name_to_remote_kv_cache_metadata[remote_engine_id][
1471
- ln
1472
- ][0]
1822
+ for ln, local_metas in self.layer_name_to_local_kv_cache_metadata.items():
1823
+ remote_metas = remote_layer_map[ln]
1824
+ if len(remote_metas) != len(local_metas):
1825
+ # Sessions are addressed by region index, so a peer that
1826
+ # registered a different number of regions for this layer
1827
+ # would not just lose a region to zip(): every later index
1828
+ # would point into the wrong layer's session.
1829
+ raise MoRIIOError(
1830
+ f"peer {remote_engine_id} registered "
1831
+ f"{len(remote_metas)} region(s) for layer {ln}, this "
1832
+ f"engine has {len(local_metas)}; the two sides must run "
1833
+ "the same model and KV cache layout"
1834
)
1474
- )
1475
- cur_remote_engine_sessions.append(
1476
- self.moriio_wrapper.build_session(
1477
- unpacked_local_memory_meta, unpacked_remote_memory_meta
1835
+ # One session per registered region, in registration order:
1836
+ # attention layers have a single region; KDA layers have two
1837
+ # (conv then ssm). This flat layout is indexed via
1838
+ # _region_session_indices(layer_name).
1839
+ for local_meta, remote_meta in zip(local_metas, remote_metas):
1840
+ unpacked_local_memory_meta = (
1841
+ self.moriio_wrapper.get_unpack_memory_metadata(local_meta)
1842
+ )
1843
+ unpacked_remote_memory_meta = (
1844
+ self.moriio_wrapper.get_unpack_memory_metadata(remote_meta)
1845
+ )
1846
+ cur_remote_engine_sessions.append(
1847
+ self.moriio_wrapper.build_session(
1848
+ unpacked_local_memory_meta, unpacked_remote_memory_meta
1849
+ )
1850
)
1479
- )
1851
self.built_write_session[remote_engine_id] = cur_remote_engine_sessions
1852
return self.built_write_session[remote_engine_id], self.remote_moriio_metadata[
1853
remote_engine_id
@@ -1788,15 +2159,60 @@ class MoRIIOConnectorWorker:
2159
def _is_mla_cache_layer(self, layer_name: str) -> bool:
2160
return is_mla_cache_layer(self.layer_to_spec, layer_name)
2161
2162
+ def _is_mamba_layer(self, layer_name: str) -> bool:
2163
+ """True for a hybrid/KDA layer backed by a packed state page."""
2164
+ return isinstance(self.layer_to_spec.get(layer_name), MambaSpec)
2165
+
2166
+ @staticmethod
2167
+ def _contiguous_byte_alias(tensor: torch.Tensor) -> torch.Tensor:
2168
+ """Return a contiguous uint8 view over ``tensor``'s full byte extent.
2169
+
2170
+ KDA conv/ssm caches are slot-strided views (``stride(0)`` may exceed the
2171
+ per-slot element count), so they are non-contiguous and cannot be passed
2172
+ to ``register_torch_tensor``. This aliases the same storage (zero-copy,
2173
+ identical ``data_ptr``) as a flat uint8 tensor spanning
2174
+ ``shape[0] * stride(0)`` elements -- the full slot-strided extent that
2175
+ transfer offsets (``slot * stride(0) * elem``) address.
2176
+ """
2177
+ esz = tensor.element_size()
2178
+ storage_nbytes = tensor.untyped_storage().nbytes()
2179
+ offset_bytes = tensor.storage_offset() * esz
2180
+ extent_bytes = tensor.shape[0] * tensor.stride(0) * esz
2181
+ # Clamp to the bytes remaining from the view's storage offset. In a
2182
+ # packed page, the ssm view starts after conv, so its slot-strided extent
2183
+ # would otherwise run past the backing allocation.
2184
+ extent_bytes = min(extent_bytes, storage_nbytes - offset_bytes)
2185
+ assert extent_bytes > 0, (
2186
+ "KDA byte alias empty: storage_offset="
2187
+ f"{tensor.storage_offset()} * esz={esz} >= "
2188
+ f"storage_nbytes={storage_nbytes}"
2189
+ )
2190
+ alias = torch.empty(0, dtype=torch.uint8, device=tensor.device)
2191
+ alias.set_(
2192
+ tensor.untyped_storage(),
2193
+ tensor.storage_offset() * esz,
2194
+ (extent_bytes,),
2195
+ (1,),
2196
+ )
2197
+ return alias
2198
+
2199
def _get_layer_transfer_geometry(
2200
self, layer_name: str, remote_num_blocks: int | None = None
2201
) -> LayerTransferGeometry:
1794
- return get_layer_transfer_geometry(
2202
+ geometry = get_layer_transfer_geometry(
2203
layer_name,
2204
self.kv_caches[layer_name],
2205
self.layer_to_spec,
2206
remote_num_blocks,
2207
)
2208
+ # get_layer_transfer_geometry also serves mamba layers, whose geometry
2209
+ # is a different NamedTuple with no block fields. Every caller here is
2210
+ # on the attention path (mamba layers return before reaching it), so
2211
+ # narrow the type instead of leaking the union.
2212
+ assert isinstance(geometry, LayerTransferGeometry), (
2213
+ f"layer {layer_name} is a mamba layer; use get_mamba_transfer_geometry"
2214
+ )
2215
+ return geometry
2216
2217
def _iter_layer_registration_regions(
2218
self, layer_name: str
@@ -1853,22 +2269,71 @@ class MoRIIOConnectorWorker:
2269
2270
def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
2271
"""Register the KV Cache data in moriio."""
2272
+ kv_caches = {
2273
+ layer_name: kv_cache
2274
+ for layer_name, kv_cache in kv_caches.items()
2275
+ if layer_name in self._transfer_layer_names
2276
+ }
2277
+ if not kv_caches:
2278
+ raise MoRIIOError("MoRIIO has no transfer-enabled KV cache layers")
2279
self.kv_caches = kv_caches # layer name to kv cache
2280
+ self._has_mamba = any(
2281
+ self._is_mamba_layer(layer_name) for layer_name in kv_caches
2282
+ )
2283
+ if self._has_mamba and self.mode == MoRIIOMode.WRITE:
2284
+ # WRITE needs a distinct conv/ssm transfer plan. Reject before
2285
+ # allocating or registering any regions rather than silently
2286
+ # serving a zero recurrent state.
2287
+ raise MoRIIOError(
2288
+ "MoRIIO hybrid (mamba/KDA) transfer is implemented for READ "
2289
+ "mode only; set kv_connector_extra_config.read_mode=true "
2290
+ "for hybrid models"
2291
+ )
2292
+ if self._has_mamba:
2293
+ mamba_specs = [
2294
+ cast(MambaSpec, self.layer_to_spec[layer_name])
2295
+ for layer_name in kv_caches
2296
+ if self._is_mamba_layer(layer_name)
2297
+ ]
2298
+ mamba_spec = _validate_mamba_specs(mamba_specs)
2299
+ assert mamba_spec is not None
2300
+
2301
+ from vllm.model_executor.layers.mamba.mamba_utils import (
2302
+ is_conv_state_dim_first,
2303
+ )
2304
+
2305
+ if not is_conv_state_dim_first():
2306
+ raise MoRIIOError(
2307
+ "MoRIIO KDA conv transfer requires the DS (dim-first) "
2308
+ "state layout; set VLLM_SSM_CONV_STATE_LAYOUT=DS"
2309
+ )
2310
+ self._conv_decomp = derive_mamba_conv_split(mamba_spec, self.world_size)
2311
+ # KDA layers use packed pages with different geometry; only record
2312
+ # attention tensor shapes here.
2313
self.kv_cache_shapes = {
1858
- layer_name: kv_cache.shape for layer_name, kv_cache in kv_caches.items()
2314
+ layer_name: kv_cache.shape
2315
+ for layer_name, kv_cache in kv_caches.items()
2316
+ if not self._is_mamba_layer(layer_name)
2317
}
2318
2319
+ # Geometry selection must pick an attention layer (never a KDA tuple):
2320
+ # prefer a standard 5-D K/V layer, else the first attention layer.
2321
+ attn_items = [
2322
+ (layer_name, kv_cache)
2323
+ for layer_name, kv_cache in kv_caches.items()
2324
+ if not self._is_mamba_layer(layer_name)
2325
+ ]
2326
first_layer_name, first_kv_cache = next(
2327
(
2328
(layer_name, kv_cache)
1864
- for layer_name, kv_cache in kv_caches.items()
2329
+ for layer_name, kv_cache in attn_items
2330
if (
2331
not self._is_mla_cache_layer(layer_name)
2332
and len(kv_cache.shape) == 5
2333
and (kv_cache.shape[0] == 2 or kv_cache.shape[1] == 2)
2334
)
2335
),
1871
- next(iter(kv_caches.items())),
2336
+ attn_items[0] if attn_items else next(iter(kv_caches.items())),
2337
)
2338
kv_elem_size = first_kv_cache.element_size()
2339
@@ -1915,6 +2380,17 @@ class MoRIIOConnectorWorker:
2380
)
2381
for layer_name in kv_caches:
2382
self.layer_base_addr_index[layer_name] = base_addr_idx
2383
+ if self._is_mamba_layer(layer_name):
2384
+ # KDA layer: two whole-tensor regions (conv, ssm); no block-size
2385
+ # geometry (recurrent state is per-slot, not paged).
2386
+ for cache, region_len in self._iter_layer_registration_regions(
2387
+ layer_name
2388
+ ):
2389
+ base_addr = cache.data_ptr()
2390
+ caches_data.append((base_addr, region_len, cache.device.index, ""))
2391
+ kv_caches_base_addr.append(base_addr)
2392
+ base_addr_idx += 1
2393
+ continue
2394
geometry = self._get_layer_transfer_geometry(layer_name)
2395
if hma_enabled:
2396
if geometry.block_len != self.block_len:
@@ -1941,31 +2417,87 @@ class MoRIIOConnectorWorker:
2417
kv_caches_base_addr.append(base_addr)
2418
base_addr_idx += 1
2419
1944
- shared_backing = len({id(t.untyped_storage()) for t in kv_caches.values()}) == 1
2420
+ shared_backing = (
2421
+ all(isinstance(t, torch.Tensor) for t in kv_caches.values())
2422
+ and len({id(t.untyped_storage()) for t in kv_caches.values()}) == 1
2423
+ )
2424
shared_mr = None
2425
if shared_backing:
2426
reg_tensor, self.kv_layer_mr_offset = self._build_shared_kv_mr(kv_caches)
2427
shared_mr = self.moriio_wrapper.register_local_tensor(reg_tensor)
2428
2429
+ # Say it out loud when the groups disagree. This is expected on a hybrid
2430
+ # model and used to be fatal, so a run that silently had one page size
2431
+ # and a run that has three should not look identical in the log.
2432
+ distinct_block_lens = sorted(set(self.block_lens.values()))
2433
+ if len(distinct_block_lens) > 1:
2434
+ logger.info(
2435
+ "MoRIIO registered %d attention layers with %d distinct block "
2436
+ "lengths (bytes): %s; transfer sizes and strides are per layer, "
2437
+ "and %d is the page advertised to the peer.",
2438
+ len(self.block_lens),
2439
+ len(distinct_block_lens),
2440
+ distinct_block_lens,
2441
+ self.block_len,
2442
+ )
2443
+
2444
for layer_name, kv_cache in kv_caches.items():
2445
if layer_name not in self.layer_name_to_local_kv_cache_metadata:
2446
self.layer_name_to_local_kv_cache_metadata[layer_name] = []
2447
1954
- moriio_mem_metadata = shared_mr or (
1955
- self.moriio_wrapper.register_local_tensor(kv_cache)
1956
- )
1957
- self.layer_name_to_local_kv_cache_metadata[layer_name].append(
1958
- moriio_mem_metadata
1959
- )
1960
-
1961
- self.local_kv_cache_size.append(
1962
- kv_cache.nelement() * kv_cache.element_size()
1963
- )
2448
+ if self._is_mamba_layer(layer_name):
2449
+ # Register conv and ssm as two whole-tensor regions, appending
2450
+ # both metadata blobs (order: conv then ssm) so the session
2451
+ # layout matches iter_layer_registration_regions. conv/ssm are
2452
+ # slot-strided views and are not contiguous, which
2453
+ # register_torch_tensor rejects; register a contiguous uint8
2454
+ # alias over each tensor's full byte extent instead (zero-copy:
2455
+ # same storage and data_ptr).
2456
+ conv, ssm = kda_conv_ssm(
2457
+ kv_cache, cast(MambaSpec, self.layer_to_spec[layer_name])
2458
+ )
2459
+ logger.debug(
2460
+ "MoRIIO KDA register %s: conv shape=%s stride=%s contig=%s "
2461
+ "storage_nbytes=%s ; ssm shape=%s stride=%s contig=%s "
2462
+ "storage_nbytes=%s",
2463
+ layer_name,
2464
+ tuple(conv.shape),
2465
+ tuple(conv.stride()),
2466
+ conv.is_contiguous(),
2467
+ conv.untyped_storage().nbytes(),
2468
+ tuple(ssm.shape),
2469
+ tuple(ssm.stride()),
2470
+ ssm.is_contiguous(),
2471
+ ssm.untyped_storage().nbytes(),
2472
+ )
2473
+ for tensor in (conv, ssm):
2474
+ alias = self._contiguous_byte_alias(tensor)
2475
+ meta = self.moriio_wrapper.register_local_tensor(alias)
2476
+ self.layer_name_to_local_kv_cache_metadata[layer_name].append(meta)
2477
+ self.local_kv_cache_size.append(alias.numel())
2478
+ else:
2479
+ moriio_mem_metadata = shared_mr or (
2480
+ self.moriio_wrapper.register_local_tensor(kv_cache)
2481
+ )
2482
+ self.layer_name_to_local_kv_cache_metadata[layer_name].append(
2483
+ moriio_mem_metadata
2484
+ )
2485
+ self.local_kv_cache_size.append(
2486
+ kv_cache.nelement() * kv_cache.element_size()
2487
+ )
2488
2489
self.kv_caches_base_addr[self.engine_id] = kv_caches_base_addr
2490
self.num_regions = len(caches_data)
2491
self.num_layers = len(self.kv_caches.keys())
2492
2493
+ # Report the prevalidated KDA decomposition after registration.
2494
+ if self._has_mamba:
2495
+ assert self._conv_decomp is not None
2496
+ logger.info(
2497
+ "MoRIIO registered %d KDA (conv+ssm) layer(s); ssm sizes=%s",
2498
+ sum(1 for ln in kv_caches if self._is_mamba_layer(ln)),
2499
+ self._conv_decomp.ssm_sizes,
2500
+ )
2501
# Optimization for models with local attention (Llama 4)
2502
if self.vllm_config.model_config.hf_config.model_type == "llama4":
2503
from transformers import Llama4TextConfig
@@ -2101,49 +2633,49 @@ class MoRIIOConnectorWorker:
2633
if get_forward_context().cudagraph_runtime_mode == CUDAGraphMode.FULL:
2634
return
2635
2104
- deadline = time.monotonic() + self.moriio_config.transfer_timeout
2105
- while True:
2106
- with self.moriio_wrapper.lock:
2107
- pending = [
2108
- status_by_layer[layer_name]
2109
- for status_by_layer in self._recving_transfers.values()
2110
- if layer_name in status_by_layer
2111
- ]
2112
-
2113
- if not pending:
2114
- return
2115
-
2116
- still_running = False
2117
- for status in pending:
2118
- # A failed read is dropped in _pop_done_transfers.
2119
- if status.Succeeded() or status.Failed():
2120
- continue
2121
- still_running = True
2636
+ with self.moriio_wrapper.lock:
2637
+ pending = [
2638
+ status
2639
+ for status_by_layer in self._recving_transfers.values()
2640
+ for status in status_by_layer.get(layer_name, ())
2641
+ ]
2642
2123
- if not still_running:
2124
- return
2643
+ if not pending:
2644
+ return
2645
2126
- if time.monotonic() > deadline:
2127
- logger.warning(
2128
- "MoRIIO READ barrier timed out for layer %s; proceeding "
2129
- "(request dropped via get_finished).",
2646
+ try:
2647
+ self.moriio_wrapper.waiting_for_transfer_complete(pending)
2648
+ except TransferError:
2649
+ if self._has_mamba:
2650
+ logger.exception(
2651
+ "MoRIIO hybrid READ failed before layer %s; aborting the "
2652
+ "forward because HMA load-error recovery is unsupported.",
2653
layer_name,
2654
)
2132
- return
2133
-
2134
- time.sleep(0.001)
2655
+ raise
2656
+ logger.warning(
2657
+ "MoRIIO READ barrier did not complete for layer %s; proceeding "
2658
+ "to transfer cleanup.",
2659
+ layer_name,
2660
+ exc_info=True,
2661
+ )
2662
2663
def _pop_done_transfers(self) -> set[str]:
2664
done_req_ids: set[str] = set()
2138
- _xfer_timeout = int(os.environ.get("VLLM_MORIIO_TRANSFER_TIMEOUT_S", "120"))
2665
+ _xfer_timeout = self.moriio_config.recv_abort_timeout
2666
with self.moriio_wrapper.lock:
2667
to_remove = []
2668
for req_id, status_by_layer in self._recving_transfers.items():
2142
- statuses = list(status_by_layer.values())
2143
- failed_status = next(
2144
- (status for status in statuses if status.Failed()), None
2145
- )
2146
- if statuses and all(status.Succeeded() for status in statuses):
2669
+ # Every layer's read must land before the request is complete,
2670
+ # so the whole batch is polled rather than just the newest
2671
+ # status.
2672
+ statuses = [
2673
+ status
2674
+ for layer_statuses in status_by_layer.values()
2675
+ for status in layer_statuses
2676
+ ]
2677
+ state = self.moriio_wrapper.poll_transfer_batch(statuses)
2678
+ if statuses and state is TransferBatchState.DONE:
2679
host, port, xfer_id = self._recving_transfers_callback_addr[req_id]
2680
done_req_ids.add(xfer_id)
2681
self.moriio_wrapper.send_notify(
@@ -2154,15 +2686,19 @@ class MoRIIOConnectorWorker:
2686
message_fields={"consumer_tp_size": self.world_size},
2687
)
2688
to_remove.append(req_id)
2157
- elif failed_status is not None:
2689
+ elif state is TransferBatchState.FAILED:
2690
+ failed_status = next(
2691
+ (status for status in statuses if status.Failed()), None
2692
+ )
2693
logger.error(
2694
"RDMA transfer failed for request %s: %s (code=%s). "
2160
- "Notifying prefill to free blocks; request will be "
2161
- "aborted by timeout.",
2695
+ "Notifying prefill to free blocks and recording the "
2696
+ "load error where scheduler recovery is supported.",
2697
req_id,
2163
- failed_status.Message(),
2164
- failed_status.Code(),
2698
+ failed_status.Message() if failed_status else "unknown",
2699
+ failed_status.Code() if failed_status else "unknown",
2700
)
2701
+ self._record_failed_recv(req_id)
2702
host, port, xfer_id = self._recving_transfers_callback_addr[req_id]
2703
try:
2704
self.moriio_wrapper.send_notify(
@@ -2178,8 +2714,9 @@ class MoRIIOConnectorWorker:
2714
req_id,
2715
)
2716
to_remove.append(req_id)
2181
- # Do NOT add to done_req_ids: decode KV cache is incomplete.
2182
- # The request will expire via the normal request timeout.
2717
+ # Deliberately not in done_req_ids: the decode KV is
2718
+ # incomplete, so this is a load error rather than a
2719
+ # completion.
2720
elif req_id in self._recving_transfers_start:
2721
# Abort still-in-flight transfers that exceed the
2722
# configured deadline. Otherwise a lost RDMA
@@ -2189,19 +2726,84 @@ class MoRIIOConnectorWorker:
2726
if _age > _xfer_timeout:
2727
logger.error(
2728
"RDMA read TIMED OUT for req %s after %.1fs "
2192
- "(VLLM_MORIIO_TRANSFER_TIMEOUT_S=%d)",
2729
+ "(kv_connector_extra_config.recv_abort_timeout=%.0f)",
2730
req_id,
2731
_age,
2732
_xfer_timeout,
2733
)
2734
+ self._record_failed_recv(req_id)
2735
to_remove.append(req_id)
2736
for req_id in to_remove:
2737
del self._recving_transfers[req_id]
2738
del self._recving_transfers_callback_addr[req_id]
2739
self._recving_transfers_start.pop(req_id, None)
2740
+ self._recving_local_blocks.pop(req_id, None)
2741
2742
return done_req_ids
2743
2744
+ def _await_reads_issued_this_step(self) -> None:
2745
+ """Step-level READ barrier, for the reads no per-layer barrier covers.
2746
+
2747
+ load_kv_async=False promises the KV is in place before the forward runs.
2748
+ wait_for_layer_load keeps that promise per layer, letting early layers
2749
+ overlap later transfers, but it only fires for layers that go through
2750
+ the attention KV-transfer decorator. Two cases fall through it:
2751
+
2752
+ * mamba/KDA layers have no such hook at all, so their conv+ssm reads
2753
+ would otherwise be consumed while still in flight;
2754
+ * under CUDAGraphMode.FULL a host-side blocking wait is illegal
2755
+ anywhere in the graph, so every read is awaited here instead.
2756
+
2757
+ start_load_kv runs outside the graph, so blocking here is safe.
2758
+ Attention reads keep their per-layer overlap in the non-FULL case.
2759
+
2760
+ Attention-only failures are reported via
2761
+ get_block_ids_with_load_errors. Hybrid failures are re-raised because
2762
+ the scheduler's recovery path does not support multiple cache groups;
2763
+ this fails the step closed instead of running forward with incomplete
2764
+ recurrent state.
2765
+ """
2766
+ all_statuses = self._reads_issued_this_step
2767
+ self._reads_issued_this_step = []
2768
+ mamba_statuses = self._mamba_reads_this_step
2769
+ self._mamba_reads_this_step = []
2770
+ if get_forward_context().cudagraph_runtime_mode == CUDAGraphMode.FULL:
2771
+ statuses = all_statuses
2772
+ else:
2773
+ statuses = mamba_statuses
2774
+ if not statuses:
2775
+ return
2776
+ try:
2777
+ self.moriio_wrapper.waiting_for_transfer_complete(statuses)
2778
+ except TransferError:
2779
+ if self._has_mamba:
2780
+ logger.exception(
2781
+ "MoRIIO hybrid READ failed before the forward; aborting "
2782
+ "because HMA load-error recovery is unsupported."
2783
+ )
2784
+ raise
2785
+ logger.exception(
2786
+ "MoRIIO reads did not complete before the forward; proceeding "
2787
+ "to transfer cleanup."
2788
+ )
2789
+
2790
+ def _record_failed_recv(self, req_id: ReqId) -> None:
2791
+ """Hand a failed attention-only read to scheduler recovery.
2792
+
2793
+ The scheduler's invalid-block recovery currently assumes exactly one
2794
+ KV cache group. Reporting IDs for a hybrid request would crash it while
2795
+ unpacking the request's groups, so match NIXL and keep HMA failures out
2796
+ of this API until scheduler recovery carries group identity.
2797
+ """
2798
+ if self._has_mamba:
2799
+ return
2800
+ self._invalid_block_ids.update(self._recving_local_blocks.get(req_id, ()))
2801
+
2802
+ def get_block_ids_with_load_errors(self) -> set[int]:
2803
+ """Drain the blocks whose read failed, for the scheduler to recompute."""
2804
+ invalid, self._invalid_block_ids = self._invalid_block_ids, set()
2805
+ return invalid
2806
+
2807
def save_kv_layer(
2808
self,
2809
metadata: MoRIIOConnectorMetadata,
@@ -2462,11 +3064,12 @@ class MoRIIOConnectorWorker:
3064
3065
continue
3066
2465
- # Handshake already completed, start async read xfer.
3067
+ # Handshake already completed, start the read xfer.
3068
self._read_blocks_for_req(req_id, meta)
3069
# Start transfers for requests whose handshakes have now finished.
3070
3071
if remote_engine_id is None and not wait_handshake_readd_req:
3072
+ self._await_reads_issued_this_step()
3073
return
3074
_deadline = time.monotonic() + self.moriio_config.transfer_timeout
3075
while True:
@@ -2493,6 +3096,7 @@ class MoRIIOConnectorWorker:
3096
else:
3097
break
3098
3099
+ self._await_reads_issued_this_step()
3100
self._reqs_to_send.update(metadata.reqs_to_send)
3101
3102
def wait_for_save(self, metadata: MoRIIOConnectorMetadata):
@@ -2717,6 +3321,163 @@ class MoRIIOConnectorWorker:
3321
remote = [x + remote_off for x in remote]
3322
return local, remote, sizes
3323
3324
+ def _region_session_indices(self, layer_name: str) -> list[int]:
3325
+ """Flat session indices for a layer's registered regions.
3326
+
3327
+ Sessions are built one per region in registration order (see
3328
+ _get_built_session), so an attention layer maps to a single index and a
3329
+ KDA layer to two indices ([conv, ssm]). Built once and cached.
3330
+ """
3331
+ if self._region_session_index is None:
3332
+ mapping: dict[str, list[int]] = {}
3333
+ idx = 0
3334
+ for ln, metas in self.layer_name_to_local_kv_cache_metadata.items():
3335
+ mapping[ln] = list(range(idx, idx + len(metas)))
3336
+ idx += len(metas)
3337
+ self._region_session_index = mapping
3338
+ return self._region_session_index[layer_name]
3339
+
3340
+ def _mamba_tp_ratio(self, remote_tp_size: int | None) -> int:
3341
+ """Validate the homogeneous-TP KDA contract."""
3342
+ remote = self.world_size if remote_tp_size is None else remote_tp_size
3343
+ if remote != self.world_size:
3344
+ raise NotImplementedError(
3345
+ "heterogeneous-TP mamba/KDA transfer is not supported yet "
3346
+ f"(local_tp={self.world_size}, remote_tp={remote}); use equal "
3347
+ "prefill/decode TP for hybrid models"
3348
+ )
3349
+ return 1
3350
+
3351
+ def _mamba_blocks_for_layer(
3352
+ self,
3353
+ layer_name: str,
3354
+ block_groups: BlockIds,
3355
+ ) -> list[int]:
3356
+ payload_index = self._mamba_payload_index_by_layer.get(layer_name)
3357
+ if payload_index is None or payload_index >= len(block_groups):
3358
+ raise MoRIIOError(
3359
+ f"KDA layer {layer_name} has no block group in the transfer payload"
3360
+ )
3361
+ return list(block_groups[payload_index])
3362
+
3363
+ def _compute_mamba_transfer_offsets(
3364
+ self,
3365
+ layer_name: str,
3366
+ local_slots: list[int],
3367
+ remote_slots: list[int],
3368
+ remote_tp_size: int | None = None,
3369
+ ) -> tuple[list[int], list[int], list[int], int]:
3370
+ """Compute (local, remote, sizes, n_conv) for a KDA layer.
3371
+
3372
+ Entries [:n_conv] address the conv region/session; entries [n_conv:]
3373
+ address the ssm region/session.
3374
+ """
3375
+ assert self._conv_decomp is not None, "KDA decomposition not derived"
3376
+ conv, ssm = kda_conv_ssm(
3377
+ self.kv_caches[layer_name],
3378
+ cast(MambaSpec, self.layer_to_spec[layer_name]),
3379
+ )
3380
+ tp_ratio = self._mamba_tp_ratio(remote_tp_size)
3381
+ # Cache the slot-independent geometry per layer; only the per-request
3382
+ # slot bases vary, and heterogeneous TP is rejected above.
3383
+ template = self._mamba_offset_templates.get(layer_name)
3384
+ if template is None:
3385
+ template = build_mamba_offset_template(
3386
+ conv,
3387
+ ssm,
3388
+ self._conv_decomp,
3389
+ tp_ratio,
3390
+ )
3391
+ self._mamba_offset_templates[layer_name] = template
3392
+ local_offs, remote_offs, sizes = apply_mamba_offset_template(
3393
+ template, local_slots, remote_slots
3394
+ )
3395
+ n_conv = compute_mamba_conv_split_count(local_slots, self._conv_decomp)
3396
+ return local_offs, remote_offs, sizes, n_conv
3397
+
3398
+ def _post_mamba_reads(
3399
+ self,
3400
+ layer_name: str,
3401
+ sessions: list,
3402
+ region_sessions: list[int],
3403
+ local_slots: list[int],
3404
+ remote_slots: list[int],
3405
+ remote_tp_size: int,
3406
+ request_id: str,
3407
+ deadline: float,
3408
+ ) -> list:
3409
+ """Post one KDA layer's conv and ssm reads; return their statuses.
3410
+
3411
+ The layer owns two registered regions, so the offset list is split at
3412
+ the conv/ssm boundary and each half goes to its own session. A half
3413
+ with nothing to move is skipped rather than posted empty.
3414
+ """
3415
+ local, remote, sizes, n_conv = self._compute_mamba_transfer_offsets(
3416
+ layer_name,
3417
+ local_slots,
3418
+ remote_slots,
3419
+ remote_tp_size=remote_tp_size,
3420
+ )
3421
+ statuses: list = []
3422
+ for sess_idx, sl in (
3423
+ (region_sessions[0], slice(0, n_conv)),
3424
+ (region_sessions[1], slice(n_conv, None)),
3425
+ ):
3426
+ region_sizes = sizes[sl]
3427
+ if not region_sizes:
3428
+ continue
3429
+ statuses.append(
3430
+ self._post_read_with_backoff(
3431
+ sessions[sess_idx],
3432
+ region_sizes,
3433
+ local[sl],
3434
+ remote[sl],
3435
+ request_id,
3436
+ layer_name,
3437
+ deadline,
3438
+ )
3439
+ )
3440
+ return statuses
3441
+
3442
+ def _post_read_with_backoff(
3443
+ self,
3444
+ session,
3445
+ sizes: list[int],
3446
+ local_offsets: list[int],
3447
+ remote_offsets: list[int],
3448
+ request_id: str,
3449
+ layer_name: str,
3450
+ deadline: float,
3451
+ ):
3452
+ """Post one RDMA READ, backing off on transient SQ-full rejection.
3453
+
3454
+ read_remote_data posts synchronously, so a send-queue-full rejection is
3455
+ a Failed() status on return; a separate CQ-poll thread drains
3456
+ completions and frees SQ depth, so we back off and re-post until
3457
+ transfer_timeout, then store the failed status (get_finished notifies
3458
+ prefill and drops the request non-fatally).
3459
+ """
3460
+ _backoff = _SQ_FULL_BACKOFF_INITIAL_S
3461
+ while True:
3462
+ transfer_status = self.moriio_wrapper.read_remote_data(
3463
+ sizes, local_offsets, remote_offsets, session
3464
+ )
3465
+ if not self._is_sq_full_status(transfer_status):
3466
+ break
3467
+ if time.monotonic() > deadline:
3468
+ logger.warning(
3469
+ "MoRIIO READ send queue stayed full past "
3470
+ "transfer_timeout for req %s layer %s; storing failed "
3471
+ "status (get_finished notifies prefill and drops the "
3472
+ "request). Raise qp_per_transfer if frequent.",
3473
+ request_id,
3474
+ layer_name,
3475
+ )
3476
+ break
3477
+ time.sleep(_backoff)
3478
+ _backoff = min(_backoff * 2, _SQ_FULL_BACKOFF_MAX_S)
3479
+ return transfer_status
3480
+
3481
@staticmethod
3482
def _is_sq_full_status(status) -> bool:
3483
"""True if a MoRIIO transfer status is a transient RDMA send-queue-full
@@ -2766,6 +3527,44 @@ class MoRIIOConnectorWorker:
3527
if chosen_tp is not None
3528
else self._remote_tp_rank(remote_tp_size)
3529
)
3530
+ if not any(local_block_ids):
3531
+ # A request aborted before decode allocation still carries the
3532
+ # producer's block table. There is no local destination or forward,
3533
+ # so release the producer blocks without building sessions or
3534
+ # posting RDMA reads.
3535
+ self.moriio_wrapper.send_notify(
3536
+ transfer_id,
3537
+ remote_host,
3538
+ str(
3539
+ remote_notify_port
3540
+ + get_port_offset(
3541
+ int(remote_dp_rank),
3542
+ eff_tp,
3543
+ remote_tp_size,
3544
+ )
3545
+ ),
3546
+ message_type="release",
3547
+ message_fields={"consumer_tp_size": self.world_size},
3548
+ )
3549
+ return
3550
+
3551
+ if self._has_mamba:
3552
+ expected_groups = 1 + self._num_mamba_transfer_groups
3553
+ if (
3554
+ len(local_block_ids) != expected_groups
3555
+ or len(remote_block_ids) != expected_groups
3556
+ ):
3557
+ raise MoRIIOError(
3558
+ "MoRIIO hybrid READ block group count mismatch: "
3559
+ f"expected={expected_groups}, local={len(local_block_ids)}, "
3560
+ f"remote={len(remote_block_ids)}"
3561
+ )
3562
+ local_attn = list(local_block_ids[0])
3563
+ remote_attn = list(remote_block_ids[0])
3564
+ self._mamba_tp_ratio(remote_tp_size)
3565
+ else:
3566
+ local_attn = remote_attn = []
3567
+
3568
if flexible:
3569
remote_dp_engine_id = self.get_engine_name_with_dp_tp(
3570
dst_engine_id, int(remote_dp_rank), eff_tp
@@ -2778,52 +3577,89 @@ class MoRIIOConnectorWorker:
3577
3578
# SQ-full backpressure deadline, shared across this request's layers.
3579
_sq_deadline = time.monotonic() + self.moriio_config.transfer_timeout
3580
+ # TODO: apply multi-session batch-read when MoRIIO supports it.
3581
for layer_name in self.layer_name_to_local_kv_cache_metadata:
2782
- sess_idx = list(self.layer_name_to_local_kv_cache_metadata.keys()).index(
2783
- layer_name
2784
- )
2785
- group_idx = self.layer_to_group[layer_name]
2786
- offs = self._compute_block_transfer_offsets(
2787
- layer_name,
2788
- local_block_ids[group_idx],
2789
- remote_block_ids[group_idx],
2790
- remote_moriio_meta,
2791
- remote_tp_size=remote_tp_size,
2792
- remote_engine_id=remote_dp_engine_id,
2793
- )
2794
- # TODO : apply multi-session batch-read when moriio support it
2795
- #
2796
- # SQ-full backpressure: read_remote_data posts the RDMA READ
2797
- # SYNCHRONOUSLY, so a send-queue-full rejection (per-QP HW cap) comes
2798
- # back as a Failed() status right here. A SEPARATE CQ-poll thread
2799
- # drains completions and frees SQ depth, so back off and RE-POST
2800
- # rather than let a transient rejection abort the request. No
2801
- # self-deadlock (the drain is off-thread); the reserve is
2802
- # all-or-nothing (nothing posted on a rejected attempt). Bounded by
2803
- # transfer_timeout; on sustained overload store the failed status and
2804
- # let get_finished handle it non-fatally (notify prefill + drop).
2805
- _backoff = 0.001
2806
- while True:
2807
- transfer_status = self.moriio_wrapper.read_remote_data(
2808
- offs[2], offs[0], offs[1], sessions[sess_idx]
3582
+ region_sessions = self._region_session_indices(layer_name)
3583
+ statuses = []
3584
+ if self._is_mamba_layer(layer_name):
3585
+ local_mamba = self._mamba_blocks_for_layer(layer_name, local_block_ids)
3586
+ remote_mamba = self._mamba_blocks_for_layer(
3587
+ layer_name, remote_block_ids
3588
)
2810
- if not self._is_sq_full_status(transfer_status):
2811
- break
2812
- if time.monotonic() > _sq_deadline:
2813
- logger.warning(
2814
- "MoRIIO READ send queue stayed full past "
2815
- "transfer_timeout for req %s layer %s; storing failed "
2816
- "status (get_finished notifies prefill and drops the "
2817
- "request). Raise qp_per_transfer if frequent.",
3589
+ # KDA layer: pull conv (sub-projection offsets) and ssm at the
3590
+ # request's recurrent-state slot. Two regions -> two sessions.
3591
+ if not local_mamba or not remote_mamba:
3592
+ if not local_attn and not remote_attn:
3593
+ # Whole-request no-op (e.g. full prefix cache hit):
3594
+ # nothing to transfer for any layer.
3595
+ continue
3596
+ # A KDA layer must have its recurrent-state slot in the
3597
+ # block-id tuple; missing it (while attention blocks are
3598
+ # present) means the mamba KV-cache group is absent -> bug.
3599
+ raise MoRIIOError(
3600
+ f"KDA layer {layer_name}: missing mamba recurrent-state "
3601
+ f"block ids (local={local_mamba}, remote={remote_mamba}) "
3602
+ f"with attention blocks present for request {request_id}; "
3603
+ "the mamba KV-cache group is absent from the transfer "
3604
+ "block-id tuple"
3605
+ )
3606
+ statuses = self._post_mamba_reads(
3607
+ layer_name,
3608
+ sessions,
3609
+ region_sessions,
3610
+ local_mamba,
3611
+ remote_mamba,
3612
+ remote_tp_size,
3613
+ request_id,
3614
+ _sq_deadline,
3615
+ )
3616
+ else:
3617
+ if self._has_mamba:
3618
+ local_layer_blocks = local_attn
3619
+ remote_layer_blocks = remote_attn
3620
+ else:
3621
+ group_idx = self.layer_to_group[layer_name]
3622
+ local_layer_blocks = local_block_ids[group_idx]
3623
+ remote_layer_blocks = remote_block_ids[group_idx]
3624
+ offs = self._compute_block_transfer_offsets(
3625
+ layer_name,
3626
+ local_layer_blocks,
3627
+ remote_layer_blocks,
3628
+ remote_moriio_meta,
3629
+ remote_tp_size=remote_tp_size,
3630
+ remote_engine_id=remote_dp_engine_id,
3631
+ )
3632
+ statuses.append(
3633
+ self._post_read_with_backoff(
3634
+ sessions[region_sessions[0]],
3635
+ offs[2],
3636
+ offs[0],
3637
+ offs[1],
3638
request_id,
3639
layer_name,
3640
+ _sq_deadline,
3641
)
2821
- break
2822
- time.sleep(_backoff)
2823
- _backoff = min(_backoff * 2, 0.05)
3642
+ )
3643
with self.moriio_wrapper.lock:
2825
- self._recving_transfers[request_id][layer_name] = transfer_status
3644
+ self._recving_transfers[request_id][layer_name] = statuses
3645
self._recving_transfers_start.setdefault(request_id, time.monotonic())
3646
+ # Destination blocks, kept for _record_failed_recv.
3647
+ if self._has_mamba:
3648
+ failed_recv_blocks = local_attn
3649
+ else:
3650
+ failed_recv_blocks = [
3651
+ block_id for group in local_block_ids for block_id in group
3652
+ ]
3653
+ self._recving_local_blocks.setdefault(
3654
+ request_id, list(failed_recv_blocks)
3655
+ )
3656
+ # Awaited before the forward when the per-layer barrier cannot
3657
+ # run (see _await_reads_issued_this_step).
3658
+ self._reads_issued_this_step.extend(statuses)
3659
+ if self._is_mamba_layer(layer_name):
3660
+ # No per-layer barrier exists for mamba layers, so these are
3661
+ # always awaited in start_load_kv.
3662
+ self._mamba_reads_this_step.extend(statuses)
3663
self._recving_transfers_callback_addr[request_id] = (
3664
remote_host,
3665
str(
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_engine.py
+34
-4
@@ -29,6 +29,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_common import (
29
MoRIIOError,
30
MoRIIOTransferAck,
31
RemoteAllocInfo,
32
+ TransferBatchState,
33
TransferError,
34
TransferId,
35
WriteTask,
@@ -58,7 +59,6 @@ try:
59
except ImportError:
60
logger.error("MoRIIO is not available")
61
61
-
62
"""Write task execution logic for MoRIIO connector."""
63
64
@@ -395,9 +395,9 @@ class MoRIIOWriter:
395
)
396
request_info.transfer_offsets[key] = offsets
397
398
- # Get session index
399
- layer_names = list(self.worker.layer_name_to_local_kv_cache_metadata.keys())
400
- sess_idx = layer_names.index(task.layer_name)
398
+ # One session per registered region; attention layers map to a single
399
+ # index (see MoRIIOConnectorWorker._region_session_indices).
400
+ sess_idx = self.worker._region_session_indices(task.layer_name)[0]
401
402
local_off, remote_off, sizes = offsets
403
@@ -662,6 +662,25 @@ class MoRIIOWrapper:
662
)
663
return transfer_status
664
665
+ def poll_transfer_batch(self, transfer_statuses: list[Any]) -> TransferBatchState:
666
+ """Non-blocking verdict over every status of one request.
667
+
668
+ Judging a request by its newest status alone calls it done while an
669
+ earlier layer is unfinished or already failed; failures do land on
670
+ layers other than the last, because _post_read_with_backoff returns a
671
+ failed status when the send queue never drains.
672
+
673
+ This stays a Python scan so it does not drive the backend's progress
674
+ callback from the engine thread alongside MoRIIO's own poller.
675
+ """
676
+ state = TransferBatchState.DONE
677
+ for status in transfer_statuses:
678
+ if status.Failed():
679
+ return TransferBatchState.FAILED
680
+ if not status.Succeeded():
681
+ state = TransferBatchState.PENDING
682
+ return state
683
+
684
def waiting_for_transfer_complete(self, transfer_statuses: list[Any] | None = None):
685
if transfer_statuses is None:
686
with self.lock:
@@ -673,6 +692,15 @@ class MoRIIOWrapper:
692
if not transfers_to_wait:
693
return
694
695
+ self._poll_transfers_until_done(transfers_to_wait)
696
+
697
+ def _poll_transfers_until_done(self, transfers_to_wait: list[Any]) -> None:
698
+ """Fallback for mori builds without the batched wait.
699
+
700
+ Gives failure precedence over pending: once any status has failed the
701
+ request is lost, so waiting out the remaining ones would only delay the
702
+ barrier by up to transfer_timeout.
703
+ """
704
timeout = self._transfer_timeout
705
deadline = time.monotonic() + timeout
706
remaining = list(transfers_to_wait)
@@ -698,6 +726,8 @@ class MoRIIOWrapper:
726
)
727
else:
728
still_waiting.append(status)
729
+ if errors:
730
+ break
731
remaining = still_waiting
732
if remaining:
733
time.sleep(0.001)
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_layout.py
+285
-5
@@ -2,14 +2,18 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
from collections.abc import Callable, Mapping
5
-from typing import NamedTuple
5
+from typing import NamedTuple, cast
6
7
import torch
8
9
+from vllm.distributed.kv_transfer.kv_connector.v1.ssm_conv_transfer_utils import (
10
+ MambaConvSplitInfo,
11
+)
12
from vllm.v1.kv_cache_interface import (
13
AttentionSpec,
14
KVCacheConfig,
15
KVCacheSpec,
16
+ MambaSpec,
17
MLAAttentionSpec,
18
SlidingWindowMLASpec,
19
UniformTypeKVCacheSpecs,
@@ -29,6 +33,85 @@ class LayerTransferGeometry(NamedTuple):
33
split_kv_regions: bool
34
35
36
+class MambaTransferGeometry(NamedTuple):
37
+ """Geometry of a hybrid (mamba/KDA) layer's recurrent state.
38
+
39
+ A KDA layer's packed page is exposed as ``conv_state`` and ``ssm_state``
40
+ views. Each is slot-strided, so a slot's bytes live at
41
+ ``slot * slot_stride * element_size`` and span ``slot_bytes``. The two
42
+ tensors are registered as two separate MoRIIO regions per layer.
43
+ """
44
+
45
+ conv_slot_stride: int
46
+ ssm_slot_stride: int
47
+ ssm_slot_bytes: int
48
+ conv_element_size: int
49
+ ssm_element_size: int
50
+ conv_region_len: int
51
+ ssm_region_len: int
52
+
53
+
54
+def get_mamba_transfer_geometry(
55
+ conv: torch.Tensor,
56
+ ssm: torch.Tensor,
57
+) -> MambaTransferGeometry:
58
+ """Derive the slot-strided geometry of a KDA layer's conv + ssm state.
59
+
60
+ ``conv``/``ssm`` are zero-copy views over the layer's packed page. Slot byte
61
+ offset is ``slot * slot_stride * element_size`` and each
62
+ slot spans ``subtensor[0].numel() * element_size`` bytes; the whole tensor
63
+ is registered as one region (``*_region_len`` bytes).
64
+ """
65
+ if conv.ndim == 0 or ssm.ndim == 0 or conv.shape[0] == 0 or ssm.shape[0] == 0:
66
+ raise ValueError("KDA conv/ssm caches must contain at least one slot")
67
+ if conv.shape[0] != ssm.shape[0]:
68
+ raise ValueError(
69
+ f"KDA conv/ssm slot count mismatch: {conv.shape[0]} != {ssm.shape[0]}"
70
+ )
71
+ conv_slot_bytes = conv[0].numel() * conv.element_size()
72
+ ssm_slot_bytes = ssm[0].numel() * ssm.element_size()
73
+ conv_available_bytes = (
74
+ conv.untyped_storage().nbytes() - conv.storage_offset() * conv.element_size()
75
+ )
76
+ ssm_available_bytes = (
77
+ ssm.untyped_storage().nbytes() - ssm.storage_offset() * ssm.element_size()
78
+ )
79
+ if conv.shape[0]:
80
+ conv_required_bytes = (conv.shape[0] - 1) * conv.stride(
81
+ 0
82
+ ) * conv.element_size() + conv_slot_bytes
83
+ ssm_required_bytes = (ssm.shape[0] - 1) * ssm.stride(
84
+ 0
85
+ ) * ssm.element_size() + ssm_slot_bytes
86
+ if conv_required_bytes > conv_available_bytes:
87
+ raise ValueError("KDA conv slots exceed the registered storage extent")
88
+ if ssm_required_bytes > ssm_available_bytes:
89
+ raise ValueError("KDA ssm slots exceed the registered storage extent")
90
+
91
+ return MambaTransferGeometry(
92
+ conv_slot_stride=conv.stride(0),
93
+ ssm_slot_stride=ssm.stride(0),
94
+ ssm_slot_bytes=ssm_slot_bytes,
95
+ conv_element_size=conv.element_size(),
96
+ ssm_element_size=ssm.element_size(),
97
+ # Registered region must span the full slot-strided extent: stride(0)
98
+ # may exceed the per-slot element count, so numel() would under-size the
99
+ # region and truncate the highest-slot transfers. shape[0]*stride(0)
100
+ # covers every byte a ``slot * slot_stride * elem`` offset can address.
101
+ # Cap at the bytes remaining from each view's storage offset. A packed
102
+ # conv+ssm page places ssm at a non-zero intra-buffer offset,
103
+ # so do not register past the end of the shared backing allocation.
104
+ conv_region_len=min(
105
+ conv.shape[0] * conv.stride(0) * conv.element_size(),
106
+ conv_available_bytes,
107
+ ),
108
+ ssm_region_len=min(
109
+ ssm.shape[0] * ssm.stride(0) * ssm.element_size(),
110
+ ssm_available_bytes,
111
+ ),
112
+ )
113
+
114
+
115
def build_layer_to_spec(kv_cache_config: KVCacheConfig) -> dict[str, KVCacheSpec]:
116
layer_to_spec: dict[str, KVCacheSpec] = {}
117
for group in kv_cache_config.kv_cache_groups:
@@ -99,10 +182,10 @@ def get_layer_transfer_geometry(
182
layer_to_spec: Mapping[str, KVCacheSpec],
183
remote_num_blocks: int | None = None,
184
) -> LayerTransferGeometry:
185
+ spec = layer_to_spec[layer_name]
186
shape = kv_cache.shape
187
stride = kv_cache.stride()
188
element_size = kv_cache.element_size()
105
- spec = layer_to_spec[layer_name]
189
is_mla_cache = is_mla_cache_layer(layer_to_spec, layer_name)
190
191
if not is_mla_cache and len(shape) == 5 and shape[0] == 2:
@@ -238,12 +321,60 @@ def get_layer_transfer_geometry(
321
)
322
323
324
+def kda_conv_ssm(
325
+ kv_cache: torch.Tensor,
326
+ spec: MambaSpec,
327
+) -> tuple[torch.Tensor, torch.Tensor]:
328
+ """Return zero-copy conv and SSM views over a packed two-state page."""
329
+ from math import prod
330
+
331
+ from vllm.utils.torch_utils import get_dtype_size
332
+
333
+ if len(spec.shapes) != 2 or len(spec.dtypes) != 2:
334
+ raise ValueError(
335
+ "MoRIIO KDA transfer requires exactly two states (conv and SSM)"
336
+ )
337
+ if kv_cache.element_size() != 1:
338
+ raise ValueError("MoRIIO KDA packed cache must use a byte-sized dtype")
339
+ if kv_cache.ndim != 4 or kv_cache.shape[1:3] != (1, 1) or kv_cache.stride(3) != 1:
340
+ raise ValueError(
341
+ "MoRIIO KDA packed cache must have shape [B, 1, 1, C] with "
342
+ "a contiguous byte dimension"
343
+ )
344
+ # Mirror MambaBase.bind_kv_cache: blocks may be strided within a shared
345
+ # multi-layer allocation, while every individual byte page is contiguous.
346
+ pages = kv_cache.squeeze(dim=(1, 2))
347
+ states: list[torch.Tensor] = []
348
+ offset = 0
349
+ for shp, dt in zip(spec.shapes, spec.dtypes, strict=True):
350
+ nbytes = prod(shp) * get_dtype_size(dt)
351
+ if offset + nbytes > pages.shape[1]:
352
+ raise ValueError(
353
+ "MoRIIO KDA state layout exceeds the packed cache page size"
354
+ )
355
+ state = pages[:, offset : offset + nbytes].view(dt)
356
+ states.append(state.view(-1, *shp))
357
+ offset += nbytes
358
+ return states[0], states[1]
359
+
360
+
361
def iter_layer_registration_regions(
362
layer_name: str,
363
kv_cache: torch.Tensor,
364
layer_to_spec: Mapping[str, KVCacheSpec],
365
) -> list[tuple[torch.Tensor, int]]:
246
- geometry = get_layer_transfer_geometry(layer_name, kv_cache, layer_to_spec)
366
+ spec = layer_to_spec[layer_name]
367
+ if isinstance(spec, MambaSpec):
368
+ # KDA layer: register the conv and ssm tensors as two whole-tensor
369
+ # regions (the conv 3-subprojection split is expressed as transfer
370
+ # offset triples, not extra registrations).
371
+ conv, ssm = kda_conv_ssm(kv_cache, spec)
372
+ geom = get_mamba_transfer_geometry(conv, ssm)
373
+ return [(conv, geom.conv_region_len), (ssm, geom.ssm_region_len)]
374
+ geometry = cast(
375
+ LayerTransferGeometry,
376
+ get_layer_transfer_geometry(layer_name, kv_cache, layer_to_spec),
377
+ )
378
region_len = geometry.num_blocks * geometry.regions_per_block * geometry.block_len
379
if geometry.regions_per_block == 1:
380
# With padded or interleaved pages the block stride exceeds the
@@ -311,9 +442,36 @@ def compute_block_transfer_offsets(
442
"local_block_ids longer than remote_block_ids: "
443
f"{len(local_block_ids)} > {len(remote_block_ids)}"
444
)
314
- geometry = get_layer_transfer_geometry(
315
- layer_name, kv_cache, layer_to_spec, remote_num_blocks
445
+ geometry = cast(
446
+ LayerTransferGeometry,
447
+ get_layer_transfer_geometry(
448
+ layer_name, kv_cache, layer_to_spec, remote_num_blocks
449
+ ),
450
+ )
451
+ invalid_local = next(
452
+ (
453
+ block_id
454
+ for block_id in local_block_ids
455
+ if not 0 <= block_id < geometry.num_blocks
456
+ ),
457
+ None,
458
+ )
459
+ if invalid_local is not None:
460
+ raise ValueError(
461
+ f"local block id {invalid_local} is outside [0, {geometry.num_blocks})"
462
+ )
463
+ invalid_remote = next(
464
+ (
465
+ block_id
466
+ for block_id in remote_block_ids
467
+ if not 0 <= block_id < remote_num_blocks
468
+ ),
469
+ None,
470
)
471
+ if invalid_remote is not None:
472
+ raise ValueError(
473
+ f"remote block id {invalid_remote} is outside [0, {remote_num_blocks})"
474
+ )
475
element_size = kv_cache.element_size()
476
transfer_size_byte = geometry.block_len
477
per_block = geometry.transfers_per_block
@@ -339,3 +497,125 @@ def compute_block_transfer_offsets(
497
w += 1
498
499
return merge_fn(offset_local, offset_remote, sizes)
500
+
501
+
502
+class MambaOffsetTemplate(NamedTuple):
503
+ """Slot-independent conv+ssm offset decomposition for a KDA layer.
504
+
505
+ Homogeneous-TP geometry (conv/ssm slot strides, conv sub-projection
506
+ offsets, ssm per-slot size) is identical across requests and across the
507
+ homogeneous GDN layers, so it is computed once and reused; only the
508
+ per-request slot bases vary (see ``apply_mamba_offset_template``).
509
+ """
510
+
511
+ num_slots: int
512
+ conv_slot_stride_bytes: int
513
+ ssm_slot_stride_bytes: int
514
+ ssm_read_bytes: int
515
+ conv_subprojs: tuple[tuple[int, int, int], ...]
516
+
517
+
518
+def build_mamba_offset_template(
519
+ conv: torch.Tensor,
520
+ ssm: torch.Tensor,
521
+ split_info: MambaConvSplitInfo,
522
+ tp_ratio: int,
523
+) -> MambaOffsetTemplate:
524
+ """Precompute the slot-independent conv+ssm offset decomposition.
525
+
526
+ Only homogeneous TP (``tp_ratio == 1``) is supported; heterogeneous TP is
527
+ gated with ``NotImplementedError`` (see the design doc). The returned
528
+ template is a pure function of the layer geometry and ``split_info``, so it
529
+ can be cached and reused across requests without recomputation.
530
+ """
531
+ if tp_ratio != 1:
532
+ # Heterogeneous-TP recurrent-state transfer needs the remote page's
533
+ # slot stride (which differs from the local page) and, for
534
+ # P_TP > D_TP, a multi-rank gather of conv/ssm slices. Both are
535
+ # follow-ups. The mamba path bypasses the attention KV-head validator,
536
+ # so fail loudly here instead of silently transferring wrong bytes.
537
+ raise NotImplementedError(
538
+ "heterogeneous-TP mamba/KDA transfer is not supported yet "
539
+ f"(tp_ratio={tp_ratio}); use equal prefill/decode TP for "
540
+ "hybrid models"
541
+ )
542
+ geom = get_mamba_transfer_geometry(conv, ssm)
543
+ conv_local_offsets = split_info.local_conv_offsets
544
+ conv_remote_offsets = split_info.remote_conv_offsets(0, 1)
545
+ ssm_read_bytes = geom.ssm_slot_bytes
546
+ conv_subprojs = tuple(
547
+ (loff, roff, rsz)
548
+ for (loff, _lsz), (roff, rsz) in zip(conv_local_offsets, conv_remote_offsets)
549
+ )
550
+ return MambaOffsetTemplate(
551
+ num_slots=conv.shape[0],
552
+ conv_slot_stride_bytes=geom.conv_slot_stride * geom.conv_element_size,
553
+ ssm_slot_stride_bytes=geom.ssm_slot_stride * geom.ssm_element_size,
554
+ ssm_read_bytes=ssm_read_bytes,
555
+ conv_subprojs=conv_subprojs,
556
+ )
557
+
558
+
559
+def apply_mamba_offset_template(
560
+ template: MambaOffsetTemplate,
561
+ local_slots: list[int],
562
+ remote_slots: list[int],
563
+) -> tuple[list[int], list[int], list[int]]:
564
+ """Apply per-request slot bases to a cached ``MambaOffsetTemplate``.
565
+
566
+ Conv sub-projection entries (all slots) come first, followed by one ssm
567
+ entry per slot.
568
+ """
569
+ if len(local_slots) != len(remote_slots):
570
+ raise ValueError(
571
+ "local and remote mamba slot counts must match: "
572
+ f"{len(local_slots)} != {len(remote_slots)}"
573
+ )
574
+ invalid_local = next(
575
+ (slot for slot in local_slots if not 0 <= slot < template.num_slots), None
576
+ )
577
+ if invalid_local is not None:
578
+ raise ValueError(
579
+ f"local mamba slot {invalid_local} is outside [0, {template.num_slots})"
580
+ )
581
+ invalid_remote = next((slot for slot in remote_slots if slot < 0), None)
582
+ if invalid_remote is not None:
583
+ raise ValueError(f"remote mamba slot {invalid_remote} must be non-negative")
584
+ # The peer's KDA slot count is not part of MoRIIOAgentMetadata yet. Do not
585
+ # compare remote slot IDs against the local pool size: two otherwise
586
+ # compatible engines may reserve different numbers of slots.
587
+ local_offs: list[int] = []
588
+ remote_offs: list[int] = []
589
+ sizes: list[int] = []
590
+
591
+ # Conv sub-projections (region 0), all slots first.
592
+ conv_slot_stride_bytes = template.conv_slot_stride_bytes
593
+ for ls, rs in zip(local_slots, remote_slots):
594
+ lbase = ls * conv_slot_stride_bytes
595
+ rbase = rs * conv_slot_stride_bytes
596
+ for loff, roff, rsz in template.conv_subprojs:
597
+ local_offs.append(lbase + loff)
598
+ remote_offs.append(rbase + roff)
599
+ sizes.append(rsz)
600
+
601
+ # SSM temporal state (region 1), one entry per slot.
602
+ ssm_slot_stride_bytes = template.ssm_slot_stride_bytes
603
+ ssm_read_bytes = template.ssm_read_bytes
604
+ for ls, rs in zip(local_slots, remote_slots):
605
+ local_offs.append(ls * ssm_slot_stride_bytes)
606
+ remote_offs.append(rs * ssm_slot_stride_bytes)
607
+ sizes.append(ssm_read_bytes)
608
+
609
+ return local_offs, remote_offs, sizes
610
+
611
+
612
+def compute_mamba_conv_split_count(
613
+ local_slots: list[int],
614
+ split_info: MambaConvSplitInfo,
615
+) -> int:
616
+ """Number of leading conv entries in a KDA layer transfer plan.
617
+
618
+ Entries ``[:count]`` are conv sub-projections (conv region/session);
619
+ entries ``[count:]`` are the per-slot ssm state (ssm region/session).
620
+ """
621
+ return len(local_slots) * len(split_info.local_conv_offsets)