[KV Connector] Add decode offloading to Mooncake Store consumers (#52466)
Signed-off-by: z-zanez <zhouzh93@mail2.sysu.edu.cn> Signed-off-by: Guanyi Chen <939416532@qq.com> Co-authored-by: z-zanez <zhouzh93@mail2.sysu.edu.cn>
Guanyi Chen committed
Aug 20, 2026 at 08:40 UTC
bf2866f8bf5bb20628e2b93835be3c281a9b4ca4
7 files changed
+518
-72
docs/features/mooncake_store_connector_usage.md
+23
@@ -136,6 +136,25 @@ vllm serve meta-llama/Llama-3.1-8B-Instruct \
136
}'
137
```
138
139
+To also offload newly completed decode KV blocks, add the following extra
140
+configuration to the decoder's `MooncakeStoreConnector` entry. This currently
141
+requires the prefill and decode instances to use the same tensor-parallel size
142
+and compatible KV-cache topology.
143
+
144
+When decode processing starts, the consumer checks the block-aligned prompt
145
+prefix and fills any blocks missing from the Store. Subsequent saves append
146
+newly completed decode blocks. This keeps a complete, reusable prefix in the
147
+Store and also covers deployments where prompt KV is delivered directly by
148
+`MooncakeConnector` instead of through the Store.
149
+
150
+```json
151
+{
152
+ "kv_connector_extra_config": {
153
+ "save_decode_cache": true
154
+ }
155
+}
156
+```
157
+
158
**Proxy:**
159
160
A disaggregation proxy is required to route requests between prefiller and decoder nodes. The proxy assigns `do_remote_prefill=True` / `do_remote_decode=True` to coordinate P2P transfer via `MooncakeConnector`. Refer to the [MooncakeConnector usage guide](mooncake_connector_usage.md) for proxy setup details.
@@ -230,6 +249,10 @@ Strict isolation requires a Mooncake master started with `--enable_multi_tenants
249
- `enable_cross_layers_blocks` (bool): Enable cross-layer block packing for reduced store operations. Default: `false`.
250
- `lookup_rpc_port` (int): Custom port for the ZMQ lookup RPC socket. Default: `0`.
251
- `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).
252
+- `save_decode_cache` (bool): Enable offloading decode tokens' KV cache. A `kv_consumer` does not save during prefill; when decode starts, it fills any missing block-aligned prompt prefix before appending completed decode blocks. Default: `false`.
253
+
254
+Decode offloading uses the existing TP-rank key namespace. Cross-TP sharing is
255
+not yet supported.
256
257
## Notes
258
tests/v1/kv_connector/unit/test_mooncake_store_scheduler.py
+187
-2
@@ -3,6 +3,8 @@
3
4
from types import SimpleNamespace
5
6
+import pytest
7
+
8
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
9
LoadSpec,
10
MooncakeStoreWorkerMetadata,
@@ -16,10 +18,16 @@ from vllm.v1.core.block_pool import BlockPool
18
19
20
def _make_bare_scheduler(
19
- *, hash_block_size: int = 16, enable_partial_hash_hits: bool = False
21
+ *,
22
+ hash_block_size: int = 16,
23
+ enable_partial_hash_hits: bool = False,
24
+ kv_role: str = "kv_both",
25
+ save_decode_cache: bool = False,
26
) -> MooncakeStoreScheduler:
27
scheduler = object.__new__(MooncakeStoreScheduler)
22
- scheduler.kv_role = "kv_both"
28
+ scheduler.kv_role = kv_role
29
+ scheduler.save_decode_cache = save_decode_cache
30
+ scheduler.enable_kv_events = False
31
scheduler.lookup_async = False
32
scheduler.enable_lookup = True
33
scheduler._block_size = 16
@@ -56,6 +64,74 @@ def _make_scheduler_output(*, scheduled_spec_tokens: list[int] | None):
64
)
65
66
67
+def _make_decode_scheduler_output(
68
+ *, num_computed_tokens: int, num_scheduled_tokens: int = 1
69
+) -> SimpleNamespace:
70
+ return SimpleNamespace(
71
+ finished_req_ids=set(),
72
+ preempted_req_ids=set(),
73
+ scheduled_new_reqs=[],
74
+ scheduled_cached_reqs=SimpleNamespace(
75
+ req_ids=["req-0"],
76
+ # The block that becomes full was allocated on an earlier step.
77
+ new_block_ids=[()],
78
+ num_computed_tokens=[num_computed_tokens],
79
+ resumed_req_ids=set(),
80
+ ),
81
+ num_scheduled_tokens={"req-0": num_scheduled_tokens},
82
+ scheduled_spec_decode_tokens={},
83
+ )
84
+
85
+
86
+def _make_new_scheduler_output() -> SimpleNamespace:
87
+ request = SimpleNamespace(
88
+ req_id="req-0",
89
+ num_computed_tokens=0,
90
+ prompt_token_ids=list(range(32)),
91
+ prefill_token_ids=None,
92
+ block_ids=([0, 1],),
93
+ block_hashes=[b"h0", b"h1"],
94
+ )
95
+ return SimpleNamespace(
96
+ request=request,
97
+ finished_req_ids=set(),
98
+ preempted_req_ids=set(),
99
+ scheduled_new_reqs=[request],
100
+ scheduled_cached_reqs=SimpleNamespace(
101
+ req_ids=[],
102
+ new_block_ids=[],
103
+ num_computed_tokens=[],
104
+ resumed_req_ids=set(),
105
+ ),
106
+ num_scheduled_tokens={"req-0": 32},
107
+ scheduled_spec_decode_tokens={},
108
+ )
109
+
110
+
111
+def test_scheduler_only_tracks_token_ids_for_kv_events():
112
+ for enable_kv_events in (False, True):
113
+ scheduler = _make_bare_scheduler()
114
+ scheduler.enable_kv_events = enable_kv_events
115
+ scheduler._unfinished_requests["req-0"] = (
116
+ _make_new_scheduler_output().request,
117
+ ([0, 1],),
118
+ )
119
+
120
+ meta = scheduler.build_connector_meta(_make_new_scheduler_output())
121
+
122
+ tracker = scheduler._request_trackers["req-0"]
123
+ req_meta = meta.requests[0]
124
+ if enable_kv_events:
125
+ assert tracker.token_ids == list(range(32))
126
+ assert req_meta.token_ids == list(range(32))
127
+ assert req_meta.token_ids_start == 0
128
+ tracker.token_ids.append(99)
129
+ assert req_meta.token_ids == list(range(32))
130
+ else:
131
+ assert tracker.token_ids is None
132
+ assert req_meta.token_ids is None
133
+
134
+
135
def _make_preemption_scheduler_output():
136
return SimpleNamespace(
137
finished_req_ids=set(),
@@ -103,6 +179,29 @@ def _add_unfinished_request(
179
)
180
181
182
+def _setup_decode_request(
183
+ *,
184
+ kv_role: str = "kv_consumer",
185
+ save_decode_cache: bool = False,
186
+ token_len: int = 47,
187
+) -> tuple[MooncakeStoreScheduler, RequestTracker]:
188
+ scheduler = _make_bare_scheduler(
189
+ kv_role=kv_role, save_decode_cache=save_decode_cache
190
+ )
191
+ token_ids = list(range(token_len + 1))
192
+ _add_unfinished_request(
193
+ scheduler,
194
+ token_ids=token_ids,
195
+ block_hashes=[b"h0", b"h1", b"h2"],
196
+ prefill_end_tokens=32,
197
+ )
198
+ tracker = scheduler._request_trackers["req-0"]
199
+ tracker.token_len = token_len
200
+ tracker.allocated_block_ids = ([0, 1, 2],)
201
+ tracker.token_ids = token_ids[:token_len]
202
+ return scheduler, tracker
203
+
204
+
205
def test_cached_request_with_spec_decode_does_not_save_scheduled_drafts():
206
# Drafts in scheduled_spec_decode_tokens are not appended to all_token_ids
207
# yet, so the tracker's token_len does not advance and num_tokens_to_save
@@ -149,6 +248,92 @@ def test_cached_request_without_spec_decode_keeps_current_step_save_overlap():
248
assert tracker.num_saved_tokens == 48
249
250
251
+@pytest.mark.parametrize("kv_role", ["kv_consumer", "kv_both"])
252
+def test_decode_tracking_is_skipped_by_default(kv_role):
253
+ scheduler, tracker = _setup_decode_request(kv_role=kv_role)
254
+
255
+ meta = scheduler.build_connector_meta(
256
+ _make_decode_scheduler_output(num_computed_tokens=47)
257
+ )
258
+
259
+ assert meta.requests == []
260
+ assert tracker.token_len == 47
261
+ assert tracker.num_saved_tokens == 32
262
+ assert tracker.allocated_block_ids == ([0, 1, 2],)
263
+ assert tracker.token_ids == list(range(47))
264
+
265
+
266
+def test_fresh_consumer_first_decode_save_can_backfill_missing_prompt():
267
+ scheduler = _make_bare_scheduler(
268
+ kv_role="kv_consumer",
269
+ save_decode_cache=True,
270
+ )
271
+ scheduler.enable_kv_events = True
272
+ new_output = _make_new_scheduler_output()
273
+ request = new_output.request
274
+ request.block_ids = ([0, 1, 2],)
275
+ request.block_hashes = [b"h0", b"h1", b"h2"]
276
+ request.all_token_ids = list(range(48))
277
+ request.num_output_placeholders = 0
278
+ scheduler._unfinished_requests["req-0"] = (request, request.block_ids)
279
+
280
+ # The consumer does not save during prefill, so its first decode save must
281
+ # still cover the prompt. The worker deduplicates prompt blocks already in
282
+ # the Store and fills any that are missing, preserving a complete prefix.
283
+ assert scheduler.build_connector_meta(new_output).requests == []
284
+ tracker = scheduler._request_trackers["req-0"]
285
+ assert tracker.num_saved_tokens == 0
286
+
287
+ # The first decode step checks/saves the block-aligned prompt. The worker's
288
+ # Store dedup turns this into a no-op when the producer already saved it.
289
+ [prompt_meta] = scheduler.build_connector_meta(
290
+ _make_decode_scheduler_output(num_computed_tokens=32)
291
+ ).requests
292
+ assert prompt_meta.token_len_chunk == 32
293
+ assert prompt_meta.token_ids_start == 0
294
+ assert prompt_meta.token_ids == list(range(32))
295
+
296
+ for num_computed_tokens in range(33, 48):
297
+ meta = scheduler.build_connector_meta(
298
+ _make_decode_scheduler_output(
299
+ num_computed_tokens=num_computed_tokens,
300
+ )
301
+ )
302
+ if num_computed_tokens < 47:
303
+ assert meta.requests == []
304
+
305
+ [req_meta] = meta.requests
306
+ assert req_meta.token_len_chunk == 48
307
+ assert req_meta.token_ids_start == 32
308
+ assert req_meta.token_ids == list(range(32, 48))
309
+ assert tracker.num_saved_tokens == 48
310
+
311
+
312
+@pytest.mark.parametrize("token_len, saved_tokens", [(46, 32), (47, 48)])
313
+def test_consumer_saves_only_full_decode_blocks(token_len, saved_tokens):
314
+ scheduler, tracker = _setup_decode_request(
315
+ save_decode_cache=True, token_len=token_len
316
+ )
317
+
318
+ meta = scheduler.build_connector_meta(
319
+ _make_decode_scheduler_output(num_computed_tokens=token_len)
320
+ )
321
+
322
+ assert tracker.token_len == token_len + 1
323
+ assert tracker.num_saved_tokens == saved_tokens
324
+ if saved_tokens == 32:
325
+ assert meta.requests == []
326
+ else:
327
+ [req_meta] = meta.requests
328
+ assert req_meta.can_save is True
329
+ assert req_meta.token_len_chunk == 48
330
+ assert req_meta.block_ids == ([0, 1, 2],)
331
+ assert req_meta.token_ids == list(range(32, 48))
332
+ assert req_meta.token_ids_start == 32
333
+ tracker.token_ids.append(999)
334
+ assert req_meta.token_ids == list(range(32, 48))
335
+
336
+
337
def test_preemption_resets_tracker():
338
scheduler = _make_bare_scheduler()
339
_add_unfinished_request(
tests/v1/kv_connector/unit/test_mooncake_store_worker.py
+157
-3
@@ -36,7 +36,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
36
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.metrics import (
37
MooncakeStoreConnectorStats,
38
)
39
-from vllm.v1.core.kv_cache_utils import BlockHash
39
+from vllm.v1.core.kv_cache_utils import BlockHash, maybe_convert_block_hash
40
41
42
class _RecordingBlockHashes:
@@ -230,6 +230,7 @@ def _make_vllm_config(
230
extra_config: dict[str, object] | None = None,
231
rank: int = 0,
232
decode_context_parallel_size: int = 1,
233
+ kv_role: str = "kv_both",
234
) -> SimpleNamespace:
235
return SimpleNamespace(
236
model_config=_FakeModelConfig(),
@@ -240,7 +241,9 @@ def _make_vllm_config(
241
decode_context_parallel_size=decode_context_parallel_size,
242
prefill_context_parallel_size=1,
243
),
243
- kv_transfer_config=_FakeKVTransferConfig(extra_config=extra_config),
244
+ kv_transfer_config=_FakeKVTransferConfig(
245
+ kv_role=kv_role, extra_config=extra_config
246
+ ),
247
cache_config=SimpleNamespace(block_size=16, num_gpu_blocks=10),
248
kv_events_config=SimpleNamespace(enable_kv_cache_events=False),
249
speculative_config=None,
@@ -943,15 +946,20 @@ def test_stale_store_job_cannot_touch_a_reused_request_id():
946
live = _make_store_req("req-a", [b"a0", b"a1"])
947
live.store_job_id = 2
948
thread.add_request(live)
949
+ thread._retry_token_ids["req-a"] = (32, list(range(32, 64)))
950
951
thread.finish_store_job(stale)
952
thread._record_saved(stale, 64)
953
thread._mark_request_skipped_for_pressure(stale)
954
+ thread._update_retry_token_ids(stale, False, 0, list(range(32)))
955
+ thread._update_retry_token_ids(stale, True, 0, None)
956
957
assert thread.is_live_store_job(live)
958
assert not thread.is_live_store_job(stale)
959
assert thread._saved_offset.get("req-a") is None
960
assert "req-a" not in thread._skip_store_requests
961
+ assert thread._get_retry_token_ids(stale) is None
962
+ assert thread._get_retry_token_ids(live) == (32, list(range(32, 64)))
963
964
965
def test_store_recving_thread_reports_failed_block_ids():
@@ -1655,6 +1663,39 @@ def test_requester_worker_init_skips_disk_budget_when_offload_disabled(
1663
assert w.disk_offload_buffer_budget_bytes is None
1664
1665
1666
+def test_save_decode_cache_keeps_transfer_path_enabled(tmp_path, monkeypatch):
1667
+ store = MagicMock()
1668
+ store.setup.return_value = 0
1669
+ _install_fake_mooncake(monkeypatch, store)
1670
+ _patch_worker_runtime(monkeypatch)
1671
+ monkeypatch.setenv(
1672
+ "MOONCAKE_CONFIG_PATH",
1673
+ _write_mooncake_config(
1674
+ tmp_path,
1675
+ {
1676
+ "metadata_server": "http://metadata/endpoint",
1677
+ "protocol": "tcp",
1678
+ "device_name": "",
1679
+ "master_server_address": "10.0.0.7:50051",
1680
+ },
1681
+ ),
1682
+ )
1683
+
1684
+ w = worker.MooncakeStoreWorker(
1685
+ _make_vllm_config(
1686
+ kv_role="kv_consumer",
1687
+ extra_config={
1688
+ "enable_lookup": False,
1689
+ "save_decode_cache": True,
1690
+ },
1691
+ ),
1692
+ _make_kv_cache_config(),
1693
+ )
1694
+
1695
+ assert w.can_put is True
1696
+ assert w._capacity_only is False
1697
+
1698
+
1699
def test_requester_worker_init_builds_replicate_config_for_preferred_segment(
1700
tmp_path,
1701
monkeypatch,
@@ -1962,7 +2003,7 @@ def test_store_sending_thread_only_stores_swa_blocks_in_window():
2003
store = MagicMock()
2004
store.batch_is_exist.side_effect = lambda keys: [0] * len(keys)
2005
store.batch_put_from_multi_buffers.side_effect = (
1965
- lambda keys, addrs, sizes, *_args: [256] * len(keys)
2006
+ lambda keys, addrs, sizes, *_args: ([256] * len(keys))
2007
)
2008
2009
full_spec = FullAttentionSpec(
@@ -2176,6 +2217,85 @@ def test_store_sending_thread_kv_events_use_group_chunk_metadata():
2217
assert swa_event.block_hashes == [maybe_convert_block_hash(BlockHash(hs[3]))]
2218
2219
2220
+def _make_event_store_req(token_len: int, token_ids_start: int = 0) -> ReqMeta:
2221
+ num_blocks = token_len // 16
2222
+ return ReqMeta(
2223
+ req_id="r0",
2224
+ token_len_chunk=token_len,
2225
+ block_ids=(list(range(num_blocks)),),
2226
+ block_hashes=[f"a{i}".encode() for i in range(num_blocks)],
2227
+ can_save=True,
2228
+ token_ids=list(range(token_ids_start, token_len)),
2229
+ token_ids_start=token_ids_start,
2230
+ )
2231
+
2232
+
2233
+@pytest.mark.parametrize(
2234
+ ("saved_offset", "put_step", "exists", "stored_indices", "parent_indices"),
2235
+ [
2236
+ pytest.param(16, 1, [0, 0, 0], [1, 2, 3], [0, 1, 2], id="suffix"),
2237
+ pytest.param(0, 1, [1, 0, 1, 0], [1, 3], [0, 2], id="dedup-holes"),
2238
+ pytest.param(0, 2, [0, 0], [0, 2], [None, 1], id="tp-stride"),
2239
+ ],
2240
+)
2241
+def test_store_sending_thread_kv_events_use_request_chain_parents(
2242
+ saved_offset, put_step, exists, stored_indices, parent_indices
2243
+):
2244
+ store = MagicMock()
2245
+ store.batch_is_exist.return_value = exists
2246
+ store.batch_put_from_multi_buffers.return_value = [256] * len(stored_indices)
2247
+ thread = _make_store_sending_thread(store, put_step=put_step)
2248
+ thread.enable_kv_event = True
2249
+
2250
+ thread._saved_offset["r0"] = saved_offset
2251
+ _run_store_req(thread, _make_event_store_req(64, saved_offset))
2252
+
2253
+ events = thread.get_kv_events()
2254
+ assert [event.block_hashes for event in events] == [
2255
+ [maybe_convert_block_hash(BlockHash(f"a{i}".encode()))] for i in stored_indices
2256
+ ]
2257
+ assert [event.parent_block_hash for event in events] == [
2258
+ (None if i is None else maybe_convert_block_hash(BlockHash(f"a{i}".encode())))
2259
+ for i in parent_indices
2260
+ ]
2261
+ assert thread._retry_token_ids == {}
2262
+
2263
+
2264
+def test_store_sending_thread_kv_events_retry_without_covered_tokens():
2265
+ store = MagicMock()
2266
+ store.batch_is_exist.return_value = [0, 0]
2267
+ store.batch_put_from_multi_buffers.return_value = [256, 256]
2268
+ thread = _make_store_sending_thread(store)
2269
+ thread.enable_kv_event = True
2270
+
2271
+ _run_store_req(thread, _make_event_store_req(32, 16))
2272
+
2273
+ retry_event, suffix_event = thread.get_kv_events()
2274
+ assert retry_event.token_ids == []
2275
+ assert suffix_event.token_ids == list(range(16, 32))
2276
+
2277
+
2278
+def test_store_sending_thread_kv_events_recover_suffix_after_put_failure():
2279
+ store = MagicMock()
2280
+ store.batch_is_exist.side_effect = ([0], [0, 0])
2281
+ store.batch_put_from_multi_buffers.side_effect = ([-1], [256, 256])
2282
+ thread = _make_store_sending_thread(store)
2283
+ thread.enable_kv_event = True
2284
+
2285
+ _run_store_req(thread, _make_event_store_req(16))
2286
+
2287
+ assert thread.get_kv_events() == []
2288
+ assert thread._retry_token_ids["r0"] == (0, list(range(16)))
2289
+
2290
+ _run_store_req(thread, _make_event_store_req(32, 16))
2291
+
2292
+ retry_event, suffix_event = thread.get_kv_events()
2293
+ assert retry_event.token_ids == list(range(16))
2294
+ assert suffix_event.token_ids == list(range(16, 32))
2295
+ assert thread._retry_token_ids == {}
2296
+ assert thread._saved_offset["r0"] == 32
2297
+
2298
+
2299
def _auto_set_ready_event(*args, **kwargs):
2300
"""Side effect for mocked thread constructors that auto-sets ready_event."""
2301
for arg in args:
@@ -2214,6 +2334,7 @@ def _make_bare_worker(
2334
num_gpu_blocks: int = 10,
2335
block_size: int = 16,
2336
kv_role: str = "kv_both",
2337
+ save_decode_cache: bool = False,
2338
) -> mooncake_store_worker.MooncakeStoreWorker:
2339
"""Construct a MooncakeStoreWorker via __new__, bypassing __init__.
2340
@@ -2227,14 +2348,17 @@ def _make_bare_worker(
2348
worker.store = MagicMock()
2349
worker.store.register_buffer.return_value = 0
2350
worker.kv_role = kv_role
2351
+ worker.can_put = kv_role in ("kv_producer", "kv_both") or save_decode_cache
2352
worker._capacity_only = False
2353
worker.block_size = block_size
2354
worker.tp_rank = 0
2355
worker.enable_kv_events = False
2356
+ worker.load_async = True
2357
worker.kv_send_thread = None
2358
worker.kv_recv_threads = []
2359
worker.num_recv_threads = 1
2360
worker.recv_request_queue = queue.Queue()
2361
+ worker.finished_store_req = set()
2362
worker.tp_size = 1
2363
worker.num_kv_head = 1
2364
worker.pp_size = 1
@@ -2743,6 +2867,36 @@ def test_lookup_applies_swa_mask_before_accessing_hashes():
2867
# ---------------------------------------------------------------------------
2868
2869
2870
+@pytest.mark.parametrize("save_decode_cache", [False, True])
2871
+def test_consumer_starts_send_thread_only_when_put_is_enabled(save_decode_cache):
2872
+ num_blocks = 10
2873
+ tensor = torch.zeros(num_blocks, 64, dtype=torch.float16)
2874
+
2875
+ worker = _make_bare_worker(
2876
+ kv_role="kv_consumer", save_decode_cache=save_decode_cache
2877
+ )
2878
+ _register_with_mocked_threads(worker, {"layer0": tensor})
2879
+ assert (worker.kv_send_thread is not None) == save_decode_cache
2880
+
2881
+
2882
+def test_putting_consumer_queues_decode_save():
2883
+ w = _make_bare_worker(kv_role="kv_consumer", save_decode_cache=True)
2884
+ send_thread = MagicMock()
2885
+ w.kv_send_thread = send_thread
2886
+ req = _make_store_req("decode-req", [b"h0", b"h1"])
2887
+ req.store_job_id = 1
2888
+ meta = mooncake_store_worker.MooncakeStoreConnectorMetadata(set(), set())
2889
+ meta.add_request(req)
2890
+
2891
+ with patch.object(torch.cuda, "Event") as event_cls:
2892
+ event = event_cls.return_value
2893
+ w.get_finished(set(), meta)
2894
+
2895
+ event.record.assert_called_once_with()
2896
+ send_thread.add_request.assert_called_once_with(req)
2897
+ assert req.current_event is event
2898
+
2899
+
2900
def test_register_kv_caches_blocks_first_single_segment():
2901
"""Blocks-first layout (FlashInfer/MLA): one segment per layer."""
2902
num_blocks = 10
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/connector.py
+6
-4
@@ -141,12 +141,14 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
141
assert vllm_config.kv_transfer_config is not None
142
assert kv_cache_config is not None, "kv_cache_config is required"
143
self.kv_role = vllm_config.kv_transfer_config.kv_role
144
+ extra_config = vllm_config.kv_transfer_config.kv_connector_extra_config
145
+ save_decode_cache = extra_config.get("save_decode_cache", False)
146
# Capacity-only: contributes its segment to the store pool but transfers
147
# no KV, so the KV-cache-shape invariants below cannot be reached.
146
- self._capacity_only = self.kv_role == "kv_consumer" and not (
147
- vllm_config.kv_transfer_config.kv_connector_extra_config.get(
148
- "enable_lookup", True
149
- )
148
+ self._capacity_only = (
149
+ self.kv_role == "kv_consumer"
150
+ and not extra_config.get("enable_lookup", True)
151
+ and not save_decode_cache
152
)
153
if not self._capacity_only:
154
self._validate_kv_cache_config(vllm_config, kv_cache_config)
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/data.py
+9
-6
@@ -362,10 +362,11 @@ class ReqMeta:
362
363
can_save: bool | None = None
364
load_spec: LoadSpec | None = None
365
- is_last_chunk: bool | None = None
365
current_event: torch.cuda.Event | None = None
366
367
token_ids: list[int] | None = None
368
+ # Absolute request offset represented by token_ids[0].
369
+ token_ids_start: int = 0
370
num_prompt_tokens: int | None = None
371
# Identifies this store job for the engine's lifetime. A request id cannot
372
# serve that purpose: it is reused once a preempted request resumes, so it
@@ -384,14 +385,14 @@ class ReqMeta:
385
load_spec: LoadSpec | None = None,
386
skip_save: bool | None = False,
387
block_hashes: list[BlockHash] | None = None,
387
- is_last_chunk: bool | None = None,
388
) -> "ReqMeta | None":
389
"""Create ReqMeta from a RequestTracker."""
390
if block_hashes is None:
391
block_hashes = []
392
input_token_len = tracker.token_len
393
394
- chunk_boundary = cdiv(tracker.num_saved_tokens + 1, block_size) * block_size
394
+ token_ids_start = tracker.num_saved_tokens
395
+ chunk_boundary = cdiv(token_ids_start + 1, block_size) * block_size
396
num_tokens_to_save = input_token_len // block_size * block_size
397
398
skip_save = skip_save or num_tokens_to_save < chunk_boundary
@@ -408,8 +409,10 @@ class ReqMeta:
409
tracker.num_saved_tokens = num_tokens_to_save
410
411
token_ids = None
411
- if tracker.token_ids:
412
- token_ids = tracker.token_ids
412
+ if tracker.token_ids and not skip_save:
413
+ # Scheduler tracking continues while this job is handled by an
414
+ # asynchronous worker, so metadata must own a stable snapshot.
415
+ token_ids = tracker.token_ids[token_ids_start:num_tokens_to_save]
416
417
if load_spec is not None and load_spec.can_load:
418
logger.debug(
@@ -433,8 +436,8 @@ class ReqMeta:
436
can_save=not skip_save,
437
load_spec=load_spec,
438
block_hashes=block_hashes,
436
- is_last_chunk=is_last_chunk,
439
token_ids=token_ids,
440
+ token_ids_start=token_ids_start,
441
num_prompt_tokens=tracker.prefill_end_tokens,
442
)
443
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/scheduler.py
+42
-33
@@ -63,6 +63,11 @@ class MooncakeStoreScheduler:
63
self.lookup_async = kvc_extra_config.get("lookup_async", False)
64
# Skips lookup CPU cost on instances that never load KV from the store.
65
self.enable_lookup = kvc_extra_config.get("enable_lookup", True)
66
+ self.save_decode_cache = kvc_extra_config.get("save_decode_cache", False)
67
+ kv_event_config = vllm_config.kv_events_config
68
+ self.enable_kv_events = bool(
69
+ kv_event_config and kv_event_config.enable_kv_cache_events
70
+ )
71
self.client = LookupKeyClient(vllm_config)
72
73
# Align with the engine's own scheduler_block_size and hash_block_size.
@@ -182,7 +187,8 @@ class MooncakeStoreScheduler:
187
self, scheduler_output: SchedulerOutput
188
) -> KVConnectorMetadata:
189
"""Build connector metadata for this scheduler step."""
185
- force_skip_save = self.kv_role == "kv_consumer"
190
+ is_consumer = self.kv_role == "kv_consumer"
191
+ can_process_cached = not is_consumer or self.save_decode_cache
192
193
for finished_req_id in scheduler_output.finished_req_ids:
194
self.client.discard(finished_req_id)
@@ -227,37 +233,38 @@ class MooncakeStoreScheduler:
233
token_len=num_tokens_to_compute,
234
allocated_block_ids=unfolded_block_ids,
235
num_saved_tokens=0,
230
- token_ids=prefill_tokens[:num_tokens_to_compute],
236
+ token_ids=(
237
+ prefill_tokens[:num_tokens_to_compute]
238
+ if self.enable_kv_events
239
+ else None
240
+ ),
241
prefill_end_tokens=len(prefill_tokens),
242
)
243
self._request_trackers[request.req_id] = request_tracker
244
235
- last_chunk_tokens_num = (
236
- len(prefill_tokens) // self._block_size * self._block_size
237
- )
238
-
245
req_meta = ReqMeta.from_request_tracker(
246
request_tracker,
247
self._block_size,
248
load_spec=load_spec,
243
- skip_save=force_skip_save,
249
+ # A consumer may write decode KV without becoming a prefill
250
+ # producer. Loads are still carried by the same metadata.
251
+ skip_save=is_consumer,
252
block_hashes=request_real.block_hashes,
245
- is_last_chunk=(request_tracker.token_len >= last_chunk_tokens_num),
253
)
254
if req_meta is not None:
255
meta.add_request(req_meta)
256
257
# Handle cached (running, or MRV1 resumed-from-preemption) requests
258
cached_reqs = scheduler_output.scheduled_cached_reqs
252
- if not force_skip_save:
259
+ if can_process_cached:
260
for i, req_id in enumerate(cached_reqs.req_ids):
261
new_block_ids = cached_reqs.new_block_ids[i]
255
- if not new_block_ids:
256
- continue
262
263
req_meta = None
264
if req_id in cached_reqs.resumed_req_ids:
265
# Resumed after preemption
266
+ if not new_block_ids:
267
+ continue
268
if isinstance(new_block_ids, tuple):
269
new_block_ids = tuple(b.copy() for b in new_block_ids)
270
else:
@@ -277,27 +284,33 @@ class MooncakeStoreScheduler:
284
token_len=num_tokens_to_compute,
285
allocated_block_ids=new_block_ids,
286
num_saved_tokens=0,
280
- token_ids=prefill_tokens[:num_tokens_to_compute].copy(),
287
+ token_ids=(
288
+ prefill_tokens[:num_tokens_to_compute]
289
+ if self.enable_kv_events
290
+ else None
291
+ ),
292
prefill_end_tokens=len(prefill_tokens),
293
)
294
self._request_trackers[req_id] = request_tracker
295
285
- last_chunk_tokens_num = (
286
- len(prefill_tokens) // self._block_size * self._block_size
287
- )
296
req_meta = ReqMeta.from_request_tracker(
297
request_tracker,
298
self._block_size,
299
load_spec=load_spec,
292
- skip_save=force_skip_save,
300
+ skip_save=is_consumer,
301
block_hashes=request_real.block_hashes,
294
- is_last_chunk=(
295
- request_tracker.token_len >= last_chunk_tokens_num
296
- ),
302
)
303
else:
304
# Decode/chunked request
305
request_tracker = self._request_trackers[req_id]
306
+ num_computed_token = cached_reqs.num_computed_tokens[i]
307
+ # Use the tracker's snapshot of the prefill range so resumed
308
+ # requests keep saving past the original prompt boundary.
309
+ prefill_end = request_tracker.prefill_end_tokens
310
+ is_decode = num_computed_token >= prefill_end
311
+ if is_decode and not self.save_decode_cache:
312
+ continue
313
+
314
num_new_tokens = scheduler_output.num_scheduled_tokens[req_id]
315
req_tuple = self._unfinished_requests.get(req_id)
316
if req_tuple:
@@ -307,30 +320,26 @@ class MooncakeStoreScheduler:
320
num_current_tokens : num_current_tokens + num_new_tokens
321
]
322
request_tracker.token_len += len(new_token_ids)
323
+ if request_tracker.token_ids is not None:
324
+ request_tracker.token_ids.extend(new_token_ids)
325
else:
326
raise ValueError(
327
f"Request {req_id} is not in _unfinished_requests"
328
)
314
- num_computed_token = cached_reqs.num_computed_tokens[i]
315
- # Use the tracker's snapshot of the prefill range so resumed
316
- # requests keep saving past the original prompt boundary.
317
- prefill_end = request_tracker.prefill_end_tokens
318
- if num_computed_token >= prefill_end:
329
+ # A block is usually allocated before the step that fills
330
+ # it, so reaching a save boundary does not imply that this
331
+ # step has new block ids.
332
+ if new_block_ids:
333
+ request_tracker.update(new_block_ids)
334
+ if is_consumer and not is_decode:
335
continue
320
- request_tracker.update(new_block_ids)
336
322
- last_chunk_tokens_num = (
323
- prefill_end // self._block_size * self._block_size
324
- )
337
req_meta = ReqMeta.from_request_tracker(
338
request_tracker,
339
self._block_size,
340
load_spec=None,
329
- skip_save=force_skip_save,
341
+ skip_save=False,
342
block_hashes=unfinished_req.block_hashes,
331
- is_last_chunk=(
332
- request_tracker.token_len >= last_chunk_tokens_num
333
- ),
343
)
344
345
if req_meta is not None:
@@ -370,7 +379,7 @@ class MooncakeStoreScheduler:
379
# emit an offload-only ReqMeta (token_len_chunk=0 skips the normal
380
# save; can_save=True takes the normal enqueue path).
381
step_partial_tails = getattr(scheduler_output, "partial_tail_offloads", None)
373
- if step_partial_tails and not force_skip_save:
382
+ if step_partial_tails and not is_consumer:
383
pending = dict(step_partial_tails)
384
for req_meta in meta.requests:
385
if req_meta.can_save:
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+94
-24
@@ -530,6 +530,9 @@ class KVCacheStoreSendingThread(KVTransferThread):
530
# Per-request high-water mark of tokens actually persisted; the next
531
# batch resumes here, so pressure-skipped or failed ranges are retried.
532
self._saved_offset: dict[str, int] = {}
533
+ # Retained only after a failed store so retry events can recover the
534
+ # token suffix without full snapshots on the normal path.
535
+ self._retry_token_ids: dict[str, tuple[int, list[int]]] = {}
536
537
def add_request(self, request: ReqMeta) -> None:
538
# Register before enqueueing so a job is never picked up unledgered.
@@ -552,6 +555,7 @@ class KVCacheStoreSendingThread(KVTransferThread):
555
del self.stored_requests[req_id]
556
self._skip_store_requests.discard(req_id)
557
self._saved_offset.pop(req_id, None)
558
+ self._retry_token_ids.pop(req_id, None)
559
560
def finish_store_job(self, req_meta: ReqMeta) -> None:
561
"""Retire a job from the ledger and report its blocks as no longer read.
@@ -585,6 +589,36 @@ class KVCacheStoreSendingThread(KVTransferThread):
589
if req_meta.store_job_id in self.stored_requests.get(req_meta.req_id, ()):
590
self._saved_offset[req_meta.req_id] = token_len
591
592
+ def _get_retry_token_ids(self, req_meta: ReqMeta) -> tuple[int, list[int]] | None:
593
+ """Return retry state only if this store job is still live."""
594
+ with self.done_task_lock:
595
+ if req_meta.store_job_id not in self.stored_requests.get(
596
+ req_meta.req_id, ()
597
+ ):
598
+ return None
599
+ return self._retry_token_ids.get(req_meta.req_id)
600
+
601
+ def _update_retry_token_ids(
602
+ self,
603
+ req_meta: ReqMeta,
604
+ save_completed: bool,
605
+ token_ids_start: int,
606
+ event_token_ids: list[int] | None,
607
+ ) -> None:
608
+ """Update retry state without letting a stale job touch a reused ID."""
609
+ with self.done_task_lock:
610
+ if req_meta.store_job_id not in self.stored_requests.get(
611
+ req_meta.req_id, ()
612
+ ):
613
+ return
614
+ if save_completed:
615
+ self._retry_token_ids.pop(req_meta.req_id, None)
616
+ elif event_token_ids is not None:
617
+ self._retry_token_ids[req_meta.req_id] = (
618
+ token_ids_start,
619
+ event_token_ids,
620
+ )
621
+
622
def _should_skip_request(self, req_id: str) -> bool:
623
with self.done_task_lock:
624
return self._store_pressure_active and req_id in self._skip_store_requests
@@ -785,18 +819,30 @@ class KVCacheStoreSendingThread(KVTransferThread):
819
def _handle_request(self, req_meta: ReqMeta):
820
# The single `finally` is the only way out, so the scheduler releases
821
# this job's GPU block references however the job ends.
822
+ save_completed = False
823
+ token_len = 0
824
+ req_id = req_meta.req_id
825
+ event_token_ids = req_meta.token_ids
826
+ token_ids_start = req_meta.token_ids_start
827
try:
828
# Cache hits are always a multiple of ``lcm_block_size`` tokens,
829
# which is also ``store_mask``'s precondition.
830
lcm_block_size = self.coord.lcm_block_size
831
token_len = req_meta.token_len_chunk // lcm_block_size * lcm_block_size
832
block_ids_per_group = req_meta.block_ids
794
- req_id = req_meta.req_id
833
current_event = req_meta.current_event
834
835
if not self.is_live_store_job(req_meta):
836
return
837
838
+ if self.enable_kv_event:
839
+ retry_token_ids = self._get_retry_token_ids(req_meta)
840
+ if retry_token_ids is not None and event_token_ids is not None:
841
+ retry_start, retry_ids = retry_token_ids
842
+ if retry_start + len(retry_ids) == token_ids_start:
843
+ event_token_ids = retry_ids + event_token_ids
844
+ token_ids_start = retry_start
845
+
846
if self._should_skip_request(req_id):
847
logger.debug(
848
"Skipping Mooncake store for request %s while CPU/disk "
@@ -852,6 +898,7 @@ class KVCacheStoreSendingThread(KVTransferThread):
898
899
if not keys:
900
self._record_saved(req_meta, token_len)
901
+ save_completed = True
902
return
903
904
# Check which blocks already exist (dedup)
@@ -878,6 +925,7 @@ class KVCacheStoreSendingThread(KVTransferThread):
925
926
if not missing_indices:
927
self._record_saved(req_meta, token_len)
928
+ save_completed = True
929
return
930
931
if len(missing_indices) != len(keys):
@@ -927,12 +975,11 @@ class KVCacheStoreSendingThread(KVTransferThread):
975
addrs.extend(group_addrs)
976
sizes.extend(group_sizes)
977
930
- # parent_block_hash chains live within a group, not across.
978
if self.enable_kv_event:
932
- prev_key_per_group: dict[int, Any] = {}
979
new_block_hashes = [
980
maybe_convert_block_hash(bh) for bh in kv_event_block_hashes
981
]
982
+ token_ids_end = token_ids_start + len(event_token_ids or ())
983
984
for idx, (s, e, g_idx) in enumerate(
985
zip(starts, ends, group_indices, strict=True)
@@ -940,13 +987,24 @@ class KVCacheStoreSendingThread(KVTransferThread):
987
db = self.token_databases[g_idx]
988
if self.enable_kv_event:
989
token_ids = (
943
- req_meta.token_ids[s:e]
944
- if req_meta.token_ids is not None
945
- else None
990
+ event_token_ids[s - token_ids_start : e - token_ids_start]
991
+ if event_token_ids is not None
992
+ and token_ids_start <= s
993
+ and e <= token_ids_end
994
+ else []
995
)
996
stored_event = BlockStored(
997
block_hashes=[new_block_hashes[idx]],
949
- parent_block_hash=prev_key_per_group.get(g_idx),
998
+ # Derive the direct predecessor from the unfiltered
999
+ # request chain. Adjacent PUTs need not be adjacent in
1000
+ # that chain after Store dedup, masks, or TP striding.
1001
+ parent_block_hash=(
1002
+ maybe_convert_block_hash(
1003
+ req_meta.block_hashes[s // db.hash_block_size - 1]
1004
+ )
1005
+ if s > 0
1006
+ else None
1007
+ ),
1008
token_ids=token_ids,
1009
block_size=db.block_size,
1010
lora_id=None,
@@ -955,7 +1013,6 @@ class KVCacheStoreSendingThread(KVTransferThread):
1013
group_idx=g_idx,
1014
)
1015
stored_events.append(stored_event)
958
- prev_key_per_group[g_idx] = new_block_hashes[idx]
1016
1017
if current_event is not None:
1018
current_event.synchronize()
@@ -984,6 +1041,13 @@ class KVCacheStoreSendingThread(KVTransferThread):
1041
)
1042
if failed:
1043
failed_codes = set(res[i] for i in failed)
1044
+ if self.enable_kv_event:
1045
+ failed_indices = set(failed)
1046
+ stored_events = [
1047
+ event
1048
+ for i, event in enumerate(stored_events)
1049
+ if i not in failed_indices
1050
+ ]
1051
logger.warning(
1052
"batch_put failed: %d/%d keys failed "
1053
"(codes=%s, batch_bytes=%d, num_keys=%d), "
@@ -1008,6 +1072,7 @@ class KVCacheStoreSendingThread(KVTransferThread):
1072
)
1073
else:
1074
self._record_saved(req_meta, token_len)
1075
+ save_completed = True
1076
if self._clear_store_pressure():
1077
logger.info(
1078
"Mooncake CPU/disk offloading pressure cleared "
@@ -1023,10 +1088,18 @@ class KVCacheStoreSendingThread(KVTransferThread):
1088
num_failed_keys=len(keys),
1089
)
1090
logger.error("Failed to put key %s, error: %s", keys, e)
1091
+ stored_events.clear()
1092
1093
if self.enable_kv_event and stored_events:
1094
self.update_kv_event(stored_events)
1095
finally:
1096
+ if self.enable_kv_event and token_len:
1097
+ self._update_retry_token_ids(
1098
+ req_meta,
1099
+ save_completed,
1100
+ token_ids_start,
1101
+ event_token_ids,
1102
+ )
1103
self.finish_store_job(req_meta)
1104
self.request_queue.task_done()
1105
@@ -1274,15 +1347,19 @@ class MooncakeStoreWorker:
1347
self.dcp_rank = get_dcp_group().rank_in_group if self.dcp_size > 1 else 0
1348
1349
assert vllm_config.kv_transfer_config is not None
1277
- self.kv_role = vllm_config.kv_transfer_config.kv_role
1278
- self.load_async = vllm_config.kv_transfer_config.kv_connector_extra_config.get(
1279
- "load_async", True
1350
+ kv_role = vllm_config.kv_transfer_config.kv_role
1351
+ assert kv_role is not None
1352
+ self.kv_role = kv_role
1353
+ extra_config = vllm_config.kv_transfer_config.kv_connector_extra_config
1354
+ self.can_put = self.kv_role in ("kv_producer", "kv_both") or (
1355
+ extra_config.get("save_decode_cache", False)
1356
)
1357
+ self.load_async = extra_config.get("load_async", True)
1358
# Mirrors MooncakeStoreConnector._capacity_only.
1282
- self._capacity_only = self.kv_role == "kv_consumer" and not (
1283
- vllm_config.kv_transfer_config.kv_connector_extra_config.get(
1284
- "enable_lookup", True
1285
- )
1359
+ self._capacity_only = (
1360
+ self.kv_role == "kv_consumer"
1361
+ and not extra_config.get("enable_lookup", True)
1362
+ and not self.can_put
1363
)
1364
self.cache_config = vllm_config.cache_config
1365
self.block_size, self.hash_block_size = resolve_kv_cache_block_sizes(
@@ -1294,11 +1371,6 @@ class MooncakeStoreWorker:
1371
1372
# Initialize MooncakeDistributedStore with its own TransferEngine
1373
store_config = MooncakeStoreConfig.load_from_config()
1297
- extra_config = (
1298
- vllm_config.kv_transfer_config.kv_connector_extra_config
1299
- if vllm_config.kv_transfer_config
1300
- else {}
1301
- )
1374
self.store = MooncakeDistributedStore()
1375
local_ip = get_ip()
1376
local_hostname = rdma_utils.get_requester_local_hostname(local_ip)
@@ -1617,7 +1689,7 @@ class MooncakeStoreWorker:
1689
db.set_block_len(block_lens)
1690
1691
# Start transfer threads
1620
- if self.kv_role in ["kv_producer", "kv_both"]:
1692
+ if self.can_put:
1693
ready_event_sending = threading.Event()
1694
self.kv_send_thread = KVCacheStoreSendingThread(
1695
self.store,
@@ -1700,7 +1772,7 @@ class MooncakeStoreWorker:
1772
1773
assert self.load_async, "load_async must be True for better performance."
1774
# Issue stores with CUDA event synchronization.
1703
- if self.kv_role in ["kv_producer", "kv_both"]:
1775
+ if self.can_put:
1776
current_event = None
1777
for request in meta.requests:
1778
if request.can_save:
@@ -1714,8 +1786,6 @@ class MooncakeStoreWorker:
1786
request.current_event = current_event
1787
assert self.kv_send_thread is not None
1788
self.kv_send_thread.add_request(request)
1717
-
1718
- if self.kv_role in ["kv_producer", "kv_both"]:
1789
self._close_ended_store_requests(finished_req_ids, meta)
1790
1791
# Blocks read by a store job are released by the scheduler when the job