[Bugfix][Mooncake] Save exact Mamba boundary states (#51358)

Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: OpenAI Codex <codex@openai.com> Co-authored-by: Dao Le <daole@inferact.ai>

Yifan Qiao committed Aug 28, 2026 at 19:40 UTC 6b110badbb22d3f66c7218b71138f13b7a6b3419
20 files changed +1494 -273
tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py
+264 -29
@@ -69,6 +69,19 @@ def test_capable_connector_uses_divergent_partial_hit_lookup():
69 manager.get_computed_blocks.assert_not_called()
70
71
72 +def drain_boundary_state_offloads(manager):
73 + """Drain exact boundary-state block ids offered to a connector."""
74 + return manager.take_boundary_state_offloads()
75 +
76 +
77 +def _free_block_ids(manager):
78 + """Block ids the pool would hand out to the next allocation."""
79 + return {
80 + block.block_id
81 + for block in manager.block_pool.free_block_queue.get_all_free_blocks()
82 + }
83 +
84 +
85 def make_full_mamba_manager(
86 *,
87 dcp_world_size: int,
@@ -618,10 +631,9 @@ def test_external_mamba_hit_same_block_uses_running_cow_on_continue():
631 assert moved[0].block_id == cow_copy.dst_block_id
632
633
621 -def test_take_partial_tail_offloads_returns_cow_target():
622 - """The connector offload hand-off exposes the mamba CoW *target* block Y
623 - (the durable boundary state), not the overwritten source X, and only at
624 - the CoW step."""
634 +def test_boundary_state_offloads_returns_cow_target():
635 + """Boundary hand-offs expose aligned snapshots and the partial-tail CoW
636 + target, never the overwritten CoW source."""
637 hash_block_size = 2
638 block_size = 2 * hash_block_size
639 kv_cache_config = KVCacheConfig(
@@ -660,8 +672,18 @@ def test_take_partial_tail_offloads_returns_cow_target():
672 computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
673 assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
674
663 - # Step A registered the partial tail but has not CoW'd yet: no offload.
664 - assert manager.take_partial_tail_offloads() == {}
675 + # Step A offers the materialized aligned checkpoint immediately.
676 + ((group_id, block_id, boundary_tokens),) = drain_boundary_state_offloads(manager)[
677 + "0"
678 + ]
679 + assert group_id == 1
680 + assert boundary_tokens == 4
681 + aligned_hash = req0.block_hashes[4 // hash_block_size - 1]
682 + aligned_block = manager.block_pool.get_cached_block(
683 + aligned_hash, kv_cache_group_ids=[1]
684 + )
685 + assert aligned_block is not None
686 + assert block_id == aligned_block[0].block_id
687
688 partial_mamba_hash = req0.block_hashes[6 // hash_block_size - 1]
689 source_block = manager.block_pool.get_cached_block(
@@ -675,34 +697,27 @@ def test_take_partial_tail_offloads_returns_cow_target():
697 req0.append_output_token_ids([3])
698 assert manager.allocate_slots(req0, 1) is not None
699
678 - offloads = manager.take_partial_tail_offloads()
700 + offloads = drain_boundary_state_offloads(manager)
701 assert list(offloads.keys()) == ["0"]
702 assert len(offloads["0"]) == 1
703 group_id, block_id, boundary_tokens = offloads["0"][0]
704 assert group_id == 1 # the mamba group
705 assert boundary_tokens == 6
684 - copies, _ = manager.take_kv_cache_block_copies()
706 + copies, retained = manager.take_kv_cache_block_copies()
707 cow_copy = next(c for c in copies if c.src_block_id == source_block_id)
708 # The offload points at the durable CoW target Y, not the overwritten X.
709 assert block_id == cow_copy.dst_block_id
710 assert block_id != source_block_id
711 # Draining clears it.
690 - assert manager.take_partial_tail_offloads() == {}
712 + assert manager.take_boundary_state_offloads() == {}
713
692 - # The hand-off pinned Y (its CoW retention is released after this step,
693 - # and Y is off the request block table); freeing the request unpins it.
694 - cow_block = manager.block_pool.blocks[block_id]
695 - pinned_ref = cow_block.ref_cnt
696 - assert pinned_ref >= 1
714 + manager.block_pool.free_blocks(retained)
715 manager.free(req0)
698 - assert cow_block.ref_cnt == pinned_ref - 1
716
717
701 -def test_partial_tail_pin_survives_released_cow_retention():
702 - """If the CoW retention is released before the hand-off is drained
703 - (immediate-free mode), the drain must rescue the cow block from the free
704 - queue: a raw ref increment would leave a ref>0 block allocatable, and the
705 - next allocation would pop it and assert."""
718 +def test_block_pool_touch_pins_released_cow_target():
719 + """The connector can rescue an offered CoW target after its step-scoped
720 + retention is released by using the bound BlockPool's touch method."""
721 hash_block_size = 2
722 block_size = 2 * hash_block_size
723 kv_cache_config = KVCacheConfig(
@@ -746,13 +761,18 @@ def test_partial_tail_pin_survives_released_cow_retention():
761 _copies, retained = manager.take_kv_cache_block_copies()
762 manager.block_pool.free_blocks(retained)
763
749 - offloads = manager.take_partial_tail_offloads()
764 + offloads = drain_boundary_state_offloads(manager)
765 ((_group_id, block_id, boundary_tokens),) = offloads["0"]
766 assert boundary_tokens == 6
767 cow_block = manager.block_pool.blocks[block_id]
768 + assert cow_block.ref_cnt == 0
769 + assert block_id in _free_block_ids(manager)
770 +
771 + manager.block_pool.touch([cow_block])
772 assert cow_block.ref_cnt == 1
773 + assert block_id not in _free_block_ids(manager)
774
755 - # The pinned block is out of the free queue: draining every free block
775 + # The connector-pinned block is out of the free queue: draining every free block
776 # neither trips the allocator's ref_cnt assert nor hands it out.
777 new_blocks = manager.block_pool.get_new_blocks(
778 manager.block_pool.get_num_free_blocks()
@@ -760,7 +780,7 @@ def test_partial_tail_pin_survives_released_cow_retention():
780 assert block_id not in {b.block_id for b in new_blocks}
781
782
763 -def test_partial_tail_offload_dropped_when_request_freed_before_drain():
783 +def test_boundary_state_offload_dropped_when_request_freed_before_drain():
784 """A hand-off recorded in the same scheduling pass as the request's death
785 must not be drained: its release hook has already run, so draining would
786 leak a pinned block."""
@@ -805,12 +825,12 @@ def test_partial_tail_offload_dropped_when_request_freed_before_drain():
825
826 # The request dies (preempt/abort) before the scheduler drains.
827 manager.block_pool.free_blocks(manager.pop_blocks_for_free(req0))
808 - assert manager.take_partial_tail_offloads() == {}
828 + assert manager.take_boundary_state_offloads() == {}
829
830
811 -def test_take_partial_tail_offloads_empty_without_partial_tail():
812 - """A prompt ending on a block boundary registers no partial tail, so there
813 - is nothing to offload."""
831 +def test_boundary_state_offloads_block_aligned_prompt():
832 + """A prompt ending on a block boundary registers no CoW partial tail; its
833 + boundary state block is handed off as a snapshot instead (once)."""
834 hash_block_size = 2
835 block_size = 2 * hash_block_size
836 kv_cache_config = KVCacheConfig(
@@ -848,12 +868,17 @@ def test_take_partial_tail_offloads_empty_without_partial_tail():
868 req0 = make_request("0", [0, 0, 1, 1], hash_block_size, sha256)
869 computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
870 assert manager.allocate_slots(req0, 4, num_computed, computed_blocks) is not None
851 - assert manager.take_partial_tail_offloads() == {}
871 + mamba_blocks = manager.coordinator.single_type_managers[1].req_to_blocks["0"]
872 + ((group_id, block_id, boundary),) = drain_boundary_state_offloads(manager)["0"]
873 + assert group_id == 1
874 + assert boundary == block_size
875 + assert block_id == mamba_blocks[0].block_id
876
877 req0.num_computed_tokens = 4
878 req0.append_output_token_ids([2])
879 assert manager.allocate_slots(req0, 1) is not None
856 - assert manager.take_partial_tail_offloads() == {}
880 + # The boundary was already handed off; decoding emits nothing new.
881 + assert manager.take_boundary_state_offloads() == {}
882
883
884 def test_truncate_computed_blocks_preserves_sparse_prefix_positions():
@@ -1786,3 +1811,213 @@ def test_dcp_partial_hit_with_eagle_rewinds_one_hash_unit():
1811 assert num_computed == 4
1812 assert [len(group) for group in computed_blocks.blocks] == [1, 1]
1813 assert manager.allocate_slots(req1, 4, num_computed, computed_blocks) is not None
1814 +
1815 +
1816 +def _snapshot_offload_kv_cache_config(
1817 + hash_block_size: int, block_size: int, num_blocks: int = 24
1818 +):
1819 + return KVCacheConfig(
1820 + num_blocks=num_blocks,
1821 + kv_cache_tensors=[],
1822 + kv_cache_groups=[
1823 + KVCacheGroupSpec(
1824 + ["full"],
1825 + FullAttentionSpec(
1826 + block_size=hash_block_size,
1827 + num_kv_heads=1,
1828 + head_size=1,
1829 + dtype=torch.float32,
1830 + ),
1831 + ),
1832 + KVCacheGroupSpec(
1833 + ["mamba"],
1834 + MambaSpec(
1835 + block_size=block_size,
1836 + shapes=(1, 1),
1837 + dtypes=(torch.float32,),
1838 + mamba_cache_mode="align",
1839 + ),
1840 + ),
1841 + ],
1842 + )
1843 +
1844 +
1845 +def test_retention_snapshots_handed_off_with_exact_block_ids():
1846 + """Under sparse retention, each retained mamba boundary state block is
1847 + handed to the connector with its exact block id. Connectors must not resolve
1848 + align-mode state blocks positionally because the block table is not
1849 + append-only."""
1850 + hash_block_size = 2
1851 + block_size = 4
1852 + manager = make_kv_cache_manager(
1853 + kv_cache_config=_snapshot_offload_kv_cache_config(hash_block_size, block_size),
1854 + max_model_len=8192,
1855 + enable_caching=True,
1856 + hash_block_size=hash_block_size,
1857 + )
1858 + manager.coordinator.retention_interval = 2 * block_size
1859 +
1860 + req0 = make_request("0", [0] * 16, hash_block_size, sha256)
1861 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
1862 + assert manager.allocate_slots(req0, 8, num_computed, computed_blocks) is not None
1863 +
1864 + mamba_blocks = manager.coordinator.single_type_managers[1].req_to_blocks["0"]
1865 + offloads = drain_boundary_state_offloads(manager)
1866 + ((group_id, block_id, boundary),) = offloads["0"]
1867 + assert group_id == 1
1868 + assert boundary == 2 * block_size
1869 + assert block_id == mamba_blocks[1].block_id
1870 +
1871 + req0.num_computed_tokens = 8
1872 + manager.new_step_starts()
1873 + assert manager.allocate_slots(req0, 8) is not None
1874 +
1875 + offloads = drain_boundary_state_offloads(manager)
1876 + ((group_id, block_id2, boundary2),) = offloads["0"]
1877 + assert group_id == 1
1878 + assert boundary2 == 4 * block_size
1879 + assert block_id2 == mamba_blocks[3].block_id
1880 + # Interior align-mode positions stay null and are never handed off.
1881 + assert mamba_blocks[0].is_null and mamba_blocks[2].is_null
1882 +
1883 + manager.free(req0)
1884 +
1885 +
1886 +def test_snapshot_handoff_dense_default_retention():
1887 + """With dense (default) retention, every materialized mamba boundary
1888 + state block is handed off — regular mamba-align + prefix-match-unit
1889 + deployments get store-able boundary snapshots without setting
1890 + VLLM_PREFIX_CACHE_RETENTION_INTERVAL."""
1891 + hash_block_size = 2
1892 + block_size = 4
1893 + manager = make_kv_cache_manager(
1894 + kv_cache_config=_snapshot_offload_kv_cache_config(hash_block_size, block_size),
1895 + max_model_len=8192,
1896 + enable_caching=True,
1897 + hash_block_size=hash_block_size,
1898 + )
1899 + assert manager.coordinator.retention_interval is None
1900 +
1901 + req0 = make_request("0", [0] * 16, hash_block_size, sha256)
1902 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
1903 + assert manager.allocate_slots(req0, 8, num_computed, computed_blocks) is not None
1904 +
1905 + mamba_blocks = manager.coordinator.single_type_managers[1].req_to_blocks["0"]
1906 + ((group_id, block_id, boundary),) = drain_boundary_state_offloads(manager)["0"]
1907 + assert group_id == 1
1908 + assert boundary == 2 * block_size
1909 + assert block_id == mamba_blocks[1].block_id
1910 +
1911 + req0.num_computed_tokens = 8
1912 + manager.new_step_starts()
1913 + assert manager.allocate_slots(req0, 8) is not None
1914 + ((group_id, block_id2, boundary2),) = drain_boundary_state_offloads(manager)["0"]
1915 + assert group_id == 1
1916 + assert boundary2 == 4 * block_size
1917 + assert block_id2 == mamba_blocks[3].block_id
1918 +
1919 +
1920 +def _run_chunked_prefill(manager, req, chunk_size, num_chunks):
1921 + """Prefill ``req`` one ``chunk_size`` chunk per scheduler step, yielding the
1922 + boundary-state hand-offs offered (and claimed) at each step."""
1923 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req)
1924 + for step in range(num_chunks):
1925 + manager.new_step_starts()
1926 + req.num_computed_tokens = step * chunk_size
1927 + if step == 0:
1928 + allocated = manager.allocate_slots(
1929 + req, chunk_size, num_computed, computed_blocks
1930 + )
1931 + else:
1932 + allocated = manager.allocate_slots(req, chunk_size)
1933 + assert allocated is not None
1934 + yield drain_boundary_state_offloads(manager).get(req.request_id, [])
1935 +
1936 +
1937 +def test_boundary_state_offer_includes_more_mamba_groups_than_two():
1938 + """One offer batch carries one exact block entry per mamba group."""
1939 + block_size = 8
1940 + num_mamba_groups = 3
1941 + manager = make_kv_cache_manager(
1942 + kv_cache_config=KVCacheConfig(
1943 + num_blocks=200,
1944 + kv_cache_tensors=[],
1945 + kv_cache_groups=[
1946 + KVCacheGroupSpec(
1947 + ["full"],
1948 + FullAttentionSpec(
1949 + block_size=block_size,
1950 + num_kv_heads=1,
1951 + head_size=1,
1952 + dtype=torch.float32,
1953 + ),
1954 + ),
1955 + *(
1956 + KVCacheGroupSpec(
1957 + [f"mamba{i}"],
1958 + MambaSpec(
1959 + block_size=block_size,
1960 + shapes=(1, 1),
1961 + dtypes=(torch.float32,),
1962 + mamba_cache_mode="align",
1963 + ),
1964 + )
1965 + for i in range(num_mamba_groups)
1966 + ),
1967 + ],
1968 + ),
1969 + max_model_len=8192,
1970 + enable_caching=True,
1971 + hash_block_size=block_size,
1972 + )
1973 +
1974 + req0 = make_request("0", [0] * (2 * block_size), block_size, sha256)
1975 + entries = next(_run_chunked_prefill(manager, req0, block_size, 1))
1976 +
1977 + assert [group_id for group_id, _, _ in entries] == [1, 2, 3]
1978 + assert {boundary for _, _, boundary in entries} == {block_size}
1979 +
1980 +
1981 +def test_boundary_states_offered_past_prompt_for_resumed_prefill():
1982 + """The core offers every committed boundary, including past
1983 + ``num_prompt_tokens``. A resumed request re-prefills its generated tokens
1984 + and every group re-saves them, so filtering on the original prompt length
1985 + would silently strip the mamba key for boundaries full attention still
1986 + stores; only the connector knows where its save window ends."""
1987 + block_size = 8
1988 + prompt_len = block_size
1989 + manager = make_kv_cache_manager(
1990 + kv_cache_config=_snapshot_offload_kv_cache_config(
1991 + hash_block_size=block_size, block_size=block_size, num_blocks=200
1992 + ),
1993 + max_model_len=8192,
1994 + enable_caching=True,
1995 + hash_block_size=block_size,
1996 + )
1997 +
1998 + req0 = make_request("0", [0] * prompt_len, block_size, sha256)
1999 + assert [
2000 + boundary
2001 + for entries in _run_chunked_prefill(manager, req0, block_size, 1)
2002 + for _, _, boundary in entries
2003 + ] == [prompt_len]
2004 +
2005 + # Generate past the next mamba boundary, then replay it as a resumed
2006 + # prefill: its committed boundary is offered even though it is past the
2007 + # prompt, matching what full attention saves for the same range.
2008 + for i in range(block_size):
2009 + manager.new_step_starts()
2010 + req0.num_computed_tokens = prompt_len + i
2011 + req0.append_output_token_ids([1])
2012 + assert manager.allocate_slots(req0, 1) is not None
2013 + drain_boundary_state_offloads(manager)
2014 + assert req0.num_tokens == 2 * block_size
2015 +
2016 + manager.free(req0)
2017 + manager.new_step_starts()
2018 + req0.num_computed_tokens = 0
2019 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
2020 + assert manager.allocate_slots(req0, req0.num_tokens - num_computed, num_computed)
2021 + offered = [b for _, _, b in drain_boundary_state_offloads(manager).get("0", [])]
2022 + assert 2 * block_size in offered
2023 + assert req0.num_prompt_tokens < 2 * block_size
tests/v1/core/test_single_type_kv_cache_manager.py
+32
@@ -15,12 +15,14 @@ from vllm.v1.core.kv_cache_utils import (
15 from vllm.v1.core.single_type_kv_cache_manager import (
16 ChunkedLocalAttentionManager,
17 FullAttentionManager,
18 + MambaManager,
19 RSWAManager,
20 SlidingWindowManager,
21 )
22 from vllm.v1.kv_cache_interface import (
23 ChunkedLocalAttentionSpec,
24 FullAttentionSpec,
25 + MambaSpec,
26 RSWASpec,
27 SlidingWindowSpec,
28 )
@@ -69,6 +71,36 @@ def test_external_computed_blocks_do_not_corrupt_free_pool():
71 assert len(manager.req_to_blocks[request_id]) == 3
72
73
74 +def test_mamba_speculative_block_relocation_requires_exclusive_ownership():
75 + spec = MambaSpec(
76 + block_size=4,
77 + shapes=((1, 1),),
78 + dtypes=(torch.float32,),
79 + mamba_cache_mode="align",
80 + num_speculative_blocks=1,
81 + )
82 + block_pool = BlockPool(num_gpu_blocks=4, enable_caching=True, hash_block_size=4)
83 + manager = MambaManager(
84 + spec,
85 + block_pool=block_pool,
86 + enable_caching=True,
87 + kv_cache_group_id=0,
88 + scheduler_block_size=4,
89 + )
90 + block = block_pool.get_new_blocks(1)[0]
91 + blocks = [block]
92 +
93 + manager._relocate_speculative_block(blocks, 0)
94 +
95 + assert blocks == [block_pool.null_block, block]
96 + assert block.ref_cnt == 1
97 +
98 + pinned_block = block_pool.get_new_blocks(1)[0]
99 + block_pool.touch((pinned_block,))
100 + with pytest.raises(AssertionError, match="exclusively owned and unhashed"):
101 + manager._relocate_speculative_block([pinned_block], 0)
102 +
103 +
104 def get_sliding_window_manager(
105 sliding_window_spec,
106 block_pool,
tests/v1/kv_connector/unit/offloading_connector/test_scheduler.py
+90 -4
@@ -36,7 +36,11 @@ from vllm.distributed.kv_transfer.kv_connector.v1.offloading.scheduler import (
36 is_store_reachable_swa_chunk,
37 )
38 from vllm.v1.core.kv_cache_manager import KVCacheBlocks
39 -from vllm.v1.core.kv_cache_utils import BlockHash, KVCacheBlock
39 +from vllm.v1.core.kv_cache_utils import BlockHash, KVCacheBlock, KVCacheBlockCopy
40 +from vllm.v1.core.sched.output import (
41 + KVConnectorBlockState,
42 + SchedulerOutput,
43 +)
44 from vllm.v1.kv_cache_interface import (
45 ChunkedLocalAttentionSpec,
46 FullAttentionSpec,
@@ -107,11 +111,16 @@ def test_partial_tail_store_uses_attention_and_recurrent_cow_sources():
111 req_status = scheduler._req_status["req"]
112 req_status.group_states[0].block_ids[:] = [11, 12]
113 req_status.group_states[1].block_ids[:] = [0, 21]
110 - scheduler.manager.prepare_store.side_effect = (
111 - lambda keys, req_context: generate_store_output(keys)
114 + scheduler.manager.prepare_store.side_effect = lambda keys, req_context: (
115 + generate_store_output(keys)
116 )
117
114 - output = SimpleNamespace(partial_tail_offloads={"req": [(1, 99, 28)]})
118 + output = SimpleNamespace(
119 + kv_connector_block_state=KVConnectorBlockState(
120 + block_ids={},
121 + boundary_state_offloads={"req": [(1, 99, 28)]},
122 + )
123 + )
124 jobs = scheduler._build_partial_tail_store_jobs(output)
125
126 assert len(jobs) == 1
@@ -155,6 +164,83 @@ def test_partial_tail_store_uses_attention_and_recurrent_cow_sources():
164 assert recurrent_event.parent_block_hash is None
165
166
167 +def test_aligned_boundary_store_uses_exact_source_with_partial_tail():
168 + scheduler = _make_partial_tail_scheduler()
169 + _make_partial_tail_request(scheduler)
170 + req_status = scheduler._req_status["req"]
171 + req_status.group_states[0].block_ids[:] = [11, 12]
172 + req_status.group_states[1].block_ids[:] = [0, 21]
173 + scheduler.manager.prepare_store.side_effect = lambda keys, req_context: (
174 + generate_store_output(keys)
175 + )
176 +
177 + output = SimpleNamespace(
178 + kv_connector_block_state=KVConnectorBlockState(
179 + block_ids={},
180 + boundary_state_offloads={"req": [(1, 98, 16), (1, 99, 28)]},
181 + )
182 + )
183 + jobs = scheduler._build_partial_tail_store_jobs(output)
184 +
185 + assert len(jobs) == 2
186 + src_spec = next(
187 + job.src_spec for job in jobs.values() if len(job.src_spec.block_ids) == 1
188 + )
189 + assert isinstance(src_spec, GPULoadStoreSpec)
190 + assert src_spec.block_ids.tolist() == [98]
191 + assert src_spec.group_sizes == [0, 1]
192 + assert src_spec.block_indices == [0, 0]
193 +
194 +
195 +def test_aligned_boundary_store_flushes_before_cow_destination_reuse():
196 + scheduler = _make_partial_tail_scheduler()
197 + _make_partial_tail_request(scheduler)
198 + scheduler.manager.prepare_store.side_effect = lambda keys, req_context: (
199 + generate_store_output(keys)
200 + )
201 +
202 + output = SchedulerOutput.make_empty()
203 + output.kv_connector_block_state = KVConnectorBlockState(
204 + block_ids={},
205 + boundary_state_offloads={"req": [(1, 99, 16)]},
206 + )
207 + meta = scheduler.build_connector_meta(output)
208 + [job_id] = meta.store_jobs
209 +
210 + output = SchedulerOutput.make_empty()
211 + output.kv_cache_block_copies = [KVCacheBlockCopy(98, 99)]
212 + meta = scheduler.build_connector_meta(output)
213 +
214 + assert meta.jobs_to_flush == {job_id}
215 +
216 +
217 +def test_normal_store_excludes_align_mode_mamba_sources():
218 + scheduler = _make_partial_tail_scheduler()
219 + request = _make_partial_tail_request(scheduler)
220 + request.num_computed_tokens = 0
221 + request.status = RequestStatus.RUNNING
222 + req_status = scheduler._req_status["req"]
223 + req_status.group_states[0].block_ids[:] = [11]
224 + req_status.group_states[1].block_ids[:] = [99]
225 + req_status.update_offload_keys()
226 + scheduler.manager.prepare_store.side_effect = lambda keys, req_context: (
227 + generate_store_output(keys)
228 + )
229 +
230 + output = SimpleNamespace(
231 + num_scheduled_tokens={"req": 16},
232 + finished_req_ids=set(),
233 + )
234 + jobs = scheduler._build_store_jobs(output)
235 +
236 + assert len(jobs) == 1
237 + [job] = jobs.values()
238 + src_spec = job.src_spec
239 + assert isinstance(src_spec, GPULoadStoreSpec)
240 + assert src_spec.block_ids.tolist() == [11]
241 + assert src_spec.group_sizes == [1, 0]
242 +
243 +
244 def test_partial_lookup_returns_exact_boundary_and_group_load_keys():
245 scheduler = _make_partial_tail_scheduler()
246 request = _make_partial_tail_request(scheduler)
tests/v1/kv_connector/unit/test_mooncake_store_connector.py
+37 -1
@@ -5,6 +5,9 @@ import threading
5 import time
6 from unittest.mock import MagicMock, patch
7
8 +import pytest
9 +import torch
10 +
11 from vllm.config import set_current_vllm_config
12 from vllm.distributed.kv_events import BlockStored
13 from vllm.distributed.kv_transfer.kv_connector.v1.base import (
@@ -31,6 +34,7 @@ from vllm.v1.kv_cache_interface import (
34 KVCacheConfig,
35 KVCacheGroupSpec,
36 KVCacheTensor,
37 + MambaSpec,
38 )
39 from vllm.v1.outputs import KVConnectorOutput
40
@@ -62,6 +66,30 @@ def _make_kv_cache_config() -> KVCacheConfig:
66 )
67
68
69 +def test_scheduler_requires_align_mode_for_mamba():
70 + vllm_config = _make_vllm_config()
71 + mamba_spec = MambaSpec(
72 + block_size=16,
73 + shapes=((1, 1),),
74 + dtypes=(torch.float32,),
75 + mamba_cache_mode="none",
76 + )
77 + kv_cache_config = KVCacheConfig(
78 + num_blocks=4,
79 + kv_cache_tensors=[],
80 + kv_cache_groups=[KVCacheGroupSpec(["mamba"], mamba_spec)],
81 + )
82 +
83 + with (
84 + patch(
85 + "vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
86 + "scheduler.LookupKeyClient"
87 + ),
88 + pytest.raises(AssertionError, match="requires mamba_cache_mode='align'"),
89 + ):
90 + scheduler.MooncakeStoreScheduler(vllm_config, kv_cache_config)
91 +
92 +
93 def _make_block_stored() -> BlockStored:
94 return BlockStored(
95 block_hashes=[b"hash"],
@@ -97,6 +125,10 @@ def test_scheduler_role_initializes_store_scheduler_only():
125 mock_worker.assert_not_called()
126 assert connector.connector_scheduler is mock_scheduler.return_value
127 assert connector.connector_worker is None
128 + block_pool = MagicMock()
129 +
130 + connector.bind_gpu_block_pool(block_pool)
131 + mock_scheduler.return_value.bind_gpu_block_pool.assert_called_once_with(block_pool)
132
133
134 def test_worker_methods_delegate_to_store_worker():
@@ -241,9 +273,13 @@ def test_update_connector_output_and_take_events():
273
274 kv_events = mooncake_store_connector.MooncakeStoreKVEvents(num_workers=1)
275 kv_events.add_events([event])
244 - connector.update_connector_output(KVConnectorOutput(kv_cache_events=kv_events))
276 + output = KVConnectorOutput(kv_cache_events=kv_events)
277 + connector.update_connector_output(output)
278
279 assert connector._kv_cache_events is kv_events
280 + connector.connector_scheduler.update_connector_output.assert_called_once_with(
281 + output
282 + )
283 assert list(connector.take_events()) == [event]
284 assert connector._kv_cache_events is None
285
tests/v1/kv_connector/unit/test_mooncake_store_coordinator.py
+21
@@ -451,6 +451,27 @@ def test_store_mask_retention_prefix_stable_as_aligned_length_grows():
451 assert longer[: len(shorter)] == shorter
452
453
454 +def test_store_mask_excludes_mamba_groups_lookup_unaffected():
455 + """Align-mode mamba block tables are not append-only (interior states are
456 + nulled/freed; speculative blocks relocate), so the positional normal save
457 + must never cover mamba chunks — regardless of retention. Lookups still
458 + probe mamba boundaries: their keys come from the pinned snapshot/CoW
459 + hand-off path instead."""
460 + groups = [
461 + KVCacheGroupSpec(["L0"], _full(16)),
462 + KVCacheGroupSpec(["L1"], _mamba_align(16)),
463 + ]
464 + coord = _make_coord(groups, hash_block_size=16)
465 + masks = coord.store_mask(64)
466 + assert masks[0] is None # full attn stays dense
467 + assert masks[1] == [False] * 4
468 +
469 + coord = _make_coord(groups, hash_block_size=16, retention_interval=32)
470 + assert coord.store_mask(64)[1] == [False] * 4
471 +
472 + assert coord.lookup_mask(64)[1] is None
473 +
474 +
475 # ----- Eagle / MTP interaction with load_mask -----
476
477
tests/v1/kv_connector/unit/test_mooncake_store_hma_e2e.py
+6 -5
@@ -236,6 +236,7 @@ def test_e2e_swa_plus_full_save_then_lookup_hits():
236 )
237
238 hs = [BlockHash(bytes([i + 1]) * 4) for i in range(4)]
239 + # 1-based: block 0 is the reserved null block and the save skips it.
240 save_req = ReqMeta(
241 req_id="r0",
242 token_len_chunk=64,
@@ -441,10 +442,10 @@ def test_sub_block_partial_tail_offload_reads_cow_block():
442 block_hashes=hs,
443 can_save=True,
444 num_prompt_tokens=20,
444 - partial_tail_offloads=[(1, mamba_cow_block, 12)],
445 + boundary_state_offloads=[(1, mamba_cow_block, 12)],
446 )
447
447 - send._maybe_offload_partial_tail(req)
448 + send._maybe_offload_boundary_states(req)
449
450 # boundary = 12 // 4 * 4 = 12 -> keyed by hs[12 // 4 - 1] = hs[2].
451 partial_hash = hs[2]
@@ -512,8 +513,8 @@ def test_offload_syncs_event_before_put():
513 block_hashes=hs,
514 can_save=True,
515 num_prompt_tokens=12,
515 - partial_tail_offloads=[(1, 7, 12)],
516 store_job_id=1,
517 + boundary_state_offloads=[(1, 7, 12)],
518 )
519 req.current_event = event
520
@@ -589,10 +590,10 @@ def test_sub_block_partial_tail_offload_covers_smaller_group_blocks():
590 block_hashes=hs,
591 can_save=True,
592 num_prompt_tokens=12,
592 - partial_tail_offloads=[(1, mamba_cow_block, 12)],
593 + boundary_state_offloads=[(1, mamba_cow_block, 12)],
594 )
595
595 - send._maybe_offload_partial_tail(req)
596 + send._maybe_offload_boundary_states(req)
597
598 # FA (block 4): full blocks ending at 4, 8 and 12, keyed by their normal
599 # block-end hashes; mamba (block 16): the partial boundary block under
tests/v1/kv_connector/unit/test_mooncake_store_scheduler.py
+276 -8
@@ -16,6 +16,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.scheduler impor
16 MooncakeStoreScheduler,
17 )
18 from vllm.v1.core.block_pool import BlockPool
19 +from vllm.v1.core.sched.output import KVConnectorBlockState
20
21
22 def _make_bare_scheduler(
@@ -47,9 +48,20 @@ def _make_bare_scheduler(
48 scheduler._num_workers = 1
49 scheduler._next_store_job_id = 0
50 scheduler._pinned_saves = {}
51 + scheduler._boundary_state_group_ids = frozenset({1})
52 return scheduler
53
54
55 +def _make_connector_block_state(
56 + block_ids: tuple[list[int], ...] | None = None,
57 + offloads: list[tuple[int, int, int]] | None = None,
58 +) -> KVConnectorBlockState:
59 + return KVConnectorBlockState(
60 + block_ids={} if block_ids is None else {"req-0": block_ids},
61 + boundary_state_offloads=({} if offloads is None else {"req-0": offloads}),
62 + )
63 +
64 +
65 def _make_scheduler_output(*, scheduled_spec_tokens: list[int] | None):
66 return SimpleNamespace(
67 finished_req_ids=set(),
@@ -65,6 +77,7 @@ def _make_scheduler_output(*, scheduled_spec_tokens: list[int] | None):
77 scheduled_spec_decode_tokens=(
78 {"req-0": scheduled_spec_tokens} if scheduled_spec_tokens else {}
79 ),
80 + kv_connector_block_state=_make_connector_block_state(block_ids=([0, 1, 2],)),
81 )
82
83
@@ -84,6 +97,7 @@ def _make_decode_scheduler_output(
97 ),
98 num_scheduled_tokens={"req-0": num_scheduled_tokens},
99 scheduled_spec_decode_tokens={},
100 + kv_connector_block_state=_make_connector_block_state(block_ids=([0, 1, 2],)),
101 )
102
103
@@ -109,6 +123,7 @@ def _make_new_scheduler_output() -> SimpleNamespace:
123 ),
124 num_scheduled_tokens={"req-0": 32},
125 scheduled_spec_decode_tokens={},
126 + kv_connector_block_state=_make_connector_block_state(block_ids=([0, 1],)),
127 )
128
129
@@ -491,6 +506,7 @@ def _make_resumed_scheduler_output(*, num_scheduled_tokens: int) -> SimpleNamesp
506 ),
507 num_scheduled_tokens={"req-0": num_scheduled_tokens},
508 scheduled_spec_decode_tokens={},
509 + kv_connector_block_state=_make_connector_block_state(block_ids=([0, 1, 2],)),
510 )
511
512
@@ -915,7 +931,10 @@ def _add_pending_partial_tail_request(
931 ),
932 num_scheduled_tokens={},
933 scheduled_spec_decode_tokens={},
918 - partial_tail_offloads={"req-0": [(1, 7, 12)]},
934 + kv_connector_block_state=_make_connector_block_state(
935 + block_ids=block_ids,
936 + offloads=[(1, 7, 12)],
937 + ),
938 )
939
940
@@ -940,15 +959,262 @@ def test_pending_partial_tail_emits_offload_only_reqmeta():
959 assert req_meta.req_id == "req-0"
960 assert req_meta.can_save is True
961 assert req_meta.token_len_chunk == 0
943 - assert req_meta.partial_tail_offloads == [(1, 7, 12)]
962 + assert req_meta.boundary_state_offloads == [(1, 7, 12)]
963 assert req_meta.num_prompt_tokens == 12
964 assert req_meta.block_ids == ([0],)
965 + store_job_id = req_meta.store_job_id
966 + assert scheduler._pinned_saves[store_job_id][0] == [7]
967 + assert scheduler._gpu_block_pool.blocks[0].ref_cnt == 0
968 + assert scheduler._gpu_block_pool.blocks[7].ref_cnt == 1
969 tracker = scheduler._request_trackers["req-0"]
970 assert tracker.num_saved_tokens == 0
971 assert tracker.has_pending_offload is True
972
973
951 -def test_resumed_partial_tail_uses_handoff_boundary():
974 +def test_decode_boundary_state_offload_dropped_unclaimed():
975 + # A hand-off past the prefill end can never complete a joint hybrid hit
976 + # (every other group stops saving there), so it is neither transferred nor
977 + # claimed — leaving the core free to release the block immediately.
978 + scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
979 + request = SimpleNamespace(
980 + all_token_ids=list(range(24)),
981 + block_hashes=[b"h0", b"h1", b"h2", b"h3", b"h4", b"h5"],
982 + num_output_placeholders=0,
983 + num_prompt_tokens=12,
984 + )
985 + scheduler._unfinished_requests["req-0"] = (request, ([0],))
986 + scheduler._request_trackers["req-0"] = RequestTracker(
987 + req_id="req-0",
988 + token_len=12,
989 + allocated_block_ids=([0],),
990 + num_saved_tokens=12,
991 + token_ids=list(range(12)),
992 + prefill_end_tokens=12,
993 + )
994 +
995 + out = SimpleNamespace(
996 + finished_req_ids=set(),
997 + preempted_req_ids=set(),
998 + scheduled_new_reqs=[],
999 + scheduled_cached_reqs=SimpleNamespace(
1000 + req_ids=[],
1001 + new_block_ids=[],
1002 + num_computed_tokens=[],
1003 + resumed_req_ids=set(),
1004 + ),
1005 + num_scheduled_tokens={},
1006 + scheduled_spec_decode_tokens={},
1007 + # Boundary 16 is a decode boundary for a 12-token prefill.
1008 + kv_connector_block_state=_make_connector_block_state(offloads=[(1, 7, 16)]),
1009 + )
1010 +
1011 + meta = scheduler.build_connector_meta(out)
1012 +
1013 + assert meta.requests == []
1014 + assert scheduler._pinned_saves == {}
1015 + assert scheduler._gpu_block_pool.blocks[7].ref_cnt == 0
1016 + assert scheduler._request_trackers["req-0"].has_pending_offload is False
1017 +
1018 +
1019 +def _register_offload_request(scheduler, *, prefill_end_tokens, num_prompt_tokens):
1020 + request = SimpleNamespace(
1021 + all_token_ids=list(range(64)),
1022 + block_hashes=[bytes([i]) for i in range(16)],
1023 + num_output_placeholders=0,
1024 + num_prompt_tokens=num_prompt_tokens,
1025 + )
1026 + scheduler._unfinished_requests["req-0"] = (request, ([0],))
1027 + scheduler._request_trackers["req-0"] = RequestTracker(
1028 + req_id="req-0",
1029 + token_len=prefill_end_tokens,
1030 + allocated_block_ids=([0],),
1031 + num_saved_tokens=prefill_end_tokens,
1032 + token_ids=list(range(prefill_end_tokens)),
1033 + prefill_end_tokens=prefill_end_tokens,
1034 + )
1035 +
1036 +
1037 +def _make_offload_only_output(entries, block_ids=([0],)):
1038 + return SimpleNamespace(
1039 + finished_req_ids=set(),
1040 + preempted_req_ids=set(),
1041 + scheduled_new_reqs=[],
1042 + scheduled_cached_reqs=SimpleNamespace(
1043 + req_ids=[],
1044 + new_block_ids=[],
1045 + num_computed_tokens=[],
1046 + resumed_req_ids=set(),
1047 + ),
1048 + num_scheduled_tokens={},
1049 + scheduled_spec_decode_tokens={},
1050 + kv_connector_block_state=_make_connector_block_state(
1051 + block_ids=block_ids,
1052 + offloads=entries,
1053 + ),
1054 + )
1055 +
1056 +
1057 +def test_resumed_prefill_claims_boundaries_past_prompt_length():
1058 + # A resumed request re-prefills its previously generated tokens, so its
1059 + # save window (`prefill_end_tokens`) extends past `num_prompt_tokens`.
1060 + # Boundaries in that range must still be claimed, or the mamba key would be
1061 + # missing for boundaries full attention does store and no joint hit could
1062 + # complete there.
1063 + scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
1064 + _register_offload_request(scheduler, prefill_end_tokens=20, num_prompt_tokens=12)
1065 + out = _make_offload_only_output([(1, 7, 16), (1, 9, 20), (1, 11, 24)])
1066 +
1067 + meta = scheduler.build_connector_meta(out)
1068 +
1069 + # 16 and 20 are inside the resumed prefill; 24 is past it.
1070 + assert meta.requests[0].boundary_state_offloads == [(1, 7, 16), (1, 9, 20)]
1071 + store_job_id = meta.requests[0].store_job_id
1072 + assert scheduler._pinned_saves[store_job_id][0] == [7, 9]
1073 +
1074 +
1075 +def test_boundary_state_job_pins_exact_blocks_once():
1076 + scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
1077 + _register_offload_request(scheduler, prefill_end_tokens=64, num_prompt_tokens=64)
1078 + scheduler._request_trackers["req-0"].allocated_block_ids = ([7],)
1079 + out = _make_offload_only_output(
1080 + [(1, 7, 16), (1, 8, 32), (1, 9, 48)], block_ids=([7],)
1081 + )
1082 +
1083 + meta = scheduler.build_connector_meta(out)
1084 +
1085 + req_meta = meta.requests[0]
1086 + assert req_meta.boundary_state_offloads == [
1087 + (1, 7, 16),
1088 + (1, 8, 32),
1089 + (1, 9, 48),
1090 + ]
1091 + store_job_id = req_meta.store_job_id
1092 + assert scheduler._pinned_saves[store_job_id][0] == [7, 8, 9]
1093 + assert [scheduler._gpu_block_pool.blocks[i].ref_cnt for i in (7, 8, 9)] == [
1094 + 1,
1095 + 1,
1096 + 1,
1097 + ]
1098 +
1099 + scheduler.update_connector_output(_make_worker_output({store_job_id: 1}))
1100 + assert [scheduler._gpu_block_pool.blocks[i].ref_cnt for i in (7, 8, 9)] == [
1101 + 0,
1102 + 0,
1103 + 0,
1104 + ]
1105 +
1106 +
1107 +def test_store_job_pins_current_non_null_non_mamba_blocks():
1108 + scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
1109 + request = SimpleNamespace(
1110 + all_token_ids=list(range(48)),
1111 + block_hashes=[bytes([i]) for i in range(12)],
1112 + num_output_placeholders=0,
1113 + )
1114 + scheduler._unfinished_requests["req-0"] = (request, ([7, 2, 0], [21, 0, 22]))
1115 + scheduler._request_trackers["req-0"] = RequestTracker(
1116 + req_id="req-0",
1117 + token_len=44,
1118 + allocated_block_ids=([7, 2, 0], [21, 0, 22]),
1119 + num_saved_tokens=32,
1120 + token_ids=list(range(44)),
1121 + prefill_end_tokens=48,
1122 + )
1123 + stale_block_ids = ([7, 2, 0, 5], [21, 0, 22, 23])
1124 + current_block_ids = ([7, 2, 0, 6], [24, 0, 25, 26])
1125 + out = SimpleNamespace(
1126 + finished_req_ids=set(),
1127 + preempted_req_ids=set(),
1128 + scheduled_new_reqs=[],
1129 + scheduled_cached_reqs=SimpleNamespace(
1130 + req_ids=["req-0"],
1131 + new_block_ids=[([5], [23])],
1132 + num_computed_tokens=[44],
1133 + resumed_req_ids=set(),
1134 + ),
1135 + num_scheduled_tokens={"req-0": 4},
1136 + scheduled_spec_decode_tokens={},
1137 + kv_connector_block_state=_make_connector_block_state(
1138 + block_ids=current_block_ids,
1139 + offloads=[(1, 8, 16)],
1140 + ),
1141 + )
1142 + pool = scheduler._gpu_block_pool
1143 +
1144 + meta = scheduler.build_connector_meta(out)
1145 +
1146 + assert scheduler._request_trackers["req-0"].allocated_block_ids == stale_block_ids
1147 + req_meta = meta.requests[0]
1148 + assert req_meta.block_ids == current_block_ids
1149 + store_job_id = req_meta.store_job_id
1150 + assert scheduler._pinned_saves[store_job_id][0] == [8, 7, 2, 6]
1151 + assert pool.blocks[0].ref_cnt == 0
1152 + assert pool.blocks[5].ref_cnt == 0
1153 + assert [pool.blocks[i].ref_cnt for i in (21, 22, 23, 24, 25, 26)] == [
1154 + 0,
1155 + 0,
1156 + 0,
1157 + 0,
1158 + 0,
1159 + 0,
1160 + ]
1161 + assert [pool.blocks[i].ref_cnt for i in (8, 7, 2, 6)] == [1, 1, 1, 1]
1162 +
1163 + scheduler.update_connector_output(_make_worker_output({store_job_id: 1}))
1164 +
1165 + assert [pool.blocks[i].ref_cnt for i in (8, 7, 2, 6)] == [0, 0, 0, 0]
1166 +
1167 +
1168 +def test_boundary_state_release_is_per_store_job():
1169 + scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
1170 + _register_offload_request(scheduler, prefill_end_tokens=64, num_prompt_tokens=64)
1171 + first = scheduler.build_connector_meta(_make_offload_only_output([(1, 7, 16)]))
1172 + second = scheduler.build_connector_meta(_make_offload_only_output([(1, 8, 32)]))
1173 + first_job_id = first.requests[0].store_job_id
1174 + second_job_id = second.requests[0].store_job_id
1175 +
1176 + scheduler.update_connector_output(_make_worker_output({first_job_id: 1}))
1177 +
1178 + assert scheduler._gpu_block_pool.blocks[7].ref_cnt == 0
1179 + assert scheduler._gpu_block_pool.blocks[8].ref_cnt == 1
1180 + assert first_job_id not in scheduler._pinned_saves
1181 + assert second_job_id in scheduler._pinned_saves
1182 +
1183 +
1184 +def test_preemption_and_request_id_reuse_do_not_release_inflight_job():
1185 + scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
1186 + _register_offload_request(scheduler, prefill_end_tokens=64, num_prompt_tokens=64)
1187 + first = scheduler.build_connector_meta(_make_offload_only_output([(1, 7, 16)]))
1188 + first_job_id = first.requests[0].store_job_id
1189 +
1190 + scheduler.build_connector_meta(_make_preemption_scheduler_output())
1191 + assert scheduler._gpu_block_pool.blocks[7].ref_cnt == 1
1192 + assert first_job_id in scheduler._pinned_saves
1193 +
1194 + _register_offload_request(scheduler, prefill_end_tokens=64, num_prompt_tokens=64)
1195 + second = scheduler.build_connector_meta(_make_offload_only_output([(1, 8, 32)]))
1196 + second_job_id = second.requests[0].store_job_id
1197 + assert second.requests[0].boundary_state_offloads == [(1, 8, 32)]
1198 +
1199 + scheduler.update_connector_output(_make_worker_output({first_job_id: 1}))
1200 + assert scheduler._gpu_block_pool.blocks[7].ref_cnt == 0
1201 + assert scheduler._gpu_block_pool.blocks[8].ref_cnt == 1
1202 + assert second_job_id in scheduler._pinned_saves
1203 +
1204 +
1205 +def test_boundary_state_never_claimed_without_a_send_thread():
1206 + # A kv_consumer has no store job that can acknowledge the block reference.
1207 + scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
1208 + scheduler.kv_role = "kv_consumer"
1209 + _register_offload_request(scheduler, prefill_end_tokens=64, num_prompt_tokens=64)
1210 + out = _make_offload_only_output([(1, 7, 16)])
1211 +
1212 + assert scheduler.build_connector_meta(out).requests == []
1213 + assert scheduler._pinned_saves == {}
1214 + assert scheduler._gpu_block_pool.blocks[7].ref_cnt == 0
1215 +
1216 +
1217 +def test_resumed_partial_tail_uses_exact_boundary():
1218 scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
1219 # Resumption replays prompt + previously generated tokens.
1220 out = _add_pending_partial_tail_request(
@@ -961,7 +1227,7 @@ def test_resumed_partial_tail_uses_handoff_boundary():
1227 meta = scheduler.build_connector_meta(out)
1228
1229 assert len(meta.requests) == 1
964 - assert meta.requests[0].partial_tail_offloads == [(1, 7, 12)]
1230 + assert meta.requests[0].boundary_state_offloads == [(1, 7, 12)]
1231 # Ordinary metadata retains the full resumed prefill range.
1232 assert meta.requests[0].num_prompt_tokens == 20
1233 tracker = scheduler._request_trackers["req-0"]
@@ -969,8 +1235,9 @@ def test_resumed_partial_tail_uses_handoff_boundary():
1235 assert tracker.has_pending_offload is True
1236
1237
972 -def test_resumed_partial_tail_attached_to_save_keeps_handoff_boundary():
1238 +def test_resumed_partial_tail_attached_to_save_keeps_exact_boundary():
1239 scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
1240 + scheduler._boundary_state_group_ids = frozenset({0})
1241 request = SimpleNamespace(
1242 all_token_ids=list(range(48)),
1243 block_hashes=[b"h0", b"h1", b"h2"],
@@ -987,13 +1254,13 @@ def test_resumed_partial_tail_attached_to_save_keeps_handoff_boundary():
1254 prefill_end_tokens=48,
1255 )
1256 out = _make_scheduler_output(scheduled_spec_tokens=None)
990 - out.partial_tail_offloads = {"req-0": [(0, 7, 36)]}
1257 + out.kv_connector_block_state.boundary_state_offloads = {"req-0": [(0, 7, 36)]}
1258
1259 meta = scheduler.build_connector_meta(out)
1260
1261 assert len(meta.requests) == 1
1262 assert meta.requests[0].can_save is True
996 - assert meta.requests[0].partial_tail_offloads == [(0, 7, 36)]
1263 + assert meta.requests[0].boundary_state_offloads == [(0, 7, 36)]
1264 assert meta.requests[0].num_prompt_tokens == 48
1265 # Ordinary saving still covers the full resumed prefill range.
1266 tracker = scheduler._request_trackers["req-0"]
@@ -1019,7 +1286,8 @@ def test_partial_tail_cow_block_is_referenced_for_the_job():
1286 store_job_id = meta.requests[0].store_job_id
1287 # It leads the list, as in `pop_blocks_for_free`, so that the reversed free
1288 # puts it last in eviction priority.
1022 - assert scheduler._pinned_saves[store_job_id][0] == [7, 0]
1289 + assert scheduler._pinned_saves[store_job_id][0] == [7]
1290 + assert pool.blocks[0].ref_cnt == 0
1291 assert pool.blocks[7].ref_cnt == 1
1292
1293 scheduler.update_connector_output(_make_worker_output({store_job_id: 1}))
tests/v1/kv_connector/unit/test_mooncake_store_worker.py
+266 -23
@@ -181,6 +181,7 @@ def _run_store_req(thread, req_meta: ReqMeta) -> None:
181
182
183 def _make_store_req(req_id: str, block_hashes: list[bytes]) -> ReqMeta:
184 + # 1-based: block 0 is the reserved null block and the save skips it.
185 return ReqMeta(
186 req_id=req_id,
187 token_len_chunk=32,
@@ -651,6 +652,7 @@ def _make_partial_tail_send_thread(
652 enable_partial_hash_hits=True,
653 hash_block_size=4,
654 lcm_block_size=16,
655 + mamba_group_ids={1},
656 )
657 db = ChunkedTokenDatabase(
658 KeyMetadata("test-model", 0, 0, 0, 0),
@@ -659,10 +661,18 @@ def _make_partial_tail_send_thread(
661 )
662 db.set_kv_caches_base_addr([0x1000])
663 db.set_block_len([256])
664 + # Group 1 models the mamba "align" group the hand-offs reference.
665 + db_mamba = ChunkedTokenDatabase(
666 + KeyMetadata("test-model", 0, 0, 0, 0, group_id=1),
667 + block_size=16,
668 + hash_block_size=4,
669 + )
670 + db_mamba.set_kv_caches_base_addr([0x2000])
671 + db_mamba.set_block_len([256])
672 return _make_store_sending_thread(
673 store,
674 coord=coord,
665 - token_databases=[db],
675 + token_databases=[db, db_mamba],
676 replicate_config=replicate_config,
677 enable_group_semantics=enable_group_semantics,
678 supports_group_ids=supports_group_ids,
@@ -673,20 +683,20 @@ def _make_partial_tail_req(block_ids: list[int]) -> ReqMeta:
683 return ReqMeta(
684 req_id="req-a",
685 token_len_chunk=0,
676 - block_ids=(block_ids,),
686 + block_ids=(block_ids, [1]),
687 block_hashes=[b"a0", b"a1", b"a2"],
688 can_save=True,
679 - partial_tail_offloads=[(1, 7, 12)],
689 + boundary_state_offloads=[(1, 7, 12)],
690 )
691
692
693 def test_partial_tail_offload_skips_null_source_blocks():
694 store = MagicMock()
695 store.batch_is_exist.side_effect = lambda keys: [0] * len(keys)
686 - store.batch_put_from_multi_buffers.return_value = [256, 256]
696 + store.batch_put_from_multi_buffers.side_effect = lambda keys, *a: [256] * len(keys)
697 thread = _make_partial_tail_send_thread(store)
698
689 - assert thread._maybe_offload_partial_tail(_make_partial_tail_req([0, 2, 3]))
699 + assert thread._maybe_offload_boundary_states(_make_partial_tail_req([0, 2, 3]))
700
701 keys, addrs, _sizes, _replicate_config = (
702 store.batch_put_from_multi_buffers.call_args.args
@@ -694,15 +704,21 @@ def test_partial_tail_offload_skips_null_source_blocks():
704 assert keys == [
705 "test-model@tp_rank:0@pcp0@dcp0@pp_rank:0@group:0@6131",
706 "test-model@tp_rank:0@pcp0@dcp0@pp_rank:0@group:0@6132",
707 + "test-model@tp_rank:0@pcp0@dcp0@pp_rank:0@group:1@6132",
708 + ]
709 + # FA reads blocks 2 and 3 (block 0 is null and skipped); the mamba
710 + # boundary block reads the core-provided CoW block 7, not block_ids.
711 + assert addrs == [
712 + [0x1000 + 2 * 256],
713 + [0x1000 + 3 * 256],
714 + [0x2000 + 7 * 256],
715 ]
698 - assert addrs == [[0x1000 + 2 * 256], [0x1000 + 3 * 256]]
716
717
718 def test_store_sending_thread_skips_null_sparse_group_blocks():
719 from vllm.v1.kv_cache_interface import (
720 FullAttentionSpec,
721 KVCacheGroupSpec,
705 - MambaSpec,
722 )
723
724 store = MagicMock()
@@ -711,16 +727,11 @@ def test_store_sending_thread_skips_null_sparse_group_blocks():
727 lambda keys, addrs, sizes, replicate_config: [256] * len(keys)
728 )
729 full = FullAttentionSpec(block_size=16, num_kv_heads=8, head_size=64, dtype=None)
714 - mamba = MambaSpec(
715 - block_size=16,
716 - shapes=((1, 1),),
717 - dtypes=(torch.float32,),
718 - mamba_cache_mode="align",
719 - )
730 + sparse = FullAttentionSpec(block_size=16, num_kv_heads=8, head_size=64, dtype=None)
731 coord = mooncake_store_worker.MooncakeStoreCoordinator(
732 [
733 KVCacheGroupSpec(["full"], full),
723 - KVCacheGroupSpec(["mamba"], mamba),
734 + KVCacheGroupSpec(["sparse"], sparse),
735 ],
736 scheduler_block_size=16,
737 hash_block_size=16,
@@ -732,17 +743,17 @@ def test_store_sending_thread_skips_null_sparse_group_blocks():
743 )
744 db_full.set_kv_caches_base_addr([0x1000])
745 db_full.set_block_len([256])
735 - db_mamba = ChunkedTokenDatabase(
746 + db_sparse = ChunkedTokenDatabase(
747 KeyMetadata("test-model", 0, 0, 0, 0, group_id=1),
748 block_size=16,
749 )
739 - db_mamba.set_kv_caches_base_addr([0x2000])
740 - db_mamba.set_block_len([512])
750 + db_sparse.set_kv_caches_base_addr([0x2000])
751 + db_sparse.set_block_len([512])
752
753 thread = _make_store_sending_thread(
754 store,
755 coord=coord,
745 - token_databases=[db_full, db_mamba],
756 + token_databases=[db_full, db_sparse],
757 )
758 _run_store_req(
759 thread,
@@ -756,15 +767,86 @@ def test_store_sending_thread_skips_null_sparse_group_blocks():
767 )
768
769 keys, addrs, _, _ = store.batch_put_from_multi_buffers.call_args.args
759 - mamba_keys = [key for key in keys if "@group:1" in key]
760 - assert [key.rsplit("@", 1)[-1] for key in mamba_keys] == [b"a1".hex()]
770 + sparse_keys = [key for key in keys if "@group:1" in key]
771 + assert [key.rsplit("@", 1)[-1] for key in sparse_keys] == [b"a1".hex()]
772 assert all(addr[0] >= 0x2000 for key, addr in zip(keys, addrs) if "@group:1" in key)
773
774
775 +def test_partial_tail_offload_skips_cap_omitted_mamba_group():
776 + """A Mamba group omitted by the handoff cap must not fall back to the
777 + connector's positional block table."""
778 + from vllm.v1.kv_cache_interface import (
779 + FullAttentionSpec,
780 + KVCacheGroupSpec,
781 + MambaSpec,
782 + )
783 +
784 + store = MagicMock()
785 + store.batch_is_exist.side_effect = lambda keys: [0] * len(keys)
786 + store.batch_put_from_multi_buffers.side_effect = lambda keys, *a: [256] * len(keys)
787 +
788 + full_spec = FullAttentionSpec(
789 + block_size=4, num_kv_heads=8, head_size=64, dtype=torch.float32
790 + )
791 + mamba_spec = MambaSpec(
792 + block_size=16,
793 + shapes=((1, 1),),
794 + dtypes=(torch.float32,),
795 + mamba_cache_mode="align",
796 + )
797 + coord = mooncake_store_worker.MooncakeStoreCoordinator(
798 + [
799 + KVCacheGroupSpec(["full"], full_spec),
800 + KVCacheGroupSpec(["mamba-1"], mamba_spec),
801 + KVCacheGroupSpec(["mamba-2"], mamba_spec),
802 + ],
803 + scheduler_block_size=16,
804 + hash_block_size=4,
805 + )
806 +
807 + def make_db(group_id: int, block_size: int, base_addr: int):
808 + db = ChunkedTokenDatabase(
809 + KeyMetadata("test-model", 0, 0, 0, 0, group_id=group_id),
810 + block_size=block_size,
811 + hash_block_size=4,
812 + )
813 + db.set_kv_caches_base_addr([base_addr])
814 + db.set_block_len([256])
815 + return db
816 +
817 + db_full = make_db(0, 4, 0x1000)
818 + db_mamba_1 = make_db(1, 16, 0x2000)
819 + db_mamba_2 = make_db(2, 16, 0x3000)
820 + thread = _make_store_sending_thread(
821 + store,
822 + coord=coord,
823 + token_databases=[db_full, db_mamba_1, db_mamba_2],
824 + )
825 +
826 + req = ReqMeta(
827 + req_id="req-a",
828 + token_len_chunk=0,
829 + block_ids=([1, 2, 3], [5], [9]),
830 + block_hashes=[b"a0", b"a1", b"a2"],
831 + can_save=True,
832 + # The scheduler accepted group 1 and omitted group 2 at boundary 12.
833 + boundary_state_offloads=[(1, 7, 12)],
834 + )
835 + assert thread._maybe_offload_boundary_states(req)
836 +
837 + keys, addrs, _sizes, _replicate_config = (
838 + store.batch_put_from_multi_buffers.call_args.args
839 + )
840 + puts = list(zip(keys, addrs, strict=True))
841 + boundary_hash = BlockHash(b"a2")
842 + assert (db_mamba_1.key_for(boundary_hash), [0x2000 + 7 * 256]) in puts
843 + assert (db_mamba_2.key_for(boundary_hash), [0x3000 + 9 * 256]) not in puts
844 +
845 +
846 def test_partial_tail_offload_replaces_stale_group_ids_after_filtering():
847 store = MagicMock()
766 - store.batch_is_exist.return_value = [1, 0, 0]
767 - store.batch_put_from_multi_buffers.return_value = [256, 256]
848 + store.batch_is_exist.return_value = [1, 0, 0, 0]
849 + store.batch_put_from_multi_buffers.return_value = [256, 256, 256]
850 replicate_config = SimpleNamespace(group_ids=["stale"])
851 thread = _make_partial_tail_send_thread(
852 store,
@@ -773,17 +855,19 @@ def test_partial_tail_offload_replaces_stale_group_ids_after_filtering():
855 supports_group_ids=True,
856 )
857
776 - assert thread._maybe_offload_partial_tail(_make_partial_tail_req([1, 2, 3]))
858 + assert thread._maybe_offload_boundary_states(_make_partial_tail_req([1, 2, 3]))
859
860 keys, _addrs, _sizes, config = store.batch_put_from_multi_buffers.call_args.args
861 assert keys == [
862 "test-model@tp_rank:0@pcp0@dcp0@pp_rank:0@group:0@6131",
863 "test-model@tp_rank:0@pcp0@dcp0@pp_rank:0@group:0@6132",
864 + "test-model@tp_rank:0@pcp0@dcp0@pp_rank:0@group:1@6132",
865 ]
866 assert config is replicate_config
867 assert config.group_ids == [
868 "vllm-mooncake-store:test-model@6131",
869 "vllm-mooncake-store:test-model@6132",
870 + "vllm-mooncake-store:test-model@6132",
871 ]
872
873
@@ -815,6 +899,165 @@ def test_partial_tail_put_failure_activates_pressure_gate():
899 assert store.batch_put_from_multi_buffers.call_count == 1
900
901
902 +def test_normal_save_excludes_mamba_group_and_null_blocks():
903 + """The positional normal save must never cover mamba chunks (align-mode
904 + block tables are not append-only) and must skip chunks whose block is the
905 + reserved null block, for any group."""
906 + from vllm.v1.kv_cache_interface import (
907 + FullAttentionSpec,
908 + KVCacheGroupSpec,
909 + MambaSpec,
910 + )
911 +
912 + store = MagicMock()
913 + store.batch_is_exist.side_effect = lambda keys: [0] * len(keys)
914 + store.batch_put_from_multi_buffers.side_effect = lambda keys, *a: [256] * len(keys)
915 +
916 + full_spec = FullAttentionSpec(
917 + block_size=16, num_kv_heads=8, head_size=64, dtype=None
918 + )
919 + mamba_spec = MambaSpec(
920 + block_size=16,
921 + shapes=((1, 1),),
922 + dtypes=(torch.float32,),
923 + mamba_cache_mode="align",
924 + )
925 + coord = mooncake_store_worker.MooncakeStoreCoordinator(
926 + [KVCacheGroupSpec(["L0"], full_spec), KVCacheGroupSpec(["L1"], mamba_spec)],
927 + scheduler_block_size=16,
928 + hash_block_size=16,
929 + )
930 + db_full = ChunkedTokenDatabase(
931 + KeyMetadata("test-model", 0, 0, 0, 0, group_id=0), block_size=16
932 + )
933 + db_full.set_kv_caches_base_addr([0x1000])
934 + db_full.set_block_len([256])
935 + db_mamba = ChunkedTokenDatabase(
936 + KeyMetadata("test-model", 0, 0, 0, 0, group_id=1), block_size=16
937 + )
938 + db_mamba.set_kv_caches_base_addr([0x2000])
939 + db_mamba.set_block_len([256])
940 + thread = _make_store_sending_thread(
941 + store, coord=coord, token_databases=[db_full, db_mamba]
942 + )
943 +
944 + _run_store_req(
945 + thread,
946 + ReqMeta(
947 + req_id="r0",
948 + token_len_chunk=64,
949 + # FA has a null at chunk 1; the mamba table is align-mode shaped
950 + # (interior nulls + one state block) and must not be read at all.
951 + block_ids=([1, 0, 3, 4], [0, 0, 0, 9]),
952 + block_hashes=[b"a0", b"a1", b"a2", b"a3"],
953 + can_save=True,
954 + ),
955 + )
956 +
957 + keys, addrs, _sizes, _ = store.batch_put_from_multi_buffers.call_args.args
958 + assert all("@group:0@" in k for k in keys)
959 + assert [k.rsplit("@", 1)[-1] for k in keys] == ["6130", "6132", "6133"]
960 + assert addrs == [[0x1000 + 256], [0x1000 + 3 * 256], [0x1000 + 4 * 256]]
961 +
962 +
963 +def test_block_aligned_snapshot_offload_uses_provided_block():
964 + """A block-aligned hand-off (sparse-retention mamba checkpoint) uploads
965 + exactly the core-pinned block under the boundary-end hash key; the
966 + positional block table entry for that chunk must be ignored."""
967 + store = MagicMock()
968 + store.batch_is_exist.side_effect = lambda keys: [0] * len(keys)
969 + store.batch_put_from_multi_buffers.side_effect = lambda keys, *a: [256] * len(keys)
970 + thread = _make_partial_tail_send_thread(store)
971 +
972 + hs = [bytes([i + 1]) * 4 for i in range(8)] # 8 hash units = 32 tokens
973 + req = ReqMeta(
974 + req_id="req-a",
975 + token_len_chunk=0,
976 + block_ids=([1, 2, 3], [5]),
977 + block_hashes=hs,
978 + can_save=True,
979 + boundary_state_offloads=[(1, 7, 32)],
980 + )
981 + assert thread._maybe_offload_boundary_states(req)
982 +
983 + keys, addrs, _sizes, _ = store.batch_put_from_multi_buffers.call_args.args
984 + # boundary 32 is block-aligned for the mamba group (block 16): one key,
985 + # keyed by hs[32 // 4 - 1], read from the handed-off block 7 — not from
986 + # block_ids[1] and with no FA gap coverage.
987 + assert keys == [thread.token_databases[1].key_for(BlockHash(hs[7]))]
988 + assert addrs == [[0x2000 + 7 * 256]]
989 +
990 +
991 +def test_mixed_snapshot_and_sub_block_offloads():
992 + """A retention snapshot and the prompt-end sub-block CoW tail can arrive
993 + in one hand-off; the sub-block path covers FA gap blocks but reads the
994 + mamba boundary only from the CoW block, never positionally."""
995 + store = MagicMock()
996 + store.batch_is_exist.side_effect = lambda keys: [0] * len(keys)
997 + store.batch_put_from_multi_buffers.side_effect = lambda keys, *a: [256] * len(keys)
998 + thread = _make_partial_tail_send_thread(store)
999 +
1000 + hs = [bytes([i + 1]) * 4 for i in range(11)] # 11 hash units = 44 tokens
1001 + req = ReqMeta(
1002 + req_id="req-a",
1003 + token_len_chunk=0,
1004 + block_ids=([1, 2, 3], [0, 0, 0]),
1005 + block_hashes=hs,
1006 + can_save=True,
1007 + boundary_state_offloads=[(1, 9, 32), (1, 7, 44)],
1008 + )
1009 + assert thread._maybe_offload_boundary_states(req)
1010 +
1011 + keys, addrs, _sizes, _ = store.batch_put_from_multi_buffers.call_args.args
1012 + db_full, db_mamba = thread.token_databases
1013 + assert keys == [
1014 + # Aligned snapshot: boundary 32 from the handed-off block 9.
1015 + db_mamba.key_for(BlockHash(hs[7])),
1016 + # Sub-block boundary 44: FA gap blocks ending at 4, 8, 12.
1017 + db_full.key_for(BlockHash(hs[0])),
1018 + db_full.key_for(BlockHash(hs[1])),
1019 + db_full.key_for(BlockHash(hs[2])),
1020 + # Mamba boundary block from the CoW hand-off (block 7).
1021 + db_mamba.key_for(BlockHash(hs[10])),
1022 + ]
1023 + assert addrs == [
1024 + [0x2000 + 9 * 256],
1025 + [0x1000 + 1 * 256],
1026 + [0x1000 + 2 * 256],
1027 + [0x1000 + 3 * 256],
1028 + [0x2000 + 7 * 256],
1029 + ]
1030 +
1031 +
1032 +def test_snapshot_offload_skips_null_handoff_block():
1033 + """A hand-off the core could not materialize (null block) carries no
1034 + committed state; persisting it would poison the boundary key."""
1035 + store = MagicMock()
1036 + thread = _make_partial_tail_send_thread(store)
1037 +
1038 + hs = [bytes([i + 1]) * 4 for i in range(8)]
1039 + assert thread._maybe_offload_boundary_states(
1040 + ReqMeta(
1041 + req_id="req-a",
1042 + token_len_chunk=0,
1043 + block_ids=([1, 2, 3], [5]),
1044 + block_hashes=hs,
1045 + can_save=True,
1046 + boundary_state_offloads=[(1, NULL_BLOCK_ID, 32)],
1047 + )
1048 + )
1049 + store.batch_is_exist.assert_not_called()
1050 + store.batch_put_from_multi_buffers.assert_not_called()
1051 +
1052 +
1053 +def test_worker_meta_is_absent_before_any_completed_job():
1054 + send_thread = _make_partial_tail_send_thread(MagicMock())
1055 + store_worker = object.__new__(mooncake_store_worker.MooncakeStoreWorker)
1056 + store_worker.kv_send_thread = send_thread
1057 +
1058 + assert store_worker.build_connector_worker_meta() is None
1059 +
1060 +
1061 def test_store_sending_thread_delta_start_rank_saves_second_local_chunk():
1062 store = MagicMock()
1063 store.batch_is_exist.side_effect = lambda keys: [0] * len(keys)
tests/v1/kv_connector/unit/test_offloading_connector.py
+14 -5
@@ -212,12 +212,21 @@ def _accuracy_test(llm: LLM, subscriber: MockSubscriber | None):
212 if subscriber is not None:
213 subscriber.get_new_cpu_stored_events()
214
215 - # Pad prompt so its token count is a multiple of cpu_block_size.
216 - # Use the tokenizer directly to avoid expensive llm.generate() calls.
215 + # Align the reusable prefix; the final token must be recomputed for logits.
216 tokenizer = llm.get_tokenizer()
218 - prompt = "Let's count to 10. One, two, three, four,"
219 - while len(tokenizer.encode(prompt)) % cpu_block_size != 0:
220 - prompt = ". " + prompt
217 + prompt_token_ids = tokenizer.encode("Let's count to 10. One, two, three, four,")
218 + reusable_prefix = prompt_token_ids[:-1]
219 + final_token = prompt_token_ids[-1:]
220 +
221 + [padding_token_id] = tokenizer.encode(".", add_special_tokens=False)
222 + padding = [padding_token_id] * (-len(reusable_prefix) % cpu_block_size)
223 + insert_at = int(reusable_prefix[0] == tokenizer.bos_token_id)
224 + reusable_prefix[insert_at:insert_at] = padding
225 + assert len(reusable_prefix) % cpu_block_size == 0
226 +
227 + prompt = TokensPrompt(
228 + prompt_token_ids=reusable_prefix + final_token,
229 + )
230
231 # Seed the CPU cache with the prompt.
232 llm.generate(prompt, sampling_params, use_tqdm=False)
tests/v1/kv_connector/unit/test_scheduler_kv_connector_override.py
+16 -1
@@ -22,8 +22,13 @@ from vllm.v1.request import Request
22
23
24 class DummyConnectorMetadata(KVConnectorMetadata):
25 - def __init__(self, block_hashes_by_req: dict[str, list[BlockHash]]):
25 + def __init__(
26 + self,
27 + block_hashes_by_req: dict[str, list[BlockHash]],
28 + block_ids_by_req: dict[str, tuple[list[int], ...]],
29 + ):
30 self.block_hashes_by_req = block_hashes_by_req
31 + self.block_ids_by_req = block_ids_by_req
32
33
34 class DummyKVConnector(KVConnectorBase_V1):
@@ -47,8 +52,12 @@ class DummyKVConnector(KVConnectorBase_V1):
52 assert block_hashes_by_req is not None, (
53 "DummyKVConnector expected 'block_hashes_by_req' on scheduler_output"
54 )
55 + block_state = scheduler_output.kv_connector_block_state
56 + assert block_state is not None
57 + block_ids_by_req = block_state.block_ids
58 return DummyConnectorMetadata(
59 block_hashes_by_req=block_hashes_by_req,
60 + block_ids_by_req=block_ids_by_req,
61 )
62
63 def start_load_kv(self, kv_caches, finished_req_ids):
@@ -129,3 +138,9 @@ def test_connector_receives_block_hashes(_load_plugin):
138 num_tokens // block_size
139 )
140 assert meta.block_hashes_by_req[req.request_id] == req.block_hashes
141 +
142 + expected_block_ids = {
143 + req.req_id: req.block_ids for req in output.scheduled_new_reqs
144 + }
145 + assert meta.block_ids_by_req == expected_block_ids
146 + assert output.kv_connector_block_state is None
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/coordinator.py
+20
@@ -87,6 +87,11 @@ class MooncakeStoreCoordinator:
87 for g in kv_cache_groups
88 ), "scheduler_block_size must be a multiple of each group's block_size"
89 self.kv_cache_groups = kv_cache_groups
90 + self.mamba_group_ids = {
91 + group_id
92 + for group_id, group in enumerate(kv_cache_groups)
93 + if isinstance(_unwrap_spec(group.kv_cache_spec), MambaSpec)
94 + }
95 self.hash_block_size = hash_block_size
96 self.lcm_block_size = scheduler_block_size
97 self.enable_partial_hash_hits = partial_hash_hits_enabled(
@@ -205,12 +210,23 @@ class MooncakeStoreCoordinator:
210
211 Reuses the engine's ``SingleTypeKVCacheManager.reachable_block_mask``
212 so the store retains exactly the blocks the local prefix cache would.
213 +
214 + Mamba groups are always all-False: the normal save resolves blocks
215 + positionally from the connector's append-only block-ID snapshot, but
216 + an align-mode mamba block table is not append-only (interior state
217 + blocks are nulled/freed, and speculative decoding relocates the spec
218 + blocks in place), so a positional read may hit a null, freed, or live
219 + speculative-state block and persist wrong bytes under a valid prefix
220 + hash. Mamba state is persisted only through the connector-pinned exact
221 + block hand-off path
222 + (``SchedulerOutput.kv_connector_block_state.boundary_state_offloads``).
223 """
224 return self._reachable_masks(
225 aligned_token_len,
226 start_token,
227 retention_interval=self.retention_interval,
228 num_prompt_tokens=num_prompt_tokens,
229 + exclude_mamba=True,
230 )
231
232 def lookup_mask(
@@ -237,6 +253,7 @@ class MooncakeStoreCoordinator:
253 *,
254 retention_interval: int | None,
255 num_prompt_tokens: int | None,
256 + exclude_mamba: bool = False,
257 ) -> tuple[list[bool] | None, ...]:
258 mask_alignment = (
259 self.hash_block_size
@@ -252,6 +269,9 @@ class MooncakeStoreCoordinator:
269 spec = _unwrap_spec(g.kv_cache_spec)
270 end_chunk = aligned_token_len // spec.block_size
271 start_chunk = min(end_chunk, max(0, cdiv(start_token, spec.block_size)))
272 + if exclude_mamba and isinstance(spec, MambaSpec):
273 + masks.append([False] * (end_chunk - start_chunk))
274 + continue
275 manager_cls = KVCacheSpecRegistry.get_manager_class(spec)
276 assert manager_cls is not None
277 use_eagle = g_idx in self.eagle_group_ids
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/data.py
+5 -5
@@ -406,11 +406,11 @@ class ReqMeta:
406 # serve that purpose: it is reused once a preempted request resumes, so it
407 # would release the wrong job's blocks.
408 store_job_id: int | None = None
409 - # Core-provided per-mamba-group
410 - # (group_id, cow_block_id, boundary_tokens) for this request's partial tail.
411 - # Present only on the producer's CoW step; drives the connector's offload
412 - # (the FA group's block is derived from block_ids and boundary_tokens).
413 - partial_tail_offloads: list[tuple[int, int, int]] | None = None
409 + # Core-provided (group_id, block_id, boundary_tokens) mamba "align"
410 + # boundary states. A block-aligned entry is a committed boundary snapshot;
411 + # a non-aligned entry is the sub-block CoW tail. The store-job reference
412 + # keeps each exact block alive until every worker rank finishes the job.
413 + boundary_state_offloads: list[tuple[int, int, int]] | None = None
414
415 @staticmethod
416 def from_request_tracker(
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/scheduler.py
+121 -52
@@ -23,11 +23,12 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.worker import (
23 LookupKeyClient,
24 )
25 from vllm.logger import init_logger
26 +from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
27 from vllm.v1.core.block_pool import BlockPool
28 from vllm.v1.core.kv_cache_manager import KVCacheBlocks
29 from vllm.v1.core.kv_cache_utils import resolve_kv_cache_block_sizes
30 from vllm.v1.core.sched.output import NewRequestData, SchedulerOutput
30 -from vllm.v1.kv_cache_interface import KVCacheConfig
31 +from vllm.v1.kv_cache_interface import KVCacheConfig, MambaSpec
32 from vllm.v1.outputs import KVConnectorOutput
33 from vllm.v1.request import Request
34
@@ -80,12 +81,15 @@ class MooncakeStoreScheduler:
81 self._hash_block_size,
82 vllm_config.parallel_config.decode_context_parallel_size,
83 )
83 -
84 - # Per-request state
85 - self.load_specs: dict[str, LoadSpec] = {} # to be loaded
86 - self._request_trackers: dict[str, RequestTracker] = {} # scheduled new requests
87 - self._unfinished_requests: dict[str, tuple[Request, tuple[list[int], ...]]] = {}
88 - self._unfinished_request_ids: set[str] = set()
84 + mamba_groups = {
85 + group_id: group.kv_cache_spec
86 + for group_id, group in enumerate(kv_cache_config.kv_cache_groups)
87 + if isinstance(group.kv_cache_spec, MambaSpec)
88 + }
89 + assert all(
90 + spec.mamba_cache_mode == "align" for spec in mamba_groups.values()
91 + ), "MooncakeStoreScheduler requires mamba_cache_mode='align'"
92 + self._boundary_state_group_ids = frozenset(mamba_groups)
93
94 self._gpu_block_pool: BlockPool | None = None
95 self._num_workers = vllm_config.parallel_config.world_size
@@ -93,6 +97,12 @@ class MooncakeStoreScheduler:
97 # store_job_id -> (referenced block ids, ranks yet to report completion)
98 self._pinned_saves: dict[int, tuple[list[int], int]] = {}
99
100 + # Per-request state
101 + self.load_specs: dict[str, LoadSpec] = {} # to be loaded
102 + self._request_trackers: dict[str, RequestTracker] = {} # scheduled new requests
103 + self._unfinished_requests: dict[str, tuple[Request, tuple[list[int], ...]]] = {}
104 + self._unfinished_request_ids: set[str] = set()
105 +
106 def bind_gpu_block_pool(self, gpu_block_pool: BlockPool) -> None:
107 self._gpu_block_pool = gpu_block_pool
108
@@ -381,47 +391,41 @@ class MooncakeStoreScheduler:
391 if req_meta is not None:
392 meta.add_request(req_meta)
393
384 - # Flush partial-tail offloads in the step they arrive: the CoW copy is
385 - # enqueued before the connector event records, so this step's event
386 - # fences the cow block. Ride the request's save meta when present, else
387 - # emit an offload-only ReqMeta (token_len_chunk=0 skips the normal
388 - # save; can_save=True takes the normal enqueue path).
389 - step_partial_tails = getattr(scheduler_output, "partial_tail_offloads", None)
390 - if step_partial_tails and not is_consumer:
391 - pending = dict(step_partial_tails)
392 - for req_meta in meta.requests:
393 - if req_meta.can_save:
394 - groups = pending.pop(req_meta.req_id, None)
395 - if groups:
396 - req_meta.partial_tail_offloads = groups
397 - tracker = self._request_trackers.get(req_meta.req_id)
398 - if tracker is not None:
399 - tracker.has_pending_offload = True
400 - for req_id, groups in pending.items():
401 - tracker = self._request_trackers.get(req_id)
402 - req_tuple = self._unfinished_requests.get(req_id)
403 - if tracker is None or req_tuple is None:
404 - # Request finished/preempted within this step; its blocks
405 - # are going away, so the offload is conservatively dropped.
406 - logger.debug("Dropping partial-tail offload for request %s", req_id)
407 - continue
408 - assert len({boundary for _, _, boundary in groups}) == 1
409 - tracker.has_pending_offload = True
410 - meta.add_request(
411 - ReqMeta(
412 - req_id=req_id,
413 - token_len_chunk=0,
414 - block_ids=tracker.allocated_block_ids,
415 - block_hashes=req_tuple[0].block_hashes,
416 - can_save=True,
417 - num_prompt_tokens=tracker.prefill_end_tokens,
418 - partial_tail_offloads=groups,
419 - )
420 - )
394 + block_state = getattr(scheduler_output, "kv_connector_block_state", None)
395 + if (
396 + block_state is not None
397 + and block_state.boundary_state_offloads
398 + and not is_consumer
399 + ):
400 + self._handle_boundary_state_offloads(
401 + block_state.boundary_state_offloads, meta
402 + )
403
404 + self._apply_current_save_block_ids(meta, scheduler_output)
405 self._reference_save_blocks(meta)
406 return meta
407
408 + def _apply_current_save_block_ids(
409 + self,
410 + meta: MooncakeStoreConnectorMetadata,
411 + scheduler_output: SchedulerOutput,
412 + ) -> None:
413 + """Replace append-only mirrors with the core's current block tables."""
414 + save_metas = [req_meta for req_meta in meta.requests if req_meta.can_save]
415 + if not save_metas:
416 + return
417 +
418 + block_state = scheduler_output.kv_connector_block_state
419 + assert block_state is not None, (
420 + "Current block tables are required for Mooncake store jobs"
421 + )
422 + for req_meta in save_metas:
423 + block_ids = block_state.block_ids.get(req_meta.req_id)
424 + assert block_ids is not None, (
425 + f"Missing current block table for store request {req_meta.req_id}"
426 + )
427 + req_meta.block_ids = block_ids
428 +
429 def _reference_save_blocks(self, meta: MooncakeStoreConnectorMetadata) -> None:
430 """Take a GPU block reference for every store job this step emits.
431
@@ -439,21 +443,86 @@ class MooncakeStoreScheduler:
443 req_meta.store_job_id = store_job_id = self._next_store_job_id
444 self._next_store_job_id += 1
445 block_ids: list[int] = []
442 - if req_meta.partial_tail_offloads:
443 - # A partial-tail CoW block is deliberately kept out of the
444 - # request's block table, so it is absent from `block_ids` even
445 - # though the worker DMAs out of it just as asynchronously.
446 - # It leads the list, as in `pop_blocks_for_free`.
447 - block_ids += [bid for _, bid, _ in req_meta.partial_tail_offloads]
446 + if req_meta.boundary_state_offloads:
447 + block_ids.extend(
448 + block_id for _, block_id, _ in req_meta.boundary_state_offloads
449 + )
450 + assert NULL_BLOCK_ID not in block_ids, (
451 + "A null block cannot back a boundary-state offload"
452 + )
453 # Every allocated block is referenced, not just the ones covering
454 # this job's token range: a rank resumes from its own last
455 # successful offset, which lags the scheduler's whenever a save was
456 # skipped or failed, so it may read anywhere below the range.
452 - block_ids += [bid for group in req_meta.block_ids for bid in group]
457 + block_ids.extend(
458 + block_id
459 + for group_id, group in enumerate(req_meta.block_ids)
460 + if group_id not in self._boundary_state_group_ids
461 + for block_id in group
462 + if block_id != NULL_BLOCK_ID
463 + )
464 + # An aligned boundary block may also be present in the request's
465 + # block table. Take and release exactly one reference per block.
466 + block_ids = list(dict.fromkeys(block_ids))
467 + assert NULL_BLOCK_ID not in block_ids
468 if not block_ids:
469 continue
470 self._pinned_saves[store_job_id] = (block_ids, self._num_workers)
456 - pool.touch([pool.blocks[bid] for bid in block_ids])
471 + pool.touch([pool.blocks[block_id] for block_id in block_ids])
472 +
473 + def _handle_boundary_state_offloads(
474 + self,
475 + offloads: dict[str, list[tuple[int, int, int]]],
476 + meta: MooncakeStoreConnectorMetadata,
477 + ) -> None:
478 + """Attach exact boundary-state blocks to store jobs for this step.
479 +
480 + Flushed in the step they arrive: the CoW copy is enqueued before the
481 + connector event records, so this step's event fences the exact block.
482 + Entries ride the request's save meta when present, else an
483 + offload-only ReqMeta (``token_len_chunk=0`` skips the normal save,
484 + ``can_save=True`` takes the normal enqueue and store-job pinning path).
485 + """
486 + save_metas = {m.req_id: m for m in meta.requests if m.can_save}
487 + for req_id, entries in offloads.items():
488 + tracker = self._request_trackers.get(req_id)
489 + req_tuple = self._unfinished_requests.get(req_id)
490 + if tracker is None or req_tuple is None:
491 + # Request finished/preempted within this step; its blocks are
492 + # going away, so the offload is conservatively dropped.
493 + logger.debug("Dropping boundary-state offload for request %s", req_id)
494 + continue
495 + accepted: list[tuple[int, int, int]] = []
496 + for group_id, block_id, boundary_tokens in entries:
497 + # Every other group stops saving at the end of this prefill, so
498 + # a mamba-only key past it can never complete a joint hybrid
499 + # hit. `prefill_end_tokens` — not the original prompt length —
500 + # is the boundary: a resumed request re-prefills and re-saves
501 + # its previously generated tokens for every group.
502 + if boundary_tokens > tracker.prefill_end_tokens:
503 + continue
504 + if block_id == NULL_BLOCK_ID:
505 + continue
506 + if group_id not in self._boundary_state_group_ids:
507 + continue
508 + accepted.append((group_id, block_id, boundary_tokens))
509 + if not accepted:
510 + continue
511 + tracker.has_pending_offload = True
512 + if (req_meta := save_metas.get(req_id)) is not None:
513 + req_meta.boundary_state_offloads = accepted
514 + continue
515 + meta.add_request(
516 + ReqMeta(
517 + req_id=req_id,
518 + token_len_chunk=0,
519 + block_ids=tracker.allocated_block_ids,
520 + block_hashes=req_tuple[0].block_hashes,
521 + can_save=True,
522 + num_prompt_tokens=tracker.prefill_end_tokens,
523 + boundary_state_offloads=accepted,
524 + )
525 + )
526
527 def update_connector_output(self, connector_output: KVConnectorOutput) -> None:
528 """Drop the block references of store jobs every rank has finished."""
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+134 -62
@@ -648,48 +648,76 @@ class KVCacheStoreSendingThread(KVTransferThread):
648 self._skip_store_requests.clear()
649 return True
650
651 - def _maybe_offload_partial_tail(self, req_meta: ReqMeta) -> bool:
652 - """Offload the request's sub-block partial tail (its last prompt hash
653 - boundary) so a later request can hit the sub-block prefix.
654 -
655 - Covers every block from the normal save's lcm floor to the boundary:
656 - the normal save floors to ``lcm_block_size``, so a smaller-block
657 - group's full blocks in that gap are never persisted elsewhere, and
658 - the consumer's lookup needs every group at every probed boundary.
659 - Full blocks are keyed by their block-end hash, the partial boundary
660 - block by the boundary sub-hash; the mamba "align" boundary block is
661 - the core-provided CoW block. All keys are deduped against the store.
662 -
663 - Returns:
664 - True when no put is needed or every put succeeds, False otherwise.
651 + def _boundary_snapshot_puts(
652 + self, req_meta: ReqMeta, entries: list[tuple[int, int, int]]
653 + ) -> list[tuple[str, list[int], list[int], KeyMetadata]]:
654 + """Puts for committed mamba "align" boundary-state snapshots.
655 +
656 + These are block-aligned boundaries, i.e. exactly what the normal save
657 + would key — but ``store_mask`` masks mamba groups out of it entirely, so
658 + this is their *only* writer. The exclusion is not an optimization: the
659 + normal save resolves a chunk's address as
660 + ``req_meta.block_ids[g][start // block_size]``, and ``block_ids`` is the
661 + connector's append-only mirror of the core's per-group table. An
662 + align-mode table is mutated in place (a superseded state block is freed
663 + and nulled; speculative blocks relocate), and the connector is never
664 + told, so a stale mirror entry is indistinguishable from a live one — a
665 + retry of a failed or pressure-skipped chunk would read a block that now
666 + belongs to another request.
667 +
668 + Each entry's handed-off block *is* the boundary state and is pinned by
669 + the core, so it is uploaded under its boundary-end hash key and never
670 + resolved positionally.
671 """
666 - if not self.coord.enable_partial_hash_hits or not req_meta.block_hashes:
667 - return True
668 - partial_tail_offloads = req_meta.partial_tail_offloads
669 - if not partial_tail_offloads:
670 - return True
672 hash_block_size = self.coord.hash_block_size
672 - boundaries = {boundary for _, _, boundary in partial_tail_offloads}
673 + puts: list[tuple[str, list[int], list[int], KeyMetadata]] = []
674 + for group_id, block_id, boundary in entries:
675 + if boundary == 0 or block_id == NULL_BLOCK_ID:
676 + continue
677 + hash_idx = boundary // hash_block_size - 1
678 + if hash_idx >= len(req_meta.block_hashes):
679 + continue
680 + db = self.token_databases[group_id]
681 + # Distribute across ranks by the same rule as normal chunks.
682 + put_step = self.group_put_steps[group_id]
683 + put_step_rank = (self.tp_rank + group_id) % put_step
684 + if (boundary // db.block_size - 1) % put_step != put_step_rank:
685 + continue
686 + addr, size = db.prepare_value_for_block(block_id)
687 + puts.append(
688 + (db.key_for(req_meta.block_hashes[hash_idx]), addr, size, db.metadata)
689 + )
690 + return puts
691 +
692 + def _sub_block_tail_puts(
693 + self, req_meta: ReqMeta, entries: list[tuple[int, int, int]]
694 + ) -> list[tuple[str, list[int], list[int], KeyMetadata]]:
695 + """Puts for the request's sub-block partial tail (its last prompt hash
696 + boundary), so a later request can hit the sub-block prefix.
697 +
698 + Covers every group's blocks from the normal save's lcm floor to the
699 + boundary: the normal save floors to ``lcm_block_size``, so a
700 + smaller-block group's full blocks in that gap are never persisted
701 + elsewhere, and the consumer's lookup needs every group at every probed
702 + boundary. Full blocks are keyed by their block-end hash and the partial
703 + boundary block by the boundary sub-hash; a mamba "align" group
704 + contributes only its boundary block, from the core-provided CoW block.
705 + """
706 + boundaries = {boundary for _, _, boundary in entries}
707 if len(boundaries) != 1:
708 raise ValueError(
675 - "Partial-tail offloads for one request must share a boundary"
709 + "Sub-block partial-tail offloads for one request must share a boundary"
710 )
711 boundary = boundaries.pop()
678 - if boundary == 0:
679 - return True
680 - if boundary // hash_block_size - 1 >= len(req_meta.block_hashes):
681 - return True
682 - mamba_offloads = {
683 - group_id: block_id for group_id, block_id, _ in partial_tail_offloads
684 - }
712 + hash_block_size = self.coord.hash_block_size
713 + if boundary == 0 or boundary // hash_block_size - 1 >= len(
714 + req_meta.block_hashes
715 + ):
716 + return []
717
686 - keys: list[str] = []
687 - addrs: list[list[int]] = []
688 - sizes: list[list[int]] = []
689 - group_ids: list[str] | None = (
690 - [] if self.enable_group_semantics and self.supports_group_ids else None
691 - )
718 + mamba_offloads = {group_id: block_id for group_id, block_id, _ in entries}
719 saved = self._saved_offset.get(req_meta.req_id, 0)
720 + puts: list[tuple[str, list[int], list[int], KeyMetadata]] = []
721 for g_idx, db in enumerate(self.token_databases):
722 group_blocks = req_meta.block_ids[g_idx]
723 # Distribute across ranks by the same rule as normal chunks.
@@ -705,16 +733,20 @@ class KVCacheStoreSendingThread(KVTransferThread):
733 continue
734 valid_end = min((block_idx + 1) * db.block_size, boundary)
735 key_hash = req_meta.block_hashes[valid_end // hash_block_size - 1]
708 - if (
709 - g_idx in mamba_offloads
710 - and valid_end == boundary
711 - and boundary % db.block_size != 0
712 - ):
713 - block_id = mamba_offloads[g_idx]
714 - else:
715 - if block_idx >= len(group_blocks):
736 + if g_idx in mamba_offloads:
737 + if valid_end != boundary:
738 + # Interior align-mode state positions are null or
739 + # stale (the block table is not append-only) and never
740 + # valid gap content; only the boundary block is
741 + # persisted, from the core-provided hand-off.
742 continue
743 + block_id = mamba_offloads[g_idx]
744 + elif g_idx in self.coord.mamba_group_ids:
745 + continue
746 + elif block_idx < len(group_blocks):
747 block_id = group_blocks[block_idx]
748 + else:
749 + continue
750 if block_id == NULL_BLOCK_ID:
751 logger.debug(
752 "Skipping unavailable partial-tail source block "
@@ -725,20 +757,58 @@ class KVCacheStoreSendingThread(KVTransferThread):
757 )
758 continue
759 addr, size = db.prepare_value_for_block(block_id)
728 - key = db.key_for(key_hash)
729 - keys.append(key)
730 - addrs.append(addr)
731 - sizes.append(size)
732 - if group_ids is not None:
733 - group_ids.append(
734 - _make_mooncake_group_id(
735 - db.metadata,
736 - key.rsplit("@", 1)[-1],
737 - )
738 - )
760 + puts.append((db.key_for(key_hash), addr, size, db.metadata))
761 + return puts
762 +
763 + def _maybe_offload_boundary_states(self, req_meta: ReqMeta) -> bool:
764 + """Persist connector-pinned mamba "align" boundary states handed off
765 + for this request, deduped against the store.
766
740 - if not keys:
767 + This is every mamba key the connector writes — ``store_mask`` excludes
768 + mamba groups from the positional normal save, aligned boundaries
769 + included (see :meth:`_boundary_snapshot_puts`).
770 +
771 + The two entry kinds are keyed and sourced differently, so they are
772 + prepared separately and put in one batch:
773 +
774 + - block-aligned for its group: a committed boundary-state snapshot,
775 + the handed-off block itself;
776 + - not block-aligned: the sub-block CoW partial tail, which also has to
777 + cover the other groups' blocks in the normal save's lcm gap.
778 +
779 + Returns:
780 + True when no put is needed or every put succeeds, False otherwise.
781 + """
782 + offloads = req_meta.boundary_state_offloads
783 + if not offloads or not req_meta.block_hashes:
784 + return True
785 +
786 + snapshots: list[tuple[int, int, int]] = []
787 + sub_block: list[tuple[int, int, int]] = []
788 + for group_id, block_id, boundary in offloads:
789 + entry = (group_id, block_id, boundary)
790 + if boundary % self.token_databases[group_id].block_size == 0:
791 + snapshots.append(entry)
792 + else:
793 + sub_block.append(entry)
794 +
795 + puts = self._boundary_snapshot_puts(req_meta, snapshots)
796 + if sub_block and self.coord.enable_partial_hash_hits:
797 + puts.extend(self._sub_block_tail_puts(req_meta, sub_block))
798 +
799 + if not puts:
800 return True
801 + keys = [key for key, _, _, _ in puts]
802 + addrs = [addr for _, addr, _, _ in puts]
803 + sizes = [size for _, _, size, _ in puts]
804 + group_ids: list[str] | None = (
805 + [
806 + _make_mooncake_group_id(metadata, key.rsplit("@", 1)[-1])
807 + for key, _, _, metadata in puts
808 + ]
809 + if self.enable_group_semantics and self.supports_group_ids
810 + else None
811 + )
812 exists_start = time.perf_counter()
813 try:
814 exists = self.store.batch_is_exist(keys)
@@ -751,7 +821,7 @@ class KVCacheStoreSendingThread(KVTransferThread):
821 num_failed_keys=len(keys),
822 )
823 logger.error(
754 - "Failed to check partial-tail keys for request %s: %s",
824 + "Failed to check boundary-state keys for request %s: %s",
825 req_meta.req_id,
826 e,
827 )
@@ -787,7 +857,7 @@ class KVCacheStoreSendingThread(KVTransferThread):
857 num_failed_keys=len(keys),
858 )
859 logger.error(
790 - "Failed to put partial-tail keys for request %s: %s",
860 + "Failed to put boundary-state keys for request %s: %s",
861 req_meta.req_id,
862 e,
863 )
@@ -805,7 +875,8 @@ class KVCacheStoreSendingThread(KVTransferThread):
875 if failed:
876 failed_codes = {res[i] for i in failed}
877 logger.warning(
808 - "Partial-tail put failed for request %s: %d/%d keys failed (codes=%s)",
878 + "Boundary-state put failed for request %s: %d/%d keys failed "
879 + "(codes=%s)",
880 req_meta.req_id,
881 len(failed),
882 len(keys),
@@ -818,7 +889,7 @@ class KVCacheStoreSendingThread(KVTransferThread):
889 if self._clear_store_pressure():
890 logger.info(
891 "Mooncake CPU/disk offloading pressure cleared after a "
821 - "successful partial-tail batch"
892 + "successful boundary-state batch"
893 )
894 return True
895
@@ -857,10 +928,10 @@ class KVCacheStoreSendingThread(KVTransferThread):
928 )
929 return
930
860 - # Offload the sub-block partial tail (independent of the normal
861 - # block-aligned save, which may be skipped this step).
862 - if req_meta.partial_tail_offloads is not None and not (
863 - self._maybe_offload_partial_tail(req_meta)
931 + # Offload the handed-off mamba boundary states (independent of the
932 + # normal positional save, which may be skipped this step).
933 + if req_meta.boundary_state_offloads is not None and not (
934 + self._maybe_offload_boundary_states(req_meta)
935 ):
936 return
937
@@ -887,6 +958,7 @@ class KVCacheStoreSendingThread(KVTransferThread):
958 # Rotate the stride phase per group to balance load across ranks.
959 put_step = self.group_put_steps[g_idx]
960 put_step_rank = (self.tp_rank + g_idx) % put_step
961 + group_blocks = block_ids_per_group[g_idx]
962 for start, end, block_hash in db.process_tokens(
963 token_len,
964 req_meta.block_hashes,
vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py
+81 -4
@@ -1130,6 +1130,9 @@ class OffloadingConnectorScheduler:
1130 if bid != 0:
1131 self._current_batch_allocated_block_ids.add(bid)
1132
1133 + for copy in scheduler_output.kv_cache_block_copies or ():
1134 + self._current_batch_allocated_block_ids.add(copy.dst_block_id)
1135 +
1136 # Zero out stale block_ids in sliding window groups' pending-store
1137 # positions. Only sliding window groups can have stale entries (blocks
1138 # freed by remove_skipped_blocks then reallocated). Only positions in
@@ -1151,18 +1154,89 @@ class OffloadingConnectorScheduler:
1154 ):
1155 group_state.block_ids[j] = 0
1156
1157 + def _build_aligned_boundary_store_jobs(
1158 + self, handoffs: dict[str, list[tuple[int, int, int]]]
1159 + ) -> dict[int, TransferJob]:
1160 + store_jobs: dict[int, TransferJob] = {}
1161 + num_groups = len(self.config.kv_group_configs)
1162 + for req_id, entries in handoffs.items():
1163 + req_status = self._req_status.get(req_id)
1164 + if req_status is None:
1165 + continue
1166 + req = req_status.req
1167 + max_boundary = self._calc_num_offloadable_tokens(req_status, req.num_tokens)
1168 + for group_idx, block_id, boundary in entries:
1169 + group_config = self.config.kv_group_configs[group_idx]
1170 + if (
1171 + block_id == 0
1172 + or boundary > max_boundary
1173 + or boundary % group_config.tokens_per_chunk != 0
1174 + ):
1175 + continue
1176 +
1177 + key = self._make_boundary_key(req, group_idx, boundary)
1178 + store_output = self.manager.prepare_store([key], req_status.req_context)
1179 + if store_output is None:
1180 + self._connector_stats.increase_counter(
1181 + _ConnectorMetricName.ALLOCATION_FAILURE
1182 + )
1183 + continue
1184 + if not store_output.keys_to_store:
1185 + continue
1186 +
1187 + job_id = self._generate_job_id()
1188 + req_status.transfer_jobs.add(job_id)
1189 + self._block_id_to_pending_jobs.setdefault(block_id, set()).add(job_id)
1190 + self._jobs[job_id] = TransferJobStatus(
1191 + req_id=req_id,
1192 + pending_count=self.config.num_workers,
1193 + keys={key},
1194 + is_store=True,
1195 + fenced_block_ids=[block_id],
1196 + )
1197 + group_sizes = [0] * num_groups
1198 + group_sizes[group_idx] = 1
1199 + block_indices = [0] * num_groups
1200 + block_indices[group_idx] = boundary // group_config.tokens_per_block - 1
1201 + store_jobs[job_id] = TransferJob(
1202 + req_id=req_id,
1203 + src_spec=GPULoadStoreSpec(
1204 + [block_id],
1205 + group_sizes=group_sizes,
1206 + block_indices=block_indices,
1207 + ),
1208 + dst_spec=store_output.store_spec,
1209 + )
1210 + self._events_tracker.record_store(
1211 + req,
1212 + group_config,
1213 + boundary // group_config.tokens_per_chunk - 1,
1214 + key,
1215 + )
1216 + return store_jobs
1217 +
1218 def _build_partial_tail_store_jobs(
1219 self, scheduler_output: SchedulerOutput
1220 ) -> dict[int, TransferJob]:
1157 - handoffs = scheduler_output.partial_tail_offloads
1158 - if not self.config.supports_partial_tail or not handoffs:
1221 + block_state = scheduler_output.kv_connector_block_state
1222 + handoffs = block_state.boundary_state_offloads if block_state else None
1223 + if not handoffs:
1224 return {}
1225
1161 - store_jobs: dict[int, TransferJob] = {}
1226 + store_jobs = self._build_aligned_boundary_store_jobs(handoffs)
1227 + if not self.config.supports_partial_tail:
1228 + return store_jobs
1229 +
1230 for req_id, entries in handoffs.items():
1231 + entries = [
1232 + entry
1233 + for entry in entries
1234 + if entry[2] % self._partial_tail_block_size != 0
1235 + ]
1236 + if not entries:
1237 + continue
1238 req_status = self._req_status.get(req_id)
1239 assert req_status is not None
1165 - assert entries
1240 boundaries = {boundary for _, _, boundary in entries}
1241 assert len(boundaries) == 1
1242 boundary = boundaries.pop()
@@ -1282,6 +1356,9 @@ class OffloadingConnectorScheduler:
1356 group_config, group_state, num_offloadable_tokens
1357 )
1358
1359 + if group_config.requires_cow_source:
1360 + continue
1361 +
1362 start_chunk_idx = group_state.next_stored_chunk_idx
1363 if num_chunks <= start_chunk_idx:
1364 continue
vllm/v1/core/block_pool.py
+4
@@ -716,6 +716,10 @@ class BlockPool:
716 if self.metrics_collector:
717 self.metrics_collector.on_block_accessed(block)
718
719 + def is_block_writable(self, block: KVCacheBlock) -> bool:
720 + """Return whether a block can be mutated by its sole owner."""
721 + return not block.is_null and block.ref_cnt == 1 and block.block_hash is None
722 +
723 def free_blocks(self, ordered_blocks: Iterable[KVCacheBlock]) -> None:
724 """Free a list of blocks. The blocks should be ordered by their
725 eviction priority, where the first block will be evicted first.
vllm/v1/core/kv_cache_manager.py
+12 -28
@@ -189,10 +189,6 @@ class KVCacheManager:
189 tuple(() for _ in range(self.num_kv_cache_groups))
190 )
191
192 - # Off-table cow blocks handed to a KV connector for partial-tail
193 - # offload; pinned until the request's blocks are freed.
194 - self._partial_tail_pins: dict[str, list[KVCacheBlock]] = {}
195 -
192 @property
193 def usage(self) -> float:
194 """Get the KV cache usage.
@@ -575,9 +571,6 @@ class KVCacheManager:
571 Args:
572 request: The request to free the blocks.
573 """
578 - pins = self._partial_tail_pins.pop(request.request_id, None)
579 - if pins:
580 - self.block_pool.free_blocks(pins)
574 self.coordinator.free(request.request_id)
575
576 def remove_skipped_blocks(
@@ -610,14 +603,7 @@ class KVCacheManager:
603 Returns:
604 The request's blocks in allocation order.
605 """
613 - blocks = self.coordinator.pop_blocks_for_free(request.request_id)
614 - # Pins ride the same (possibly deferred) free as the request blocks.
615 - # Preemption may release a pin under a still-queued offload — the same
616 - # exposure normal saves of table blocks already have.
617 - pins = self._partial_tail_pins.pop(request.request_id, None)
618 - if pins:
619 - blocks = pins + blocks
620 - return blocks
606 + return self.coordinator.pop_blocks_for_free(request.request_id)
607
608 def evict_blocks(self, block_ids: set[int]) -> None:
609 """evict blocks from the prefix cache by their block IDs.
@@ -854,18 +840,18 @@ class KVCacheManager:
840 retained_blocks = [block for pair in pending_copies for block in pair]
841 return copies, retained_blocks
842
857 - def take_partial_tail_offloads(self) -> dict[str, list[tuple[int, int, int]]]:
858 - """Drain producer partial-tail offload hand-offs per request.
843 + def take_boundary_state_offloads(
844 + self,
845 + ) -> dict[str, list[tuple[int, int, int]]]:
846 + """Drain this step's boundary-state hand-offs for a KV connector.
847
848 Returns ``{request_id: [(group_id, block_id, boundary_tokens), ...]}``
861 - for the durable boundary blocks of producers' last-prompt-boundary
862 - partial tails. Only mamba "align" groups contribute; empty otherwise.
863 - A KV connector reads the referenced blocks and offloads them so a later
864 - request can hit the sub-block prefix.
865 -
866 - Each handed-off block lives off the request block table, so it is
867 - pinned here and unpinned when the request's blocks are freed — for a
868 - producer with saved tokens, after the connector reports sends done.
849 + for mamba "align" boundary states: the
850 + request's committed boundary-state snapshots and, on a sub-block partial
851 + hit, the CoW copy of its last-prompt-boundary state. Only mamba "align"
852 + groups contribute; empty otherwise. A connector reads the referenced
853 + blocks — never resolving them positionally — and offloads them so a
854 + later request can hit that prefix.
855 """
856 offloads: dict[str, list[tuple[int, int, int]]] = {}
857 for mgr in self.coordinator.single_type_managers:
@@ -874,9 +860,7 @@ class KVCacheManager:
860 group_id,
861 block,
862 boundary_tokens,
877 - ) in mgr.take_pending_partial_tail_offloads():
878 - self.block_pool.touch((block,))
879 - self._partial_tail_pins.setdefault(req_id, []).append(block)
863 + ) in mgr.take_pending_boundary_state_offloads():
864 offloads.setdefault(req_id, []).append(
865 (group_id, block.block_id, boundary_tokens)
866 )
vllm/v1/core/sched/output.py
+12 -5
@@ -205,6 +205,16 @@ class ScheduledEncoderInputStats:
205 output_tokens: int = 0
206
207
208 +@dataclass
209 +class KVConnectorBlockState:
210 + """Scheduler-local block state offered to a producer-side KV connector."""
211 +
212 + # Authoritative current block-table snapshots.
213 + block_ids: dict[str, tuple[list[int], ...]]
214 + # Exact Mamba "align" boundary-state hand-offs.
215 + boundary_state_offloads: dict[str, list[tuple[int, int, int]]]
216 +
217 +
218 @dataclass
219 class SchedulerOutput:
220 # list of the requests that are scheduled for the first time.
@@ -278,11 +288,8 @@ class SchedulerOutput:
288 # CoW copies to apply after zeroing new blocks and before forward.
289 kv_cache_block_copies: list[KVCacheBlockCopy] | None = None
290
281 - # Producer partial-tail offload hand-off for external KV connectors:
282 - # {request_id: [(group_id, block_id, boundary_tokens), ...]} pointing at
283 - # the durable boundary block of a producer's last-prompt-boundary partial
284 - # tail (mamba "align" CoW target). None unless partial hash hits are active.
285 - partial_tail_offloads: dict[str, list[tuple[int, int, int]]] | None = None
291 + # Scheduler-local; always None by the time this reaches a worker.
292 + kv_connector_block_state: KVConnectorBlockState | None = None
293
294 # Dynamic speculative decoding: optimal K chosen by scheduler.
295 # Number of spec tokens to schedule for the next step.
vllm/v1/core/sched/scheduler.py
+31 -15
@@ -42,6 +42,7 @@ from vllm.v1.core.sched.interface import PauseState, SchedulerInterface
42 from vllm.v1.core.sched.output import (
43 CachedRequestData,
44 GrammarOutput,
45 + KVConnectorBlockState,
46 NewRequestData,
47 ScheduledEncoderInputStats,
48 SchedulerOutput,
@@ -1263,20 +1264,32 @@ class Scheduler(SchedulerInterface):
1264 self.prev_step_scheduled_req_ids.clear()
1265 self.prev_step_scheduled_req_ids.update(num_scheduled_tokens.keys())
1266
1266 - # Producer partial-tail hand-off for external KV connectors. Drained
1267 - # before the CoW retentions are released below, so the pin lands while
1268 - # the cow block still holds a retention ref. Without a producer-side
1269 - # connector nothing consumes the hand-off, so skip the drain (and its
1270 - # pin); the manager drops stale entries when the request's blocks are
1271 - # popped for free.
1272 - pending_partial_tail_offloads = None
1273 - if (
1274 - self.connector is not None
1275 - and self.vllm_config.kv_transfer_config is not None
1276 - and self.vllm_config.kv_transfer_config.is_kv_producer
1277 - ):
1278 - pending_partial_tail_offloads = (
1279 - self.kv_cache_manager.take_partial_tail_offloads() or None
1267 + # Mamba "align" boundary states must be handed off with exact block ids;
1268 + # they cannot be reconstructed from a connector's append-only block
1269 + # table. Drained every step so stale offers cannot accumulate.
1270 + boundary_state_offloads = self.kv_cache_manager.take_boundary_state_offloads()
1271 +
1272 + kv_connector_block_state = None
1273 + if self.connector is not None:
1274 + snapshot_req_ids = {req.req_id for req in new_reqs_data}
1275 + snapshot_req_ids.update(
1276 + req_id
1277 + for req_id, block_ids in zip(
1278 + cached_reqs_data.req_ids,
1279 + cached_reqs_data.new_block_ids,
1280 + strict=True,
1281 + )
1282 + if block_ids
1283 + )
1284 + snapshot_req_ids.update(
1285 + req_id for req_id in boundary_state_offloads if req_id in self.requests
1286 + )
1287 + kv_connector_block_state = KVConnectorBlockState(
1288 + block_ids={
1289 + req_id: self.kv_cache_manager.get_block_ids(req_id)
1290 + for req_id in snapshot_req_ids
1291 + },
1292 + boundary_state_offloads=boundary_state_offloads,
1293 )
1294
1295 kv_cache_block_copies, cow_retained_blocks = (
@@ -1325,7 +1338,7 @@ class Scheduler(SchedulerInterface):
1338 new_block_ids_to_zero=self._get_new_block_ids_to_zero(),
1339 has_sync_kv_loads=has_sync_kv_loads,
1340 kv_cache_block_copies=pending_kv_cache_block_copies,
1328 - partial_tail_offloads=pending_partial_tail_offloads,
1341 + kv_connector_block_state=kv_connector_block_state,
1342 num_spec_tokens_to_schedule=num_spec_tokens_to_schedule,
1343 ec_manager_metadata=self.encoder_cache_manager.get_manager_metadata(),
1344 )
@@ -1345,6 +1358,9 @@ class Scheduler(SchedulerInterface):
1358 )
1359 scheduler_output.ec_connector_metadata = ec_meta
1360
1361 + # Connector-only block state must not be dispatched to workers.
1362 + scheduler_output.kv_connector_block_state = None
1363 +
1364 # Advance the fence only for non-empty steps (those that actually
1365 # write KV and have their output processed later in update_from_output).
1366 if self.defer_block_free and total_num_scheduled_tokens > 0:
vllm/v1/core/single_type_kv_cache_manager.py
+52 -26
@@ -112,13 +112,14 @@ class SingleTypeKVCacheManager(ABC):
112 # managers (full attention, mamba "align"); harmlessly empty elsewhere.
113 self._partial_hit_reqs: dict[str, tuple[int, KVCacheBlock]] = {}
114 self._pending_cow_copies: list[tuple[KVCacheBlock, KVCacheBlock]] = []
115 - # Partial-tail offload hand-off for external KV connectors: when a
116 - # producer registers its last-prompt-boundary partial tail and the
117 - # durable boundary block is not on the append-only request block table
118 - # (mamba "align" CoW target), record the request, group, block, and
119 - # exact token boundary so a connector can offload it under the right
120 - # hash. Populated only by mamba "align".
121 - self._pending_partial_tail_offloads: list[
115 + # Boundary-state offload hand-off for external KV connectors. A mamba
116 + # "align" block table is not append-only (interior states are
117 + # nulled/freed and speculative blocks relocate in place), so a
118 + # connector cannot resolve its state blocks positionally. Record
119 + # (request, group, block, exact token boundary) for each committed
120 + # boundary state so a connector can offload the right block under the
121 + # right hash. Populated only by mamba "align".
122 + self._pending_boundary_state_offloads: list[
123 tuple[str, int, KVCacheBlock, int]
124 ] = []
125
@@ -385,19 +386,19 @@ class SingleTypeKVCacheManager(ABC):
386 self._pending_cow_copies = []
387 return pending_copies
388
388 - def take_pending_partial_tail_offloads(
389 + def take_pending_boundary_state_offloads(
390 self,
391 ) -> list[tuple[str, int, KVCacheBlock, int]]:
391 - """Drain producer partial-tail hand-offs.
392 + """Drain producer boundary-state hand-offs.
393
394 Entries are ``(req_id, group_id, block, boundary_tokens)``.
395
395 - Only mamba "align" populates this. The block lives off the request
396 - block table, so the caller must pin it until the connector has read
397 - it — nothing else keeps it alive once the CoW retention is released.
396 + Only mamba "align" populates this. The blocks are not kept alive by
397 + the request block table for the whole request, so a caller that reads
398 + them asynchronously must pin them first.
399 """
399 - pending = self._pending_partial_tail_offloads
400 - self._pending_partial_tail_offloads = []
400 + pending = self._pending_boundary_state_offloads
401 + self._pending_boundary_state_offloads = []
402 return pending
403
404 def _apply_cow(
@@ -1292,7 +1293,7 @@ class MambaManager(SingleTypeKVCacheManager):
1293 # Requests that registered their own last-prompt-boundary partial
1294 # tail (producers). On the next step's CoW the boundary state moves
1295 # into a private cow_block; we record that block for connector
1295 - # offload (see _pending_partial_tail_offloads).
1296 + # offload (see _pending_boundary_state_offloads).
1297 self._producer_partial_tail_reqs: dict[str, int] = {}
1298
1299 @classmethod
@@ -1625,13 +1626,12 @@ class MambaManager(SingleTypeKVCacheManager):
1626 )
1627
1628 if blocks_allocated:
1628 - # reuse previous speculative blocks in this step
1629 + # Relocate exclusively owned speculative scratch blocks.
1630 for block_idx in range(
1631 prev_block_len - self.num_speculative_blocks, prev_block_len
1632 ):
1633 if block_idx < num_skipped_blocks:
1633 - req_blocks.append(req_blocks[block_idx])
1634 - req_blocks[block_idx] = self._null_block
1634 + self._relocate_speculative_block(req_blocks, block_idx)
1635 else:
1636 break
1637 num_new_blocks = num_required_blocks - len(req_blocks)
@@ -1664,7 +1664,7 @@ class MambaManager(SingleTypeKVCacheManager):
1664 # This CoW preserved a producer's own boundary
1665 # state in cow_block; hand it to the connector for
1666 # partial-tail offload once the copy has run.
1667 - self._pending_partial_tail_offloads.append(
1667 + self._pending_boundary_state_offloads.append(
1668 (
1669 request_id,
1670 self.kv_cache_group_id,
@@ -1685,17 +1685,31 @@ class MambaManager(SingleTypeKVCacheManager):
1685 returned_blocks.extend(new_blocks)
1686 return returned_blocks
1687
1688 + def _relocate_speculative_block(
1689 + self, req_blocks: list[KVCacheBlock], block_idx: int
1690 + ) -> None:
1691 + block = req_blocks[block_idx]
1692 + assert self.block_pool.is_block_writable(block), (
1693 + "Speculative Mamba blocks must be exclusively owned and unhashed "
1694 + "before relocation"
1695 + )
1696 + req_blocks.append(block)
1697 + req_blocks[block_idx] = self._null_block
1698 +
1699 def pop_blocks_for_free(self, request_id: str) -> list[KVCacheBlock]:
1700 if self.mamba_cache_mode == "align":
1701 self._allocated_block_reqs.discard(request_id)
1702 self.last_state_block_idx.pop(request_id, None)
1703 self._num_checkpoint_blocks.pop(request_id, None)
1704 self._producer_partial_tail_reqs.pop(request_id, None)
1694 - # A hand-off whose request died in this same scheduling pass must
1695 - # not reach the connector: its unpin hook (free) has already run.
1696 - self._pending_partial_tail_offloads = [
1705 + # An offer is only guaranteed to hold committed bytes until the end
1706 + # of the pass that made it. This request's blocks are going back to
1707 + # the pool now, so drop its not-yet-offered hand-offs rather than
1708 + # let a connector claim a block another request may already have
1709 + # been handed.
1710 + self._pending_boundary_state_offloads = [
1711 entry
1698 - for entry in self._pending_partial_tail_offloads
1712 + for entry in self._pending_boundary_state_offloads
1713 if entry[0] != request_id
1714 ]
1715 return super().pop_blocks_for_free(request_id)
@@ -1722,9 +1736,9 @@ class MambaManager(SingleTypeKVCacheManager):
1736 if partial_hash is not None:
1737 self.cached_blocks_this_step.add(partial_hash)
1738 if num_cached_blocks_after > num_cached_blocks_before:
1725 - for block in self.req_to_blocks[request.request_id][
1726 - num_cached_blocks_before:num_cached_blocks_after
1727 - ]:
1739 + blocks = self.req_to_blocks[request.request_id]
1740 + for idx in range(num_cached_blocks_before, num_cached_blocks_after):
1741 + block = blocks[idx]
1742 # Skip null blocks (align-mode skipped states) and blocks that
1743 # were not cached this step — with sparse retention
1744 # (reachable_block_mask) the intermediate state snapshots carry
@@ -1732,6 +1746,18 @@ class MambaManager(SingleTypeKVCacheManager):
1746 if block.is_null or block.block_hash is None:
1747 continue
1748 self.cached_blocks_this_step.add(block.block_hash)
1749 + if self.mamba_cache_mode == "align":
1750 + # Offer every retained boundary with its exact block.
1751 + # The connector filters against its save window, which may
1752 + # extend past the original prompt during resumed prefill.
1753 + self._pending_boundary_state_offloads.append(
1754 + (
1755 + request.request_id,
1756 + self.kv_cache_group_id,
1757 + block,
1758 + (idx + 1) * self.block_size,
1759 + )
1760 + )
1761
1762 def new_step_starts(self) -> None:
1763 self.cached_blocks_this_step.clear()