[KV Connector][Mooncake] Async lookup to reduce scheduler overhead (#45659)
Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Nick Hill <nickhill123@gmail.com>
Yifan Qiao committed
Jun 18, 2026 at 14:44 UTC
35e4dd4a69b6b95feb74866341daa46c3836aed0
6 files changed
+197
-11
docs/features/mooncake_store_connector_usage.md
+1
@@ -203,6 +203,7 @@ the vLLM JSON config.
203
### kv_connector_extra_config
204
205
- `load_async` (bool): Enable asynchronous loading for better compute-I/O overlap. Default: `true`.
206
+- `lookup_async` (bool): Run the external prefix-cache lookup on a background thread so it never blocks the scheduler step. The request is held until the in-flight lookup completes, then resumed on a later step. Default: `false`.
207
- `enable_cross_layers_blocks` (bool): Enable cross-layer block packing for reduced store operations. Default: `false`.
208
- `lookup_rpc_port` (int): Custom port for the ZMQ lookup RPC socket. Default: `0`.
209
- `cache_prefix` (str): Namespace prepended to every store key. Lets separate deployments share one Mooncake master without polluting each other — instances configured with different prefixes never see each other's cached blocks, even for identical prompts. All instances that should share a prefix cache must use the same value. Default: `""` (no prefix; keys are byte-identical to the unprefixed format).
tests/v1/kv_connector/unit/test_mooncake_store_connector.py
+126
-1
@@ -1,6 +1,8 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+import threading
5
+import time
6
from unittest.mock import MagicMock, patch
7
8
from vllm.config import set_current_vllm_config
@@ -406,7 +408,9 @@ def test_lookup_key_client_lookup_prepends_typed_tag():
408
fake_socket = mock_make_socket.return_value
409
fake_socket.recv.return_value = (5).to_bytes(4, "big")
410
409
- assert client.lookup(token_len=128, block_hashes=[]) == 5
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
414
415
sent_frames = fake_socket.send_multipart.call_args[0][0]
416
assert sent_frames[0] == protocol.LOOKUP_MSG
@@ -435,6 +439,127 @@ 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):
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)
447
+ if result is not None:
448
+ return result
449
+ time.sleep(0.005)
450
+ return None
451
+
452
+
453
+def _gated_recv(gate: threading.Event, value: int):
454
+ """Mock recv side-effect that blocks until ``gate`` is set, so the
455
+ executor's lookup can be held pending deterministically."""
456
+
457
+ def recv():
458
+ gate.wait()
459
+ return value.to_bytes(4, "big")
460
+
461
+ return recv
462
+
463
+
464
+def test_lookup_key_client_non_block_lookup_async():
465
+ """Non-blocking lookup defers to the executor: None first, hit once the
466
+ Future resolves."""
467
+ vllm_config = _make_vllm_config()
468
+
469
+ with patch(
470
+ "vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
471
+ "worker.make_zmq_socket"
472
+ ) as mock_make_socket:
473
+ client = worker.LookupKeyClient(vllm_config)
474
+
475
+ fake_socket = mock_make_socket.return_value
476
+ # Hold the executor's lookup pending until we release the gate.
477
+ gate = threading.Event()
478
+ fake_socket.recv.side_effect = _gated_recv(gate, 7)
479
+
480
+ # First query submits the lookup and returns None while it is in flight.
481
+ assert client.lookup("req1", 128, [], non_block=True) is None
482
+ # Release the executor; a later poll returns the hit length.
483
+ gate.set()
484
+ assert _poll_lookup(client, "req1") == 7
485
+ # Future is consumed (popped) on read.
486
+ assert "req1" not in client.futures
487
+
488
+
489
+def test_lookup_key_client_discard_clears_state():
490
+ """discard() drops a completed lookup Future so it is not served stale."""
491
+ vllm_config = _make_vllm_config()
492
+
493
+ with patch(
494
+ "vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
495
+ "worker.make_zmq_socket"
496
+ ) as mock_make_socket:
497
+ client = worker.LookupKeyClient(vllm_config)
498
+
499
+ fake_socket = mock_make_socket.return_value
500
+ gate = threading.Event()
501
+ fake_socket.recv.side_effect = _gated_recv(gate, 9)
502
+
503
+ # Submit while gated so the call returns None and the Future stays in
504
+ # `futures` (unconsumed) once it resolves.
505
+ assert client.lookup("req2", 128, [], non_block=True) is None
506
+ gate.set()
507
+ deadline = time.monotonic() + 5.0
508
+ while time.monotonic() < deadline:
509
+ if client.futures["req2"].done():
510
+ break
511
+ time.sleep(0.005)
512
+ # discard() drops the completed result before any lookup consumes it.
513
+ client.discard("req2")
514
+ assert "req2" not in client.futures
515
+ # A fresh query re-submits rather than returning a stale value: hold the
516
+ # gate so the resubmitted lookup stays in flight.
517
+ gate.clear()
518
+ assert client.lookup("req2", 128, [], non_block=True) is None
519
+ gate.set() # release the executor so the worker thread can drain
520
+
521
+
522
+def test_get_num_new_matched_tokens_async_defers_then_reports():
523
+ """Async lookup returns (None, False) until ready, then the hit count."""
524
+ vllm_config = create_vllm_config(
525
+ kv_connector="MooncakeStoreConnector",
526
+ kv_role="kv_both",
527
+ kv_connector_extra_config={"lookup_async": True},
528
+ )
529
+ kv_cache_config = _make_kv_cache_config()
530
+
531
+ with (
532
+ set_current_vllm_config(vllm_config),
533
+ patch(
534
+ "vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store."
535
+ "scheduler.LookupKeyClient"
536
+ ) as mock_client_cls,
537
+ ):
538
+ sched = scheduler.MooncakeStoreScheduler(vllm_config, kv_cache_config)
539
+
540
+ assert sched.lookup_async is True
541
+ mock_client = mock_client_cls.return_value
542
+
543
+ block_size = sched._block_size
544
+ request = MagicMock()
545
+ request.request_id = "r1"
546
+ request.num_tokens = 4 * block_size
547
+ request.block_hashes = []
548
+
549
+ # Lookup not ready -> defer.
550
+ mock_client.lookup.return_value = None
551
+ assert sched.get_num_new_matched_tokens(request, 0) == (None, False)
552
+ assert "r1" not in sched.load_specs
553
+
554
+ # Lookup ready with a hit -> report need_to_allocate + async-load flag.
555
+ hit = 3 * block_size
556
+ mock_client.lookup.return_value = hit
557
+ need, load_async = sched.get_num_new_matched_tokens(request, 0)
558
+ assert need == hit
559
+ assert load_async == sched.load_async
560
+ assert sched.load_specs["r1"].kvpool_cached_tokens == hit
561
+
562
+
563
def test_protocol_tags_are_distinct_and_non_empty():
564
"""Protocol tags must be unique and non-empty to avoid collision."""
565
tags = {protocol.LOOKUP_MSG, protocol.RESET_MSG}
tests/v1/kv_connector/unit/test_mooncake_store_scheduler.py
+8
-1
@@ -16,6 +16,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.scheduler impor
16
def _make_bare_scheduler() -> MooncakeStoreScheduler:
17
scheduler = object.__new__(MooncakeStoreScheduler)
18
scheduler.kv_role = "kv_both"
19
+ scheduler.lookup_async = False
20
scheduler._block_size = 16
21
scheduler.load_specs = {}
22
scheduler._preempted_req_ids = set()
@@ -405,7 +406,13 @@ class _StubLookupClient:
406
def __init__(self, hit_tokens: int) -> None:
407
self._hit_tokens = hit_tokens
408
408
- def lookup(self, token_len: int, block_hashes: list[bytes]) -> int:
409
+ def lookup(
410
+ self,
411
+ req_id: str,
412
+ token_len: int,
413
+ block_hashes: list[bytes],
414
+ non_block: bool = False,
415
+ ) -> int:
416
return self._hit_tokens
417
418
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/connector.py
+1
-1
@@ -176,7 +176,7 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
176
self,
177
request: Request,
178
num_computed_tokens: int,
179
- ) -> tuple[int, bool]:
179
+ ) -> tuple[int | None, bool]:
180
assert self.connector_scheduler is not None
181
return self.connector_scheduler.get_num_new_matched_tokens(
182
request, num_computed_tokens
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/scheduler.py
+19
-6
@@ -54,9 +54,9 @@ class MooncakeStoreScheduler:
54
):
55
assert vllm_config.kv_transfer_config is not None
56
self.kv_role = vllm_config.kv_transfer_config.kv_role
57
- self.load_async = vllm_config.kv_transfer_config.kv_connector_extra_config.get(
58
- "load_async", True
59
- )
57
+ kvc_extra_config = vllm_config.kv_transfer_config.kv_connector_extra_config
58
+ self.load_async = kvc_extra_config.get("load_async", True)
59
+ self.lookup_async = kvc_extra_config.get("lookup_async", False)
60
self.client = LookupKeyClient(vllm_config)
61
62
# Align with the engine's own scheduler_block_size and hash_block_size.
@@ -75,14 +75,26 @@ class MooncakeStoreScheduler:
75
self,
76
request: Request,
77
num_computed_tokens: int,
78
- ) -> tuple[int, bool]:
79
- """Check for external KV cache hit."""
78
+ ) -> tuple[int | None, bool]:
79
+ """Check for external KV cache hit.
80
+
81
+ Returns ``(None, False)`` when an async lookup is still in flight,
82
+ signaling the scheduler to retry this request on a later step.
83
+ """
84
# Look up against the full prefill range, not just the prompt.
85
token_len = request.num_tokens // self._block_size * self._block_size
86
if token_len < self._block_size:
87
return 0, False
88
85
- num_external_hit_tokens = self.client.lookup(token_len, request.block_hashes)
89
+ num_external_hit_tokens = self.client.lookup(
90
+ request.request_id,
91
+ token_len,
92
+ request.block_hashes,
93
+ non_block=self.lookup_async,
94
+ )
95
+ if num_external_hit_tokens is None:
96
+ # Lookup not ready yet; scheduler will retry on a later step.
97
+ return None, False
98
99
if num_external_hit_tokens == request.num_tokens:
100
# Leave a sub-block tail uncomputed for sampling, on a block
@@ -158,6 +170,7 @@ class MooncakeStoreScheduler:
170
force_skip_save = self.kv_role == "kv_consumer"
171
172
for finished_req_id in scheduler_output.finished_req_ids:
173
+ self.client.discard(finished_req_id)
174
self.load_specs.pop(finished_req_id, None)
175
self._request_trackers.pop(finished_req_id, None)
176
self._unfinished_requests.pop(finished_req_id, None)
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+42
-2
@@ -19,6 +19,7 @@ import threading
19
import time
20
from collections import defaultdict
21
from collections.abc import Callable
22
+from concurrent.futures import Future, ThreadPoolExecutor
23
from dataclasses import dataclass
24
from typing import Any, Literal, TypeVar
25
@@ -1560,7 +1561,13 @@ class LookupKeyClient:
1561
bind=False,
1562
)
1563
1563
- def lookup(self, token_len: int, block_hashes: list[BlockHash]) -> int:
1564
+ # Async lookup support
1565
+ self.executor = ThreadPoolExecutor(
1566
+ max_workers=1, thread_name_prefix="MooncakeLookupClient"
1567
+ )
1568
+ self.futures: dict[str, Future[int]] = {}
1569
+
1570
+ def _lookup(self, token_len: int, block_hashes: list[BlockHash]) -> int:
1571
hash_strs = [h.hex() for h in block_hashes]
1572
hash_frames = self.encoder.encode(hash_strs)
1573
token_len_bytes = token_len.to_bytes(4, byteorder="big")
@@ -1570,7 +1577,36 @@ class LookupKeyClient:
1577
result = int.from_bytes(resp, "big")
1578
return result
1579
1573
- def reset(self) -> bool:
1580
+ def lookup(
1581
+ self,
1582
+ req_id: str,
1583
+ token_len: int,
1584
+ block_hashes: list[BlockHash],
1585
+ non_block: bool = False,
1586
+ ) -> int | None:
1587
+ """If non_block is True, will return None until the result is ready,
1588
+ so the caller retries on a later step."""
1589
+ future = self.futures.get(req_id)
1590
+ if future is None:
1591
+ future = self.executor.submit(self._lookup, token_len, list(block_hashes))
1592
+ self.futures[req_id] = future
1593
+ if non_block and not future.done():
1594
+ return None
1595
+ try:
1596
+ return future.result()
1597
+ except Exception as e:
1598
+ logger.error("Async Mooncake lookup failed for %s: %s", req_id, e)
1599
+ return 0
1600
+ finally:
1601
+ del self.futures[req_id]
1602
+
1603
+ def discard(self, req_id: str) -> None:
1604
+ """Drop any cached/in-flight lookup for ``req_id`` (e.g. on abort)."""
1605
+ future = self.futures.pop(req_id, None)
1606
+ if future is not None:
1607
+ future.cancel()
1608
+
1609
+ def _reset(self) -> bool:
1610
"""Trigger ``store.remove_all(force=True)`` on worker rank 0.
1611
1612
Ordering assumption: caller MUST ensure no in-flight Mooncake
@@ -1582,7 +1618,11 @@ class LookupKeyClient:
1618
resp = self.socket.recv()
1619
return bytes(resp) == RESP_OK
1620
1621
+ def reset(self) -> bool:
1622
+ return self.executor.submit(self._reset).result()
1623
+
1624
def close(self):
1625
+ self.executor.shutdown(wait=False, cancel_futures=True)
1626
self.socket.close(linger=0)
1627
1628