[KV Connector] Support MooncakeStore with hybrid DCP prefix caching (#53324)

Signed-off-by: wzhao18 <wzhao18.sz@gmail.com> Signed-off-by: wzhao18 <weizha@nvidia.com> Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: Yifan Qiao <yifanqiao@inferact.ai>

Wei Zhao committed Aug 28, 2026 at 22:10 UTC 7fd9cc036e3c0cbf0dfa4d1a9bbd755ed4922031
14 files changed +560 -85
tests/v1/core/test_kv_cache_utils.py
+35
@@ -2334,6 +2334,41 @@ def test_hidden_state_group_preserves_hybrid_prefix_cache_granularity():
2334 ) == (544, 136)
2335
2336
2337 +def test_resolve_dcp_kv_block_size_unwraps_uniform_type_specs():
2338 + attention = FullAttentionSpec(
2339 + block_size=16,
2340 + num_kv_heads=1,
2341 + head_size=64,
2342 + dtype=torch.float16,
2343 + )
2344 + mamba = MambaSpec(
2345 + block_size=16,
2346 + shapes=((1, 1),),
2347 + dtypes=(torch.float16,),
2348 + )
2349 + wrapped_attention = UniformTypeKVCacheSpecs(
2350 + block_size=16,
2351 + kv_cache_specs={"attention.0": attention, "attention.1": attention},
2352 + )
2353 + wrapped_mamba = UniformTypeKVCacheSpecs(
2354 + block_size=16,
2355 + kv_cache_specs={"mamba.0": mamba, "mamba.1": mamba},
2356 + )
2357 +
2358 + assert kv_cache_utils.resolve_dcp_kv_block_size(attention, 4) == 64
2359 + assert kv_cache_utils.resolve_dcp_kv_block_size(wrapped_attention, 4) == 64
2360 + assert kv_cache_utils.resolve_dcp_kv_block_size(mamba, 4) == 16
2361 + assert kv_cache_utils.resolve_dcp_kv_block_size(wrapped_mamba, 4) == 16
2362 +
2363 + scaled_attention = kv_cache_utils.resolve_dcp_kv_cache_spec(wrapped_attention, 4)
2364 + assert scaled_attention.block_size == 64
2365 + assert isinstance(scaled_attention, UniformTypeKVCacheSpecs)
2366 + assert all(
2367 + spec.block_size == 64 for spec in scaled_attention.kv_cache_specs.values()
2368 + )
2369 + assert kv_cache_utils.resolve_dcp_kv_cache_spec(wrapped_mamba, 4) is wrapped_mamba
2370 +
2371 +
2372 def test_multi_run_layer_compact_strides_place_hoisted_heads():
2373 """A layer-compact run region is its own dense allocation: under LHBNC the head
2374 groups sit between the layers and the blocks, so a run's block stride is one head
tests/v1/kv_connector/unit/test_mooncake_store_connector.py
+27 -3
@@ -19,7 +19,9 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store import (
19 worker,
20 )
21 from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
22 + MooncakeLookupResult,
23 MooncakeStoreConnectorMetadata,
24 + TailKeyBoundary,
25 )
26 from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.metrics import (
27 MooncakeStoreConnectorStats,
@@ -121,10 +123,14 @@ def test_worker_methods_delegate_to_store_worker():
123 connector.bind_connector_metadata(metadata)
124
125 connector.register_kv_caches(kv_caches)
126 + connector.start_load_kv(MagicMock())
127 + connector.wait_for_save()
128 result = connector.get_finished(finished_req_ids)
129 invalid_block_ids = connector.get_block_ids_with_load_errors()
130
131 worker.register_kv_caches.assert_called_once_with(kv_caches)
132 + worker.start_load_kv.assert_called_once_with(metadata)
133 + worker.wait_for_save.assert_called_once_with(metadata)
134 worker.get_finished.assert_called_once_with(finished_req_ids, metadata)
135 assert result == ({"req-1"}, {"req-2"})
136 worker.get_block_ids_with_load_errors.assert_called_once_with()
@@ -359,7 +365,9 @@ def test_lookup_key_client_lookup_prepends_typed_tag():
365
366 # Blocking lookup (non_block defaults to False) runs on the executor and
367 # returns the resolved hit length.
362 - assert client.lookup("req0", num_tokens=128, block_hashes=[]) == 5
368 + result = client.lookup("req0", num_tokens=128, block_hashes=[])
369 + assert result is not None
370 + assert result.hit_length == 5
371
372 sent_frames = fake_socket.send_multipart.call_args[0][0]
373 assert sent_frames[0] == protocol.LOOKUP_MSG
@@ -394,7 +402,7 @@ def _poll_lookup(client, req_id, num_tokens=128, block_hashes=(), timeout=5.0):
402 while time.monotonic() < deadline:
403 result = client.lookup(req_id, num_tokens, list(block_hashes), non_block=True)
404 if result is not None:
397 - return result
405 + return result.hit_length
406 time.sleep(0.005)
407 return None
408
@@ -502,11 +510,13 @@ def test_get_num_new_matched_tokens_async_defers_then_reports():
510
511 # Lookup ready with a hit -> report need_to_allocate + async-load flag.
512 hit = 3 * block_size
505 - mock_client.lookup.return_value = hit
513 + boundary = TailKeyBoundary(group_id=0, num_tokens=4 * block_size)
514 + mock_client.lookup.return_value = MooncakeLookupResult(hit, (boundary,))
515 need, load_async = sched.get_num_new_matched_tokens(request, 0)
516 assert need == hit
517 assert load_async == sched.load_async
518 assert sched.load_specs["r1"].kvpool_cached_tokens == hit
519 + assert sched.load_specs["r1"].tail_key_boundaries == (boundary,)
520
521
522 def test_protocol_tags_are_distinct_and_non_empty():
@@ -519,6 +529,20 @@ def test_protocol_tags_are_distinct_and_non_empty():
529 assert protocol.RESP_OK != protocol.RESP_ERR
530
531
532 +def test_lookup_response_round_trip_preserves_tail_keys():
533 + result = MooncakeLookupResult(
534 + hit_length=20,
535 + tail_key_boundaries=(
536 + TailKeyBoundary(group_id=0, num_tokens=24),
537 + TailKeyBoundary(group_id=1, num_tokens=20),
538 + ),
539 + )
540 +
541 + assert protocol.decode_lookup_response(protocol.encode_lookup_response(result)) == (
542 + result
543 + )
544 +
545 +
546 def test_scheduler_reset_connector_cache_invokes_connector_reset():
547 """Cascade test: Scheduler.reset_prefix_cache(reset_connector=True)
548 cascades into MooncakeStoreConnector.reset_cache without dragging in
tests/v1/kv_connector/unit/test_mooncake_store_hma_e2e.py
+4 -4
@@ -254,10 +254,10 @@ def test_e2e_swa_plus_full_save_then_lookup_hits():
254 worker.store = store
255
256 # Both groups stored all 4 blocks -> full hit.
257 - assert worker.lookup(num_tokens=65, block_hashes=hs) == 64
257 + assert worker.lookup(num_tokens=65, block_hashes=hs).hit_length == 64
258 # Exact-multiple prompt: the full hit is re-derived one block lower,
259 # where both groups' stored blocks still cover the SWA window.
260 - assert worker.lookup(num_tokens=64, block_hashes=hs) == 48
260 + assert worker.lookup(num_tokens=64, block_hashes=hs).hit_length == 48
261
262 # Evict SWA's first two blocks (outside its window of 32 tokens = 2 blocks).
263 swa_keys_outside_window = [
@@ -270,12 +270,12 @@ def test_e2e_swa_plus_full_save_then_lookup_hits():
270
271 # SWA window=32 -> only last 2 blocks must be present in SWA group.
272 # Full has all 4. Coordinator should still return 64.
273 - assert worker.lookup(num_tokens=65, block_hashes=hs) == 64
273 + assert worker.lookup(num_tokens=65, block_hashes=hs).hit_length == 64
274 # Exact-multiple prompt after eviction: the boundary one block lower
275 # needs SWA block 1, which is gone — no usable stored boundary remains
276 # (the pre-fix arithmetic clamp would have returned 48 and livelocked
277 # on load failure -> recompute -> same lookup).
278 - assert worker.lookup(num_tokens=64, block_hashes=hs) == 0
278 + assert worker.lookup(num_tokens=64, block_hashes=hs).hit_length == 0
279
280
281 def test_recv_skips_swa_blocks_before_window():
tests/v1/kv_connector/unit/test_mooncake_store_scheduler.py
+3 -2
@@ -7,6 +7,7 @@ import pytest
7
8 from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
9 LoadSpec,
10 + MooncakeLookupResult,
11 MooncakeStoreWorkerMetadata,
12 ReqMeta,
13 RequestTracker,
@@ -702,9 +703,9 @@ class _StubLookupClient:
703 num_tokens: int,
704 block_hashes: list[bytes],
705 non_block: bool = False,
705 - ) -> int:
706 + ) -> MooncakeLookupResult:
707 self.num_tokens.append(num_tokens)
707 - return self._hit_tokens
708 + return MooncakeLookupResult(self._hit_tokens)
709
710
711 def test_full_external_hit_keeps_kvpool_cached_tokens_block_aligned():
tests/v1/kv_connector/unit/test_mooncake_store_worker.py
+249 -13
@@ -31,8 +31,10 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
31 ChunkedTokenDatabase,
32 KeyMetadata,
33 LoadSpec,
34 + MooncakeLookupResult,
35 PoolKey,
36 ReqMeta,
37 + TailKeyBoundary,
38 )
39 from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.metrics import (
40 MooncakeStoreConnectorStats,
@@ -150,6 +152,7 @@ def _make_load_req(
152 *,
153 token_len: int,
154 vllm_cached_tokens: int = 0,
155 + tail_key_boundaries: tuple[TailKeyBoundary, ...] = (),
156 ) -> ReqMeta:
157 return ReqMeta(
158 req_id=req_id,
@@ -161,6 +164,7 @@ def _make_load_req(
164 kvpool_cached_tokens=token_len,
165 can_load=True,
166 token_len=token_len,
167 + tail_key_boundaries=tail_key_boundaries,
168 ),
169 )
170
@@ -305,15 +309,16 @@ def _patch_worker_runtime(
309 local_ip: str = "10.0.0.7",
310 tp_rank: int = 0,
311 tp_size: int = 1,
312 + pcp_size: int = 1,
313 dcp_size: int = 1,
314 ) -> None:
310 - single_rank_group = SimpleNamespace(world_size=1, rank_in_group=0)
315 + pcp_group = SimpleNamespace(world_size=pcp_size, rank_in_group=0)
316 # DCP groups are contiguous splits of the TP group (see
317 # parallel_state.py), so dcp_rank == tp_rank % dcp_size.
318 dcp_group = SimpleNamespace(world_size=dcp_size, rank_in_group=tp_rank % dcp_size)
319 monkeypatch.setattr(worker, "get_tensor_model_parallel_rank", lambda: tp_rank)
320 monkeypatch.setattr(worker, "get_tensor_model_parallel_world_size", lambda: tp_size)
316 - monkeypatch.setattr(worker, "get_pcp_group", lambda: single_rank_group)
321 + monkeypatch.setattr(worker, "get_pcp_group", lambda: pcp_group)
322 monkeypatch.setattr(worker, "get_dcp_group", lambda: dcp_group)
323 monkeypatch.setattr(worker, "get_ip", lambda: local_ip)
324 monkeypatch.setattr(worker, "LookupKeyServer", MagicMock())
@@ -1433,6 +1438,29 @@ def test_recv_thread_uses_single_batch_when_no_disk_offload_budget(monkeypatch):
1438 store.batch_get_replica_desc.assert_not_called()
1439
1440
1441 +def test_recv_thread_keys_chunk_by_lookup_selected_boundary():
1442 + store = MagicMock()
1443 + store.batch_get_into_multi_buffers.return_value = [256, 256]
1444 + thread = _make_store_recving_thread(store)
1445 +
1446 + # 32-token hit over 16-token chunks: chunk 1 would default to the hash at
1447 + # the 32-token boundary (a1); the lookup matched the 48-token one (a2).
1448 + req = _make_load_req(
1449 + "req-a",
1450 + [b"a0", b"a1", b"a2"],
1451 + token_len=32,
1452 + tail_key_boundaries=(TailKeyBoundary(group_id=0, num_tokens=48),),
1453 + )
1454 +
1455 + thread._handle_request(req)
1456 +
1457 + keys = store.batch_get_into_multi_buffers.call_args.args[0]
1458 + assert keys == [
1459 + "test-model@tp_rank:0@pcp0@dcp0@pp_rank:0@group:0@6130",
1460 + "test-model@tp_rank:0@pcp0@dcp0@pp_rank:0@group:0@6132",
1461 + ]
1462 +
1463 +
1464 def test_recv_thread_logs_tier_summary_when_enabled(monkeypatch, caplog_vllm):
1465 monkeypatch.setenv("VLLM_MOONCAKE_STORE_TIER_LOG", "1")
1466 caplog_vllm.set_level(logging.INFO, logger=worker.logger.name)
@@ -1799,6 +1827,59 @@ def test_requester_worker_init_builds_replicate_config_for_preferred_segment(
1827 assert w.store_replicate_config.preferred_segment == "10.0.0.7:50053"
1828
1829
1830 +def test_worker_scales_uniform_attention_group_under_dcp(tmp_path, monkeypatch):
1831 + from vllm.v1.kv_cache_interface import (
1832 + KVCacheConfig,
1833 + UniformTypeKVCacheSpecs,
1834 + )
1835 +
1836 + store = MagicMock()
1837 + store.setup.return_value = 0
1838 + _install_fake_mooncake(monkeypatch, store)
1839 + _patch_worker_runtime(monkeypatch, tp_size=4, dcp_size=4)
1840 + monkeypatch.setenv(
1841 + "MOONCAKE_CONFIG_PATH",
1842 + _write_mooncake_config(
1843 + tmp_path,
1844 + {
1845 + "metadata_server": "http://metadata/endpoint",
1846 + "protocol": "tcp",
1847 + "device_name": "",
1848 + "master_server_address": "10.0.0.7:50051",
1849 + },
1850 + ),
1851 + )
1852 + inner_specs = {
1853 + "layer0": FullAttentionSpec(
1854 + block_size=16, num_kv_heads=8, head_size=64, dtype=None
1855 + ),
1856 + "layer1": FullAttentionSpec(
1857 + block_size=16, num_kv_heads=4, head_size=64, dtype=None
1858 + ),
1859 + }
1860 + uniform_spec = UniformTypeKVCacheSpecs(
1861 + block_size=16,
1862 + kv_cache_specs=inner_specs,
1863 + )
1864 + kv_cache_config = KVCacheConfig(
1865 + num_blocks=10,
1866 + kv_cache_tensors=[],
1867 + kv_cache_groups=[KVCacheGroupSpec(list(inner_specs), uniform_spec)],
1868 + )
1869 +
1870 + w = worker.MooncakeStoreWorker(
1871 + _make_vllm_config(decode_context_parallel_size=4),
1872 + kv_cache_config,
1873 + )
1874 +
1875 + scaled_spec = w._kv_cache_groups[0].kv_cache_spec
1876 + assert isinstance(scaled_spec, UniformTypeKVCacheSpecs)
1877 + assert scaled_spec.block_size == 64
1878 + assert all(spec.block_size == 64 for spec in scaled_spec.kv_cache_specs.values())
1879 + assert w.coord.attention_groups[0].spec.block_size == 64
1880 + assert w.token_dbs[0].block_size == 64
1881 +
1882 +
1883 @pytest.mark.parametrize("dcp_size", [1, 4])
1884 def test_worker_put_striding_covers_every_rank_get_namespace(
1885 tmp_path, monkeypatch, dcp_size
@@ -2668,12 +2749,12 @@ def test_lookup_rejects_boundary_missing_one_mamba_shard():
2749 # 33 tokens for two 16-token blocks: the hit stops below the request end,
2750 # so the full-hit re-derivation stays out of the shard accounting.
2751 worker.store.batch_is_exist.side_effect = lambda keys: [1] * len(keys)
2671 - assert worker.lookup(33, [b"h0", b"h1"]) == 32
2752 + assert worker.lookup(33, [b"h0", b"h1"]).hit_length == 32
2753
2754 worker.store.batch_is_exist.side_effect = lambda keys: [
2755 0 if "tp_rank:1" in k and "group:1" in k else 1 for k in keys
2756 ]
2676 - assert worker.lookup(33, [b"h0", b"h1"]) == 0
2757 + assert worker.lookup(33, [b"h0", b"h1"]).hit_length == 0
2758
2759
2760 def test_lookup_requires_all_dcp_rank_namespaces():
@@ -2684,7 +2765,7 @@ def test_lookup_requires_all_dcp_rank_namespaces():
2765 _refresh_group_tp_replication_factors(worker)
2766 worker.store.batch_is_exist.return_value = [1, 1, 0, 1]
2767
2687 - assert worker.lookup(16, [b"a0"]) == 0
2768 + assert worker.lookup(16, [b"a0"]).hit_length == 0
2769 assert worker.store.batch_is_exist.call_args.args[0] == [
2770 "test-model@tp_rank:0@pcp0@dcp0@pp_rank:0@group:0@6130",
2771 "test-model@tp_rank:1@pcp0@dcp1@pp_rank:0@group:0@6130",
@@ -2696,7 +2777,7 @@ def test_lookup_requires_all_dcp_rank_namespaces():
2777 def test_lookup_partial_prefix_returns_first_hit_length():
2778 worker = _make_bare_worker()
2779 worker.store.batch_is_exist.return_value = [1, 1, 0]
2699 - assert worker.lookup(48, [b"a0", b"a1", b"a2"]) == 32
2780 + assert worker.lookup(48, [b"a0", b"a1", b"a2"]).hit_length == 32
2781
2782
2783 def test_lookup_partial_tail_uses_hash_alignment():
@@ -2736,7 +2817,7 @@ def test_lookup_partial_tail_uses_hash_alignment():
2817 _refresh_group_tp_replication_factors(worker)
2818 worker.store.batch_is_exist.return_value = [0, 0, 1, 0, 0, 1]
2819
2739 - assert worker.lookup(13, [b"h0", b"h1", b"h2"]) == 12
2820 + assert worker.lookup(13, [b"h0", b"h1", b"h2"]).hit_length == 12
2821
2822
2823 def test_lookup_full_hit_reuses_existing_boundary():
@@ -2744,7 +2825,10 @@ def test_lookup_full_hit_reuses_existing_boundary():
2825 worker = _make_bare_worker(block_size=16)
2826 worker.store.batch_is_exist.return_value = [1, 1]
2827
2747 - assert worker.lookup(32, [b"h0", b"h1"]) == 16
2828 + assert worker.lookup(32, [b"h0", b"h1"]) == MooncakeLookupResult(
2829 + hit_length=16,
2830 + tail_key_boundaries=(TailKeyBoundary(group_id=0, num_tokens=16),),
2831 + )
2832 assert worker.store.batch_is_exist.call_count == 1
2833
2834
@@ -2765,10 +2849,144 @@ def test_lookup_full_hit_with_eagle_pops_once_not_twice():
2849 # 64-token exact-multiple prompt, all 4 blocks stored: one eagle pop
2850 # gives 48; a spurious re-derivation (anchored at 48) would pop again
2851 # and return 32.
2768 - assert worker.lookup(64, [b"h0", b"h1", b"h2", b"h3"]) == 48
2852 + assert worker.lookup(64, [b"h0", b"h1", b"h2", b"h3"]).hit_length == 48
2853 assert worker.store.batch_is_exist.call_count == 1
2854
2855
2856 +def test_lookup_plan_resolves_group_tail_keys_from_existing_hashes():
2857 + from vllm.v1.kv_cache_interface import (
2858 + FullAttentionSpec,
2859 + KVCacheGroupSpec,
2860 + MambaSpec,
2861 + )
2862 +
2863 + worker = _make_bare_worker(block_size=16)
2864 + full = FullAttentionSpec(block_size=16, num_kv_heads=8, head_size=64, dtype=None)
2865 + mamba = MambaSpec(
2866 + block_size=16,
2867 + shapes=((1, 1),),
2868 + dtypes=(torch.float32,),
2869 + mamba_cache_mode="align",
2870 + )
2871 + worker._kv_cache_groups = [
2872 + KVCacheGroupSpec(["full"], full),
2873 + KVCacheGroupSpec(["mamba"], mamba),
2874 + ]
2875 + worker.hash_block_size = 4
2876 + worker.token_dbs = [
2877 + ChunkedTokenDatabase(
2878 + KeyMetadata("test-model", 0, 0, 0, 0, group_id=group_id),
2879 + block_size=16,
2880 + hash_block_size=4,
2881 + )
2882 + for group_id in range(2)
2883 + ]
2884 + worker.coord = mooncake_store_worker.MooncakeStoreCoordinator(
2885 + worker._kv_cache_groups,
2886 + scheduler_block_size=16,
2887 + hash_block_size=4,
2888 + use_eagle=True,
2889 + )
2890 + _refresh_group_tp_replication_factors(worker)
2891 + hashes = [BlockHash(f"h{i}".encode()) for i in range(6)]
2892 + present = {
2893 + (0, bytes(hashes[3])),
2894 + (0, bytes(hashes[5])),
2895 + (1, bytes(hashes[4])),
2896 + # A later Mamba state also exists, but its exact hit-boundary hash
2897 + # must win and therefore needs no override.
2898 + (1, bytes(hashes[5])),
2899 + }
2900 +
2901 + def exists(keys):
2902 + return [
2903 + int(
2904 + any(
2905 + f"@group:{group_id}@{block_hash.hex()}" in key
2906 + for group_id, block_hash in present
2907 + )
2908 + )
2909 + for key in keys
2910 + ]
2911 +
2912 + worker.store.batch_is_exist.side_effect = exists
2913 +
2914 + result = worker.lookup(25, hashes)
2915 +
2916 + # The eagle drop trims the hit to 20 tokens, but the block that survives
2917 + # truncation is the one keyed at the 24-token boundary (hashes[5]), which
2918 + # the load path cannot derive from hit_length.
2919 + assert result.hit_length == 20
2920 + assert result.tail_key_boundaries == (
2921 + TailKeyBoundary(group_id=0, num_tokens=24),
2922 + TailKeyBoundary(group_id=1, num_tokens=20),
2923 + )
2924 +
2925 +
2926 +def test_lookup_plan_recovers_tail_key_after_multi_chunk_convergence():
2927 + from vllm.v1.kv_cache_interface import (
2928 + FullAttentionSpec,
2929 + KVCacheGroupSpec,
2930 + MambaSpec,
2931 + )
2932 +
2933 + worker = _make_bare_worker(block_size=16)
2934 + full = FullAttentionSpec(block_size=16, num_kv_heads=8, head_size=64, dtype=None)
2935 + mamba = MambaSpec(
2936 + block_size=16,
2937 + shapes=((1, 1),),
2938 + dtypes=(torch.float32,),
2939 + mamba_cache_mode="align",
2940 + )
2941 + worker._kv_cache_groups = [
2942 + KVCacheGroupSpec(["full"], full),
2943 + KVCacheGroupSpec(["mamba"], mamba),
2944 + ]
2945 + worker.hash_block_size = 4
2946 + worker.token_dbs = [
2947 + ChunkedTokenDatabase(
2948 + KeyMetadata("test-model", 0, 0, 0, 0, group_id=group_id),
2949 + block_size=16,
2950 + hash_block_size=4,
2951 + )
2952 + for group_id in range(2)
2953 + ]
2954 + worker.coord = mooncake_store_worker.MooncakeStoreCoordinator(
2955 + worker._kv_cache_groups,
2956 + scheduler_block_size=16,
2957 + hash_block_size=4,
2958 + )
2959 + _refresh_group_tp_replication_factors(worker)
2960 + hashes = [BlockHash(f"h{i}".encode()) for i in range(12)]
2961 + present = {
2962 + (0, bytes(hashes[3])),
2963 + (0, bytes(hashes[7])),
2964 + (0, bytes(hashes[11])),
2965 + (1, bytes(hashes[4])),
2966 + }
2967 +
2968 + def exists(keys):
2969 + return [
2970 + int(
2971 + any(
2972 + f"@group:{group_id}@{block_hash.hex()}" in key
2973 + for group_id, block_hash in present
2974 + )
2975 + )
2976 + for key in keys
2977 + ]
2978 +
2979 + worker.store.batch_is_exist.side_effect = exists
2980 +
2981 + result = worker.lookup(49, hashes)
2982 +
2983 + assert result.hit_length == 20
2984 + assert result.tail_key_boundaries == (
2985 + TailKeyBoundary(group_id=0, num_tokens=32),
2986 + TailKeyBoundary(group_id=1, num_tokens=20),
2987 + )
2988 +
2989 +
2990 def test_lookup_full_hit_swa_degrades_when_no_stored_boundary_is_usable():
2991 """The motivating livelock: the producer of a 64-token prompt stored
2992 only its SWA tail window (blocks 2-3). The old arithmetic clamp turned
@@ -2790,7 +3008,7 @@ def test_lookup_full_hit_swa_degrades_when_no_stored_boundary_is_usable():
3008 )
3009 worker.store.batch_is_exist.return_value = [0, 0, 1, 1]
3010
2793 - assert worker.lookup(64, [b"h0", b"h1", b"h2", b"h3"]) == 0
3011 + assert worker.lookup(64, [b"h0", b"h1", b"h2", b"h3"]).hit_length == 0
3012 assert worker.store.batch_is_exist.call_count == 1
3013
3014
@@ -2811,7 +3029,7 @@ def test_lookup_swa_single_group_returns_full_when_tail_window_present():
3029 hash_block_size=worker.hash_block_size,
3030 )
3031 worker.store.batch_is_exist.return_value = [0, 0, 1, 1]
2814 - assert worker.lookup(65, [b"h0", b"h1", b"h2", b"h3"]) == 64
3032 + assert worker.lookup(65, [b"h0", b"h1", b"h2", b"h3"]).hit_length == 64
3033
3034
3035 def test_lookup_checks_all_potential_swa_hit_boundaries():
@@ -2865,7 +3083,7 @@ def test_lookup_checks_all_potential_swa_hit_boundaries():
3083 [f"h{i}".encode() for i in range(12)],
3084 )
3085
2868 - assert result == 32
3086 + assert result.hit_length == 32
3087 keys = worker.store.batch_is_exist.call_args.args[0]
3088 assert len(keys) == 6
3089 swa_keys = [key for key in keys if "@group:1@" in key]
@@ -2949,6 +3167,24 @@ def test_consumer_starts_send_thread_only_when_put_is_enabled(save_decode_cache)
3167 assert (worker.kv_send_thread is not None) == save_decode_cache
3168
3169
3170 +def test_start_load_kv_queues_async_load():
3171 + w = _make_bare_worker()
3172 + req = _make_load_req(
3173 + "load-req",
3174 + [b"h0", b"h1"],
3175 + token_len=32,
3176 + )
3177 + assert req.load_spec is not None
3178 + req.load_spec.token_len = 0
3179 + meta = mooncake_store_worker.MooncakeStoreConnectorMetadata(set(), set())
3180 + meta.add_request(req)
3181 +
3182 + w.start_load_kv(meta)
3183 +
3184 + assert req.load_spec.token_len == 32
3185 + assert w.recv_request_queue.get_nowait() is req
3186 +
3187 +
3188 def test_putting_consumer_queues_decode_save():
3189 w = _make_bare_worker(kv_role="kv_consumer", save_decode_cache=True)
3190 send_thread = MagicMock()
@@ -3385,7 +3621,7 @@ def test_lookup_records_mooncake_metrics():
3621 result = worker.lookup(33, [b"a0", b"a1"])
3622 stats = worker.get_kv_connector_stats()
3623
3388 - assert result == 32
3624 + assert result.hit_length == 32
3625 assert isinstance(stats, MooncakeStoreConnectorStats)
3626 assert len(stats.data["lookup_exists"]) == 1
3627 assert stats.data["lookup_exists"][0]["num_keys"] == 2
tests/v1/kv_connector/unit/utils.py
+2
@@ -465,6 +465,7 @@ def make_kv_cache_config(
465 mamba_enabled: bool = False,
466 sw_size: int = 128,
467 num_blocks: int = 100,
468 + mamba_cache_mode: Literal["all", "align", "none"] = "none",
469 ) -> KVCacheConfig:
470 kv_cache_groups = [
471 KVCacheGroupSpec(
@@ -498,6 +499,7 @@ def make_kv_cache_config(
499 block_size=block_size,
500 shapes=((16,), (16,)),
501 dtypes=(torch.float16,),
502 + mamba_cache_mode=mamba_cache_mode,
503 ),
504 )
505 )
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/connector.py
+2 -5
@@ -109,11 +109,8 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
109 f"{cache_block_size} (mamba_cache_mode != 'align')"
110 )
111 pcp = vllm_config.parallel_config.prefill_context_parallel_size
112 - dcp = vllm_config.parallel_config.decode_context_parallel_size
113 - if len(kv_cache_config.transfer_groups) > 1 and pcp * dcp > 1:
114 - unsupported.append(
115 - f"PCP/DCP > 1 (pcp={pcp}, dcp={dcp}) with hybrid attention"
116 - )
112 + if len(kv_cache_config.transfer_groups) > 1 and pcp > 1:
113 + unsupported.append(f"PCP > 1 (pcp={pcp}) with hybrid attention")
114 if unsupported:
115 raise ValueError(
116 "MooncakeStoreConnector does not support: " + "; ".join(unsupported)
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/coordinator.py
+21 -11
@@ -55,6 +55,12 @@ class ExternalCachedBlockPool:
55 return [self._present_block] * len(group_ids)
56 return None
57
58 + def contains(self, group_id: int, block_hash: BlockHash) -> bool:
59 + """Return whether a group has a loadable key for this hash."""
60 + return (
61 + self._exists is not None and (group_id, bytes(block_hash)) in self._exists
62 + )
63 +
64
65 class MooncakeStoreCoordinator:
66 """Mirror of ``HybridKVCacheCoordinator.find_longest_cache_hit`` over an
@@ -67,6 +73,7 @@ class MooncakeStoreCoordinator:
73 hash_block_size: int,
74 use_eagle: bool = False,
75 retention_interval: int | None = None,
76 + dcp_world_size: int = 1,
77 ) -> None:
78 assert all(
79 g.kv_cache_spec.block_size % hash_block_size == 0 for g in kv_cache_groups
@@ -83,7 +90,7 @@ class MooncakeStoreCoordinator:
90 self.hash_block_size = hash_block_size
91 self.lcm_block_size = scheduler_block_size
92 self.enable_partial_hash_hits = partial_hash_hits_enabled(
86 - kv_cache_groups, hash_block_size
93 + kv_cache_groups, hash_block_size, dcp_world_size
94 )
95 self.use_eagle = use_eagle
96 # Mirror vLLM core's KVCacheCoordinator.retention_interval.
@@ -376,10 +383,11 @@ class MooncakeStoreCoordinator:
383 # Truncate full-attention hit_blocks to final converged length;
384 # other specs already trim themselves inside their hit logic. cdiv keeps
385 # the partial tail block when hit_length is not block-aligned.
379 - first_group = self.attention_groups[0]
380 - if isinstance(first_group.spec, FullAttentionSpec):
381 - num_blocks = cdiv(hit_length, first_group.spec.block_size)
382 - for group_id in first_group.group_ids:
386 + for group in self.attention_groups:
387 + if not isinstance(group.spec, FullAttentionSpec):
388 + continue
389 + num_blocks = cdiv(hit_length, group.spec.block_size)
390 + for group_id in group.group_ids:
391 full_blks = hit_blocks_by_group[group_id]
392 assert full_blks is not None
393 del full_blks[num_blocks:]
@@ -398,15 +406,17 @@ def _unwrap_spec(spec: KVCacheSpec) -> KVCacheSpec:
406
407
408 def partial_hash_hits_enabled(
401 - kv_cache_groups: list[KVCacheGroupSpec], hash_block_size: int
409 + kv_cache_groups: list[KVCacheGroupSpec],
410 + hash_block_size: int,
411 + dcp_world_size: int = 1,
412 ) -> bool:
403 - """Mirror of core's ``HybridKVCacheCoordinator.enable_partial_hash_hits``
404 - (its dcp == 1 clause holds: the connector rejects hybrid + DCP/PCP > 1).
405 - Single copy on purpose — scheduler and coordinator must not disagree.
406 - """
413 + """Match core's DCP-aware Mamba partial-hit condition."""
414 return any(
415 isinstance(spec := _unwrap_spec(g.kv_cache_spec), MambaSpec)
416 and spec.mamba_cache_mode == "align"
410 - and spec.block_size > hash_block_size
417 + and (
418 + (dcp_world_size == 1 and spec.block_size > hash_block_size)
419 + or (dcp_world_size > 1 and spec.block_size >= hash_block_size)
420 + )
421 for g in kv_cache_groups
422 )
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/data.py
+34
@@ -300,6 +300,39 @@ class ChunkedTokenDatabase:
300 yield start_idx, end_idx, h
301
302
303 +@dataclass(frozen=True)
304 +class TailKeyBoundary:
305 + """Hash boundary used to key a group's tail block in the store.
306 +
307 + Attributes:
308 + group_id: KV-cache group containing the tail block.
309 + num_tokens: Token boundary whose prefix hash identifies the matched
310 + stored block. The loader uses
311 + ``block_hashes[num_tokens // hash_block_size - 1]`` instead of the
312 + hash implied by ``MooncakeLookupResult.hit_length``. This changes
313 + only the load key, not the reusable prefix.
314 + """
315 +
316 + group_id: int
317 + num_tokens: int
318 +
319 +
320 +@dataclass
321 +class MooncakeLookupResult:
322 + """Lookup result used to build the subsequent load request.
323 +
324 + Attributes:
325 + hit_length: Longest prefix that every KV-cache group can reuse after
326 + their individual cache hits converge.
327 + tail_key_boundaries: Hash boundary used to store each cache group's
328 + tail block when ``hit_length`` does not identify its store key.
329 + There is one entry per group for every nonzero hit.
330 + """
331 +
332 + hit_length: int
333 + tail_key_boundaries: tuple[TailKeyBoundary, ...] = ()
334 +
335 +
336 @dataclass
337 class LoadSpec:
338 """Specification for loading KV cache from external store."""
@@ -308,6 +341,7 @@ class LoadSpec:
341 kvpool_cached_tokens: int
342 can_load: bool
343 token_len: int = 0
344 + tail_key_boundaries: tuple[TailKeyBoundary, ...] = ()
345
346
347 @dataclass
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/protocol.py
+42 -1
@@ -16,7 +16,9 @@ Wire format (REQ/REP over IPC):
16 fixed-size block hash (0 when there are no hashes)
17 frame 3: raw block hashes concatenated back-to-back (each hash_len
18 bytes); the server splits on hash_len
19 - Response: [hit_count: u32 big-endian, 4 bytes]
19 + Response: hit_length (u32 big-endian, first 4 bytes), followed by zero
20 + or more 8-byte tail-key boundaries. Each entry is group_id
21 + (u32), then boundary_tokens (u32).
22
23 msg_type == RESET_MSG:
24 (no payload frames)
@@ -31,6 +33,11 @@ Mirrors the named-tag convention used by the NIXL connector (see
33 ``vllm/distributed/kv_transfer/kv_connector/v1/nixl/metadata.py``).
34 """
35
36 +from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
37 + MooncakeLookupResult,
38 + TailKeyBoundary,
39 +)
40 +
41 # Request message-type tags. Frame 0 of every request.
42 LOOKUP_MSG: bytes = b"lookup"
43 RESET_MSG: bytes = b"reset"
@@ -38,3 +45,37 @@ RESET_MSG: bytes = b"reset"
45 # Single-byte response status codes for admin commands.
46 RESP_OK: bytes = b"\x01"
47 RESP_ERR: bytes = b"\x00"
48 +
49 +# group_id (u32), boundary_tokens (u32).
50 +TAIL_KEY_BOUNDARY_ENTRY_SIZE: int = 8
51 +
52 +
53 +def encode_lookup_response(result: MooncakeLookupResult) -> bytes:
54 + hit_length = result.hit_length.to_bytes(4, "big")
55 + if not result.tail_key_boundaries:
56 + return hit_length
57 +
58 + payload = bytearray(hit_length)
59 + for boundary in result.tail_key_boundaries:
60 + payload.extend(boundary.group_id.to_bytes(4, "big"))
61 + payload.extend(boundary.num_tokens.to_bytes(4, "big"))
62 + return bytes(payload)
63 +
64 +
65 +def decode_lookup_response(payload: bytes) -> MooncakeLookupResult:
66 + if len(payload) < 4 or (len(payload) - 4) % TAIL_KEY_BOUNDARY_ENTRY_SIZE:
67 + raise ValueError("Invalid Mooncake lookup response")
68 +
69 + hit_length = int.from_bytes(payload[:4], "big")
70 + if len(payload) == 4:
71 + return MooncakeLookupResult(hit_length)
72 +
73 + boundaries = []
74 + for offset in range(4, len(payload), TAIL_KEY_BOUNDARY_ENTRY_SIZE):
75 + boundaries.append(
76 + TailKeyBoundary(
77 + group_id=int.from_bytes(payload[offset : offset + 4], "big"),
78 + num_tokens=int.from_bytes(payload[offset + 4 : offset + 8], "big"),
79 + )
80 + )
81 + return MooncakeLookupResult(hit_length, tuple(boundaries))
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/scheduler.py
+7 -3
@@ -76,7 +76,9 @@ class MooncakeStoreScheduler:
76 kv_cache_config, vllm_config
77 )
78 self.enable_partial_hash_hits = partial_hash_hits_enabled(
79 - kv_cache_config.kv_cache_groups, self._hash_block_size
79 + kv_cache_config.kv_cache_groups,
80 + self._hash_block_size,
81 + vllm_config.parallel_config.decode_context_parallel_size,
82 )
83
84 # Per-request state
@@ -115,15 +117,16 @@ class MooncakeStoreScheduler:
117 if request.num_tokens < align:
118 return 0, False
119
118 - num_external_hit_tokens = self.client.lookup(
120 + lookup_result = self.client.lookup(
121 request.request_id,
122 request.num_tokens,
123 request.block_hashes,
124 non_block=self.lookup_async,
125 )
124 - if num_external_hit_tokens is None:
126 + if lookup_result is None:
127 # Lookup not ready yet; scheduler will retry on a later step.
128 return None, False
129 + num_external_hit_tokens = lookup_result.hit_length
130
131 if num_external_hit_tokens < num_computed_tokens:
132 need_to_allocate = 0
@@ -145,6 +148,7 @@ class MooncakeStoreScheduler:
148 vllm_cached_tokens=num_computed_tokens,
149 kvpool_cached_tokens=num_external_hit_tokens,
150 can_load=False,
151 + tail_key_boundaries=lookup_result.tail_key_boundaries,
152 )
153
154 return need_to_allocate, self.load_async
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+98 -32
@@ -44,16 +44,20 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
44 BlobBlockHashes,
45 ChunkedTokenDatabase,
46 KeyMetadata,
47 + MooncakeLookupResult,
48 MooncakeStoreConnectorMetadata,
49 MooncakeStoreWorkerMetadata,
50 PoolKey,
51 ReqMeta,
52 + TailKeyBoundary,
53 )
54 from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.protocol import ( # noqa: E501
55 LOOKUP_MSG,
56 RESET_MSG,
57 RESP_ERR,
58 RESP_OK,
59 + decode_lookup_response,
60 + encode_lookup_response,
61 )
62 from vllm.logger import init_logger
63 from vllm.utils.math_utils import cdiv
@@ -63,11 +67,11 @@ from vllm.v1.attention.backends.utils import NULL_BLOCK_ID
67 from vllm.v1.core.kv_cache_utils import (
68 BlockHash,
69 maybe_convert_block_hash,
70 + resolve_dcp_kv_cache_spec,
71 resolve_kv_cache_block_sizes,
72 )
73 from vllm.v1.kv_cache_interface import (
74 KVCacheConfig,
70 - KVCacheGroupSpec,
75 KVCacheSpec,
76 MambaSpec,
77 MLAAttentionSpec,
@@ -1179,6 +1183,12 @@ class KVCacheStoreRecvingThread(KVTransferThread):
1183 # Skip chunks the consumer's per-group spec wouldn't populate
1184 # locally (e.g. SWA pre-window) even if the producer stored them.
1185 load_mask_per_group = self.coord.load_mask(req_meta.block_hashes, token_len)
1186 + tail_key_boundaries = {
1187 + boundary.group_id: boundary.num_tokens
1188 + for boundary in (
1189 + req_meta.load_spec.tail_key_boundaries # type: ignore[union-attr]
1190 + )
1191 + }
1192
1193 addr_list: list[list[int]] = []
1194 size_list: list[list[int]] = []
@@ -1193,6 +1203,13 @@ class KVCacheStoreRecvingThread(KVTransferThread):
1203 chunk_idx = start // db.block_size
1204 if chunk_idx >= len(mask) or not mask[chunk_idx]:
1205 continue
1206 + boundary_tokens = (
1207 + tail_key_boundaries.get(g_idx) if end == token_len else None
1208 + )
1209 + if boundary_tokens is not None:
1210 + block_hash = req_meta.block_hashes[
1211 + boundary_tokens // db.hash_block_size - 1
1212 + ]
1213 key_list.append(db.key_for(block_hash))
1214 chunks.append((start, end))
1215 g_addrs, g_sizes, g_block_ids = db.prepare_values(
@@ -1494,21 +1511,15 @@ class MooncakeStoreWorker:
1511 )
1512 return
1513
1497 - # Single-group + PCP/DCP > 1: scale the lone group's spec.block_size to
1498 - # self.block_size (= scheduler_block_size) so the coordinator's
1499 - # ``block_size % hash_block_size == 0`` invariant holds.
1500 - groups = list(kv_cache_config.transfer_groups)
1501 - if len(groups) == 1 and groups[0].kv_cache_spec.block_size != self.block_size:
1502 - g = groups[0]
1503 - groups = [
1504 - dataclasses.replace(
1505 - g,
1506 - kv_cache_spec=dataclasses.replace(
1507 - g.kv_cache_spec, block_size=self.block_size
1508 - ),
1509 - )
1510 - ]
1511 - self._kv_cache_groups: list[KVCacheGroupSpec] = groups
1514 + self._kv_cache_groups = [
1515 + dataclasses.replace(
1516 + group,
1517 + kv_cache_spec=resolve_dcp_kv_cache_spec(
1518 + group.kv_cache_spec, self.dcp_size
1519 + ),
1520 + )
1521 + for group in kv_cache_config.transfer_groups
1522 + ]
1523 spec_cfg = getattr(vllm_config, "speculative_config", None)
1524 use_eagle = bool(
1525 spec_cfg.use_eagle()
@@ -1521,6 +1532,7 @@ class MooncakeStoreWorker:
1532 hash_block_size=self.hash_block_size,
1533 use_eagle=use_eagle,
1534 retention_interval=kv_cache_config.prefix_cache_retention_interval,
1535 + dcp_world_size=self.dcp_size,
1536 )
1537 # One ChunkedTokenDatabase per group; addresses populated in
1538 # register_kv_caches once the kv-cache layout is known. Each group's
@@ -1881,7 +1893,9 @@ class MooncakeStoreWorker:
1893 return None
1894 return MooncakeStoreWorkerMetadata(completed_saves=completed_saves)
1895
1884 - def lookup(self, num_tokens: int, block_hashes: Sequence[BlockHash]) -> int:
1896 + def lookup(
1897 + self, num_tokens: int, block_hashes: Sequence[BlockHash]
1898 + ) -> MooncakeLookupResult:
1899 """Check how many prefix tokens exist in the store.
1900
1901 Checks across all rank-specific key namespaces that may be loaded. A
@@ -1889,11 +1903,11 @@ class MooncakeStoreWorker:
1903 the last token is recomputed for sampling.
1904 """
1905 if self._capacity_only:
1892 - return 0
1906 + return MooncakeLookupResult(0)
1907
1908 token_len = self.coord.align_lookup_length(num_tokens)
1909 if not block_hashes or token_len <= 0:
1896 - return 0
1910 + return MooncakeLookupResult(0)
1911
1912 # Build per-(group, hash) candidate keys expanded across rank namespaces.
1913 # candidate_meta stores the (group, hash_bytes) for key slice.
@@ -1934,7 +1948,7 @@ class MooncakeStoreWorker:
1948 candidate_meta.append((g_idx, bytes(h)))
1949
1950 if not candidate_keys:
1937 - return 0
1951 + return MooncakeLookupResult(0)
1952
1953 lookup_start = time.perf_counter()
1954 try:
@@ -1953,7 +1967,7 @@ class MooncakeStoreWorker:
1967 num_failed_keys=len(candidate_keys),
1968 )
1969 logger.error("Remote connection failed in lookup: %s", e)
1956 - return 0
1970 + return MooncakeLookupResult(0)
1971
1972 # A (group, hash) is "present" only when every namespace that will be
1973 # loaded has it (per-group count: sharded groups need every rank's
@@ -1970,7 +1984,7 @@ class MooncakeStoreWorker:
1984 self.hash_block_size,
1985 exists_set,
1986 )
1973 - _masks, hit_length = self.coord.find_longest_cache_hit(
1987 + _, hit_length = self.coord.find_longest_cache_hit(
1988 block_hashes,
1989 token_len,
1990 cached_block_pool,
@@ -1978,13 +1992,63 @@ class MooncakeStoreWorker:
1992 if hit_length >= num_tokens:
1993 usable_length = self.coord.align_lookup_length(num_tokens - 1)
1994 if usable_length <= 0:
1981 - return 0
1982 - _masks, hit_length = self.coord.find_longest_cache_hit(
1995 + return MooncakeLookupResult(0)
1996 + _, hit_length = self.coord.find_longest_cache_hit(
1997 block_hashes,
1998 usable_length,
1999 cached_block_pool,
2000 )
1987 - return hit_length
2001 + return MooncakeLookupResult(
2002 + hit_length,
2003 + self._tail_key_boundaries(
2004 + block_hashes,
2005 + hit_length,
2006 + cached_block_pool,
2007 + ),
2008 + )
2009 +
2010 + def _tail_key_boundaries(
2011 + self,
2012 + block_hashes: Sequence[BlockHash],
2013 + hit_length: int,
2014 + cached_block_pool: ExternalCachedBlockPool,
2015 + ) -> tuple[TailKeyBoundary, ...]:
2016 + """Return the hash boundary used to store each group's tail block.
2017 +
2018 + With fine-grained prefix matching, ``hit_length`` may fall within a
2019 + physical cache block and may not align with the hash boundary used to
2020 + store that block. For each KV-cache group, return the token boundary
2021 + whose hash was used as the store key.
2022 + """
2023 + if hit_length <= 0:
2024 + return ()
2025 +
2026 + boundaries = []
2027 + hit_boundary_hash_idx = hit_length // self.hash_block_size - 1
2028 + for group_id, db in enumerate(self.token_dbs):
2029 + chunk_id = cdiv(hit_length, db.block_size) - 1
2030 + boundary_tokens = hit_length
2031 + contains_hit_boundary = cached_block_pool.contains(
2032 + group_id, block_hashes[hit_boundary_hash_idx]
2033 + )
2034 + if not self.coord.enable_partial_hash_hits:
2035 + assert contains_hit_boundary
2036 + if not contains_hit_boundary:
2037 + next_chunk_hash_idx = min(
2038 + (chunk_id + 1) * db.block_size // self.hash_block_size,
2039 + len(block_hashes),
2040 + )
2041 + for hash_idx in range(hit_boundary_hash_idx + 1, next_chunk_hash_idx):
2042 + if cached_block_pool.contains(group_id, block_hashes[hash_idx]):
2043 + boundary_tokens = (hash_idx + 1) * self.hash_block_size
2044 + break
2045 + else:
2046 + raise AssertionError(
2047 + f"No tail key found for cache group {group_id} at "
2048 + f"hit length {hit_length}"
2049 + )
2050 + boundaries.append(TailKeyBoundary(group_id, boundary_tokens))
2051 + return tuple(boundaries)
2052
2053 def get_kv_events(self) -> list[BlockStored]:
2054 if self.enable_kv_events and self.kv_send_thread is not None:
@@ -2017,7 +2081,7 @@ class LookupKeyServer:
2081 """ZMQ server on worker rank 0 for the LookupKey admin channel.
2082
2083 Handles two request types, tagged at frame 0:
2020 - - ``LOOKUP_MSG``: prefix-cache hit query, returns hit count.
2084 + - ``LOOKUP_MSG``: prefix-cache hit query, returns its load plan.
2085 - ``RESET_MSG``: drains the send thread queue, then runs
2086 ``store.remove_all(force=True)``. Caller must have paused the
2087 scheduler first.
@@ -2054,7 +2118,7 @@ class LookupKeyServer:
2118 blob = all_frames[3].buffer
2119 block_hashes = BlobBlockHashes(blob, hash_len)
2120 result = self.store_worker.lookup(num_tokens, block_hashes)
2057 - self.socket.send(result.to_bytes(4, "big"))
2121 + self.socket.send(encode_lookup_response(result))
2122
2123 elif msg_type == RESET_MSG:
2124 try:
@@ -2114,9 +2178,11 @@ class LookupKeyClient:
2178 self.executor = ThreadPoolExecutor(
2179 max_workers=1, thread_name_prefix="MooncakeLookupClient"
2180 )
2117 - self.futures: dict[str, Future[int]] = {}
2181 + self.futures: dict[str, Future[MooncakeLookupResult]] = {}
2182
2119 - def _lookup(self, num_tokens: int, block_hashes: list[BlockHash]) -> int:
2183 + def _lookup(
2184 + self, num_tokens: int, block_hashes: list[BlockHash]
2185 + ) -> MooncakeLookupResult:
2186 hash_len = len(block_hashes[0]) if block_hashes else 0
2187 all_frames = (
2188 LOOKUP_MSG,
@@ -2126,7 +2192,7 @@ class LookupKeyClient:
2192 )
2193 self.socket.send_multipart(all_frames, copy=False)
2194 resp = self.socket.recv()
2129 - return int.from_bytes(resp, "big")
2195 + return decode_lookup_response(resp)
2196
2197 def lookup(
2198 self,
@@ -2134,7 +2200,7 @@ class LookupKeyClient:
2200 num_tokens: int,
2201 block_hashes: list[BlockHash],
2202 non_block: bool = False,
2137 - ) -> int | None:
2203 + ) -> MooncakeLookupResult | None:
2204 """If non_block is True, will return None until the result is ready,
2205 so the caller retries on a later step."""
2206 future = self.futures.get(req_id)
@@ -2147,7 +2213,7 @@ class LookupKeyClient:
2213 return future.result()
2214 except Exception as e:
2215 logger.error("Async Mooncake lookup failed for %s: %s", req_id, e)
2150 - return 0
2216 + return MooncakeLookupResult(0)
2217 finally:
2218 del self.futures[req_id]
2219
vllm/v1/core/kv_cache_coordinator.py
+7 -7
@@ -867,14 +867,14 @@ class HybridKVCacheCoordinator(KVCacheCoordinator):
867 if is_simple_hybrid:
868 break
869
870 - # Truncate full attention blocks to final hit_length (if present)
871 - first_group = self.attention_groups[0]
872 - if isinstance(first_group.spec, FullAttentionSpec):
873 - group_block_size = self.single_type_managers[
874 - first_group.group_ids[0]
875 - ].block_size
870 + # Truncate every full-attention group (target and draft) blocks
871 + # to final hit_length.
872 + for group in self.attention_groups:
873 + if not isinstance(group.spec, FullAttentionSpec):
874 + continue
875 + group_block_size = self.single_type_managers[group.group_ids[0]].block_size
876 num_blocks = cdiv(hit_length, group_block_size)
877 - for group_id in first_group.group_ids:
877 + for group_id in group.group_ids:
878 if (blks := hit_blocks_by_group[group_id]) is not None:
879 del blks[num_blocks:]
880 hit_length_by_group[group_id] = hit_length
vllm/v1/core/kv_cache_utils.py
+29 -4
@@ -35,6 +35,7 @@ from vllm.v1.kv_cache_interface import (
35 SlidingWindowSpec,
36 UniformTypeKVCacheSpecs,
37 compute_layout_strides,
38 + iter_layer_specs,
39 replace_as,
40 )
41 from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
@@ -647,6 +648,33 @@ def hash_block_tokens(
648 )
649
650
651 +def resolve_dcp_kv_block_size(spec: KVCacheSpec, dcp_world_size: int) -> int:
652 + """Return the token span of a cache block under DCP."""
653 + layer_specs = iter_layer_specs(spec)
654 + if len(layer_specs) > 0 and all(
655 + isinstance(layer_spec, AttentionSpec) for layer_spec in layer_specs
656 + ):
657 + return spec.block_size * dcp_world_size
658 + return spec.block_size
659 +
660 +
661 +def resolve_dcp_kv_cache_spec(spec: KVCacheSpec, dcp_world_size: int) -> KVCacheSpec:
662 + """Return a KV cache spec with block sizes adjusted for DCP."""
663 + block_size = resolve_dcp_kv_block_size(spec, dcp_world_size)
664 + if block_size == spec.block_size:
665 + return spec
666 + if isinstance(spec, UniformTypeKVCacheSpecs):
667 + return replace(
668 + spec,
669 + block_size=block_size,
670 + kv_cache_specs={
671 + name: resolve_dcp_kv_cache_spec(layer_spec, dcp_world_size)
672 + for name, layer_spec in spec.kv_cache_specs.items()
673 + },
674 + )
675 + return replace(spec, block_size=block_size)
676 +
677 +
678 def resolve_kv_cache_block_sizes(
679 kv_cache_config: KVCacheConfig,
680 vllm_config: VllmConfig,
@@ -674,10 +702,7 @@ def resolve_kv_cache_block_sizes(
702 return bs, bs
703
704 group_block_sizes = [
677 - g.kv_cache_spec.block_size * dcp
678 - if isinstance(g.kv_cache_spec, AttentionSpec)
679 - else g.kv_cache_spec.block_size
680 - for g in groups
705 + resolve_dcp_kv_block_size(g.kv_cache_spec, dcp) for g in groups
706 ]
707 scheduler_block_size = math.lcm(*group_block_sizes)
708