[MooncakeStore] Re-derive full external hits on stored boundaries (#49481)
Signed-off-by: Dao Le <Dao007forever@gmail.com> Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: Claude <noreply@anthropic.com>
Dao007forever committed
Jul 23, 2026 at 03:12 UTC
a76df87db89bb87213f60cff817d5789796ee158
8 files changed
+110
-38
tests/v1/kv_connector/unit/test_mooncake_store_connector.py
+3
-3
@@ -410,7 +410,7 @@ def test_lookup_key_client_lookup_prepends_typed_tag():
410
411
# Blocking lookup (non_block defaults to False) runs on the executor and
412
# returns the resolved hit length.
413
- assert client.lookup("req0", token_len=128, block_hashes=[]) == 5
413
+ assert client.lookup("req0", num_tokens=128, block_hashes=[]) == 5
414
415
sent_frames = fake_socket.send_multipart.call_args[0][0]
416
assert sent_frames[0] == protocol.LOOKUP_MSG
@@ -439,11 +439,11 @@ def test_lookup_key_client_reset_uses_typed_protocol():
439
assert client.reset() is False
440
441
442
-def _poll_lookup(client, req_id, token_len=128, block_hashes=(), timeout=5.0):
442
+def _poll_lookup(client, req_id, num_tokens=128, block_hashes=(), timeout=5.0):
443
"""Drive non-blocking lookup until the executor completes it."""
444
deadline = time.monotonic() + timeout
445
while time.monotonic() < deadline:
446
- result = client.lookup(req_id, token_len, list(block_hashes), non_block=True)
446
+ result = client.lookup(req_id, num_tokens, list(block_hashes), non_block=True)
447
if result is not None:
448
return result
449
time.sleep(0.005)
tests/v1/kv_connector/unit/test_mooncake_store_hma_e2e.py
+10
-2
@@ -240,7 +240,10 @@ def test_e2e_swa_plus_full_save_then_lookup_hits():
240
worker.store = store
241
242
# Both groups stored all 4 blocks -> full hit.
243
- assert worker.lookup(token_len=64, block_hashes=hs) == 64
243
+ assert worker.lookup(num_tokens=65, block_hashes=hs) == 64
244
+ # Exact-multiple prompt: the full hit is re-derived one block lower,
245
+ # where both groups' stored blocks still cover the SWA window.
246
+ assert worker.lookup(num_tokens=64, block_hashes=hs) == 48
247
248
# Evict SWA's first two blocks (outside its window of 32 tokens = 2 blocks).
249
swa_keys_outside_window = [
@@ -253,7 +256,12 @@ def test_e2e_swa_plus_full_save_then_lookup_hits():
256
257
# SWA window=32 -> only last 2 blocks must be present in SWA group.
258
# Full has all 4. Coordinator should still return 64.
256
- assert worker.lookup(token_len=64, block_hashes=hs) == 64
259
+ assert worker.lookup(num_tokens=65, block_hashes=hs) == 64
260
+ # Exact-multiple prompt after eviction: the boundary one block lower
261
+ # needs SWA block 1, which is gone — no usable stored boundary remains
262
+ # (the pre-fix arithmetic clamp would have returned 48 and livelocked
263
+ # on load failure -> recompute -> same lookup).
264
+ assert worker.lookup(num_tokens=64, block_hashes=hs) == 0
265
266
267
def test_recv_skips_swa_blocks_before_window():
tests/v1/kv_connector/unit/test_mooncake_store_scheduler.py
+8
-9
@@ -473,27 +473,25 @@ def test_from_request_tracker_no_load_saves_normally():
473
class _StubLookupClient:
474
def __init__(self, hit_tokens: int) -> None:
475
self._hit_tokens = hit_tokens
476
+ self.num_tokens: list[int] = []
477
478
def lookup(
479
self,
480
req_id: str,
480
- token_len: int,
481
+ num_tokens: int,
482
block_hashes: list[bytes],
483
non_block: bool = False,
484
) -> int:
485
+ self.num_tokens.append(num_tokens)
486
return self._hit_tokens
487
488
489
def test_full_external_hit_keeps_kvpool_cached_tokens_block_aligned():
488
- # When the external store hits the entire prompt, scheduler must leave at
489
- # least one token uncomputed for sampling but stay on a block boundary.
490
- # Otherwise the recv-side load mask floors token_len to
491
- # (num_tokens-1)//block_size, the tail partial chunk is dropped, and -- if
492
- # the local cache covers the aligned prefix -- key_list ends up empty
493
- # (ZeroDivisionError in the recv thread's `tp_rank % len(key_list)`).
490
+ # The worker re-derives a full external hit below the request end on an
491
+ # existing boundary, so the scheduler receives the usable aligned hit.
492
scheduler = _make_bare_scheduler()
493
scheduler.load_async = True
496
- scheduler.client = _StubLookupClient(hit_tokens=48) # full hit on 48-token prompt
494
+ scheduler.client = _StubLookupClient(hit_tokens=32)
495
496
request = SimpleNamespace(
497
request_id="req-0",
@@ -510,6 +508,7 @@ def test_full_external_hit_keeps_kvpool_cached_tokens_block_aligned():
508
assert need_to_allocate == 16
509
assert load_async is True
510
load_spec = scheduler.load_specs["req-0"]
511
+ assert scheduler.client.num_tokens == [48]
512
assert load_spec.vllm_cached_tokens == 16
513
assert load_spec.kvpool_cached_tokens == 32
514
assert load_spec.kvpool_cached_tokens % 16 == 0
@@ -522,7 +521,7 @@ def test_full_external_hit_with_full_local_hit_skips_load():
521
# into any block-aligned key.
522
scheduler = _make_bare_scheduler()
523
scheduler.load_async = True
525
- scheduler.client = _StubLookupClient(hit_tokens=48)
524
+ scheduler.client = _StubLookupClient(hit_tokens=32)
525
526
request = SimpleNamespace(
527
request_id="req-0",
tests/v1/kv_connector/unit/test_mooncake_store_worker.py
+57
-2
@@ -1737,6 +1737,61 @@ def test_lookup_partial_prefix_returns_first_hit_length():
1737
assert worker.lookup(48, [b"a0", b"a1", b"a2"]) == 32
1738
1739
1740
+def test_lookup_full_hit_reuses_existing_boundary():
1741
+ """A full hit is re-derived below the request end without another RPC."""
1742
+ worker = _make_bare_worker(block_size=16)
1743
+ worker.store.batch_is_exist.return_value = [1, 1]
1744
+
1745
+ assert worker.lookup(32, [b"h0", b"h1"]) == 16
1746
+ assert worker.store.batch_is_exist.call_count == 1
1747
+
1748
+
1749
+def test_lookup_full_hit_with_eagle_pops_once_not_twice():
1750
+ """Eagle already leaves the last block for the drafter, so a
1751
+ full-prompt re-derivation must never fire for eagle-governed hits:
1752
+ firing would anchor the search one block lower and pop a second
1753
+ block, regressing the hit by an extra producer boundary."""
1754
+ worker = _make_bare_worker(block_size=16)
1755
+ worker.coord = mooncake_store_worker.MooncakeStoreCoordinator(
1756
+ worker._kv_cache_groups,
1757
+ scheduler_block_size=16,
1758
+ hash_block_size=16,
1759
+ use_eagle=True,
1760
+ )
1761
+ worker.store.batch_is_exist.return_value = [1, 1, 1, 1]
1762
+
1763
+ # 64-token exact-multiple prompt, all 4 blocks stored: one eagle pop
1764
+ # gives 48; a spurious re-derivation (anchored at 48) would pop again
1765
+ # and return 32.
1766
+ assert worker.lookup(64, [b"h0", b"h1", b"h2", b"h3"]) == 48
1767
+ assert worker.store.batch_is_exist.call_count == 1
1768
+
1769
+
1770
+def test_lookup_full_hit_swa_degrades_when_no_stored_boundary_is_usable():
1771
+ """The motivating livelock: the producer of a 64-token prompt stored
1772
+ only its SWA tail window (blocks 2-3). The old arithmetic clamp turned
1773
+ the full hit into 48, whose SWA window needs the never-written block 1,
1774
+ so every load failed and the recompute re-entered the same lookup. The
1775
+ re-derivation must report that no stored boundary below the request end
1776
+ is usable."""
1777
+ from vllm.v1.kv_cache_interface import KVCacheGroupSpec, SlidingWindowSpec
1778
+
1779
+ worker = _make_bare_worker(block_size=16)
1780
+ swa = SlidingWindowSpec(
1781
+ block_size=16, num_kv_heads=8, head_size=64, dtype=None, sliding_window=32
1782
+ )
1783
+ worker._kv_cache_groups = [KVCacheGroupSpec(["layer0"], swa)]
1784
+ worker.coord = mooncake_store_worker.MooncakeStoreCoordinator(
1785
+ worker._kv_cache_groups,
1786
+ scheduler_block_size=worker.hash_block_size,
1787
+ hash_block_size=worker.hash_block_size,
1788
+ )
1789
+ worker.store.batch_is_exist.return_value = [0, 0, 1, 1]
1790
+
1791
+ assert worker.lookup(64, [b"h0", b"h1", b"h2", b"h3"]) == 0
1792
+ assert worker.store.batch_is_exist.call_count == 1
1793
+
1794
+
1795
def test_lookup_swa_single_group_returns_full_when_tail_window_present():
1796
"""Single-SWA, sliding_window=32 (= 2 blocks): producer stored only the
1797
tail. Coordinator-driven lookup returns full prefix even though the
@@ -1754,7 +1809,7 @@ def test_lookup_swa_single_group_returns_full_when_tail_window_present():
1809
hash_block_size=worker.hash_block_size,
1810
)
1811
worker.store.batch_is_exist.return_value = [0, 0, 1, 1]
1757
- assert worker.lookup(64, [b"h0", b"h1", b"h2", b"h3"]) == 64
1812
+ assert worker.lookup(65, [b"h0", b"h1", b"h2", b"h3"]) == 64
1813
1814
1815
def test_lookup_checks_all_potential_swa_hit_boundaries():
@@ -2219,7 +2274,7 @@ def test_lookup_records_mooncake_metrics():
2274
worker = _make_bare_worker()
2275
worker.store.batch_is_exist.return_value = [1, 1]
2276
2222
- result = worker.lookup(32, [b"a0", b"a1"])
2277
+ result = worker.lookup(33, [b"a0", b"a1"])
2278
stats = worker.get_kv_connector_stats()
2279
2280
assert result == 32
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/coordinator.py
+3
@@ -93,6 +93,9 @@ class MooncakeStoreCoordinator:
93
self.eagle_group_ids = set(range(len(kv_cache_groups)))
94
self._verify_and_split_kv_cache_groups()
95
96
+ def align_lookup_length(self, length: int) -> int:
97
+ return length // self.lcm_block_size * self.lcm_block_size
98
+
99
def _verify_and_split_kv_cache_groups(self) -> None:
100
"""Mirrors KVCacheCoordinator.verify_and_split_kv_cache_groups but
101
dispatches via spec_manager_map (we don't allocate managers).
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/protocol.py
+2
-1
@@ -10,7 +10,8 @@ Wire format (REQ/REP over IPC):
10
Request: [msg_type: bytes] [payload_frames...]
11
12
msg_type == LOOKUP_MSG:
13
- frame 1: token_len (u32 big-endian, 4 bytes)
13
+ frame 1: num_tokens (u32 big-endian, 4 bytes); the worker derives
14
+ the aligned lookup length
15
frame 2: hash_len (u16 big-endian, 2 bytes) — byte length of each
16
fixed-size block hash (0 when there are no hashes)
17
frame 3: raw block hashes concatenated back-to-back (each hash_len
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/scheduler.py
+2
-12
@@ -80,14 +80,12 @@ class MooncakeStoreScheduler:
80
Returns ``(None, False)`` when an async lookup is still in flight,
81
signaling the scheduler to retry this request on a later step.
82
"""
83
- # Look up against the full prefill range, not just the prompt.
84
- token_len = request.num_tokens // self._block_size * self._block_size
85
- if token_len < self._block_size:
83
+ if request.num_tokens < self._block_size:
84
return 0, False
85
86
num_external_hit_tokens = self.client.lookup(
87
request.request_id,
90
- token_len,
88
+ request.num_tokens,
89
request.block_hashes,
90
non_block=self.lookup_async,
91
)
@@ -95,14 +93,6 @@ class MooncakeStoreScheduler:
93
# Lookup not ready yet; scheduler will retry on a later step.
94
return None, False
95
98
- if num_external_hit_tokens == request.num_tokens:
99
- # Leave a sub-block tail uncomputed for sampling, on a block
100
- # boundary so the recv-side load mask covers every yielded chunk.
101
- num_external_hit_tokens = max(
102
- 0,
103
- (request.num_tokens - 1) // self._block_size * self._block_size,
104
- )
105
-
96
if num_external_hit_tokens < num_computed_tokens:
97
need_to_allocate = 0
98
else:
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+25
-9
@@ -1467,11 +1467,14 @@ class MooncakeStoreWorker:
1467
1468
return finished_sending
1469
1470
- def lookup(self, token_len: int, block_hashes: Sequence[BlockHash]) -> int:
1470
+ def lookup(self, num_tokens: int, block_hashes: Sequence[BlockHash]) -> int:
1471
"""Check how many prefix tokens exist in the store.
1472
1473
- Checks across all rank-specific key namespaces that may be loaded.
1473
+ Checks across all rank-specific key namespaces that may be loaded. A
1474
+ hit covering all ``num_tokens`` is re-derived below the request end so
1475
+ the last token is recomputed for sampling.
1476
"""
1477
+ token_len = self.coord.align_lookup_length(num_tokens)
1478
if not block_hashes or token_len <= 0:
1479
return 0
1480
@@ -1535,11 +1538,24 @@ class MooncakeStoreWorker:
1538
)
1539
}
1540
1541
+ cached_block_pool = ExternalCachedBlockPool(
1542
+ self.hash_block_size,
1543
+ exists_set,
1544
+ )
1545
_masks, hit_length = self.coord.find_longest_cache_hit(
1546
block_hashes,
1547
token_len,
1541
- ExternalCachedBlockPool(self.hash_block_size, exists_set),
1548
+ cached_block_pool,
1549
)
1550
+ if hit_length >= num_tokens:
1551
+ usable_length = self.coord.align_lookup_length(num_tokens - 1)
1552
+ if usable_length <= 0:
1553
+ return 0
1554
+ _masks, hit_length = self.coord.find_longest_cache_hit(
1555
+ block_hashes,
1556
+ usable_length,
1557
+ cached_block_pool,
1558
+ )
1559
return hit_length
1560
1561
def get_kv_events(self) -> list[BlockStored]:
@@ -1605,11 +1621,11 @@ class LookupKeyServer:
1621
msg_type = bytes(all_frames[0])
1622
1623
if msg_type == LOOKUP_MSG:
1608
- token_len = int.from_bytes(all_frames[1], byteorder="big")
1624
+ num_tokens = int.from_bytes(all_frames[1], byteorder="big")
1625
hash_len = int.from_bytes(all_frames[2], byteorder="big")
1626
blob = all_frames[3].buffer
1627
block_hashes = BlobBlockHashes(blob, hash_len)
1612
- result = self.store_worker.lookup(token_len, block_hashes)
1628
+ result = self.store_worker.lookup(num_tokens, block_hashes)
1629
self.socket.send(result.to_bytes(4, "big"))
1630
1631
elif msg_type == RESET_MSG:
@@ -1672,11 +1688,11 @@ class LookupKeyClient:
1688
)
1689
self.futures: dict[str, Future[int]] = {}
1690
1675
- def _lookup(self, token_len: int, block_hashes: list[BlockHash]) -> int:
1691
+ def _lookup(self, num_tokens: int, block_hashes: list[BlockHash]) -> int:
1692
hash_len = len(block_hashes[0]) if block_hashes else 0
1693
all_frames = (
1694
LOOKUP_MSG,
1679
- token_len.to_bytes(4, byteorder="big"),
1695
+ num_tokens.to_bytes(4, byteorder="big"),
1696
hash_len.to_bytes(2, byteorder="big"),
1697
b"".join(block_hashes),
1698
)
@@ -1687,7 +1703,7 @@ class LookupKeyClient:
1703
def lookup(
1704
self,
1705
req_id: str,
1690
- token_len: int,
1706
+ num_tokens: int,
1707
block_hashes: list[BlockHash],
1708
non_block: bool = False,
1709
) -> int | None:
@@ -1695,7 +1711,7 @@ class LookupKeyClient:
1711
so the caller retries on a later step."""
1712
future = self.futures.get(req_id)
1713
if future is None:
1698
- future = self.executor.submit(self._lookup, token_len, list(block_hashes))
1714
+ future = self.executor.submit(self._lookup, num_tokens, list(block_hashes))
1715
self.futures[req_id] = future
1716
if non_block and not future.done():
1717
return None