[KVConnector][MoRIIO] Support K3 DSpark hybrid READ (#57700)

Signed-off-by: Yichao Zhu <Yichao.Zhu@amd.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

YukioZzz committed Sep 30, 2026 at 03:14 UTC 30ae8248b663ea56398d44d30c0d8276a598c4a0
5 files changed +288 -45
tests/v1/kv_connector/unit/test_moriio_hma_scheduler.py
+194 -6
@@ -21,6 +21,9 @@ import pytest
21 import torch
22
23 from vllm.platforms import current_platform
24 +from vllm.v1.core.block_pool import BlockPool
25 +from vllm.v1.core.single_type_kv_cache_manager import FullAttentionManager
26 +from vllm.v1.kv_cache_interface import FullAttentionSpec, UniformTypeKVCacheSpecs
27
28 mori_available = importlib.util.find_spec("mori") is not None
29
@@ -44,6 +47,16 @@ MoRIIOMode = moriio_connector.MoRIIOMode
47 moriio_common = importlib.import_module(
48 "vllm.distributed.kv_transfer.kv_connector.v1.moriio.moriio_common"
49 )
50 +get_role = moriio_common.get_role
51 +set_role = moriio_common.set_role
52 +
53 +
54 +@pytest.fixture
55 +def restore_moriio_role():
56 + """Undo the process-global role a full scheduler construction leaves behind."""
57 + role = get_role()
58 + yield
59 + set_role(role)
60
61
62 class _FakeScheduler(moriio_connector.MoRIIOConnectorScheduler): # type: ignore[name-defined]
@@ -53,6 +66,7 @@ class _FakeScheduler(moriio_connector.MoRIIOConnectorScheduler): # type: ignore
66 def __init__(self, **attrs):
67 self._mamba_group_ids: list[int] = []
68 self._attn_group_ids: list[int] = [0]
69 + self._num_ssm_scratch_blocks = 0
70 self._ssm_state_slots_are_positional = False
71 self._is_hma_required = False
72 self.kv_cache_config = SimpleNamespace(
@@ -110,22 +124,56 @@ def _gdn_split_info(conv_rows=3, key_dim=4, value_dim=8, dtype_size=2):
124 )
125
126
113 -def _mamba_spec(num_states: int = 2):
127 +def _mamba_spec(num_states: int = 2, *, num_speculative_blocks: int = 0):
128 shapes = tuple((1, 1) for _ in range(num_states))
129 return moriio_connector.MambaSpec(
130 block_size=16,
131 shapes=shapes,
132 dtypes=(torch.float32,) * num_states,
133 mamba_cache_mode="all",
134 + num_speculative_blocks=num_speculative_blocks,
135 )
136
137
123 -def _gate_vllm_config(*, read_mode: bool = True, speculative_config=None):
138 +def _spec_config(method: str):
139 + return SimpleNamespace(method=method, use_dspark=lambda: method == "dspark")
140 +
141 +
142 +def _gate_vllm_config(
143 + *,
144 + read_mode: bool = True,
145 + speculative_config=None,
146 + block_size: int = 16,
147 + num_lookahead_tokens: int = 0,
148 + num_speculative_tokens: int = 0,
149 + mamba_cache_mode: str = "all",
150 + dcp_size: int = 1,
151 +):
152 return SimpleNamespace(
153 kv_transfer_config=SimpleNamespace(
126 - kv_connector_extra_config={"read_mode": read_mode}
154 + kv_connector_extra_config={
155 + "read_mode": read_mode,
156 + "host_ip": "127.0.0.1",
157 + "handshake_port": 6001,
158 + "notify_port": 6002,
159 + },
160 + kv_role="kv_consumer",
161 ),
162 speculative_config=speculative_config,
163 + cache_config=SimpleNamespace(
164 + block_size=block_size,
165 + mamba_cache_mode=mamba_cache_mode,
166 + ),
167 + scheduler_config=SimpleNamespace(disable_hybrid_kv_cache_manager=False),
168 + num_lookahead_tokens=num_lookahead_tokens,
169 + num_speculative_tokens=num_speculative_tokens,
170 + parallel_config=SimpleNamespace(
171 + tensor_parallel_size=1,
172 + decode_context_parallel_size=dcp_size,
173 + data_parallel_rank=0,
174 + data_parallel_size_local=1,
175 + data_parallel_size=1,
176 + ),
177 )
178
179
@@ -196,6 +244,7 @@ def test_exchange_blocks_ignore_transfer_disabled_group():
244
245
246 def test_scheduler_rejects_multiple_attention_groups_with_mamba():
247 + """A drafter owning its own transfer group is refused, with a hint."""
248 config = SimpleNamespace(
249 kv_cache_groups=[
250 SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=object()),
@@ -205,7 +254,7 @@ def test_scheduler_rejects_multiple_attention_groups_with_mamba():
254 )
255 config.transfer_groups = tuple(config.kv_cache_groups)
256
208 - with pytest.raises(moriio_common.MoRIIOError, match="exactly one transferable"):
257 + with pytest.raises(moriio_common.MoRIIOError, match="separate attention group"):
258 moriio_connector.MoRIIOConnectorScheduler(_gate_vllm_config(), "engine", config)
259
260
@@ -251,7 +300,7 @@ def test_scheduler_rejects_mamba_group_without_two_states():
300 moriio_connector.MoRIIOConnectorScheduler(_gate_vllm_config(), "engine", config)
301
302
254 -def test_scheduler_rejects_speculative_hybrid_read():
303 +def test_scheduler_rejects_non_dspark_speculative_hybrid_read():
304 config = SimpleNamespace(
305 kv_cache_groups=[
306 SimpleNamespace(enable_kv_transfer=True, kv_cache_spec=object()),
@@ -262,7 +311,105 @@ def test_scheduler_rejects_speculative_hybrid_read():
311
312 with pytest.raises(moriio_common.MoRIIOError, match="speculative decoding"):
313 moriio_connector.MoRIIOConnectorScheduler(
265 - _gate_vllm_config(speculative_config=object()), "engine", config
314 + _gate_vllm_config(speculative_config=_spec_config("ngram")),
315 + "engine",
316 + config,
317 + )
318 +
319 +
320 +def _dspark_wrapped_attention_config(*, dcp_sharded: bool = True):
321 + """Mamba first, so a wrapped full-attention group at index 1 has to be found.
322 +
323 + A bare ``isinstance(FullAttentionSpec)`` misses the wrapper and leaves
324 + ``_full_attn_group_idx`` at its initial 0, which is the Mamba group.
325 + """
326 + full = FullAttentionSpec(
327 + block_size=4,
328 + num_kv_heads=1,
329 + head_size=1,
330 + dtype=torch.float32,
331 + dcp_sharded=dcp_sharded,
332 + )
333 + config = SimpleNamespace(
334 + kv_cache_groups=[
335 + SimpleNamespace(
336 + enable_kv_transfer=True,
337 + kv_cache_spec=_mamba_spec(num_speculative_blocks=2),
338 + ),
339 + SimpleNamespace(
340 + enable_kv_transfer=True,
341 + kv_cache_spec=UniformTypeKVCacheSpecs(
342 + block_size=4, kv_cache_specs={"target": full, "draft": full}
343 + ),
344 + ),
345 + ],
346 + )
347 + config.transfer_groups = tuple(config.kv_cache_groups)
348 + return config
349 +
350 +
351 +@pytest.mark.usefixtures("restore_moriio_role")
352 +@pytest.mark.parametrize(
353 + "dcp_size,dcp_sharded,expected_tail", [(1, True, 4), (4, True, 1), (4, False, 4)]
354 +)
355 +def test_scheduler_accepts_dspark_with_wrapped_full_attention(
356 + dcp_size, dcp_sharded, expected_tail
357 +):
358 + config = _dspark_wrapped_attention_config(dcp_sharded=dcp_sharded)
359 + vllm_config = _gate_vllm_config(
360 + speculative_config=_spec_config("dspark"),
361 + block_size=4,
362 + num_lookahead_tokens=7,
363 + num_speculative_tokens=7,
364 + mamba_cache_mode="align",
365 + dcp_size=dcp_size,
366 + )
367 + scheduler = moriio_connector.MoRIIOConnectorScheduler(vllm_config, "engine", config)
368 +
369 + assert scheduler._full_attn_group_idx == 1
370 + assert scheduler._num_ssm_scratch_blocks == 2
371 + assert scheduler._max_decode_tail_blocks == expected_tail
372 +
373 + # A first decode step reserves padded target rows AND drafter lookahead.
374 + spec = config.transfer_groups[1].kv_cache_spec
375 + manager = FullAttentionManager(
376 + spec,
377 + block_pool=BlockPool(32, enable_caching=False, hash_block_size=4),
378 + enable_caching=False,
379 + kv_cache_group_id=1,
380 + scheduler_block_size=16,
381 + dcp_world_size=dcp_size,
382 + )
383 + imported_tokens = manager.block_size
384 + target_tokens = 1 + vllm_config.num_speculative_tokens
385 + manager.allocate_new_blocks(
386 + "decode",
387 + num_tokens=imported_tokens + target_tokens + vllm_config.num_lookahead_tokens,
388 + num_tokens_main_model=imported_tokens + target_tokens,
389 + )
390 + local = [b.block_id for b in manager.req_to_blocks["decode"]]
391 + remote = [100]
392 + assert scheduler._align_read_blocks(
393 + local, remote, scheduler._max_decode_tail_blocks
394 + ) == (local[:1], remote)
395 + with pytest.raises(ValueError, match="tail"):
396 + scheduler._align_read_blocks(
397 + local + [99], remote, scheduler._max_decode_tail_blocks
398 + )
399 +
400 +
401 +def test_scheduler_rejects_dspark_with_positional_mamba_cache():
402 + """Positional mode counts lookahead in the Mamba allocation, so the
403 + one-block recurrent-state tail bound no longer holds."""
404 + with pytest.raises(moriio_common.MoRIIOError, match="mamba_cache_mode"):
405 + moriio_connector.MoRIIOConnectorScheduler(
406 + _gate_vllm_config(
407 + speculative_config=_spec_config("dspark"),
408 + block_size=4,
409 + mamba_cache_mode="all",
410 + ),
411 + "engine",
412 + _dspark_wrapped_attention_config(),
413 )
414
415
@@ -316,6 +463,30 @@ def test_split_block_groups_keeps_only_running_state_outside_all_mode():
463 assert sched.split_block_groups(([1], [40, 41])) == ([1], [[41]])
464
465
466 +@pytest.mark.parametrize(
467 + "positional,blocks,expected",
468 + [
469 + # Trailing scratch slots go in either cache mode.
470 + (True, [40, 41, 42], [40]),
471 + # Outside "all" the running state is picked only after they are gone.
472 + (False, [40, 41, 42, 43], [41]),
473 + # Never empty: a list shorter than the scratch count keeps one state.
474 + (True, [40], [40]),
475 + (False, [], []),
476 + ],
477 +)
478 +def test_split_block_groups_strips_dspark_scratch_slots(positional, blocks, expected):
479 + sched = _FakeScheduler(
480 + _has_mamba=True,
481 + _attn_group_ids=[0],
482 + _mamba_group_ids=[1],
483 + _num_ssm_scratch_blocks=2,
484 + _ssm_state_slots_are_positional=positional,
485 + )
486 +
487 + assert sched.split_block_groups(([1], blocks)) == ([1], [expected])
488 +
489 +
490 # --------------------------------------------------------------------------
491 # request_finished_all_groups
492 # --------------------------------------------------------------------------
@@ -434,6 +605,23 @@ def test_update_state_pairs_shorter_local_blocks_with_remote_suffix():
605 assert sched._req_kv_params["req"]["remote_block_ids"] == [[12], [92]]
606
607
608 +def test_update_state_pairs_trimmed_mamba_state_with_remote_state():
609 + """The DSpark shape end to end: scratch slots are stripped, the running
610 + state is then selected, and the attention list loses its lookahead tail."""
611 + sched = _make_read_scheduler()
612 + sched._ssm_state_slots_are_positional = False
613 + sched._num_ssm_scratch_blocks = 2
614 + sched._max_decode_tail_blocks = 2
615 + request = _make_read_request([[10, 11], [90]])
616 + # Mamba group: [null, running state, scratch, scratch].
617 + blocks = _FakeBlocks(all_groups=([100, 101, 102, 103], [200, 201, 202, 203]))
618 +
619 + sched.update_state_after_alloc(request, blocks, num_external_tokens=256)
620 +
621 + assert sched._reqs_need_recv["req"][1] == [[100, 101], [201]]
622 + assert sched._req_kv_params["req"]["remote_block_ids"] == [[10, 11], [90]]
623 +
624 +
625 def test_update_state_full_attention_hit_still_carries_mamba_state():
626 sched = _make_read_scheduler()
627 request = _make_read_request([[10], [90]])
tests/v1/kv_connector/unit/test_nixl_connector_hma.py
+9
@@ -2055,6 +2055,15 @@ def test_exchange_clipped_blocks_ssm_single_state():
2055 # Scratch slots not allocated: the state slot still survives.
2056 assert sched.get_exchange_clipped_blocks(([1], [5]))[1] == [5]
2057
2058 + assert sched.get_exchange_clipped_blocks(([1], [])) == ([1], [])
2059 + # Per-step partial lists must not be interpreted as state + scratch slots.
2060 + assert sched.get_exchange_clipped_blocks(([1], [7, 8]), clip_ssm=False) == (
2061 + [1],
2062 + [7, 8],
2063 + )
2064 + sched.blocks_per_sw = [2, 0]
2065 + assert sched.get_exchange_clipped_blocks(([1, 2, 3], [7, 8, 9])) == ([2, 3], [7])
2066 +
2067 # Non-mamba models pass through unchanged.
2068 fa_sched = make_nixl_scheduler(has_mamba=False)
2069 assert fa_sched.get_exchange_clipped_blocks(([1, 2],)) == ([1, 2],)
vllm/distributed/kv_transfer/kv_connector/utils.py
+12
@@ -33,6 +33,18 @@ EngineId = str
33 BlockIds = tuple[list[int], ...] | list[list[int]]
34
35
36 +def clip_ssm_state_blocks(
37 + blocks: list[int], num_spec_blocks: int, positional: bool
38 +) -> list[int]:
39 + """Drop speculative scratch slots, retaining position-indexed or running state."""
40 + if not blocks:
41 + return blocks
42 + # Keep at least the running-state slot, even before scratch allocation.
43 + if num_scratch := min(num_spec_blocks, len(blocks) - 1):
44 + blocks = blocks[:-num_scratch]
45 + return blocks if positional else blocks[-1:]
46 +
47 +
48 def get_kv_connector_cache_layout(vllm_config: VllmConfig | None = None):
49 # NOTE (NickLucche) When running disaggregated PD with NIXL, LBHNC layout is
50 # used for faster transfer.
vllm/distributed/kv_transfer/kv_connector/v1/moriio/moriio_connector.py
+69 -34
@@ -17,7 +17,10 @@ import torch
17 import zmq
18
19 from vllm.config import CUDAGraphMode, VllmConfig
20 -from vllm.distributed.kv_transfer.kv_connector.utils import BlockIds
20 +from vllm.distributed.kv_transfer.kv_connector.utils import (
21 + BlockIds,
22 + clip_ssm_state_blocks,
23 +)
24 from vllm.distributed.kv_transfer.kv_connector.v1.base import (
25 KVConnectorBase_V1,
26 KVConnectorMetadata,
@@ -88,10 +91,10 @@ from vllm.utils.network_utils import (
91 from vllm.v1.attention.selector import get_attn_backend
92 from vllm.v1.core.sched.output import SchedulerOutput
93 from vllm.v1.kv_cache_interface import (
91 - FullAttentionSpec,
94 KVCacheConfig,
95 MambaSpec,
96 SlidingWindowSpec,
97 + is_full_attention_spec,
98 )
99 from vllm.v1.outputs import KVConnectorOutput
100 from vllm.v1.request import RequestStatus
@@ -108,7 +111,7 @@ logger = init_logger(__name__)
111 # wait only needs to be long enough for it to make progress.
112 _SQ_FULL_BACKOFF_INITIAL_S = 0.001
113 _SQ_FULL_BACKOFF_MAX_S = 0.05
111 -_MAX_LOCAL_DECODE_TAIL_BLOCKS = 1
114 +_MAX_LOCAL_MAMBA_TAIL_BLOCKS = 1
115
116
117 try:
@@ -436,6 +439,26 @@ def _split_kv_cache_group_kinds(
439 return attn, mamba
440
441
442 +def _validate_hybrid_speculation(vllm_config: VllmConfig) -> None:
443 + speculative_config = vllm_config.speculative_config
444 + if speculative_config is None:
445 + return
446 + # DSpark is allowlisted because it is the only hybrid speculative method
447 + # validated end to end with MoRIIO READ.
448 + if not speculative_config.use_dspark():
449 + raise MoRIIOError(
450 + "MoRIIO hybrid READ supports DSpark speculative decoding only, got "
451 + f"method={speculative_config.method!r}"
452 + )
453 + if vllm_config.cache_config.mamba_cache_mode == "all":
454 + # Positional allocation counts lookahead, so its local tail can exceed
455 + # the single running-state block supported by hybrid READ.
456 + raise MoRIIOError(
457 + "MoRIIO hybrid READ does not support DSpark with "
458 + "mamba_cache_mode='all'; use 'align'"
459 + )
460 +
461 +
462 def _validate_mamba_specs(specs: Collection[MambaSpec]) -> MambaSpec | None:
463 """Validate the common packed-state contract and return its representative."""
464 specs = tuple(specs)
@@ -477,22 +500,22 @@ class MoRIIOConnectorScheduler:
500 kv_cache_config
501 )
502 self._has_mamba = bool(self._mamba_group_ids)
503 + mamba_spec = None
504 if self._has_mamba:
505 if len(self._attn_group_ids) != 1:
506 raise MoRIIOError(
507 "MoRIIO hybrid READ requires exactly one transferable "
508 "attention group, got "
485 - f"{len(self._attn_group_ids)} attention groups"
509 + f"{len(self._attn_group_ids)}; a drafter that owns a "
510 + "separate attention group is not supported, give it the "
511 + "target's group or disable KV transfer for it"
512 )
513 mamba_specs = [
514 cast(MambaSpec, kv_cache_config.transfer_groups[group_id].kv_cache_spec)
515 for group_id in self._mamba_group_ids
516 ]
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 - )
517 + mamba_spec = _validate_mamba_specs(mamba_specs)
518 + _validate_hybrid_speculation(vllm_config)
519 if self.mode != MoRIIOMode.READ:
520 raise MoRIIOError(
521 "MoRIIO hybrid (mamba/KDA) transfer is implemented for READ "
@@ -503,18 +526,31 @@ class MoRIIOConnectorScheduler:
526 self._ssm_state_slots_are_positional = (
527 vllm_config.cache_config.mamba_cache_mode == "all"
528 )
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)
529 + self._num_ssm_scratch_blocks = (
530 + mamba_spec.num_speculative_blocks if mamba_spec is not None else 0
531 )
532 + self.block_size = vllm_config.cache_config.block_size
533 + attn_block_size = self.block_size
534 + max_decode_tail_tokens = 1 + vllm_config.num_lookahead_tokens
535 + if self._has_mamba:
536 + attn_spec = kv_cache_config.transfer_groups[
537 + self._attn_group_ids[0]
538 + ].kv_cache_spec
539 + attn_block_size = attn_spec.block_size
540 + if attn_spec.dcp_sharded:
541 + attn_block_size *= (
542 + vllm_config.parallel_config.decode_context_parallel_size
543 + )
544 + # Admission can pad the final prompt token to 1 + num_spec rows;
545 + # allocate_slots reserves drafter lookahead in addition to those.
546 + max_decode_tail_tokens += vllm_config.num_speculative_tokens
547 + self._max_decode_tail_blocks = cdiv(max_decode_tail_tokens, attn_block_size)
548 self.engine_id: EngineId = engine_id
549
550 self._is_hma_required = (
551 not vllm_config.scheduler_config.disable_hybrid_kv_cache_manager
552 and any(
517 - not isinstance(g.kv_cache_spec, FullAttentionSpec)
553 + not is_full_attention_spec(g.kv_cache_spec)
554 for g in kv_cache_config.transfer_groups
555 )
556 )
@@ -524,9 +560,9 @@ class MoRIIOConnectorScheduler:
560 unsupported = [
561 type(g.kv_cache_spec).__name__
562 for g in kv_cache_config.transfer_groups
527 - if not isinstance(
528 - g.kv_cache_spec,
529 - (FullAttentionSpec, SlidingWindowSpec, MambaSpec),
563 + if not (
564 + is_full_attention_spec(g.kv_cache_spec)
565 + or isinstance(g.kv_cache_spec, (SlidingWindowSpec, MambaSpec))
566 )
567 ]
568 if unsupported:
@@ -565,7 +601,7 @@ class MoRIIOConnectorScheduler:
601 self._full_attn_group_idx = 0
602 self._full_attn_block_size = self.block_size
603 for gi, group in enumerate(kv_cache_config.kv_cache_groups):
568 - if isinstance(group.kv_cache_spec, FullAttentionSpec):
604 + if is_full_attention_spec(group.kv_cache_spec):
605 self._full_attn_group_idx = gi
606 self._full_attn_block_size = getattr(
607 group.kv_cache_spec, "block_size", self.block_size
@@ -953,6 +989,9 @@ class MoRIIOConnectorScheduler:
989 )
990 aligned_local_mamba: list[list[int]] = []
991 aligned_remote_mamba: list[list[int]] = []
992 + # Scratch slots were removed by
993 + # split_block_groups, so only one local running
994 + # state tail can remain.
995 for local_group, remote_group in zip(
996 mamba_block_groups,
997 remote_mamba_groups,
@@ -960,7 +999,9 @@ class MoRIIOConnectorScheduler:
999 ):
1000 aligned_local, aligned_remote = (
1001 self._align_read_blocks(
963 - local_group, remote_group, 1
1002 + local_group,
1003 + remote_group,
1004 + _MAX_LOCAL_MAMBA_TAIL_BLOCKS,
1005 )
1006 )
1007 aligned_local_mamba.append(aligned_local)
@@ -1257,21 +1298,15 @@ class MoRIIOConnectorScheduler:
1298 transfer_block_ids = self.kv_cache_config.select_transfer_block_ids(block_ids)
1299 attn = list(transfer_block_ids[self._attn_group_ids[0]])
1300 mamba_groups = [
1260 - self._clip_mamba_group(list(transfer_block_ids[group_id]))
1301 + clip_ssm_state_blocks(
1302 + list(transfer_block_ids[group_id]),
1303 + self._num_ssm_scratch_blocks,
1304 + self._ssm_state_slots_are_positional,
1305 + )
1306 for group_id in self._mamba_group_ids
1307 ]
1308 return attn, mamba_groups
1309
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 -
1310 @staticmethod
1311 def _align_read_blocks(
1312 local_block_ids: list[int],
@@ -1282,9 +1317,9 @@ class MoRIIOConnectorScheduler:
1317
1318 Prefix hits can make the decode's unhashed suffix shorter than the
1319 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.
1320 + decoder can instead have extra tail blocks for the locally recomputed
1321 + final token and speculative slots; these have no remote source and are
1322 + not transferred.
1323 """
1324 if len(local_block_ids) > len(remote_block_ids):
1325 extra_decode_blocks = len(local_block_ids) - len(remote_block_ids)
vllm/distributed/kv_transfer/kv_connector/v1/nixl/base_scheduler.py
+4 -5
@@ -13,6 +13,7 @@ from vllm import envs
13 from vllm.distributed.kv_transfer.kv_connector.utils import (
14 BlockIds,
15 EngineId,
16 + clip_ssm_state_blocks,
17 yield_req_data,
18 )
19 from vllm.distributed.kv_transfer.kv_connector.v1.base import (
@@ -288,11 +289,9 @@ class NixlBaseConnectorScheduler:
289 and blocks
290 and (n_spec_blocks := self._ssm_spec_blocks[i]) is not None
291 ):
291 - if n_spec := min(n_spec_blocks, len(blocks) - 1):
292 - blocks = blocks[:-n_spec]
293 - if not self._ssm_state_slots_are_positional:
294 - # Never empty: downstream reads that as a full prefix hit.
295 - blocks = blocks[-1:]
292 + blocks = clip_ssm_state_blocks(
293 + blocks, n_spec_blocks, self._ssm_state_slots_are_positional
294 + )
295 clipped.append(blocks)
296 return tuple(clipped)
297