[Bugfix][KVConnector] Finalize saves on steps without a forward (#57775)

Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: OpenAI Codex <noreply@openai.com> Co-authored-by: Kimi Code <noreply@moonshot.ai>

Yifan Qiao committed Sep 24, 2026 at 17:06 UTC b3a87b2d04e6439e2a1aef0cbf9ca65328076c86
11 files changed +56 -37
tests/v1/kv_connector/unit/offloading_connector/utils.py
+1
@@ -517,6 +517,7 @@ class RequestRunner:
517 if complete_transfers:
518 self.offloading_spec.complete_transfers()
519
520 + self.worker_connector.wait_for_save()
521 finished_sending, finished_recving = self.worker_connector.get_finished(
522 scheduler_output.finished_req_ids
523 )
tests/v1/kv_connector/unit/test_kv_connector_lifecycle.py
+1
@@ -71,6 +71,7 @@ def test_kv_connector_mixin_clears_metadata():
71 assert connector._connector_metadata is None
72 # Test connector wrapper records method calls
73 assert connector.call_record.get("bind_connector_metadata", 0) == 1
74 + assert connector.call_record.get("wait_for_save", 0) == 1
75 assert connector.call_record.get("clear_connector_metadata", 0) == 1
76 finally:
77 # Ensure we clean up the global connector between tests
tests/v1/kv_connector/unit/test_lmcache_connector.py
+31
@@ -1,10 +1,12 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +from collections.abc import Iterator
4 from unittest.mock import MagicMock
5
6 import pytest
7
8 from vllm.distributed.kv_events import BlockStored
9 +from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorMetadata
10 from vllm.distributed.kv_transfer.kv_connector.v1.lmcache_connector import (
11 LMCacheConnectorV1,
12 LMCacheKVEvents,
@@ -783,3 +785,32 @@ class TestIntegrationScenarios:
785 assert aggregated_events[0].block_hashes == ["hash_common"]
786 assert aggregated_events[0].parent_block_hash == "parent_common"
787 assert aggregated_events[0].token_ids == [1, 2, 3]
788 +
789 +
790 +def test_legacy_layerwise_engine_does_not_reuse_storers_on_empty_steps():
791 + """Empty steps must not advance generators exhausted by the last forward."""
792 +
793 + class LegacyLayerwiseEngine:
794 + layerwise_storers: list[Iterator[None]]
795 +
796 + def save_kv_layer(self, *args, **kwargs):
797 + self.layerwise_storers.append(iter([None]))
798 +
799 + def wait_for_save(self):
800 + for storer in self.layerwise_storers:
801 + next(storer)
802 +
803 + connector = LMCacheConnectorV1.__new__(LMCacheConnectorV1)
804 + connector._lmcache_engine = LegacyLayerwiseEngine()
805 + metadata = KVConnectorMetadata()
806 + connector.bind_connector_metadata(metadata)
807 + connector.save_kv_layer("layer", MagicMock(), MagicMock())
808 + connector.wait_for_save()
809 + connector.clear_connector_metadata()
810 +
811 + for _ in range(2):
812 + connector.bind_connector_metadata(metadata)
813 + connector.wait_for_save()
814 + connector.clear_connector_metadata()
815 +
816 + assert connector._lmcache_engine.layerwise_storers == []
tests/v1/worker/test_gpu_kv_connector.py
+1 -1
@@ -96,4 +96,4 @@ def test_no_forward_starts_deferred_load_once(monkeypatch: pytest.MonkeyPatch):
96
97 connector.no_forward(_scheduler_output(False)) # type: ignore[arg-type]
98
99 - assert events == ["handle", "bind", "start", "clear"]
99 + assert events == ["handle", "bind", "start", "wait", "clear"]
vllm/distributed/kv_transfer/kv_connector/v1/base.py
+8 -5
@@ -31,7 +31,7 @@ The class provides the following primitives:
31 wait_for_layer_load() - blocks until layer i load is done
32
33 save_kv_layer() - starts saving KV for layer i (maybe async)
34 - wait_for_save() - blocks until all saves are done
34 + wait_for_save() - submits saves and performs required synchronization
35
36 get_transfer_results() - returns async send/receive completions and
37 receive failures in one snapshot.
@@ -356,11 +356,14 @@ class KVConnectorBase_V1(ABC):
356
357 @abstractmethod
358 def wait_for_save(self):
359 - """Block until all the save operations is done. This is called
360 - as the forward context exits to ensure that the async saving
361 - from save_kv_layer is complete before finishing the forward.
359 + """Submit saves and perform required synchronization.
360
363 - This prevents overwrites of paged KV buffer before saving done.
361 + Called once per step, including empty steps, after any target and draft
362 + forwards. Metadata remains bound during this call. Do nothing if there
363 + is no save work.
364 +
365 + Async saves must retain their source blocks until completion is reported
366 + to the scheduler. Otherwise, wait for saves to finish before returning.
367 """
368 pass
369
vllm/distributed/kv_transfer/kv_connector/v1/lmcache_connector.py
+1
@@ -142,6 +142,7 @@ class LMCacheConnectorV1(KVConnectorBase_V1):
142 engine.current_layer = 0
143 if hasattr(engine, "layerwise_retrievers"):
144 engine.layerwise_retrievers = []
145 + engine.layerwise_storers = []
146
147 def start_load_kv(self, forward_context: "ForwardContext", **kwargs: Any) -> None:
148 """Start loading the KV cache from the connector to vLLM's paged
vllm/distributed/kv_transfer/kv_connector/v1/lmcache_integration/vllm_v1_adapter.py
+2
@@ -606,6 +606,7 @@ class LMCacheConnectorV1Impl:
606
607 self.async_loading = config.enable_async_loading
608 self.layerwise_retrievers: list[Generator[torch.Tensor | None, None, None]] = []
609 + self.layerwise_storers: list[Generator[Any, None, None]] = []
610 self._stats_monitor = LMCStatsMonitor.GetOrCreate()
611 if role == KVConnectorRole.SCHEDULER:
612 # Create lookup client using factory
@@ -798,6 +799,7 @@ class LMCacheConnectorV1Impl:
799 """
800 self.current_layer = 0
801 self.layerwise_retrievers = []
802 + self.layerwise_storers = []
803
804 @_lmcache_nvtx_annotate
805 def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
vllm/distributed/kv_transfer/kv_connector/v1/offloading_connector.py
+4 -9
@@ -131,19 +131,14 @@ class OffloadingConnector(KVConnectorBase_V1, SupportsHMA):
131 pass
132
133 def wait_for_save(self):
134 - # Store deferral is handled in get_finished(), which always runs even
135 - # when wait_for_save() is skipped (e.g. kv_connector_no_forward).
136 - pass
137 -
138 - def get_finished(self, finished_req_ids: set[str]) -> tuple[set[str], set[str]]:
134 assert self.connector_worker is not None
135 assert isinstance(self._connector_metadata, OffloadingConnectorMetadata)
141 -
142 - # Defer store jobs to the next step's start_kv_transfers. Done here
143 - # (rather than wait_for_save) so stores are queued even on steps where
144 - # wait_for_save is skipped.
136 + # Defer store jobs to the next step's start_kv_transfers.
137 self.connector_worker.prepare_store_kv(self._connector_metadata)
138
139 + def get_finished(self, finished_req_ids: set[str]) -> tuple[set[str], set[str]]:
140 + assert self.connector_worker is not None
141 + assert isinstance(self._connector_metadata, OffloadingConnectorMetadata)
142 return self.connector_worker.get_finished(finished_req_ids)
143
144 def build_connector_worker_meta(self) -> OffloadingWorkerMetadata | None:
vllm/v1/simple_kv_offload/worker.py
+1 -10
@@ -67,7 +67,6 @@ class SimpleCPUOffloadWorker:
67
68 # Metadata for the current step
69 self._connector_metadata: SimpleCPUOffloadMetadata | None = None
70 - self._store_submitted = False
70
71 # Compute-done event recorded before each store; reused across steps
72 # (get_finished runs once per step, copy queue is FIFO).
@@ -246,15 +245,12 @@ class SimpleCPUOffloadWorker:
245
246 def bind_connector_metadata(self, metadata: SimpleCPUOffloadMetadata) -> None:
247 self._connector_metadata = metadata
249 - self._store_submitted = False
248 if metadata.load_event >= 0:
249 self._pending_load_event_indices.add(metadata.load_event)
250 if metadata.store_event >= 0:
251 self._pending_store_event_indices.add(metadata.store_event)
252
253 def clear_connector_metadata(self) -> None:
256 - # No-forward steps skip the normal wait_for_save hook.
257 - self.wait_for_save()
254 self._connector_metadata = None
255
256 def start_load_kv(self) -> None:
@@ -283,11 +279,7 @@ class SimpleCPUOffloadWorker:
279 #45704 for the bug and #39306 for the srcAccessOrder rationale.
280 """
281 metadata = self._connector_metadata
286 - if (
287 - metadata is not None
288 - and metadata.store_gpu_blocks
289 - and not self._store_submitted
290 - ):
282 + if metadata is not None and metadata.store_gpu_blocks:
283 backend = self._backend
284 assert backend is not None
285 if self._store_compute_done is None:
@@ -301,7 +293,6 @@ class SimpleCPUOffloadWorker:
293 events_list=self._store_events,
294 wait_event=self._store_compute_done,
295 )
304 - self._store_submitted = True
296
297 def get_finished(
298 self, finished_req_ids: set[str]
vllm/v1/worker/gpu/kv_connector.py
+4 -9
@@ -35,9 +35,7 @@ class KVConnector:
35 def finish_forward(self) -> None:
36 pass
37
38 - def post_forward(
39 - self, finished_req_ids: set[str], wait_for_save: bool = True
40 - ) -> KVConnectorOutput | None:
38 + def post_forward(self, finished_req_ids: set[str]) -> KVConnectorOutput | None:
39 return None
40
41 def no_forward(self, scheduler_output: "SchedulerOutput") -> ModelRunnerOutput:
@@ -97,9 +95,7 @@ class ActiveKVConnector(KVConnector):
95 def reset_capture_state(self) -> None:
96 self.kv_connector.reset_capture_state()
97
100 - def post_forward(
101 - self, finished_req_ids: set[str], wait_for_save: bool = True
102 - ) -> KVConnectorOutput | None:
98 + def post_forward(self, finished_req_ids: set[str]) -> KVConnectorOutput | None:
99 if self._disabled:
100 return None
101
@@ -107,8 +103,7 @@ class ActiveKVConnector(KVConnector):
103 self._start_load_kv()
104
105 output = KVConnectorOutput()
110 - if wait_for_save:
111 - self.kv_connector.wait_for_save()
106 + self.kv_connector.wait_for_save()
107 transfer_results = self.kv_connector.get_transfer_results(finished_req_ids)
108 output.finished_sending = transfer_results.finished_sending or None
109 output.finished_recving = transfer_results.finished_recving or None
@@ -129,7 +124,7 @@ class ActiveKVConnector(KVConnector):
124 self.pre_forward(scheduler_output)
125 self.finish_forward()
126 finished_req_ids = scheduler_output.finished_req_ids
132 - kv_connector_output = self.post_forward(finished_req_ids, wait_for_save=False)
127 + kv_connector_output = self.post_forward(finished_req_ids)
128 return ModelRunnerOutput.with_kv_conn_output_only(kv_connector_output)
129
130 def set_disabled(self, disabled: bool) -> None:
vllm/v1/worker/kv_connector_model_runner_mixin.py
+2 -3
@@ -29,7 +29,7 @@ class KVConnectorModelRunnerMixin:
29 with (
30 set_forward_context(None, vllm_config),
31 KVConnectorModelRunnerMixin._get_kv_connector_output(
32 - scheduler_output, wait_for_save=False
32 + scheduler_output
33 ) as kv_connector_output,
34 ):
35 pass
@@ -66,7 +66,6 @@ class KVConnectorModelRunnerMixin:
66 @contextmanager
67 def _get_kv_connector_output(
68 scheduler_output: "SchedulerOutput",
69 - wait_for_save: bool = True,
69 defer_finalize: bool = False,
70 ) -> Generator[KVConnectorOutput, None, None]:
71 output = KVConnectorOutput()
@@ -89,7 +88,7 @@ class KVConnectorModelRunnerMixin:
88 finally:
89 if start_after_forward:
90 kv_connector.start_load_kv(get_forward_context())
92 - if wait_for_save and not defer_finalize:
91 + if not defer_finalize:
92 kv_connector.wait_for_save()
93
94 transfer_results = kv_connector.get_transfer_results(