@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / d49f273144
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 6, 10:45 UTC

[SSM/Mamba] Follow-up: N-1 prefill for P/D disaggregation (#37310)

zhanqiuhu committed Mar 19, 2026 at 03:22 UTC d49f27314454afa687fb0323cfce7819e123c1c9
5 files changed +263 -13
tests/v1/kv_connector/unit/test_nixl_connector.py
+1 -1
@@ -2007,7 +2007,7 @@ def test_transfer_failure_logging(
2007 connector = NixlConnector(
2008 vllm_config,
2009 KVConnectorRole.WORKER,
2010 - make_kv_cache_config(block_size=16, hma_enabled=enable_hma),
2010 + make_kv_cache_config(block_size=16, swa_enabled=enable_hma),
2011 )
2012 connector.connector_worker = FakeNixlConnectorWorker(
2013 vllm_config,
tests/v1/kv_connector/unit/test_nixl_connector_hma.py
+113 -8
@@ -1,6 +1,6 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 -"""Unit tests for NixlConnectorScheduler sw_sizes calculation with HMA."""
3 +"""Unit tests for NixlConnectorScheduler with HMA and Mamba N-1 prefill."""
4
5 from unittest.mock import patch
6
@@ -14,24 +14,26 @@ from vllm.v1.core.single_type_kv_cache_manager import (
14 )
15
16 from .utils import (
17 + create_request,
18 create_vllm_config,
19 make_kv_cache_config,
20 + make_nixl_scheduler,
21 )
22
23
24 @pytest.mark.cpu_test
25 @pytest.mark.parametrize(
24 - "hma_enabled,expected_sw_sizes",
26 + "swa_enabled,expected_sw_sizes",
27 [
26 - # HMA enabled: FullAttentionSpec (0) + SlidingWindowSpec (2048/16=128)
28 + # SWA enabled: FullAttentionSpec (0) + SlidingWindowSpec (2048/16=128)
29 (True, [0, 128 + 1]),
28 - # HMA disabled: only FullAttentionSpec (0)
30 + # SWA disabled: only FullAttentionSpec (0)
31 (False, [0]),
32 ],
33 )
34 @patch("vllm.distributed.kv_transfer.kv_connector.v1.nixl_connector.current_platform")
33 -def test_sw_sizes(mock_platform, hma_enabled, expected_sw_sizes):
34 - """Test sw_sizes is correctly computed based on HMA enabled/disabled."""
35 +def test_sw_sizes(mock_platform, swa_enabled, expected_sw_sizes):
36 + """Test sw_sizes is correctly computed based on SWA enabled/disabled."""
37 from vllm.distributed.kv_transfer.kv_connector.v1.nixl_connector import (
38 NixlConnectorScheduler,
39 )
@@ -42,7 +44,7 @@ def test_sw_sizes(mock_platform, hma_enabled, expected_sw_sizes):
44 vllm_config = create_vllm_config(block_size=block_size)
45 # SW 2048 tokens=>128 blocks
46 kv_cache_config = make_kv_cache_config(
45 - block_size=block_size, hma_enabled=hma_enabled, sw_size=2048
47 + block_size=block_size, swa_enabled=swa_enabled, sw_size=2048
48 )
49
50 scheduler = NixlConnectorScheduler(
@@ -75,7 +77,7 @@ def test_logical_to_kernel_block_ids_with_hma():
77 # So each logical block maps to 2 kernel blocks eg [0]->[0,1]
78 worker._physical_blocks_per_logical_kv_block = 2
79 # FA + SW groups (neither is MambaSpec, so both get expanded)
78 - worker.kv_cache_config = make_kv_cache_config(block_size=16, hma_enabled=True)
80 + worker.kv_cache_config = make_kv_cache_config(block_size=16, swa_enabled=True)
81
82 # Test conversion: FA + SW group
83 logical_block_ids = [[0, 1, 2], [3, 4]]
@@ -313,3 +315,106 @@ def test_nixl_metadata_hybrid_ssm_block_ids():
315 assert list(req_meta.remote.block_ids[0]) == [10, 11, 12, 13, 14, 15, 16, 17]
316 assert list(req_meta.remote.block_ids[1]) == [20, 21]
317 assert len(req_meta.remote.block_ids[0]) != len(req_meta.remote.block_ids[1])
318 +
319 +
320 +# ── Mamba N-1 prefill tests ──────────────────────────────────────────────
321 +
322 +
323 +@pytest.mark.cpu_test
324 +@pytest.mark.parametrize(
325 + "has_mamba,is_hma_required,expected_count",
326 + [
327 + (True, True, 9),
328 + (False, False, 10),
329 + (False, True, 10),
330 + ],
331 + ids=["mamba", "fa_only", "swa_only"],
332 +)
333 +def test_mamba_n1_d_side(has_mamba, is_hma_required, expected_count):
334 + """D-side: Mamba gets N-1 matched tokens, non-Mamba gets N."""
335 + sched = make_nixl_scheduler(has_mamba=has_mamba, is_hma_required=is_hma_required)
336 + req = create_request(num_tokens=10, do_remote_prefill=True)
337 +
338 + count, is_async = sched.get_num_new_matched_tokens(req, num_computed_tokens=0)
339 + assert count == expected_count
340 + assert is_async is True
341 +
342 +
343 +@pytest.mark.cpu_test
344 +def test_mamba_n1_p_side_truncation():
345 + """P-side: Mamba truncates prompt to N-1, sets max_tokens=1.
346 +
347 + Also verifies idempotency (calling again is a no-op) which is
348 + needed for preemption safety via the _p_side_truncated guard,
349 + and that non-Mamba models skip truncation entirely.
350 + """
351 + sched = make_nixl_scheduler(has_mamba=True, is_hma_required=True)
352 + req = create_request(num_tokens=10, do_remote_decode=True)
353 + req.max_tokens = 128
354 + original_len = len(req.prompt_token_ids)
355 +
356 + count, is_async = sched.get_num_new_matched_tokens(req, num_computed_tokens=0)
357 +
358 + assert count == 0
359 + assert is_async is False
360 + assert len(req.prompt_token_ids) == original_len - 1
361 + assert req.num_prompt_tokens == original_len - 1
362 + assert req.max_tokens == 1
363 + assert req.kv_transfer_params["_p_side_truncated"] is True
364 +
365 + # Idempotency: second call must not truncate further
366 + sched.get_num_new_matched_tokens(req, num_computed_tokens=0)
367 + assert len(req.prompt_token_ids) == original_len - 1
368 +
369 + # Non-Mamba: truncation is skipped
370 + fa_sched = make_nixl_scheduler(has_mamba=False, is_hma_required=False)
371 + fa_req = create_request(num_tokens=10, do_remote_decode=True)
372 + fa_original = len(fa_req.prompt_token_ids)
373 +
374 + fa_sched.get_num_new_matched_tokens(fa_req, num_computed_tokens=0)
375 + assert len(fa_req.prompt_token_ids) == fa_original
376 +
377 +
378 +@pytest.mark.cpu_test
379 +@pytest.mark.parametrize(
380 + "swa_enabled,mamba_enabled,expected_has_mamba,expected_is_hma",
381 + [
382 + (True, True, True, True),
383 + (True, False, False, True),
384 + (False, False, False, False),
385 + ],
386 + ids=["fa_swa_mamba", "fa_swa_only", "fa_only"],
387 +)
388 +@patch("vllm.distributed.kv_transfer.kv_connector.v1.nixl_connector.current_platform")
389 +def test_has_mamba_init(
390 + mock_platform,
391 + swa_enabled,
392 + mamba_enabled,
393 + expected_has_mamba,
394 + expected_is_hma,
395 +):
396 + """Test _has_mamba / _is_hma_required derived from kv_cache_groups."""
397 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl_connector import (
398 + NixlConnectorScheduler,
399 + )
400 +
401 + mock_platform.device_type = "cpu"
402 +
403 + block_size = 16
404 + vllm_config = create_vllm_config(block_size=block_size)
405 + # VllmConfig.__post_init__ auto-disables HMA when kv_transfer_config
406 + # is set; override so we can test the scheduler's own derivation.
407 + vllm_config.scheduler_config.disable_hybrid_kv_cache_manager = False
408 + kv_cache_config = make_kv_cache_config(
409 + block_size=block_size,
410 + swa_enabled=swa_enabled,
411 + mamba_enabled=mamba_enabled,
412 + )
413 +
414 + scheduler = NixlConnectorScheduler(
415 + vllm_config=vllm_config,
416 + engine_id="test-engine",
417 + kv_cache_config=kv_cache_config,
418 + )
419 + assert scheduler._has_mamba is expected_has_mamba
420 + assert scheduler._is_hma_required is expected_is_hma
tests/v1/kv_connector/unit/test_remote_prefill_lifecycle.py
+77 -1
@@ -1,10 +1,15 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import copy
4 +from unittest.mock import patch
5
6 import pytest
7
7 -from vllm.v1.outputs import EMPTY_MODEL_RUNNER_OUTPUT, KVConnectorOutput
8 +from vllm.v1.outputs import (
9 + EMPTY_MODEL_RUNNER_OUTPUT,
10 + KVConnectorOutput,
11 + ModelRunnerOutput,
12 +)
13 from vllm.v1.request import FinishReason, RequestStatus
14
15 from .utils import (
@@ -13,6 +18,7 @@ from .utils import (
18 create_request,
19 create_scheduler,
20 create_vllm_config,
21 + make_kv_cache_config,
22 )
23
24 pytestmark = pytest.mark.cpu_test
@@ -579,3 +585,73 @@ def test_cannot_recv():
585 scheduler.update_from_output(scheduler_output, model_runner_output)
586 _ = scheduler.schedule()
587 assert_scheduler_empty(scheduler)
588 +
589 +
590 +@patch("vllm.distributed.kv_transfer.kv_connector.v1.nixl_connector.current_platform")
591 +def test_p_side_chunked_prefill_mamba(mock_platform):
592 + """P-side integration: Mamba N-1 truncation + chunked prefill completes.
593 +
594 + A 64-token P-side request is truncated to 63 by the N-1 fix, then
595 + chunked into two prefill steps (32 + 31) and finishes with
596 + LENGTH_CAPPED because max_tokens is set to 1.
597 + """
598 + mock_platform.device_type = "cpu"
599 +
600 + BATCH_SIZE = 32
601 + NUM_TOKENS = 64
602 + BLOCK_SIZE = 16
603 +
604 + vllm_config = create_vllm_config(
605 + max_num_batched_tokens=BATCH_SIZE,
606 + block_size=BLOCK_SIZE,
607 + )
608 + vllm_config.scheduler_config.disable_hybrid_kv_cache_manager = False
609 +
610 + kv_cache_config = make_kv_cache_config(
611 + block_size=BLOCK_SIZE,
612 + mamba_enabled=True,
613 + num_blocks=10000,
614 + )
615 +
616 + scheduler = create_scheduler(vllm_config, kv_cache_config=kv_cache_config)
617 +
618 + request = create_request(
619 + num_tokens=NUM_TOKENS,
620 + do_remote_decode=True,
621 + block_size=BLOCK_SIZE,
622 + )
623 + request.max_tokens = 128
624 + scheduler.add_request(request)
625 + request_id = request.request_id
626 +
627 + # ── Step 1: first chunk ──
628 + scheduler_output = scheduler.schedule()
629 +
630 + assert len(request.prompt_token_ids) == NUM_TOKENS - 1
631 + assert request.max_tokens == 1
632 + assert scheduler_output.num_scheduled_tokens[request_id] == BATCH_SIZE
633 + assert request.num_computed_tokens == BATCH_SIZE
634 +
635 + # Model returns no tokens for intermediate prefill chunk
636 + intermediate_output = ModelRunnerOutput(
637 + req_ids=[request.request_id],
638 + req_id_to_index={request.request_id: 0},
639 + sampled_token_ids=[[]],
640 + )
641 + scheduler.update_from_output(scheduler_output, intermediate_output)
642 +
643 + # ── Step 2: remaining chunk ──
644 + scheduler_output = scheduler.schedule()
645 +
646 + remaining = NUM_TOKENS - 1 - BATCH_SIZE # 31
647 + assert scheduler_output.num_scheduled_tokens[request_id] == remaining
648 + assert request.num_computed_tokens == NUM_TOKENS - 1
649 +
650 + # Prefill complete: model generates 1 decode token
651 + final_output = create_model_runner_output([request])
652 + engine_core_outputs = scheduler.update_from_output(scheduler_output, final_output)
653 +
654 + # max_tokens=1 → request finishes with LENGTH
655 + outputs = engine_core_outputs[0].outputs
656 + assert len(outputs) == 1
657 + assert outputs[0].finish_reason == FinishReason.LENGTH
tests/v1/kv_connector/unit/utils.py
+30 -2
@@ -37,6 +37,7 @@ from vllm.v1.kv_cache_interface import (
37 FullAttentionSpec,
38 KVCacheConfig,
39 KVCacheGroupSpec,
40 + MambaSpec,
41 SlidingWindowSpec,
42 )
43 from vllm.v1.outputs import KVConnectorOutput, ModelRunnerOutput
@@ -423,7 +424,8 @@ KVConnectorFactory.register_connector(
424
425 def make_kv_cache_config(
426 block_size: int,
426 - hma_enabled: bool = False,
427 + swa_enabled: bool = False,
428 + mamba_enabled: bool = False,
429 sw_size: int = 128,
430 num_blocks: int = 100,
431 ) -> KVCacheConfig:
@@ -438,7 +440,7 @@ def make_kv_cache_config(
440 ),
441 )
442 ]
441 - if hma_enabled:
443 + if swa_enabled:
444 kv_cache_groups.append(
445 KVCacheGroupSpec(
446 ["layer1", "layer3"],
@@ -451,6 +453,32 @@ def make_kv_cache_config(
453 ),
454 )
455 )
456 + if mamba_enabled:
457 + kv_cache_groups.append(
458 + KVCacheGroupSpec(
459 + ["mamba0", "mamba1"],
460 + MambaSpec(
461 + block_size=block_size,
462 + shapes=((16,), (16,)),
463 + dtypes=(torch.float16,),
464 + ),
465 + )
466 + )
467 return KVCacheConfig(
468 num_blocks=num_blocks, kv_cache_tensors=[], kv_cache_groups=kv_cache_groups
469 )
470 +
471 +
472 +def make_nixl_scheduler(has_mamba: bool = False, is_hma_required: bool = False):
473 + """Create a NixlConnectorScheduler via __new__ (skipping __init__).
474 +
475 + Only sets the two flags needed by the N-1 prefill logic.
476 + """
477 + from vllm.distributed.kv_transfer.kv_connector.v1.nixl_connector import (
478 + NixlConnectorScheduler,
479 + )
480 +
481 + sched = object.__new__(NixlConnectorScheduler)
482 + sched._has_mamba = has_mamba
483 + sched._is_hma_required = is_hma_required
484 + return sched
vllm/distributed/kv_transfer/kv_connector/v1/nixl_connector.py
+42 -1
@@ -572,6 +572,10 @@ class NixlConnectorScheduler:
572 for g in kv_cache_config.kv_cache_groups
573 )
574 )
575 + self._has_mamba = any(
576 + isinstance(g.kv_cache_spec, MambaSpec)
577 + for g in kv_cache_config.kv_cache_groups
578 + )
579
580 logger.info("Initializing NIXL Scheduler %s", engine_id)
581 if vllm_config.scheduler_config.disable_hybrid_kv_cache_manager:
@@ -717,6 +721,39 @@ class NixlConnectorScheduler:
721 logger.warning("Connection listener got unexpected message %s", msg)
722 sock.send_multipart((identity, b"", encoded_data[target_tp_rank]))
723
724 + def _mamba_prefill_token_count(self, num_prompt_tokens: int) -> int:
725 + """D-side only. Returns N-1 for Mamba models since the decoder
726 + always recomputes the last token and must start from h(N-1)."""
727 + if self._has_mamba and num_prompt_tokens > 1:
728 + return num_prompt_tokens - 1
729 + return num_prompt_tokens
730 +
731 + def _truncate_mamba_request_for_prefill(self, request: "Request") -> None:
732 + """P-side only: drop the last prompt token so the prefiller computes
733 + h(N-1) instead of h(N). The decoder recomputes the last token to
734 + derive h(N) correctly.
735 +
736 + Guarded by ``_p_side_truncated`` to avoid repeated truncation if the
737 + request is preempted and rescheduled."""
738 + params = request.kv_transfer_params
739 + if (
740 + params is not None
741 + # Guard against repeated truncation after preemption/reschedule.
742 + and not params.get("_p_side_truncated")
743 + and request.num_prompt_tokens > 1
744 + ):
745 + if request.prompt_token_ids is not None:
746 + request.prompt_token_ids.pop()
747 + elif request.prompt_embeds is not None:
748 + request.prompt_embeds = request.prompt_embeds[:-1]
749 + else:
750 + return
751 +
752 + request._all_token_ids.pop()
753 + request.num_prompt_tokens -= 1
754 + request.max_tokens = 1
755 + params["_p_side_truncated"] = True
756 +
757 def get_num_new_matched_tokens(
758 self, request: "Request", num_computed_tokens: int
759 ) -> tuple[int, bool]:
@@ -746,10 +783,14 @@ class NixlConnectorScheduler:
783 if params is not None and params.get("do_remote_prefill"):
784 # Remote prefill: get all prompt blocks from remote.
785 token_ids = request.prompt_token_ids or []
749 - count = len(token_ids) - num_computed_tokens
786 + actual = self._mamba_prefill_token_count(len(token_ids))
787 + count = actual - num_computed_tokens
788 if count > 0:
789 return count, True
790
791 + if params is not None and params.get("do_remote_decode") and self._has_mamba:
792 + self._truncate_mamba_request_for_prefill(request)
793 +
794 # No remote prefill for this request.
795 return 0, False
796