[Bugfix][Mooncake] Offload producer partial tails on request finish (#52832)

Signed-off-by: Dao Le <Dao007forever@gmail.com> Co-authored-by: Roger Wang <hey@rogerw.io> Co-authored-by: OpenAI Codex <noreply@openai.com>

Dao007forever committed Sep 1, 2026 at 13:23 UTC 7cb9a88f50088453bbcf86704c376103dcd6b699
10 files changed +375 -10
tests/v1/core/prefix_cache/test_partial_prefix_cache_hits.py
+111
@@ -12,6 +12,7 @@ import pytest
12 import torch
13
14 from tests.v1.core.test_prefix_caching import make_kv_cache_manager, make_request
15 +from vllm.distributed.kv_transfer.kv_connector.v1.base import SupportsHMA
16 from vllm.utils.hashing import sha256
17 from vllm.v1.core.kv_cache_utils import (
18 KVCacheBlockCopy,
@@ -735,11 +736,121 @@ def test_boundary_state_offloads_returns_cow_target():
736 assert block_id != source_block_id
737 # Draining clears it.
738 assert manager.take_boundary_state_offloads() == {}
739 + # CoW consumed the producer marker, so finishing cannot emit the same
740 + # boundary a second time.
741 + assert manager.finalize_partial_tail_offloads(req0) == []
742
743 manager.block_pool.free_blocks(retained)
744 manager.free(req0)
745
746
747 +def test_finished_partial_tail_uses_table_source_once():
748 + """A finish without another allocation hands off the unchanged table block."""
749 + hash_block_size = 2
750 + block_size = 2 * hash_block_size
751 + kv_cache_config = KVCacheConfig(
752 + num_blocks=24,
753 + kv_cache_tensors=[],
754 + kv_cache_groups=[
755 + KVCacheGroupSpec(
756 + ["full"],
757 + FullAttentionSpec(
758 + block_size=hash_block_size,
759 + num_kv_heads=1,
760 + head_size=1,
761 + dtype=torch.float32,
762 + ),
763 + ),
764 + KVCacheGroupSpec(
765 + ["mamba"],
766 + MambaSpec(
767 + block_size=block_size,
768 + shapes=(1, 1),
769 + dtypes=(torch.float32,),
770 + mamba_cache_mode="align",
771 + ),
772 + ),
773 + ],
774 + )
775 + manager = make_kv_cache_manager(
776 + kv_cache_config=kv_cache_config,
777 + max_model_len=8192,
778 + enable_caching=True,
779 + hash_block_size=hash_block_size,
780 + )
781 +
782 + req0 = make_request("0", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
783 + computed_blocks, num_computed, _ = manager.get_computed_blocks(req0)
784 + assert manager.allocate_slots(req0, 6, num_computed, computed_blocks) is not None
785 + source_block_id = manager.get_blocks("0").get_block_ids()[1][1]
786 +
787 + req0.num_computed_tokens = 6
788 + offloads = manager.finalize_partial_tail_offloads(req0)
789 +
790 + assert offloads == [(1, source_block_id, 6)]
791 + assert manager.finalize_partial_tail_offloads(req0) == []
792 + assert manager.take_boundary_state_offloads() == {}
793 +
794 + # A table state that advanced beyond the boundary is unsafe and is
795 + # consumed without producing a handoff.
796 + advanced_manager = make_kv_cache_manager(
797 + kv_cache_config=kv_cache_config,
798 + max_model_len=8192,
799 + enable_caching=True,
800 + hash_block_size=hash_block_size,
801 + )
802 + req1 = make_request("1", [0, 0, 1, 1, 2, 2], hash_block_size, sha256)
803 + computed_blocks, num_computed, _ = advanced_manager.get_computed_blocks(req1)
804 + assert (
805 + advanced_manager.allocate_slots(req1, 6, num_computed, computed_blocks)
806 + is not None
807 + )
808 + req1.num_computed_tokens = 7
809 + assert advanced_manager.finalize_partial_tail_offloads(req1) == []
810 + assert advanced_manager.finalize_partial_tail_offloads(req1) == []
811 +
812 +
813 +def test_connector_finish_registers_partial_tail_before_cleanup():
814 + class _Connector(SupportsHMA):
815 + def register_finished_partial_tail(self, request, block_ids, offloads):
816 + self.registered = (request, block_ids, offloads)
817 + return True
818 +
819 + def request_finished_all_groups(self, request, block_ids):
820 + return False, None
821 +
822 + scheduler = object.__new__(Scheduler)
823 + scheduler.connector = _Connector()
824 + scheduler.vllm_config = SimpleNamespace(
825 + kv_transfer_config=SimpleNamespace(is_kv_producer=True)
826 + )
827 + scheduler.kv_cache_manager = MagicMock()
828 + scheduler.kv_cache_manager.finalize_partial_tail_offloads.return_value = [
829 + (1, 9, 12)
830 + ]
831 + scheduler.kv_cache_manager.get_block_ids_for_computed_tokens.return_value = (
832 + [3],
833 + [9],
834 + )
835 + request = SimpleNamespace(
836 + request_id="0",
837 + num_computed_tokens=12,
838 + num_in_flight_tokens=0,
839 + num_prompt_tokens=12,
840 + )
841 +
842 + delay_free, kv_xfer_params = scheduler._connector_finished(request)
843 +
844 + assert delay_free is True
845 + assert kv_xfer_params is None
846 + assert scheduler.connector.registered == (
847 + request,
848 + ([3], [9]),
849 + [(1, 9, 12)],
850 + )
851 + scheduler.kv_cache_manager.remove_skipped_blocks.assert_called_once()
852 +
853 +
854 def test_block_pool_touch_pins_released_cow_target():
855 """The connector can rescue an offered CoW target after its step-scoped
856 retention is released by using the bound BlockPool's touch method."""
tests/v1/kv_connector/unit/test_mooncake_store_scheduler.py
+60
@@ -42,6 +42,7 @@ def _make_bare_scheduler(
42 scheduler._unfinished_request_ids = {"req-0"}
43 scheduler._unfinished_requests = {}
44 scheduler._request_trackers = {}
45 + scheduler._finished_partial_tail_metas = {}
46 scheduler._gpu_block_pool = BlockPool(
47 num_gpu_blocks=64, enable_caching=True, hash_block_size=hash_block_size
48 )
@@ -971,6 +972,65 @@ def test_pending_partial_tail_emits_offload_only_reqmeta():
972 assert tracker.has_pending_offload is True
973
974
975 +def test_finished_partial_tail_is_pre_pinned_as_store_job():
976 + scheduler = _make_bare_scheduler(hash_block_size=4, enable_partial_hash_hits=True)
977 + scheduler.client = SimpleNamespace(discard=lambda *_: None)
978 + request = SimpleNamespace(
979 + request_id="req-0",
980 + block_hashes=[b"h0", b"h1", b"h2"],
981 + )
982 + scheduler._request_trackers["req-0"] = RequestTracker(
983 + req_id="req-0",
984 + token_len=12,
985 + allocated_block_ids=([3], [9]),
986 + token_ids=list(range(12)),
987 + prefill_end_tokens=12,
988 + )
989 + block_ids = ([3], [9])
990 +
991 + delay_free = scheduler.register_finished_partial_tail(
992 + request,
993 + block_ids,
994 + [(1, 9, 12)],
995 + )
996 +
997 + # The exact source is pinned immediately, so the request can be freed
998 + # before the next connector metadata build.
999 + assert delay_free is False
1000 + assert scheduler._gpu_block_pool.blocks[9].ref_cnt == 1
1001 + assert scheduler._gpu_block_pool.blocks[3].ref_cnt == 0
1002 +
1003 + out = SimpleNamespace(
1004 + finished_req_ids={"req-0"},
1005 + preempted_req_ids=set(),
1006 + scheduled_new_reqs=[],
1007 + scheduled_cached_reqs=SimpleNamespace(
1008 + req_ids=[],
1009 + new_block_ids=[],
1010 + num_computed_tokens=[],
1011 + resumed_req_ids=set(),
1012 + ),
1013 + num_scheduled_tokens={},
1014 + scheduled_spec_decode_tokens={},
1015 + kv_connector_block_state=_make_connector_block_state(),
1016 + )
1017 + meta = scheduler.build_connector_meta(out)
1018 +
1019 + assert len(meta.requests) == 1
1020 + req_meta = meta.requests[0]
1021 + assert req_meta.req_id == "req-0"
1022 + assert req_meta.token_len_chunk == 0
1023 + assert req_meta.block_ids == block_ids
1024 + assert req_meta.block_hashes == request.block_hashes
1025 + assert req_meta.boundary_state_offloads == [(1, 9, 12)]
1026 + assert scheduler._pinned_saves[req_meta.store_job_id][0] == [9]
1027 + assert scheduler._gpu_block_pool.blocks[9].ref_cnt == 1
1028 + assert scheduler._finished_partial_tail_metas == {}
1029 +
1030 + scheduler.update_connector_output(_make_worker_output({req_meta.store_job_id: 1}))
1031 + assert scheduler._gpu_block_pool.blocks[9].ref_cnt == 0
1032 +
1033 +
1034 def test_decode_boundary_state_offload_dropped_unclaimed():
1035 # A hand-off past the prefill end can never complete a joint hybrid hit
1036 # (every other group stops saving there), so it is neither transferred nor
tests/v1/kv_connector/unit/test_multi_connector.py
+20
@@ -148,6 +148,26 @@ KVConnectorFactory.register_connector(
148 )
149
150
151 +def test_register_finished_partial_tail_notifies_every_connector():
152 + connector = object.__new__(MultiConnector)
153 + first = MagicMock(spec_set=KVConnectorBase_V1)
154 + second = MagicMock(spec_set=KVConnectorBase_V1)
155 + first.register_finished_partial_tail.return_value = True
156 + second.register_finished_partial_tail.return_value = False
157 + connector._connectors = [first, second]
158 + request = MagicMock()
159 + block_ids = ([1], [2])
160 + offloads = [(1, 2, 12)]
161 +
162 + assert connector.register_finished_partial_tail(request, block_ids, offloads)
163 + first.register_finished_partial_tail.assert_called_once_with(
164 + request, block_ids, offloads
165 + )
166 + second.register_finished_partial_tail.assert_called_once_with(
167 + request, block_ids, offloads
168 + )
169 +
170 +
171 @pytest.fixture
172 def mc() -> MultiConnector:
173 """MultiConnector using two mocked connectors"""
vllm/distributed/kv_transfer/kv_connector/v1/base.py
+13
@@ -561,6 +561,19 @@ class KVConnectorBase_V1(ABC):
561 """
562 return False, None
563
564 + def register_finished_partial_tail(
565 + self,
566 + request: "Request",
567 + block_ids: tuple[list[int], ...],
568 + partial_tail_offloads: list[tuple[int, int, int]],
569 + ) -> bool:
570 + """Register finish-time partial-tail sources before block cleanup.
571 +
572 + Returns True when the connector accepts responsibility for the sources
573 + and the request's blocks must remain alive until ``get_finished()``.
574 + """
575 + return False
576 +
577 def take_events(self) -> Iterable["KVCacheEvent"]:
578 """
579 Take the KV cache events from the connector.
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/connector.py
+11
@@ -265,6 +265,17 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
265 # so a finishing request never has to defer freeing them.
266 return False, None
267
268 + def register_finished_partial_tail(
269 + self,
270 + request: Request,
271 + block_ids: tuple[list[int], ...],
272 + partial_tail_offloads: list[tuple[int, int, int]],
273 + ) -> bool:
274 + assert self.connector_scheduler is not None
275 + return self.connector_scheduler.register_finished_partial_tail(
276 + request, block_ids, partial_tail_offloads
277 + )
278 +
279 def reset_cache(self) -> bool | None:
280 """Reset the external Mooncake store on prefix-cache reset.
281
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/scheduler.py
+66
@@ -102,6 +102,7 @@ class MooncakeStoreScheduler:
102 self._request_trackers: dict[str, RequestTracker] = {} # scheduled new requests
103 self._unfinished_requests: dict[str, tuple[Request, tuple[list[int], ...]]] = {}
104 self._unfinished_request_ids: set[str] = set()
105 + self._finished_partial_tail_metas: dict[str, ReqMeta] = {}
106
107 def bind_gpu_block_pool(self, gpu_block_pool: BlockPool) -> None:
108 self._gpu_block_pool = gpu_block_pool
@@ -402,6 +403,14 @@ class MooncakeStoreScheduler:
403 )
404
405 self._apply_current_save_block_ids(meta, scheduler_output)
406 +
407 + # Finish-time handoffs arrive after the producing step's metadata was
408 + # built. Their exact blocks were pinned when they were registered, so
409 + # they remain valid after request cleanup and need no current snapshot.
410 + for req_meta in self._finished_partial_tail_metas.values():
411 + meta.add_request(req_meta)
412 + self._finished_partial_tail_metas.clear()
413 +
414 self._reference_save_blocks(meta)
415 return meta
416
@@ -440,6 +449,9 @@ class MooncakeStoreScheduler:
449 assert pool is not None, (
450 "GPU block pool must be bound before any store job is emitted"
451 )
452 + if req_meta.store_job_id is not None:
453 + assert req_meta.store_job_id in self._pinned_saves
454 + continue
455 req_meta.store_job_id = store_job_id = self._next_store_job_id
456 self._next_store_job_id += 1
457 block_ids: list[int] = []
@@ -470,6 +482,60 @@ class MooncakeStoreScheduler:
482 self._pinned_saves[store_job_id] = (block_ids, self._num_workers)
483 pool.touch([pool.blocks[block_id] for block_id in block_ids])
484
485 + def register_finished_partial_tail(
486 + self,
487 + request: Request,
488 + block_ids: tuple[list[int], ...],
489 + partial_tail_offloads: list[tuple[int, int, int]],
490 + ) -> bool:
491 + """Queue and pin a finish-time tail for the next connector step."""
492 + if self.kv_role == "kv_consumer" or not partial_tail_offloads:
493 + return False
494 + tracker = self._request_trackers.get(request.request_id)
495 + if tracker is None or not any(block_ids):
496 + return False
497 + boundaries = {boundary for _, _, boundary in partial_tail_offloads}
498 + if len(boundaries) != 1:
499 + raise ValueError(
500 + "Partial-tail offloads for one request must share a boundary"
501 + )
502 + boundary_tokens = next(iter(boundaries))
503 + if boundary_tokens > tracker.prefill_end_tokens:
504 + return False
505 +
506 + pinned_block_ids: list[int] = []
507 + for group_id, block_id, _ in partial_tail_offloads:
508 + if group_id not in self._boundary_state_group_ids:
509 + return False
510 + if block_id == NULL_BLOCK_ID:
511 + return False
512 + pinned_block_ids.append(block_id)
513 + pinned_block_ids = list(dict.fromkeys(pinned_block_ids))
514 +
515 + pool = self._gpu_block_pool
516 + assert pool is not None, (
517 + "GPU block pool must be bound before a finish-time handoff"
518 + )
519 + assert request.request_id not in self._finished_partial_tail_metas
520 + store_job_id = self._next_store_job_id
521 + self._next_store_job_id += 1
522 + self._pinned_saves[store_job_id] = (pinned_block_ids, self._num_workers)
523 + pool.touch([pool.blocks[block_id] for block_id in pinned_block_ids])
524 +
525 + self._finished_partial_tail_metas[request.request_id] = ReqMeta(
526 + req_id=request.request_id,
527 + token_len_chunk=0,
528 + block_ids=tuple(group.copy() for group in block_ids),
529 + block_hashes=list(request.block_hashes),
530 + can_save=True,
531 + num_prompt_tokens=tracker.prefill_end_tokens,
532 + store_job_id=store_job_id,
533 + boundary_state_offloads=partial_tail_offloads,
534 + )
535 + tracker.has_pending_offload = True
536 + # The store job owns exact block refs, so request cleanup need not wait.
537 + return False
538 +
539 def _handle_boundary_state_offloads(
540 self,
541 offloads: dict[str, list[tuple[int, int, int]]],
vllm/distributed/kv_transfer/kv_connector/v1/multi_connector.py
+12
@@ -523,6 +523,18 @@ class MultiConnector(KVConnectorBase_V1, SupportsHMA):
523 lambda c: c.request_finished(request, blocks),
524 )
525
526 + def register_finished_partial_tail(
527 + self,
528 + request: "Request",
529 + block_ids: tuple[list[int], ...],
530 + partial_tail_offloads: list[tuple[int, int, int]],
531 + ) -> bool:
532 + accepted = [
533 + c.register_finished_partial_tail(request, block_ids, partial_tail_offloads)
534 + for c in self._connectors
535 + ]
536 + return any(accepted)
537 +
538 def request_finished_all_groups(
539 self,
540 request: "Request",
vllm/v1/core/kv_cache_manager.py
+23
@@ -874,6 +874,29 @@ class KVCacheManager:
874 )
875 return offloads
876
877 + def finalize_partial_tail_offloads(
878 + self, request: Request
879 + ) -> list[tuple[int, int, int]]:
880 + """Consume safe producer partial tails when a request finishes.
881 +
882 + A mamba align table block is a valid boundary source only if no later
883 + token was forwarded. The connector pins and queues the exact table
884 + block before request cleanup, then releases the pin when every worker
885 + reports the store job complete.
886 + """
887 + offloads: list[tuple[int, int, int]] = []
888 + for mgr in self.coordinator.single_type_managers:
889 + finalized = mgr.finalize_partial_tail_offload(
890 + request.request_id,
891 + request.num_computed_tokens,
892 + request.num_in_flight_tokens,
893 + )
894 + if finalized is None:
895 + continue
896 + group_id, block, boundary_tokens = finalized
897 + offloads.append((group_id, block.block_id, boundary_tokens))
898 + return offloads
899 +
900 def new_step_starts(self) -> None:
901 """Notify the coordinator that a new step is starting."""
902 self.coordinator.new_step_starts()
vllm/v1/core/sched/scheduler.py
+22 -3
@@ -2775,6 +2775,13 @@ class Scheduler(SchedulerInterface):
2775 if self.connector is None:
2776 return False, None
2777
2778 + finished_partial_tails: list[tuple[int, int, int]] = []
2779 + kv_transfer_config = self.vllm_config.kv_transfer_config
2780 + if kv_transfer_config is not None and kv_transfer_config.is_kv_producer:
2781 + finished_partial_tails = (
2782 + self.kv_cache_manager.finalize_partial_tail_offloads(request)
2783 + )
2784 +
2785 # Free any out-of-window prefix blocks before we hand the block table to
2786 # the connector, on the processed-token basis (see `allocate_slots`).
2787 self.kv_cache_manager.remove_skipped_blocks(
@@ -2789,6 +2796,13 @@ class Scheduler(SchedulerInterface):
2796 request_id=request.request_id,
2797 num_computed_tokens=request.num_computed_tokens,
2798 )
2799 + partial_tail_delay = False
2800 + if finished_partial_tails:
2801 + partial_tail_delay = self.connector.register_finished_partial_tail(
2802 + request,
2803 + block_ids,
2804 + finished_partial_tails,
2805 + )
2806
2807 if not isinstance(self.connector, SupportsHMA):
2808 # NOTE(Kuntai): We should deprecate this code path after we enforce
@@ -2796,9 +2810,14 @@ class Scheduler(SchedulerInterface):
2810 # Hybrid memory allocator should be already turned off for this
2811 # code path, but let's double-check here.
2812 assert len(self.kv_cache_config.kv_cache_groups) == 1
2799 - return self.connector.request_finished(request, block_ids[0])
2800 -
2801 - return self.connector.request_finished_all_groups(request, block_ids)
2813 + delay_free, kv_xfer_params = self.connector.request_finished(
2814 + request, block_ids[0]
2815 + )
2816 + else:
2817 + delay_free, kv_xfer_params = self.connector.request_finished_all_groups(
2818 + request, block_ids
2819 + )
2820 + return delay_free or partial_tail_delay, kv_xfer_params
2821
2822 def _request_remaining_blocks(self, request: Request) -> int:
2823 """Blocks `request` still needs to allocate to hold its full sequence."""
vllm/v1/core/single_type_kv_cache_manager.py
+37 -7
@@ -407,6 +407,15 @@ class SingleTypeKVCacheManager(ABC):
407 self._pending_boundary_state_offloads = []
408 return pending
409
410 + def finalize_partial_tail_offload(
411 + self,
412 + request_id: str,
413 + num_computed_tokens: int,
414 + num_in_flight_tokens: int,
415 + ) -> tuple[int, KVCacheBlock, int] | None:
416 + """Finalize a producer partial tail when its request finishes."""
417 + return None
418 +
419 def _apply_cow(
420 self,
421 request_id: str,
@@ -1383,10 +1392,10 @@ class MambaManager(SingleTypeKVCacheManager):
1392 # current allocation.
1393 self._num_checkpoint_blocks: dict[str, int] = {}
1394 # Requests that registered their own last-prompt-boundary partial
1386 - # tail (producers). On the next step's CoW the boundary state moves
1387 - # into a private cow_block; we record that block for connector
1388 - # offload (see _pending_boundary_state_offloads).
1389 - self._producer_partial_tail_reqs: dict[str, int] = {}
1395 + # tail (producers). A later CoW hands its private copy to the
1396 + # connector; a request that finishes first hands off this table
1397 + # source directly.
1398 + self._producer_partial_tail_reqs: dict[str, tuple[KVCacheBlock, int]] = {}
1399
1400 @classmethod
1401 def find_longest_cache_hit(
@@ -1749,10 +1758,12 @@ class MambaManager(SingleTypeKVCacheManager):
1758 self.block_pool.move_block_hashes(source_block, cow_block)
1759 self._pending_cow_copies.append((source_block, cow_block))
1760 source_block.ref_cnt += 1
1752 - boundary_tokens = self._producer_partial_tail_reqs.pop(
1761 + producer_tail = self._producer_partial_tail_reqs.pop(
1762 request_id, None
1763 )
1755 - if boundary_tokens is not None:
1764 + if producer_tail is not None:
1765 + marker_block, boundary_tokens = producer_tail
1766 + assert marker_block is source_block
1767 # This CoW preserved a producer's own boundary
1768 # state in cow_block; hand it to the connector for
1769 # partial-tail offload once the copy has run.
@@ -1788,6 +1799,22 @@ class MambaManager(SingleTypeKVCacheManager):
1799 req_blocks.append(block)
1800 req_blocks[block_idx] = self._null_block
1801
1802 + def finalize_partial_tail_offload(
1803 + self,
1804 + request_id: str,
1805 + num_computed_tokens: int,
1806 + num_in_flight_tokens: int,
1807 + ) -> tuple[int, KVCacheBlock, int] | None:
1808 + if self.mamba_cache_mode != "align":
1809 + return None
1810 + producer_tail = self._producer_partial_tail_reqs.pop(request_id, None)
1811 + if producer_tail is None:
1812 + return None
1813 + source_block, boundary_tokens = producer_tail
1814 + if num_in_flight_tokens != 0 or num_computed_tokens != boundary_tokens:
1815 + return None
1816 + return self.kv_cache_group_id, source_block, boundary_tokens
1817 +
1818 def pop_blocks_for_free(self, request_id: str) -> list[KVCacheBlock]:
1819 if self.mamba_cache_mode == "align":
1820 self._allocated_block_reqs.discard(request_id)
@@ -1894,7 +1921,10 @@ class MambaManager(SingleTypeKVCacheManager):
1921 # in ``source_block`` but the next step's forward overwrites it. The
1922 # upcoming CoW copies it into a durable cow_block; record the req so
1923 # allocate_new_blocks hands that block to the connector for offload.
1897 - self._producer_partial_tail_reqs[request.request_id] = num_tokens
1924 + self._producer_partial_tail_reqs[request.request_id] = (
1925 + source_block,
1926 + num_tokens,
1927 + )
1928 return partial_hash
1929
1930