[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>
This commit is contained in:
Yifan Qiao
2026-09-25 00:06:36 +00:00
committed by GitHub
co-authored by OpenAI Codex Kimi Code
parent 7833ff25dd
commit b3a87b2d04
11 changed files with 56 additions and 37 deletions
@@ -517,6 +517,7 @@ class RequestRunner:
if complete_transfers:
self.offloading_spec.complete_transfers()
self.worker_connector.wait_for_save()
finished_sending, finished_recving = self.worker_connector.get_finished(
scheduler_output.finished_req_ids
)
@@ -71,6 +71,7 @@ def test_kv_connector_mixin_clears_metadata():
assert connector._connector_metadata is None
# Test connector wrapper records method calls
assert connector.call_record.get("bind_connector_metadata", 0) == 1
assert connector.call_record.get("wait_for_save", 0) == 1
assert connector.call_record.get("clear_connector_metadata", 0) == 1
finally:
# Ensure we clean up the global connector between tests
@@ -1,10 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from collections.abc import Iterator
from unittest.mock import MagicMock
import pytest
from vllm.distributed.kv_events import BlockStored
from vllm.distributed.kv_transfer.kv_connector.v1.base import KVConnectorMetadata
from vllm.distributed.kv_transfer.kv_connector.v1.lmcache_connector import (
LMCacheConnectorV1,
LMCacheKVEvents,
@@ -783,3 +785,32 @@ class TestIntegrationScenarios:
assert aggregated_events[0].block_hashes == ["hash_common"]
assert aggregated_events[0].parent_block_hash == "parent_common"
assert aggregated_events[0].token_ids == [1, 2, 3]
def test_legacy_layerwise_engine_does_not_reuse_storers_on_empty_steps():
"""Empty steps must not advance generators exhausted by the last forward."""
class LegacyLayerwiseEngine:
layerwise_storers: list[Iterator[None]]
def save_kv_layer(self, *args, **kwargs):
self.layerwise_storers.append(iter([None]))
def wait_for_save(self):
for storer in self.layerwise_storers:
next(storer)
connector = LMCacheConnectorV1.__new__(LMCacheConnectorV1)
connector._lmcache_engine = LegacyLayerwiseEngine()
metadata = KVConnectorMetadata()
connector.bind_connector_metadata(metadata)
connector.save_kv_layer("layer", MagicMock(), MagicMock())
connector.wait_for_save()
connector.clear_connector_metadata()
for _ in range(2):
connector.bind_connector_metadata(metadata)
connector.wait_for_save()
connector.clear_connector_metadata()
assert connector._lmcache_engine.layerwise_storers == []
+1 -1
View File
@@ -96,4 +96,4 @@ def test_no_forward_starts_deferred_load_once(monkeypatch: pytest.MonkeyPatch):
connector.no_forward(_scheduler_output(False)) # type: ignore[arg-type]
assert events == ["handle", "bind", "start", "clear"]
assert events == ["handle", "bind", "start", "wait", "clear"]
@@ -31,7 +31,7 @@ The class provides the following primitives:
wait_for_layer_load() - blocks until layer i load is done
save_kv_layer() - starts saving KV for layer i (maybe async)
wait_for_save() - blocks until all saves are done
wait_for_save() - submits saves and performs required synchronization
get_transfer_results() - returns async send/receive completions and
receive failures in one snapshot.
@@ -356,11 +356,14 @@ class KVConnectorBase_V1(ABC):
@abstractmethod
def wait_for_save(self):
"""Block until all the save operations is done. This is called
as the forward context exits to ensure that the async saving
from save_kv_layer is complete before finishing the forward.
"""Submit saves and perform required synchronization.
This prevents overwrites of paged KV buffer before saving done.
Called once per step, including empty steps, after any target and draft
forwards. Metadata remains bound during this call. Do nothing if there
is no save work.
Async saves must retain their source blocks until completion is reported
to the scheduler. Otherwise, wait for saves to finish before returning.
"""
pass
@@ -142,6 +142,7 @@ class LMCacheConnectorV1(KVConnectorBase_V1):
engine.current_layer = 0
if hasattr(engine, "layerwise_retrievers"):
engine.layerwise_retrievers = []
engine.layerwise_storers = []
def start_load_kv(self, forward_context: "ForwardContext", **kwargs: Any) -> None:
"""Start loading the KV cache from the connector to vLLM's paged
@@ -606,6 +606,7 @@ class LMCacheConnectorV1Impl:
self.async_loading = config.enable_async_loading
self.layerwise_retrievers: list[Generator[torch.Tensor | None, None, None]] = []
self.layerwise_storers: list[Generator[Any, None, None]] = []
self._stats_monitor = LMCStatsMonitor.GetOrCreate()
if role == KVConnectorRole.SCHEDULER:
# Create lookup client using factory
@@ -798,6 +799,7 @@ class LMCacheConnectorV1Impl:
"""
self.current_layer = 0
self.layerwise_retrievers = []
self.layerwise_storers = []
@_lmcache_nvtx_annotate
def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
@@ -131,19 +131,14 @@ class OffloadingConnector(KVConnectorBase_V1, SupportsHMA):
pass
def wait_for_save(self):
# Store deferral is handled in get_finished(), which always runs even
# when wait_for_save() is skipped (e.g. kv_connector_no_forward).
pass
assert self.connector_worker is not None
assert isinstance(self._connector_metadata, OffloadingConnectorMetadata)
# Defer store jobs to the next step's start_kv_transfers.
self.connector_worker.prepare_store_kv(self._connector_metadata)
def get_finished(self, finished_req_ids: set[str]) -> tuple[set[str], set[str]]:
assert self.connector_worker is not None
assert isinstance(self._connector_metadata, OffloadingConnectorMetadata)
# Defer store jobs to the next step's start_kv_transfers. Done here
# (rather than wait_for_save) so stores are queued even on steps where
# wait_for_save is skipped.
self.connector_worker.prepare_store_kv(self._connector_metadata)
return self.connector_worker.get_finished(finished_req_ids)
def build_connector_worker_meta(self) -> OffloadingWorkerMetadata | None:
+1 -10
View File
@@ -67,7 +67,6 @@ class SimpleCPUOffloadWorker:
# Metadata for the current step
self._connector_metadata: SimpleCPUOffloadMetadata | None = None
self._store_submitted = False
# Compute-done event recorded before each store; reused across steps
# (get_finished runs once per step, copy queue is FIFO).
@@ -246,15 +245,12 @@ class SimpleCPUOffloadWorker:
def bind_connector_metadata(self, metadata: SimpleCPUOffloadMetadata) -> None:
self._connector_metadata = metadata
self._store_submitted = False
if metadata.load_event >= 0:
self._pending_load_event_indices.add(metadata.load_event)
if metadata.store_event >= 0:
self._pending_store_event_indices.add(metadata.store_event)
def clear_connector_metadata(self) -> None:
# No-forward steps skip the normal wait_for_save hook.
self.wait_for_save()
self._connector_metadata = None
def start_load_kv(self) -> None:
@@ -283,11 +279,7 @@ class SimpleCPUOffloadWorker:
#45704 for the bug and #39306 for the srcAccessOrder rationale.
"""
metadata = self._connector_metadata
if (
metadata is not None
and metadata.store_gpu_blocks
and not self._store_submitted
):
if metadata is not None and metadata.store_gpu_blocks:
backend = self._backend
assert backend is not None
if self._store_compute_done is None:
@@ -301,7 +293,6 @@ class SimpleCPUOffloadWorker:
events_list=self._store_events,
wait_event=self._store_compute_done,
)
self._store_submitted = True
def get_finished(
self, finished_req_ids: set[str]
+4 -9
View File
@@ -35,9 +35,7 @@ class KVConnector:
def finish_forward(self) -> None:
pass
def post_forward(
self, finished_req_ids: set[str], wait_for_save: bool = True
) -> KVConnectorOutput | None:
def post_forward(self, finished_req_ids: set[str]) -> KVConnectorOutput | None:
return None
def no_forward(self, scheduler_output: "SchedulerOutput") -> ModelRunnerOutput:
@@ -97,9 +95,7 @@ class ActiveKVConnector(KVConnector):
def reset_capture_state(self) -> None:
self.kv_connector.reset_capture_state()
def post_forward(
self, finished_req_ids: set[str], wait_for_save: bool = True
) -> KVConnectorOutput | None:
def post_forward(self, finished_req_ids: set[str]) -> KVConnectorOutput | None:
if self._disabled:
return None
@@ -107,8 +103,7 @@ class ActiveKVConnector(KVConnector):
self._start_load_kv()
output = KVConnectorOutput()
if wait_for_save:
self.kv_connector.wait_for_save()
self.kv_connector.wait_for_save()
transfer_results = self.kv_connector.get_transfer_results(finished_req_ids)
output.finished_sending = transfer_results.finished_sending or None
output.finished_recving = transfer_results.finished_recving or None
@@ -129,7 +124,7 @@ class ActiveKVConnector(KVConnector):
self.pre_forward(scheduler_output)
self.finish_forward()
finished_req_ids = scheduler_output.finished_req_ids
kv_connector_output = self.post_forward(finished_req_ids, wait_for_save=False)
kv_connector_output = self.post_forward(finished_req_ids)
return ModelRunnerOutput.with_kv_conn_output_only(kv_connector_output)
def set_disabled(self, disabled: bool) -> None:
@@ -29,7 +29,7 @@ class KVConnectorModelRunnerMixin:
with (
set_forward_context(None, vllm_config),
KVConnectorModelRunnerMixin._get_kv_connector_output(
scheduler_output, wait_for_save=False
scheduler_output
) as kv_connector_output,
):
pass
@@ -66,7 +66,6 @@ class KVConnectorModelRunnerMixin:
@contextmanager
def _get_kv_connector_output(
scheduler_output: "SchedulerOutput",
wait_for_save: bool = True,
defer_finalize: bool = False,
) -> Generator[KVConnectorOutput, None, None]:
output = KVConnectorOutput()
@@ -89,7 +88,7 @@ class KVConnectorModelRunnerMixin:
finally:
if start_after_forward:
kv_connector.start_load_kv(get_forward_context())
if wait_for_save and not defer_finalize:
if not defer_finalize:
kv_connector.wait_for_save()
transfer_results = kv_connector.get_transfer_results(