[Bugfix][Mooncake] Reference GPU blocks for in-flight store jobs and key the store ledger by store_job_id (#52372)
Signed-off-by: Guanyi Chen <939416532@qq.com>
Guanyi Chen committed
Aug 17, 2026 at 07:09 UTC
dc9ae4b8ac2331991ad7091812ef82ece4f8fdc2
7 files changed
+474
-250
tests/v1/kv_connector/unit/test_mooncake_store_hma_e2e.py
+7
-8
@@ -228,12 +228,11 @@ def test_e2e_swa_plus_full_save_then_lookup_hits():
228
block_ids=([0, 1, 2, 3], [0, 1, 2, 3]),
229
block_hashes=hs,
230
can_save=True,
231
+ store_job_id=1,
232
)
232
- send_thread.add_stored_request("r0")
233
- # Put the request in the queue so task_done() doesn't underflow.
234
- send_thread.request_queue.put(save_req)
235
- req = send_thread.request_queue.get()
236
- send_thread._handle_request(req)
233
+ # add_request also queues the job, so task_done() doesn't underflow.
234
+ send_thread.add_request(save_req)
235
+ send_thread._handle_request(send_thread.request_queue.get())
236
237
# Point worker.store at the dict store (the worker constructor captured
238
# the MagicMock; replace with the real dict store for lookup).
@@ -499,15 +498,15 @@ def test_offload_syncs_event_before_put():
498
can_save=True,
499
num_prompt_tokens=12,
500
partial_tail_offloads=[(1, 7, 12)],
501
+ store_job_id=1,
502
)
503
req.current_event = event
504
- send.add_stored_request("r1")
504
506
- send.request_queue.put(req)
505
+ send.add_request(req)
506
send._handle_request(send.request_queue.get())
507
assert send.request_queue.qsize() == 0
508
assert store._data
510
- assert send.stored_requests["r1"] == 0
509
+ assert send.stored_requests["r1"] == set()
510
event.synchronize.assert_called_once()
511
512
tests/v1/kv_connector/unit/test_mooncake_store_scheduler.py
+125
-54
@@ -5,12 +5,14 @@ from types import SimpleNamespace
5
6
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
7
LoadSpec,
8
+ MooncakeStoreWorkerMetadata,
9
ReqMeta,
10
RequestTracker,
11
)
12
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.scheduler import (
13
MooncakeStoreScheduler,
14
)
15
+from vllm.v1.core.block_pool import BlockPool
16
17
18
def _make_bare_scheduler(
@@ -27,6 +29,12 @@ def _make_bare_scheduler(
29
scheduler._unfinished_request_ids = {"req-0"}
30
scheduler._unfinished_requests = {}
31
scheduler._request_trackers = {}
32
+ scheduler._gpu_block_pool = BlockPool(
33
+ num_gpu_blocks=64, enable_caching=True, hash_block_size=hash_block_size
34
+ )
35
+ scheduler._num_workers = 1
36
+ scheduler._next_store_job_id = 0
37
+ scheduler._pinned_saves = {}
38
return scheduler
39
40
@@ -64,6 +72,14 @@ def _make_preemption_scheduler_output():
72
)
73
74
75
+def _make_worker_output(completed_saves: dict[int, int]) -> SimpleNamespace:
76
+ return SimpleNamespace(
77
+ kv_connector_worker_meta=MooncakeStoreWorkerMetadata(
78
+ completed_saves=completed_saves
79
+ )
80
+ )
81
+
82
+
83
def _add_unfinished_request(
84
scheduler: MooncakeStoreScheduler,
85
*,
@@ -133,7 +149,7 @@ def test_cached_request_without_spec_decode_keeps_current_step_save_overlap():
149
assert tracker.num_saved_tokens == 48
150
151
136
-def test_preemption_resets_tracker_before_request_finished():
152
+def test_preemption_resets_tracker():
153
scheduler = _make_bare_scheduler()
154
_add_unfinished_request(
155
scheduler,
@@ -152,8 +168,6 @@ def test_preemption_resets_tracker_before_request_finished():
168
assert tracker.token_ids is None
169
assert tracker.has_pending_offload is False
170
assert tracker.prefill_end_tokens == 0
155
- request = SimpleNamespace(request_id="req-0")
156
- assert scheduler.request_finished(request, ([0, 1],)) == (False, None)
171
172
173
def test_preemption_clears_stale_load_state():
@@ -215,7 +229,7 @@ def test_pending_load_does_not_co_queue_save():
229
# enqueue a save in the same scheduling step. Co-queuing both produces a
230
# recv+send pair for the same req_id, and the scheduler's
231
# _update_from_kv_xfer_finished then trips `assert req_id in self.requests`
218
- # when both completions land for the delay-freed request.
232
+ # when a completion lands for a request it has already dropped.
233
scheduler = _make_bare_scheduler()
234
_make_pending_load_unfinished_request(
235
scheduler,
@@ -238,9 +252,7 @@ def test_pending_load_does_not_co_queue_save():
252
# Load is still issued as planned.
253
assert req_meta.load_spec is not None
254
assert req_meta.load_spec.can_load is True
241
- # And the tracker's saved-tokens watermark stays at 0 so request_finished
242
- # later sees `num_saved_tokens <= 0` and frees immediately rather than
243
- # waiting for a finished_sending that will never come.
255
+ # And the save watermark does not advance for a save that was never queued.
256
tracker = scheduler._request_trackers["req-0"]
257
assert tracker.num_saved_tokens == 0
258
@@ -661,30 +673,34 @@ def test_disabled_lookup_reports_no_hit_without_querying_client():
673
assert scheduler.load_specs == {}
674
675
664
-def test_pending_partial_tail_emits_offload_only_reqmeta():
665
- # A sub-block prompt never produces a block-aligned save, so the partial-
666
- # tail offload arriving this step is emitted as an offload-only ReqMeta
667
- # (can_save=True so it takes the normal enqueue path, token_len_chunk=0 so
668
- # the worker skips the normal save). Pending-offload state delays the free
669
- # without advancing the normal-save watermark before the put succeeds.
670
- scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
676
+def _add_pending_partial_tail_request(
677
+ scheduler: MooncakeStoreScheduler,
678
+ *,
679
+ num_tokens: int,
680
+ block_hashes: list[bytes],
681
+ block_ids: tuple[list[int], ...],
682
+) -> SimpleNamespace:
683
+ """Register a sub-block request and return the step that offloads its tail.
684
+
685
+ The CoW block holding the tail is block 7, which the core deliberately keeps
686
+ out of the request's block table.
687
+ """
688
request = SimpleNamespace(
672
- all_token_ids=list(range(12)),
673
- block_hashes=[b"h0", b"h1", b"h2"],
689
+ all_token_ids=list(range(num_tokens)),
690
+ block_hashes=block_hashes,
691
num_output_placeholders=0,
692
num_prompt_tokens=12,
693
)
677
- scheduler._unfinished_requests["req-0"] = (request, ([0],))
694
+ scheduler._unfinished_requests["req-0"] = (request, block_ids)
695
scheduler._request_trackers["req-0"] = RequestTracker(
696
req_id="req-0",
680
- token_len=12,
681
- allocated_block_ids=([0],),
697
+ token_len=num_tokens,
698
+ allocated_block_ids=block_ids,
699
num_saved_tokens=0,
683
- token_ids=list(range(12)),
684
- prefill_end_tokens=12,
700
+ token_ids=list(range(num_tokens)),
701
+ prefill_end_tokens=num_tokens,
702
)
686
-
687
- out = SimpleNamespace(
703
+ return SimpleNamespace(
704
finished_req_ids=set(),
705
preempted_req_ids=set(),
706
scheduled_new_reqs=[],
@@ -699,6 +715,21 @@ def test_pending_partial_tail_emits_offload_only_reqmeta():
715
partial_tail_offloads={"req-0": [(1, 7, 12)]},
716
)
717
718
+
719
+def test_pending_partial_tail_emits_offload_only_reqmeta():
720
+ # A sub-block prompt never produces a block-aligned save, so the partial-
721
+ # tail offload arriving this step is emitted as an offload-only ReqMeta
722
+ # (can_save=True so it takes the normal enqueue path, token_len_chunk=0 so
723
+ # the worker skips the normal save), without advancing the normal-save
724
+ # watermark before the put succeeds.
725
+ scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
726
+ out = _add_pending_partial_tail_request(
727
+ scheduler,
728
+ num_tokens=12,
729
+ block_hashes=[b"h0", b"h1", b"h2"],
730
+ block_ids=([0],),
731
+ )
732
+
733
meta = scheduler.build_connector_meta(out)
734
735
assert len(meta.requests) == 1
@@ -712,42 +743,16 @@ def test_pending_partial_tail_emits_offload_only_reqmeta():
743
tracker = scheduler._request_trackers["req-0"]
744
assert tracker.num_saved_tokens == 0
745
assert tracker.has_pending_offload is True
715
- request = SimpleNamespace(request_id="req-0")
716
- assert scheduler.request_finished(request, ([0],)) == (True, None)
746
747
748
def test_resumed_partial_tail_uses_handoff_boundary():
749
scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
721
- request = SimpleNamespace(
722
- all_token_ids=list(range(20)),
750
+ # Resumption replays prompt + previously generated tokens.
751
+ out = _add_pending_partial_tail_request(
752
+ scheduler,
753
+ num_tokens=20,
754
block_hashes=[b"h0", b"h1", b"h2", b"h3", b"h4"],
724
- num_output_placeholders=0,
725
- num_prompt_tokens=12,
726
- )
727
- scheduler._unfinished_requests["req-0"] = (request, ([0, 1],))
728
- scheduler._request_trackers["req-0"] = RequestTracker(
729
- req_id="req-0",
730
- token_len=20,
731
- allocated_block_ids=([0, 1],),
732
- num_saved_tokens=0,
733
- token_ids=list(range(20)),
734
- # Resumption replays prompt + previously generated tokens.
735
- prefill_end_tokens=20,
736
- )
737
-
738
- out = SimpleNamespace(
739
- finished_req_ids=set(),
740
- preempted_req_ids=set(),
741
- scheduled_new_reqs=[],
742
- scheduled_cached_reqs=SimpleNamespace(
743
- req_ids=[],
744
- new_block_ids=[],
745
- num_computed_tokens=[],
746
- resumed_req_ids=set(),
747
- ),
748
- num_scheduled_tokens={},
749
- scheduled_spec_decode_tokens={},
750
- partial_tail_offloads={"req-0": [(1, 7, 12)]},
755
+ block_ids=([0, 1],),
756
)
757
758
meta = scheduler.build_connector_meta(out)
@@ -791,3 +796,69 @@ def test_resumed_partial_tail_attached_to_save_keeps_handoff_boundary():
796
tracker = scheduler._request_trackers["req-0"]
797
assert tracker.num_saved_tokens == 48
798
assert tracker.has_pending_offload is True
799
+
800
+
801
+def test_partial_tail_cow_block_is_referenced_for_the_job():
802
+ # The CoW block a partial-tail offload reads is deliberately kept out of the
803
+ # request block table, so it is absent from ReqMeta.block_ids. The worker
804
+ # DMAs out of it just as asynchronously, so it needs its own reference.
805
+ scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
806
+ out = _add_pending_partial_tail_request(
807
+ scheduler,
808
+ num_tokens=12,
809
+ block_hashes=[b"h0", b"h1", b"h2"],
810
+ block_ids=([0],),
811
+ )
812
+ pool = scheduler._gpu_block_pool
813
+
814
+ meta = scheduler.build_connector_meta(out)
815
+
816
+ store_job_id = meta.requests[0].store_job_id
817
+ # It leads the list, as in `pop_blocks_for_free`, so that the reversed free
818
+ # puts it last in eviction priority.
819
+ assert scheduler._pinned_saves[store_job_id][0] == [7, 0]
820
+ assert pool.blocks[7].ref_cnt == 1
821
+
822
+ scheduler.update_connector_output(_make_worker_output({store_job_id: 1}))
823
+ assert pool.blocks[7].ref_cnt == 0
824
+
825
+
826
+def test_store_job_blocks_are_released_once_every_rank_reports():
827
+ # Every rank DMAs the job's blocks on its own, so the reference can only be
828
+ # dropped once the last of them reports. Until then the engine has to keep
829
+ # stepping: a completion only reaches the scheduler as worker metadata
830
+ # attached to a step, and a finishing request no longer defers its own free.
831
+ scheduler = _make_bare_scheduler()
832
+ scheduler._num_workers = 2
833
+ _add_unfinished_request(
834
+ scheduler,
835
+ token_ids=list(range(48)),
836
+ block_hashes=[b"h0", b"h1", b"h2"],
837
+ prefill_end_tokens=48,
838
+ )
839
+ pool = scheduler._gpu_block_pool
840
+ assert scheduler.has_pending_push_work() is False
841
+
842
+ meta = scheduler.build_connector_meta(
843
+ _make_scheduler_output(scheduled_spec_tokens=None)
844
+ )
845
+ store_job_id = meta.requests[0].store_job_id
846
+ assert pool.blocks[2].ref_cnt == 1
847
+ assert scheduler.has_pending_push_work() is True
848
+
849
+ scheduler.update_connector_output(_make_worker_output({store_job_id: 1}))
850
+ assert pool.blocks[2].ref_cnt == 1
851
+ assert scheduler.has_pending_push_work() is True
852
+
853
+ scheduler.update_connector_output(_make_worker_output({store_job_id: 1}))
854
+ assert pool.blocks[2].ref_cnt == 0
855
+ assert scheduler.has_pending_push_work() is False
856
+
857
+
858
+def test_worker_metadata_aggregates_completions_across_ranks():
859
+ # The engine merges each rank's metadata before the scheduler sees it, so a
860
+ # job that every rank finished in one step arrives as a single count.
861
+ merged = MooncakeStoreWorkerMetadata(completed_saves={1: 1}).aggregate(
862
+ MooncakeStoreWorkerMetadata(completed_saves={1: 1, 2: 1})
863
+ )
864
+ assert merged.completed_saves == {1: 2, 2: 1}
tests/v1/kv_connector/unit/test_mooncake_store_worker.py
+117
-99
@@ -1,6 +1,8 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4
+import contextlib
5
+import itertools
6
import json
7
import logging
8
import math
@@ -156,6 +158,17 @@ def _make_load_req(
158
)
159
160
161
+_TEST_SAVE_SEQ = itertools.count(1)
162
+
163
+
164
+def _run_store_req(thread, req_meta: ReqMeta) -> None:
165
+ """Register, enqueue and run a store job the way the worker does."""
166
+ if req_meta.store_job_id is None:
167
+ req_meta.store_job_id = next(_TEST_SAVE_SEQ)
168
+ thread.add_request(req_meta)
169
+ thread._handle_request(thread.request_queue.get())
170
+
171
+
172
def _make_store_req(req_id: str, block_hashes: list[bytes]) -> ReqMeta:
173
return ReqMeta(
174
req_id=req_id,
@@ -385,27 +398,23 @@ def test_store_sending_thread_skips_request_during_cpu_pressure():
398
]
399
thread = _make_store_sending_thread(store)
400
388
- thread.add_stored_request("req-a")
389
- thread._handle_request(_make_store_req("req-a", [b"a0", b"a1"]))
401
+ _run_store_req(thread, _make_store_req("req-a", [b"a0", b"a1"]))
402
403
assert thread._store_pressure_active is True
404
assert "req-a" in thread._skip_store_requests
405
assert store.batch_put_from_multi_buffers.call_count == 1
406
395
- thread.add_stored_request("req-a")
396
- thread._handle_request(_make_store_req("req-a", [b"a2", b"a3"]))
407
+ _run_store_req(thread, _make_store_req("req-a", [b"a2", b"a3"]))
408
409
assert store.batch_put_from_multi_buffers.call_count == 1
410
400
- thread.add_stored_request("req-b")
401
- thread._handle_request(_make_store_req("req-b", [b"b0", b"b1"]))
411
+ _run_store_req(thread, _make_store_req("req-b", [b"b0", b"b1"]))
412
413
assert thread._store_pressure_active is False
414
assert "req-a" not in thread._skip_store_requests
415
assert store.batch_put_from_multi_buffers.call_count == 2
416
407
- thread.add_stored_request("req-a")
408
- thread._handle_request(_make_store_req("req-a", [b"a4", b"a5"]))
417
+ _run_store_req(thread, _make_store_req("req-a", [b"a4", b"a5"]))
418
419
assert store.batch_put_from_multi_buffers.call_count == 3
420
@@ -418,8 +427,7 @@ def test_store_sending_thread_records_mooncake_metrics():
427
stats = MooncakeStoreConnectorStats()
428
thread._record_operation_cb = stats.record_operation
429
421
- thread.add_stored_request("req-a")
422
- thread._handle_request(_make_store_req("req-a", [b"a0", b"a1"]))
430
+ _run_store_req(thread, _make_store_req("req-a", [b"a0", b"a1"]))
431
432
assert len(stats.data["save_exists"]) == 1
433
assert stats.data["save_exists"][0]["num_keys"] == 2
@@ -497,16 +505,16 @@ def test_store_sending_thread_delta_saves_only_new_full_attention_chunks():
505
store.batch_put_from_multi_buffers.return_value = [256, 256]
506
thread = _make_store_sending_thread(store)
507
500
- thread.add_stored_request("req-a")
508
thread._saved_offset["req-a"] = 32
502
- thread._handle_request(
509
+ _run_store_req(
510
+ thread,
511
ReqMeta(
512
req_id="req-a",
513
token_len_chunk=64,
514
block_ids=([0, 1, 2, 3],),
515
block_hashes=[b"a0", b"a1", b"a2", b"a3"],
516
can_save=True,
509
- )
517
+ ),
518
)
519
520
keys = store.batch_is_exist.call_args.args[0]
@@ -523,16 +531,16 @@ def test_store_sending_thread_delta_strides_with_local_phase():
531
store.batch_put_from_multi_buffers.return_value = [256]
532
thread = _make_store_sending_thread(store, tp_rank=0, put_step=2)
533
526
- thread.add_stored_request("req-a")
534
thread._saved_offset["req-a"] = 16
528
- thread._handle_request(
535
+ _run_store_req(
536
+ thread,
537
ReqMeta(
538
req_id="req-a",
539
token_len_chunk=64,
540
block_ids=([0, 1, 2, 3],),
541
block_hashes=[b"a0", b"a1", b"a2", b"a3"],
542
can_save=True,
535
- )
543
+ ),
544
)
545
546
keys = store.batch_is_exist.call_args.args[0]
@@ -550,15 +558,15 @@ def test_tp_sharded_group_saves_every_block_on_every_rank():
558
thread = _make_store_sending_thread(store, tp_rank=0, put_step=2)
559
thread.group_put_steps = [1]
560
553
- thread.add_stored_request("req-a")
554
- thread._handle_request(
561
+ _run_store_req(
562
+ thread,
563
ReqMeta(
564
req_id="req-a",
565
token_len_chunk=64,
566
block_ids=([0, 1, 2, 3],),
567
block_hashes=[b"a0", b"a1", b"a2", b"a3"],
568
can_save=True,
561
- )
569
+ ),
570
)
571
572
keys = store.batch_is_exist.call_args.args[0]
@@ -576,15 +584,15 @@ def test_store_sending_thread_retries_skipped_range_after_pressure():
584
thread._store_pressure_active = True
585
thread._skip_store_requests.add("req-a")
586
579
- thread.add_stored_request("req-a")
580
- thread._handle_request(
587
+ _run_store_req(
588
+ thread,
589
ReqMeta(
590
req_id="req-a",
591
token_len_chunk=16,
592
block_ids=([0],),
593
block_hashes=[b"a0"],
594
can_save=True,
587
- )
595
+ ),
596
)
597
598
store.batch_is_exist.assert_not_called()
@@ -595,15 +603,15 @@ def test_store_sending_thread_retries_skipped_range_after_pressure():
603
604
# The next batch resumes from offset 0, re-covering the chunk skipped under
605
# pressure (chunk 0) rather than losing it.
598
- thread.add_stored_request("req-a")
599
- thread._handle_request(
606
+ _run_store_req(
607
+ thread,
608
ReqMeta(
609
req_id="req-a",
610
token_len_chunk=64,
611
block_ids=([0, 1, 2, 3],),
612
block_hashes=[b"a0", b"a1", b"a2", b"a3"],
613
can_save=True,
606
- )
614
+ ),
615
)
616
617
keys = store.batch_is_exist.call_args.args[0]
@@ -703,13 +711,11 @@ def test_partial_tail_offload_honors_active_pressure_gate():
711
thread = _make_partial_tail_send_thread(store)
712
thread._store_pressure_active = True
713
thread._skip_store_requests.add("req-a")
706
- thread.add_stored_request("req-a")
714
708
- thread._handle_request(_make_partial_tail_req([1, 2, 3]))
715
+ _run_store_req(thread, _make_partial_tail_req([1, 2, 3]))
716
717
store.batch_is_exist.assert_not_called()
718
store.batch_put_from_multi_buffers.assert_not_called()
712
- assert thread.stored_requests["req-a"] == 0
719
720
721
def test_partial_tail_put_failure_activates_pressure_gate():
@@ -717,17 +723,14 @@ def test_partial_tail_put_failure_activates_pressure_gate():
723
store.batch_is_exist.side_effect = lambda keys: [0] * len(keys)
724
store.batch_put_from_multi_buffers.return_value = [256, -200, 256]
725
thread = _make_partial_tail_send_thread(store)
720
- thread.add_stored_request("req-a")
726
722
- thread._handle_request(_make_partial_tail_req([1, 2, 3]))
727
+ _run_store_req(thread, _make_partial_tail_req([1, 2, 3]))
728
729
assert thread._store_pressure_active is True
730
assert thread._skip_store_requests == {"req-a"}
731
assert thread._saved_offset.get("req-a", 0) == 0
727
- assert thread.stored_requests["req-a"] == 0
732
729
- thread.add_stored_request("req-a")
730
- thread._handle_request(_make_partial_tail_req([1, 2, 3]))
733
+ _run_store_req(thread, _make_partial_tail_req([1, 2, 3]))
734
assert store.batch_put_from_multi_buffers.call_count == 1
735
736
@@ -737,16 +740,16 @@ def test_store_sending_thread_delta_start_rank_saves_second_local_chunk():
740
store.batch_put_from_multi_buffers.return_value = [256, 256]
741
thread = _make_store_sending_thread(store, tp_rank=1, put_step=2)
742
740
- thread.add_stored_request("req-a")
743
thread._saved_offset["req-a"] = 16
742
- thread._handle_request(
744
+ _run_store_req(
745
+ thread,
746
ReqMeta(
747
req_id="req-a",
748
token_len_chunk=64,
749
block_ids=([0, 1, 2, 3],),
750
block_hashes=[b"a0", b"a1", b"a2", b"a3"],
751
can_save=True,
749
- )
752
+ ),
753
)
754
755
keys = store.batch_is_exist.call_args.args[0]
@@ -790,16 +793,16 @@ def test_store_sending_thread_delta_saves_only_new_masked_chunks():
793
token_databases=[db_full, db_masked],
794
)
795
793
- thread.add_stored_request("req-a")
796
thread._saved_offset["req-a"] = 32
795
- thread._handle_request(
797
+ _run_store_req(
798
+ thread,
799
ReqMeta(
800
req_id="req-a",
801
token_len_chunk=64,
802
block_ids=([0, 1, 2, 3], [0, 1, 2, 3]),
803
block_hashes=[b"a0", b"a1", b"a2", b"a3"],
804
can_save=True,
802
- )
805
+ ),
806
)
807
808
keys = store.batch_is_exist.call_args.args[0]
@@ -845,15 +848,15 @@ def test_store_sending_thread_prepares_missing_chunks_once_per_group():
848
coord=coord,
849
token_databases=[db0, db1],
850
)
848
- thread.add_stored_request("req-a")
849
- thread._handle_request(
851
+ _run_store_req(
852
+ thread,
853
ReqMeta(
854
req_id="req-a",
855
token_len_chunk=48,
856
block_ids=([0, 1, 2], [2, 1, 0]),
857
block_hashes=[b"a0", b"a1", b"a2"],
858
can_save=True,
856
- )
859
+ ),
860
)
861
862
db0.prepare_value.assert_not_called()
@@ -881,46 +884,71 @@ def test_store_sending_thread_only_skips_on_no_available_handle():
884
]
885
thread = _make_store_sending_thread(store)
886
884
- thread.add_stored_request("req-a")
885
- thread._handle_request(_make_store_req("req-a", [b"a0", b"a1"]))
887
+ _run_store_req(thread, _make_store_req("req-a", [b"a0", b"a1"]))
888
889
assert thread._store_pressure_active is False
890
assert "req-a" not in thread._skip_store_requests
891
assert store.batch_put_from_multi_buffers.call_count == 1
892
891
- thread.add_stored_request("req-a")
892
- thread._handle_request(_make_store_req("req-a", [b"a2", b"a3"]))
893
+ _run_store_req(thread, _make_store_req("req-a", [b"a2", b"a3"]))
894
895
assert store.batch_put_from_multi_buffers.call_count == 2
896
897
897
-def test_store_sending_thread_releases_pin_on_batch_is_exist_failure():
898
- # `batch_is_exist` raising must still decrement `stored_requests` so the
899
- # scheduler can drop `delay_free_blocks` and release the pinned GPU blocks.
898
+@pytest.mark.parametrize(
899
+ "failing_call", ["batch_is_exist", "batch_put_from_multi_buffers"]
900
+)
901
+def test_store_sending_thread_reports_job_when_store_raises(failing_call):
902
+ # A store that blows up must still report its job, or the scheduler keeps
903
+ # the job's GPU block references for the rest of the run.
904
store = MagicMock()
901
- store.batch_is_exist.side_effect = RuntimeError("mooncake down")
905
+ store.batch_is_exist.return_value = [0, 0]
906
+ getattr(store, failing_call).side_effect = RuntimeError("mooncake down")
907
thread = _make_store_sending_thread(store)
908
+ req = _make_store_req("req-a", [b"a0", b"a1"])
909
904
- thread.add_stored_request("req-a")
905
- with pytest.raises(RuntimeError):
906
- thread._handle_request(_make_store_req("req-a", [b"a0", b"a1"]))
910
+ with contextlib.suppress(RuntimeError):
911
+ _run_store_req(thread, req)
912
908
- assert thread.stored_requests["req-a"] == 0
909
- store.batch_put_from_multi_buffers.assert_not_called()
913
+ assert thread.take_completed_saves() == {req.store_job_id: 1}
914
915
912
-def test_store_sending_thread_releases_pin_on_batch_put_failure():
913
- # `batch_put_from_multi_buffers` raising is logged (not re-raised), and the
914
- # pin must still be released through the finally block.
915
- store = MagicMock()
916
- store.batch_is_exist.return_value = [0, 0]
917
- store.batch_put_from_multi_buffers.side_effect = RuntimeError("rdma error")
918
- thread = _make_store_sending_thread(store)
916
+def test_store_sending_thread_reports_job_when_the_preamble_raises():
917
+ # The report has to survive a failure before the first store call too, so
918
+ # every dequeue leaves through the same exit.
919
+ thread = _make_store_sending_thread(MagicMock())
920
+ req = _make_store_req("req-a", [b"a0", b"a1"])
921
+ req.token_len_chunk = None # type: ignore[assignment]
922
+
923
+ with contextlib.suppress(TypeError):
924
+ _run_store_req(thread, req)
925
920
- thread.add_stored_request("req-a")
921
- thread._handle_request(_make_store_req("req-a", [b"a0", b"a1"]))
926
+ assert thread.take_completed_saves() == {req.store_job_id: 1}
927
+ thread.request_queue.task_done.assert_called_once()
928
923
- assert thread.stored_requests["req-a"] == 0
929
+
930
+def test_stale_store_job_cannot_touch_a_reused_request_id():
931
+ # A preempted request resumes under its original id, so a job left over from
932
+ # the retired generation carries a req_id that now belongs to a live one.
933
+ thread = _make_store_sending_thread(MagicMock())
934
+ stale = _make_store_req("req-a", [b"a0", b"a1"])
935
+ stale.store_job_id = 1
936
+ thread.add_request(stale)
937
+ thread._record_saved(stale, 32)
938
+ thread.delete_finished_stored_request("req-a")
939
+
940
+ live = _make_store_req("req-a", [b"a0", b"a1"])
941
+ live.store_job_id = 2
942
+ thread.add_request(live)
943
+
944
+ thread.finish_store_job(stale)
945
+ thread._record_saved(stale, 64)
946
+ thread._mark_request_skipped_for_pressure(stale)
947
+
948
+ assert thread.is_live_store_job(live)
949
+ assert not thread.is_live_store_job(stale)
950
+ assert thread._saved_offset.get("req-a") is None
951
+ assert "req-a" not in thread._skip_store_requests
952
953
954
def test_store_recving_thread_reports_failed_block_ids():
@@ -991,8 +1019,7 @@ def test_store_sending_thread_passes_replicate_config_when_preferred_segment_set
1019
replicate_config = SimpleNamespace(preferred_segment="10.0.0.7:50053")
1020
thread = _make_store_sending_thread(store, replicate_config=replicate_config)
1021
994
- thread.add_stored_request("req-a")
995
- thread._handle_request(_make_store_req("req-a", [b"a0", b"a1"]))
1022
+ _run_store_req(thread, _make_store_req("req-a", [b"a0", b"a1"]))
1023
1024
assert store.batch_put_from_multi_buffers.call_count == 1
1025
call_args = store.batch_put_from_multi_buffers.call_args.args
@@ -1010,8 +1037,7 @@ def test_store_sending_thread_passes_default_replicate_config_when_no_preferred_
1037
replicate_config = SimpleNamespace()
1038
thread = _make_store_sending_thread(store, replicate_config=replicate_config)
1039
1013
- thread.add_stored_request("req-a")
1014
- thread._handle_request(_make_store_req("req-a", [b"a0", b"a1"]))
1040
+ _run_store_req(thread, _make_store_req("req-a", [b"a0", b"a1"]))
1041
1042
assert store.batch_put_from_multi_buffers.call_count == 1
1043
call_args = store.batch_put_from_multi_buffers.call_args.args
@@ -1067,8 +1093,7 @@ def test_store_sending_thread_sets_group_ids_when_enabled():
1093
supports_group_ids=True,
1094
)
1095
1070
- thread.add_stored_request("req-a")
1071
- thread._handle_request(_make_store_req("req-a", [b"a0", b"a1"]))
1096
+ _run_store_req(thread, _make_store_req("req-a", [b"a0", b"a1"]))
1097
1098
assert store.batch_put_from_multi_buffers.call_count == 1
1099
keys, _addrs, _sizes, config = store.batch_put_from_multi_buffers.call_args.args
@@ -1092,8 +1117,7 @@ def test_store_sending_thread_leaves_group_ids_unchanged_when_flag_disabled():
1117
supports_group_ids=True,
1118
)
1119
1095
- thread.add_stored_request("req-a")
1096
- thread._handle_request(_make_store_req("req-a", [b"a0", b"a1"]))
1120
+ _run_store_req(thread, _make_store_req("req-a", [b"a0", b"a1"]))
1121
1122
assert store.batch_put_from_multi_buffers.call_count == 1
1123
assert store.batch_put_from_multi_buffers.call_args.args[3] is replicate_config
@@ -1112,8 +1136,7 @@ def test_store_sending_thread_leaves_group_ids_unchanged_when_unsupported():
1136
supports_group_ids=False,
1137
)
1138
1115
- thread.add_stored_request("req-a")
1116
- thread._handle_request(_make_store_req("req-a", [b"a0", b"a1"]))
1139
+ _run_store_req(thread, _make_store_req("req-a", [b"a0", b"a1"]))
1140
1141
assert store.batch_put_from_multi_buffers.call_count == 1
1142
assert store.batch_put_from_multi_buffers.call_args.args[3] is replicate_config
@@ -1180,8 +1203,7 @@ def test_store_sending_thread_group_id_excludes_physical_sharding():
1203
supports_group_ids=True,
1204
)
1205
1183
- thread.add_stored_request("req-a")
1184
- thread._handle_request(_make_store_req("req-a", [b"a0", b"a1"]))
1206
+ _run_store_req(thread, _make_store_req("req-a", [b"a0", b"a1"]))
1207
1208
keys, _addrs, _sizes, config = store.batch_put_from_multi_buffers.call_args.args
1209
assert keys == [
@@ -1210,8 +1232,7 @@ def test_store_sending_thread_multiple_segments_share_logical_group_id():
1232
supports_group_ids=True,
1233
)
1234
1213
- thread.add_stored_request("req-a")
1214
- thread._handle_request(_make_store_req("req-a", [b"a0", b"a1"]))
1235
+ _run_store_req(thread, _make_store_req("req-a", [b"a0", b"a1"]))
1236
1237
keys, addrs, sizes, config = store.batch_put_from_multi_buffers.call_args.args
1238
assert keys == [
@@ -1260,8 +1281,7 @@ def test_store_sending_thread_group_ids_share_across_kv_cache_groups():
1281
supports_group_ids=True,
1282
)
1283
1263
- thread.add_stored_request("req-a")
1264
- thread._handle_request(_make_multi_group_store_req("req-a", [b"a0", b"a1"]))
1284
+ _run_store_req(thread, _make_multi_group_store_req("req-a", [b"a0", b"a1"]))
1285
1286
keys, addrs, sizes, config = store.batch_put_from_multi_buffers.call_args.args
1287
assert keys == [
@@ -1296,8 +1316,7 @@ def test_store_sending_thread_group_ids_follow_missing_key_filter():
1316
supports_group_ids=True,
1317
)
1318
1299
- thread.add_stored_request("req-a")
1300
- thread._handle_request(_make_store_req("req-a", [b"a0", b"a1"]))
1319
+ _run_store_req(thread, _make_store_req("req-a", [b"a0", b"a1"]))
1320
1321
keys, _addrs, _sizes, config = store.batch_put_from_multi_buffers.call_args.args
1322
assert keys == ["test-model@tp_rank:0@pcp0@dcp0@pp_rank:0@group:0@6131"]
@@ -1866,15 +1885,15 @@ def test_store_sending_thread_clamps_token_len_to_lcm():
1885
# token_len_chunk=33 clamps to 32 → 2 chunks (not 3 with a partial 1-token chunk).
1886
thread = _make_store_sending_thread(store)
1887
1869
- thread.add_stored_request("r0")
1870
- thread._handle_request(
1888
+ _run_store_req(
1889
+ thread,
1890
ReqMeta(
1891
req_id="r0",
1892
token_len_chunk=33,
1893
block_ids=([0, 1, 2],),
1894
block_hashes=[b"a0", b"a1", b"a2"],
1895
can_save=True,
1877
- )
1896
+ ),
1897
)
1898
1899
keys = store.batch_put_from_multi_buffers.call_args.args[0]
@@ -1905,20 +1924,19 @@ def test_store_sending_thread_skips_when_token_len_below_lcm():
1924
store, coord=coord, token_databases=[db], block_size=64
1925
)
1926
1908
- thread.add_stored_request("r0")
1909
- thread._handle_request(
1927
+ _run_store_req(
1928
+ thread,
1929
ReqMeta(
1930
req_id="r0",
1931
token_len_chunk=32,
1932
block_ids=([0, 1],),
1933
block_hashes=[b"a0", b"a1"],
1934
can_save=True,
1916
- )
1935
+ ),
1936
)
1937
1938
store.batch_is_exist.assert_not_called()
1939
store.batch_put_from_multi_buffers.assert_not_called()
1921
- assert thread.stored_requests["r0"] == 0
1940
1941
1942
def test_store_sending_thread_only_stores_swa_blocks_in_window():
@@ -1982,15 +2000,15 @@ def test_store_sending_thread_only_stores_swa_blocks_in_window():
2000
)
2001
2002
hs = [bytes([i + 1]) * 4 for i in range(8)]
1985
- thread.add_stored_request("r0")
1986
- thread._handle_request(
2003
+ _run_store_req(
2004
+ thread,
2005
ReqMeta(
2006
req_id="r0",
2007
token_len_chunk=64,
2008
block_ids=([0, 1], list(range(8))),
2009
block_hashes=hs,
2010
can_save=True,
1993
- )
2011
+ ),
2012
)
2013
2014
keys = store.batch_put_from_multi_buffers.call_args.args[0]
@@ -2057,16 +2075,16 @@ def test_store_sending_thread_delta_saves_only_new_swa_boundary_chunks():
2075
)
2076
2077
hs = [bytes([i + 1]) * 4 for i in range(8)]
2060
- thread.add_stored_request("r0")
2078
thread._saved_offset["r0"] = 32
2062
- thread._handle_request(
2079
+ _run_store_req(
2080
+ thread,
2081
ReqMeta(
2082
req_id="r0",
2083
token_len_chunk=64,
2084
block_ids=([0, 1], list(range(8))),
2085
block_hashes=hs,
2086
can_save=True,
2069
- )
2087
+ ),
2088
)
2089
2090
keys = store.batch_put_from_multi_buffers.call_args.args[0]
@@ -2128,8 +2146,8 @@ def test_store_sending_thread_kv_events_use_group_chunk_metadata():
2146
thread.enable_kv_event = True
2147
2148
hs = [bytes([i + 1]) * 4 for i in range(4)]
2131
- thread.add_stored_request("r0")
2132
- thread._handle_request(
2149
+ _run_store_req(
2150
+ thread,
2151
ReqMeta(
2152
req_id="r0",
2153
token_len_chunk=32,
@@ -2137,7 +2155,7 @@ def test_store_sending_thread_kv_events_use_group_chunk_metadata():
2155
block_hashes=hs,
2156
can_save=True,
2157
token_ids=list(range(32)),
2140
- )
2158
+ ),
2159
)
2160
2161
full_event, swa_event = thread.get_kv_events()
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/connector.py
+20
-2
@@ -26,6 +26,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.base import (
26
KVConnectorBase_V1,
27
KVConnectorMetadata,
28
KVConnectorRole,
29
+ KVConnectorWorkerMetadata,
30
SupportsHMA,
31
)
32
from vllm.distributed.kv_transfer.kv_connector.v1.metrics import (
@@ -37,6 +38,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.metrics import (
38
from vllm.forward_context import ForwardContext
39
from vllm.logger import init_logger
40
from vllm.v1.attention.backend import AttentionMetadata
41
+from vllm.v1.core.block_pool import BlockPool
42
from vllm.v1.core.kv_cache_manager import KVCacheBlocks
43
from vllm.v1.core.sched.output import SchedulerOutput
44
from vllm.v1.kv_cache_interface import KVCacheConfig
@@ -201,6 +203,14 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
203
request, blocks, num_external_tokens
204
)
205
206
+ def bind_gpu_block_pool(self, gpu_block_pool: BlockPool) -> None:
207
+ assert self.connector_scheduler is not None
208
+ self.connector_scheduler.bind_gpu_block_pool(gpu_block_pool)
209
+
210
+ def has_pending_push_work(self) -> bool:
211
+ assert self.connector_scheduler is not None
212
+ return self.connector_scheduler.has_pending_push_work()
213
+
214
def build_connector_meta(
215
self,
216
scheduler_output: SchedulerOutput,
@@ -208,6 +218,10 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
218
assert self.connector_scheduler is not None
219
return self.connector_scheduler.build_connector_meta(scheduler_output)
220
221
+ def build_connector_worker_meta(self) -> KVConnectorWorkerMetadata | None:
222
+ assert self.connector_worker is not None
223
+ return self.connector_worker.build_connector_worker_meta()
224
+
225
def request_finished(
226
self,
227
request: Request,
@@ -220,8 +234,9 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
234
request: Request,
235
block_ids: tuple[list[int], ...],
236
) -> tuple[bool, dict[str, Any] | None]:
223
- assert self.connector_scheduler is not None
224
- return self.connector_scheduler.request_finished(request, block_ids)
237
+ # An in-flight store job holds its own reference on the blocks it reads,
238
+ # so a finishing request never has to defer freeing them.
239
+ return False, None
240
241
def reset_cache(self) -> bool | None:
242
"""Reset the external Mooncake store on prefix-cache reset.
@@ -241,6 +256,9 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
256
return None
257
258
def update_connector_output(self, connector_output: KVConnectorOutput):
259
+ assert self.connector_scheduler is not None
260
+ self.connector_scheduler.update_connector_output(connector_output)
261
+
262
kv_cache_events = connector_output.kv_cache_events
263
if not kv_cache_events or not isinstance(
264
kv_cache_events, MooncakeStoreKVEvents
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/data.py
+23
-1
@@ -6,7 +6,7 @@
6
"""Data classes for MooncakeStoreConnector."""
7
8
from collections.abc import Iterable, Sequence
9
-from dataclasses import dataclass
9
+from dataclasses import dataclass, field
10
from typing import cast
11
12
import numpy as np
@@ -14,6 +14,7 @@ import torch
14
15
from vllm.distributed.kv_transfer.kv_connector.v1.base import (
16
KVConnectorMetadata,
17
+ KVConnectorWorkerMetadata,
18
)
19
from vllm.logger import init_logger
20
from vllm.utils.math_utils import cdiv
@@ -366,6 +367,10 @@ class ReqMeta:
367
368
token_ids: list[int] | None = None
369
num_prompt_tokens: int | None = None
370
+ # Identifies this store job for the engine's lifetime. A request id cannot
371
+ # serve that purpose: it is reused once a preempted request resumes, so it
372
+ # would release the wrong job's blocks.
373
+ store_job_id: int | None = None
374
# Core-provided per-mamba-group
375
# (group_id, cow_block_id, boundary_tokens) for this request's partial tail.
376
# Present only on the producer's CoW step; drives the connector's offload
@@ -434,6 +439,23 @@ class ReqMeta:
439
)
440
441
442
+@dataclass
443
+class MooncakeStoreWorkerMetadata(KVConnectorWorkerMetadata):
444
+ """Maps ``ReqMeta.store_job_id`` to the number of ranks done with that job."""
445
+
446
+ completed_saves: dict[int, int] = field(default_factory=dict)
447
+
448
+ def aggregate(
449
+ self, other: "KVConnectorWorkerMetadata"
450
+ ) -> "MooncakeStoreWorkerMetadata":
451
+ assert isinstance(other, MooncakeStoreWorkerMetadata)
452
+ for store_job_id, count in other.completed_saves.items():
453
+ self.completed_saves[store_job_id] = (
454
+ self.completed_saves.get(store_job_id, 0) + count
455
+ )
456
+ return self
457
+
458
+
459
class MooncakeStoreConnectorMetadata(KVConnectorMetadata):
460
"""Metadata passed from scheduler to worker."""
461
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/scheduler.py
+78
-26
@@ -5,8 +5,6 @@
5
# (vllm_ascend/distributed/kv_transfer/kv_pool/ascend_store/).
6
"""Scheduler-side logic for MooncakeStoreConnector."""
7
8
-from typing import Any
9
-
8
from vllm.config import VllmConfig
9
from vllm.distributed.kv_transfer.kv_connector.v1.base import (
10
KVConnectorMetadata,
@@ -17,6 +15,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.coordinator imp
15
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import ( # noqa: E501
16
LoadSpec,
17
MooncakeStoreConnectorMetadata,
18
+ MooncakeStoreWorkerMetadata,
19
ReqMeta,
20
RequestTracker,
21
)
@@ -24,10 +23,12 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.worker import (
23
LookupKeyClient,
24
)
25
from vllm.logger import init_logger
26
+from vllm.v1.core.block_pool import BlockPool
27
from vllm.v1.core.kv_cache_manager import KVCacheBlocks
28
from vllm.v1.core.kv_cache_utils import resolve_kv_cache_block_sizes
29
from vllm.v1.core.sched.output import NewRequestData, SchedulerOutput
30
from vllm.v1.kv_cache_interface import KVCacheConfig
31
+from vllm.v1.outputs import KVConnectorOutput
32
from vllm.v1.request import Request
33
34
logger = init_logger(__name__)
@@ -78,6 +79,15 @@ class MooncakeStoreScheduler:
79
self._unfinished_requests: dict[str, tuple[Request, tuple[list[int], ...]]] = {}
80
self._unfinished_request_ids: set[str] = set()
81
82
+ self._gpu_block_pool: BlockPool | None = None
83
+ self._num_workers = vllm_config.parallel_config.world_size
84
+ self._next_store_job_id = 0
85
+ # store_job_id -> (referenced block ids, ranks yet to report completion)
86
+ self._pinned_saves: dict[int, tuple[list[int], int]] = {}
87
+
88
+ def bind_gpu_block_pool(self, gpu_block_pool: BlockPool) -> None:
89
+ self._gpu_block_pool = gpu_block_pool
90
+
91
def get_num_new_matched_tokens(
92
self,
93
request: Request,
@@ -392,33 +402,75 @@ class MooncakeStoreScheduler:
402
)
403
)
404
405
+ self._reference_save_blocks(meta)
406
return meta
407
397
- def request_finished(
398
- self,
399
- request: Request,
400
- block_ids: tuple[list[int], ...],
401
- ) -> tuple[bool, dict[str, Any] | None]:
402
- """Determine whether to delay freeing blocks for async save."""
403
- if self.kv_role == "kv_consumer":
404
- return False, None
405
- tracker = self._request_trackers.get(request.request_id)
406
- # Missing tracker can happen when the request is aborted before the
407
- # connector observes the normal finished lifecycle or is preempted
408
- # before finishing.
409
- if tracker is None or (
410
- tracker.num_saved_tokens <= 0 and not tracker.has_pending_offload
411
- ):
412
- return False, None
413
- total_blocks = sum(len(g) for g in block_ids)
414
- delay_free_blocks = total_blocks > 0
415
- if delay_free_blocks:
416
- logger.debug(
417
- "Delaying free of %d blocks for request %s",
418
- total_blocks,
419
- request.request_id,
408
+ def _reference_save_blocks(self, meta: MooncakeStoreConnectorMetadata) -> None:
409
+ """Take a GPU block reference for every store job this step emits.
410
+
411
+ The worker DMAs out of these blocks after the step that scheduled them,
412
+ so a reference keeps them out of the free queue even once the request
413
+ itself is freed, until every rank reports the job done.
414
+ """
415
+ pool = self._gpu_block_pool
416
+ for req_meta in meta.requests:
417
+ if not req_meta.can_save:
418
+ continue
419
+ assert pool is not None, (
420
+ "GPU block pool must be bound before any store job is emitted"
421
+ )
422
+ req_meta.store_job_id = store_job_id = self._next_store_job_id
423
+ self._next_store_job_id += 1
424
+ block_ids: list[int] = []
425
+ if req_meta.partial_tail_offloads:
426
+ # A partial-tail CoW block is deliberately kept out of the
427
+ # request's block table, so it is absent from `block_ids` even
428
+ # though the worker DMAs out of it just as asynchronously.
429
+ # It leads the list, as in `pop_blocks_for_free`.
430
+ block_ids += [bid for _, bid, _ in req_meta.partial_tail_offloads]
431
+ # Every allocated block is referenced, not just the ones covering
432
+ # this job's token range: a rank resumes from its own last
433
+ # successful offset, which lags the scheduler's whenever a save was
434
+ # skipped or failed, so it may read anywhere below the range.
435
+ block_ids += [bid for group in req_meta.block_ids for bid in group]
436
+ if not block_ids:
437
+ continue
438
+ self._pinned_saves[store_job_id] = (block_ids, self._num_workers)
439
+ pool.touch([pool.blocks[bid] for bid in block_ids])
440
+
441
+ def update_connector_output(self, connector_output: KVConnectorOutput) -> None:
442
+ """Drop the block references of store jobs every rank has finished."""
443
+ meta = connector_output.kv_connector_worker_meta
444
+ if not isinstance(meta, MooncakeStoreWorkerMetadata):
445
+ return
446
+ pool = self._gpu_block_pool
447
+ assert pool is not None
448
+ for store_job_id, count in meta.completed_saves.items():
449
+ pinned = self._pinned_saves.get(store_job_id)
450
+ if pinned is None:
451
+ # The job referenced no blocks, so nothing was recorded for it.
452
+ continue
453
+ block_ids, remaining = pinned
454
+ remaining -= count
455
+ if remaining > 0:
456
+ self._pinned_saves[store_job_id] = (block_ids, remaining)
457
+ continue
458
+ assert remaining == 0, (
459
+ f"store job {store_job_id} reported by too many ranks"
460
)
421
- return delay_free_blocks, None
461
+ del self._pinned_saves[store_job_id]
462
+ # Tail-first, as elsewhere, so the shared prefix is evicted last.
463
+ pool.free_blocks(pool.blocks[bid] for bid in reversed(block_ids))
464
+
465
+ def has_pending_push_work(self) -> bool:
466
+ """Keep the engine stepping while any store job still holds block refs.
467
+
468
+ Completions only reach the scheduler as worker metadata on a step, so an
469
+ engine that quiesced with jobs in flight would leave those references
470
+ held indefinitely. Nothing else keeps it alive now that a finishing
471
+ request no longer defers its own free.
472
+ """
473
+ return bool(self._pinned_saves)
474
475
def reset_store(self) -> bool:
476
"""Trigger a global ``remove_all(force=True)`` on the Mooncake master.
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+104
-60
@@ -17,7 +17,6 @@ import queue
17
import socket
18
import threading
19
import time
20
-from collections import defaultdict
20
from collections.abc import Callable, Sequence
21
from concurrent.futures import Future, ThreadPoolExecutor
22
from dataclasses import dataclass
@@ -46,6 +45,7 @@ from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.store.data import (
45
ChunkedTokenDatabase,
46
KeyMetadata,
47
MooncakeStoreConnectorMetadata,
48
+ MooncakeStoreWorkerMetadata,
49
PoolKey,
50
ReqMeta,
51
)
@@ -506,7 +506,16 @@ class KVCacheStoreSendingThread(KVTransferThread):
506
self.group_put_steps = group_put_steps
507
self.coord = coord
508
self.kv_role = kv_role
509
- self.stored_requests: defaultdict[str, int] = defaultdict(int)
509
+ # req_id -> ids of its store jobs that are still queued or running.
510
+ # Keying by store_job_id, which never repeats for the engine's lifetime,
511
+ # rather than counting jobs per request id makes the ledger immune to id
512
+ # reuse across preemption: a job left over from a retired generation is
513
+ # missing from the set its resumed generation builds, so it can no longer
514
+ # retire that generation, rewind its resume offset, or mark it skipped.
515
+ self.stored_requests: dict[str, set[int]] = {}
516
+ # store_job_id -> times this rank finished with it, drained every step
517
+ # so the scheduler can release the blocks it referenced for those jobs.
518
+ self._completed_saves: dict[int, int] = {}
519
self.enable_kv_event = enable_kv_event
520
# Caller always passes a non-None ReplicateConfig — see
521
# MooncakeStoreWorker.__init__ where store_replicate_config is built.
@@ -522,14 +531,20 @@ class KVCacheStoreSendingThread(KVTransferThread):
531
# batch resumes here, so pressure-skipped or failed ranges are retried.
532
self._saved_offset: dict[str, int] = {}
533
525
- def add_stored_request(self, req_id: str):
534
+ def add_request(self, request: ReqMeta) -> None:
535
+ # Register before enqueueing so a job is never picked up unledgered.
536
+ assert request.store_job_id is not None
537
with self.done_task_lock:
527
- self.stored_requests[req_id] += 1
538
+ self.stored_requests.setdefault(request.req_id, set()).add(
539
+ request.store_job_id
540
+ )
541
+ super().add_request(request)
542
529
- def dec_stored_request(self, req_id: str):
543
+ def is_live_store_job(self, req_meta: ReqMeta) -> bool:
544
with self.done_task_lock:
531
- if req_id in self.stored_requests:
532
- self.stored_requests[req_id] -= 1
545
+ return req_meta.store_job_id in self.stored_requests.get(
546
+ req_meta.req_id, ()
547
+ )
548
549
def delete_finished_stored_request(self, req_id: str):
550
with self.done_task_lock:
@@ -538,21 +553,51 @@ class KVCacheStoreSendingThread(KVTransferThread):
553
self._skip_store_requests.discard(req_id)
554
self._saved_offset.pop(req_id, None)
555
541
- def _record_saved(self, req_id: str, token_len: int) -> None:
542
- # Guard on liveness so a concurrent finish/preempt pop isn't recreated.
556
+ def finish_store_job(self, req_meta: ReqMeta) -> None:
557
+ """Retire a job from the ledger and report its blocks as no longer read.
558
+
559
+ Every path out of a job must reach this, skips and failures included: a
560
+ job that never reports leaves its blocks referenced for the rest of the
561
+ run. The discard is a no-op for a job whose generation already retired.
562
+ """
563
+ store_job_id = req_meta.store_job_id
564
+ assert store_job_id is not None, (
565
+ "a queued store job always carries a store_job_id"
566
+ )
567
with self.done_task_lock:
544
- if req_id in self.stored_requests:
545
- self._saved_offset[req_id] = token_len
568
+ live = self.stored_requests.get(req_meta.req_id)
569
+ if live is not None:
570
+ live.discard(store_job_id)
571
+ self._completed_saves[store_job_id] = (
572
+ self._completed_saves.get(store_job_id, 0) + 1
573
+ )
574
+
575
+ def take_completed_saves(self) -> dict[int, int]:
576
+ with self.done_task_lock:
577
+ completed = self._completed_saves
578
+ self._completed_saves = {}
579
+ return completed
580
+
581
+ def _record_saved(self, req_meta: ReqMeta, token_len: int) -> None:
582
+ # Guard on job liveness so neither a concurrent finish/preempt pop nor a
583
+ # stale job's offset is written back over the live generation's.
584
+ with self.done_task_lock:
585
+ if req_meta.store_job_id in self.stored_requests.get(req_meta.req_id, ()):
586
+ self._saved_offset[req_meta.req_id] = token_len
587
588
def _should_skip_request(self, req_id: str) -> bool:
589
with self.done_task_lock:
590
return self._store_pressure_active and req_id in self._skip_store_requests
591
551
- def _mark_request_skipped_for_pressure(self, req_id: str) -> bool:
592
+ def _mark_request_skipped_for_pressure(self, req_meta: ReqMeta) -> bool:
593
+ req_id = req_meta.req_id
594
with self.done_task_lock:
595
already_skipped = req_id in self._skip_store_requests
596
self._store_pressure_active = True
555
- self._skip_store_requests.add(req_id)
597
+ # The pressure itself is global, but only a live job may sentence its
598
+ # own request to being skipped.
599
+ if req_meta.store_job_id in self.stored_requests.get(req_id, ()):
600
+ self._skip_store_requests.add(req_id)
601
return already_skipped
602
603
def _clear_store_pressure(self) -> bool:
@@ -727,7 +772,7 @@ class KVCacheStoreSendingThread(KVTransferThread):
772
failed_codes,
773
)
774
if MOONCAKE_NO_AVAILABLE_HANDLE in failed_codes:
730
- self._mark_request_skipped_for_pressure(req_meta.req_id)
775
+ self._mark_request_skipped_for_pressure(req_meta)
776
return False
777
778
if self._clear_store_pressure():
@@ -738,22 +783,20 @@ class KVCacheStoreSendingThread(KVTransferThread):
783
return True
784
785
def _handle_request(self, req_meta: ReqMeta):
741
- # Cache hits are always a multiple of ``lcm_block_size`` tokens, which
742
- # is also ``store_mask``'s precondition.
743
- lcm_block_size = self.coord.lcm_block_size
744
- token_len = req_meta.token_len_chunk // lcm_block_size * lcm_block_size
745
- block_ids_per_group = req_meta.block_ids
746
- req_id = req_meta.req_id
747
- current_event = req_meta.current_event
748
-
749
- if req_id not in self.stored_requests:
750
- self.request_queue.task_done()
751
- return
752
-
753
- # Decrement the in-flight counter and signal task_done() in `finally`
754
- # so the scheduler can release the GPU blocks it pinned for this
755
- # request (via `delay_free_blocks`) even when the store path raises.
786
+ # The single `finally` is the only way out, so the scheduler releases
787
+ # this job's GPU block references however the job ends.
788
try:
789
+ # Cache hits are always a multiple of ``lcm_block_size`` tokens,
790
+ # which is also ``store_mask``'s precondition.
791
+ lcm_block_size = self.coord.lcm_block_size
792
+ token_len = req_meta.token_len_chunk // lcm_block_size * lcm_block_size
793
+ block_ids_per_group = req_meta.block_ids
794
+ req_id = req_meta.req_id
795
+ current_event = req_meta.current_event
796
+
797
+ if not self.is_live_store_job(req_meta):
798
+ return
799
+
800
if self._should_skip_request(req_id):
801
logger.debug(
802
"Skipping Mooncake store for request %s while CPU/disk "
@@ -808,7 +851,7 @@ class KVCacheStoreSendingThread(KVTransferThread):
851
group_indices.append(g_idx)
852
853
if not keys:
811
- self._record_saved(req_id, token_len)
854
+ self._record_saved(req_meta, token_len)
855
return
856
857
# Check which blocks already exist (dedup)
@@ -834,7 +877,7 @@ class KVCacheStoreSendingThread(KVTransferThread):
877
]
878
879
if not missing_indices:
837
- self._record_saved(req_id, token_len)
880
+ self._record_saved(req_meta, token_len)
881
return
882
883
if len(missing_indices) != len(keys):
@@ -954,7 +997,7 @@ class KVCacheStoreSendingThread(KVTransferThread):
997
)
998
if (
999
MOONCAKE_NO_AVAILABLE_HANDLE in failed_codes
957
- and not self._mark_request_skipped_for_pressure(req_id)
1000
+ and not self._mark_request_skipped_for_pressure(req_meta)
1001
):
1002
logger.warning(
1003
"Detected Mooncake CPU/disk offloading pressure "
@@ -964,7 +1007,7 @@ class KVCacheStoreSendingThread(KVTransferThread):
1007
req_id,
1008
)
1009
else:
967
- self._record_saved(req_id, token_len)
1010
+ self._record_saved(req_meta, token_len)
1011
if self._clear_store_pressure():
1012
logger.info(
1013
"Mooncake CPU/disk offloading pressure cleared "
@@ -984,7 +1027,7 @@ class KVCacheStoreSendingThread(KVTransferThread):
1027
if self.enable_kv_event and stored_events:
1028
self.update_kv_event(stored_events)
1029
finally:
987
- self.dec_stored_request(req_id)
1030
+ self.finish_store_job(req_meta)
1031
self.request_queue.task_done()
1032
1033
@@ -1670,16 +1713,15 @@ class MooncakeStoreWorker:
1713
continue
1714
request.current_event = current_event
1715
assert self.kv_send_thread is not None
1673
- self.kv_send_thread.add_stored_request(request.req_id)
1716
self.kv_send_thread.add_request(request)
1717
1676
- # Check completion of previously queued transfers
1677
- done_sending = (
1678
- self._get_and_clear_finished_sending(finished_req_ids, meta)
1679
- if self.kv_role in ["kv_producer", "kv_both"]
1680
- else set()
1681
- )
1718
+ if self.kv_role in ["kv_producer", "kv_both"]:
1719
+ self._close_ended_store_requests(finished_req_ids, meta)
1720
1721
+ # Blocks read by a store job are released by the scheduler when the job
1722
+ # reports back (see build_connector_worker_meta), so no request ever waits
1723
+ # on a `finished_sending` signal to get its blocks back.
1724
+ done_sending: set[str] = set()
1725
done_recving: set[str] = set()
1726
if self.load_async:
1727
for recv_thread in self.kv_recv_threads:
@@ -1727,35 +1769,37 @@ class MooncakeStoreWorker:
1769
self.kv_connector_stats = MooncakeStoreConnectorStats()
1770
return kv_connector_stats
1771
1730
- def _get_and_clear_finished_sending(
1772
+ def _close_ended_store_requests(
1773
self,
1774
finished_req_ids: set[str],
1775
meta: MooncakeStoreConnectorMetadata,
1734
- ) -> set[str]:
1776
+ ) -> None:
1777
+ """Retire the ledger entries of requests that finished or were preempted.
1778
+
1779
+ An entry may only go once its jobs have drained, because they still read
1780
+ the resume offset it owns; a request that comes back after preemption
1781
+ then saves from the start rather than from where the last attempt got to.
1782
+ """
1783
assert self.kv_send_thread is not None
1736
- finished_sending: set[str] = set()
1784
1785
for req_id in meta.preempted_req_ids:
1786
self.kv_send_thread.delete_finished_stored_request(req_id)
1787
1741
- for req_id in self.kv_send_thread.stored_requests.copy():
1742
- if (
1743
- self.kv_send_thread.stored_requests[req_id] == 0
1744
- and req_id in self.finished_store_req
1745
- ):
1746
- self.finished_store_req.remove(req_id)
1747
- finished_sending.add(req_id)
1748
- self.kv_send_thread.delete_finished_stored_request(req_id)
1749
-
1750
- for req_id in finished_req_ids:
1751
- req_remain_jobs = self.kv_send_thread.stored_requests.get(req_id)
1752
- if req_remain_jobs == 0:
1753
- finished_sending.add(req_id)
1754
- self.kv_send_thread.delete_finished_stored_request(req_id)
1755
- elif req_remain_jobs is not None:
1788
+ for req_id in finished_req_ids | self.finished_store_req:
1789
+ if self.kv_send_thread.stored_requests.get(req_id):
1790
+ # Queued jobs still need the resume offset; retire on a later step.
1791
self.finished_store_req.add(req_id)
1792
+ else:
1793
+ self.finished_store_req.discard(req_id)
1794
+ self.kv_send_thread.delete_finished_stored_request(req_id)
1795
1758
- return finished_sending
1796
+ def build_connector_worker_meta(self) -> MooncakeStoreWorkerMetadata | None:
1797
+ if self.kv_send_thread is None:
1798
+ return None
1799
+ completed_saves = self.kv_send_thread.take_completed_saves()
1800
+ if not completed_saves:
1801
+ return None
1802
+ return MooncakeStoreWorkerMetadata(completed_saves=completed_saves)
1803
1804
def lookup(self, num_tokens: int, block_hashes: Sequence[BlockHash]) -> int:
1805
"""Check how many prefix tokens exist in the store.