[Core][KV Connector] Start async KV loads after the forward launch when no sync loads are scheduled (#53333)

Signed-off-by: Summer Yang <girasoleyang@gmail.com> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Nick Hill <nickhill123@gmail.com>

Summer Yang committed Aug 28, 2026 at 14:58 UTC 2aac565cae880087d752e90f1a08dcd9b369f9a0
17 files changed +293 -118
tests/v1/core/test_scheduler.py
+41
@@ -1975,6 +1975,47 @@ def _step_until_kv_transfer_finished(scheduler: Scheduler, req_ids: list[str]):
1975 return initial_ecos
1976
1977
1978 +@pytest.mark.parametrize(
1979 + ("load_modes", "expected_has_sync_loads"),
1980 + [
1981 + ((True,), False),
1982 + ((False,), True),
1983 + ((True, False), True),
1984 + ],
1985 +)
1986 +@pytest.mark.skip_global_cleanup
1987 +def test_has_sync_kv_loads(
1988 + load_modes: tuple[bool, ...],
1989 + expected_has_sync_loads: bool,
1990 + tmp_path,
1991 +):
1992 + (tmp_path / "config.json").write_text(
1993 + '{"architectures": ["OPTForCausalLM"], "model_type": "opt"}'
1994 + )
1995 + block_size = 16
1996 + scheduler = create_scheduler(
1997 + model=str(tmp_path),
1998 + skip_tokenizer_init=True,
1999 + use_kv_connector=mock_kv(matched_tokens=block_size, is_async=False),
2000 + block_size=block_size,
2001 + )
2002 + requests = create_requests(
2003 + num_requests=len(load_modes),
2004 + num_tokens=block_size * 2,
2005 + block_size=block_size,
2006 + )
2007 + for request in requests:
2008 + scheduler.add_request(request)
2009 +
2010 + scheduler.connector.get_num_new_matched_tokens = Mock(
2011 + side_effect=[(block_size, is_async) for is_async in load_modes]
2012 + )
2013 +
2014 + output = scheduler.schedule()
2015 +
2016 + assert output.has_sync_kv_loads is expected_has_sync_loads
2017 +
2018 +
2019 @pytest.mark.parametrize("is_async", [False, True])
2020 def test_kv_connector_basic(is_async: bool):
2021 """
tests/v1/kv_connector/unit/test_mooncake_store_worker.py
+1 -1
@@ -2960,7 +2960,7 @@ def test_putting_consumer_queues_decode_save():
2960
2961 with patch.object(torch.cuda, "Event") as event_cls:
2962 event = event_cls.return_value
2963 - w.get_finished(set(), meta)
2963 + w.wait_for_save(meta)
2964
2965 event.record.assert_called_once_with()
2966 send_thread.add_request.assert_called_once_with(req)
tests/v1/kv_connector/unit/test_multi_connector.py
+9 -15
@@ -294,18 +294,23 @@ def test_multi_example_connector_consistency():
294 "update_state_after_alloc num_blocks=[7] 0",
295 "build_connector_meta",
296 ]
297 - # First three events are from initialization (register_kv_caches,
298 - # set_host_xfer_buffer_ops, get_handshake_metadata), then generate() events.
299 - assert events["storage1-WORKER"][:8] == [
297 + # First three events are from initialization. During generate(), layer hooks
298 + # run before the deferred load is started after the forward pass.
299 + expected_worker_prefix = [
300 "register_kv_caches",
301 "set_host_xfer_buffer_ops",
302 "get_handshake_metadata",
303 "handle_preemptions",
304 "bind_connector_metadata",
305 - "start_load_kv",
305 "wait_for_layer_load",
306 "save_kv_layer",
307 ]
308 + for connector_name in ("storage1-WORKER", "storage2-WORKER"):
309 + worker_events = events[connector_name]
310 + assert worker_events[:7] == expected_worker_prefix
311 + assert worker_events.index("start_load_kv") > worker_events.index(
312 + "save_kv_layer"
313 + )
314 assert storage2_scheduler_events[:7] == [
315 "bind_gpu_block_pool",
316 "get_finished_count",
@@ -315,17 +320,6 @@ def test_multi_example_connector_consistency():
320 "update_state_after_alloc num_blocks=[7] 0",
321 "build_connector_meta",
322 ]
318 - assert events["storage2-WORKER"][:8] == [
319 - "register_kv_caches",
320 - "set_host_xfer_buffer_ops",
321 - "get_handshake_metadata",
322 - "handle_preemptions",
323 - "bind_connector_metadata",
324 - "start_load_kv",
325 - "wait_for_layer_load",
326 - "save_kv_layer",
327 - ]
328 -
323 # Reset prefix cache or else we'll just get the tokens back from there.
324 llm.reset_prefix_cache()
325
tests/v1/kv_connector/unit/test_nixl_simple_cpu_offload.py
+4 -1
@@ -213,7 +213,7 @@ def _multi_connector_config(swa_enabled: bool = False):
213 def test_nixl_wins_load_over_cpu_offload():
214 """When NixlConnector (index 0) has matched tokens from a remote prefill, it should
215 win the load: Nixl metadata tracks the recv while CPU offload metadata has no load
216 - scheduled."""
216 + scheduled."""
217 vllm_config, kv_cache_config = _multi_connector_config()
218 scheduler = create_scheduler(vllm_config, kv_cache_config=kv_cache_config)
219 mc = scheduler.connector
@@ -227,6 +227,9 @@ def test_nixl_wins_load_over_cpu_offload():
227 )
228 scheduler.add_request(request)
229 sched_out = scheduler.schedule()
230 + # The remote-prefill load is async, so the step has no sync KV loads
231 + # and the worker will start the load after the forward.
232 + assert not sched_out.has_sync_kv_loads
233
234 assert mc._requests_to_connector[request.request_id] == 0
235
tests/v1/simple_kv_offload/test_worker.py
+4 -3
@@ -152,8 +152,8 @@ class _RecordingBackend:
152 self.calls.append({"is_store": is_store, "wait_event": wait_event})
153
154
155 -def test_get_finished_passes_wait_event_for_store_only():
156 - """get_finished gates stores on a compute-done event but not loads."""
155 +def test_transfer_hooks_pass_wait_event_for_store_only():
156 + """wait_for_save gates stores on a compute-done event; start_load_kv does not."""
157 worker = SimpleCPUOffloadWorker(
158 vllm_config=None, kv_cache_config=None, cpu_capacity_bytes=0
159 )
@@ -168,7 +168,8 @@ def test_get_finished_passes_wait_event_for_store_only():
168 store_cpu_blocks=[1],
169 )
170
171 - worker.get_finished(set())
171 + worker.start_load_kv()
172 + worker.wait_for_save()
173
174 store_calls = [c for c in recording.calls if c["is_store"]]
175 load_calls = [c for c in recording.calls if not c["is_store"]]
tests/v1/worker/test_gpu_kv_connector.py new
+79
@@ -0,0 +1,79 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from types import SimpleNamespace
5 +from unittest.mock import Mock
6 +
7 +import pytest
8 +
9 +import vllm.v1.worker.gpu.kv_connector as kv_connector_module
10 +from vllm.config import KVTransferConfig
11 +from vllm.v1.worker.gpu.kv_connector import ActiveKVConnector
12 +
13 +
14 +def _make_connector(
15 + monkeypatch: pytest.MonkeyPatch,
16 + events: list[str],
17 +) -> ActiveKVConnector:
18 + backend = Mock()
19 + backend.handle_preemptions.side_effect = lambda _: events.append("handle")
20 + backend.bind_connector_metadata.side_effect = lambda _: events.append("bind")
21 + backend.start_load_kv.side_effect = lambda _: events.append("start")
22 + backend.wait_for_save.side_effect = lambda: events.append("wait")
23 + backend.get_finished.side_effect = lambda _: (set(), set())
24 + backend.get_block_ids_with_load_errors.return_value = set()
25 + backend.get_kv_connector_stats.return_value = None
26 + backend.get_kv_connector_kv_cache_events.return_value = None
27 + backend.build_connector_worker_meta.return_value = None
28 + backend.clear_connector_metadata.side_effect = lambda: events.append("clear")
29 + monkeypatch.setattr(kv_connector_module, "get_kv_transfer_group", lambda: backend)
30 + monkeypatch.setattr(
31 + kv_connector_module, "is_forward_context_available", lambda: True
32 + )
33 + monkeypatch.setattr(kv_connector_module, "get_forward_context", object)
34 +
35 + kv_config = KVTransferConfig(
36 + kv_connector="NixlConnector",
37 + kv_role="kv_consumer",
38 + kv_buffer_device="cpu",
39 + )
40 + connector = ActiveKVConnector( # type: ignore[arg-type]
41 + SimpleNamespace(kv_transfer_config=kv_config), {}
42 + )
43 + events.clear()
44 + return connector
45 +
46 +
47 +def _scheduler_output(has_sync_kv_loads: bool) -> SimpleNamespace:
48 + return SimpleNamespace(
49 + kv_connector_metadata=object(),
50 + finished_req_ids=set(),
51 + has_sync_kv_loads=has_sync_kv_loads,
52 + )
53 +
54 +
55 +@pytest.mark.parametrize("has_sync_kv_loads", [False, True])
56 +def test_load_start_phase(
57 + monkeypatch: pytest.MonkeyPatch,
58 + has_sync_kv_loads: bool,
59 +):
60 + events: list[str] = []
61 + connector = _make_connector(monkeypatch, events)
62 + output = _scheduler_output(has_sync_kv_loads)
63 +
64 + connector.pre_forward(output) # type: ignore[arg-type]
65 + assert events == (
66 + ["handle", "bind", "start"] if has_sync_kv_loads else ["handle", "bind"]
67 + )
68 +
69 + connector.post_forward(set())
70 + assert events == ["handle", "bind", "start", "wait", "clear"]
71 +
72 +
73 +def test_no_forward_starts_deferred_load_once(monkeypatch: pytest.MonkeyPatch):
74 + events: list[str] = []
75 + connector = _make_connector(monkeypatch, events)
76 +
77 + connector.no_forward(_scheduler_output(False)) # type: ignore[arg-type]
78 +
79 + assert events == ["handle", "bind", "start", "clear"]
vllm/distributed/kv_transfer/kv_connector/v1/base.py
+2 -2
@@ -289,8 +289,8 @@ class KVConnectorBase_V1(ABC):
289 def start_load_kv(self, forward_context: "ForwardContext", **kwargs: Any) -> None:
290 """
291 Start loading the KV cache from the connector to vLLM's paged
292 - KV buffer. This is called from the forward context before the
293 - forward pass to enable async loading during model execution.
292 + KV buffer. Loads required by the current forward start before it;
293 + independent asynchronous loads may start after it is submitted.
294
295 Args:
296 forward_context (ForwardContext): the forward context.
vllm/distributed/kv_transfer/kv_connector/v1/lmcache_connector.py
+15
@@ -133,6 +133,21 @@ class LMCacheConnectorV1(KVConnectorBase_V1):
133 "please check and use the latest version"
134 )
135
136 + def bind_connector_metadata(self, connector_metadata: KVConnectorMetadata) -> None:
137 + super().bind_connector_metadata(connector_metadata)
138 + engine = self._lmcache_engine
139 + if hasattr(engine, "bind_connector_metadata"):
140 + engine.bind_connector_metadata(connector_metadata)
141 + else:
142 + # Compat for engines predating the bind hook: start_load_kv may
143 + # run after the forward (SchedulerOutput.has_sync_kv_loads), so
144 + # the per-step layerwise state it used to reset must be reset
145 + # here. Remove once LMCache ships bind_connector_metadata.
146 + if hasattr(engine, "current_layer"):
147 + engine.current_layer = 0
148 + if hasattr(engine, "layerwise_retrievers"):
149 + engine.layerwise_retrievers = []
150 +
151 def start_load_kv(self, forward_context: "ForwardContext", **kwargs: Any) -> None:
152 """
153 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
+11 -4
@@ -783,6 +783,17 @@ class LMCacheConnectorV1Impl:
783 ####################
784 # Worker side APIs
785 ####################
786 + def bind_connector_metadata(self, metadata: "KVConnectorMetadata") -> None:
787 + """Per-step init, called when the runner binds this step's metadata.
788 +
789 + The layerwise hooks fire during every forward once metadata is
790 + bound, while start_load_kv may run after the forward launch on
791 + steps without sync loads (SchedulerOutput.has_sync_kv_loads), so
792 + the per-step state they consume must be reset here.
793 + """
794 + self.current_layer = 0
795 + self.layerwise_retrievers = []
796 +
797 @_lmcache_nvtx_annotate
798 def register_kv_caches(self, kv_caches: dict[str, torch.Tensor]):
799 logger.info("Registering KV caches")
@@ -806,8 +817,6 @@ class LMCacheConnectorV1Impl:
817 The number of elements in kv_caches and layer_names should be
818 the same.
819 """
809 - self.current_layer = 0
810 -
820 if len(self.kv_caches) == 0:
821 self._init_kv_caches_from_forward_context(forward_context)
822
@@ -826,8 +835,6 @@ class LMCacheConnectorV1Impl:
835
836 self.lmcache_engine.post_init(kvcaches=kvcaches)
837
829 - self.layerwise_retrievers = []
830 -
838 for idx, request in enumerate(metadata.requests):
839 if request.load_spec is None:
840 continue
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/connector.py
+8 -4
@@ -283,8 +283,10 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
283 self.connector_worker.register_kv_caches(kv_caches)
284
285 def start_load_kv(self, forward_context: ForwardContext, **kwargs: Any) -> None:
286 - # No-op: loads are issued in get_finished() for compute overlap.
287 - pass
286 + assert self.connector_worker is not None
287 + metadata = self._get_connector_metadata()
288 + assert isinstance(metadata, MooncakeStoreConnectorMetadata)
289 + self.connector_worker.start_load_kv(metadata)
290
291 def wait_for_layer_load(self, layer_name: str) -> None:
292 # No layerwise support - no-op
@@ -301,8 +303,10 @@ class MooncakeStoreConnector(KVConnectorBase_V1, SupportsHMA):
303 return
304
305 def wait_for_save(self):
304 - # No-op: stores are issued in get_finished() for compute overlap.
305 - pass
306 + assert self.connector_worker is not None
307 + metadata = self._get_connector_metadata()
308 + assert isinstance(metadata, MooncakeStoreConnectorMetadata)
309 + self.connector_worker.wait_for_save(metadata)
310
311 def get_finished(
312 self, finished_req_ids: set[str]
vllm/distributed/kv_transfer/kv_connector/v1/mooncake/store/worker.py
+40 -40
@@ -1743,36 +1743,17 @@ class MooncakeStoreWorker:
1743 "Started %d Mooncake KV-load receive thread(s)", self.num_recv_threads
1744 )
1745
1746 - def start_load_kv(
1747 - self,
1748 - metadata: MooncakeStoreConnectorMetadata,
1749 - ):
1750 - """No-op: loads are issued in get_finished() for overlap."""
1751 - pass
1746 + def start_load_kv(self, metadata: MooncakeStoreConnectorMetadata):
1747 + """Issue async loads.
1748
1753 - def wait_for_save(
1754 - self,
1755 - metadata: MooncakeStoreConnectorMetadata,
1756 - ):
1757 - """No-op: stores are issued in get_finished() for overlap."""
1758 - pass
1759 -
1760 - def get_finished(
1761 - self,
1762 - finished_req_ids: set[str],
1763 - meta: MooncakeStoreConnectorMetadata,
1764 - ) -> tuple[set[str], set[str]]:
1765 - """Issue all I/O and get completed send/recv request IDs.
1766 -
1767 - All load and store I/O requests are issued here (after model
1768 - compute is launched on the compute stream) for better
1769 - compute-I/O overlap.
1749 + Runs after the forward launch on steps without sync loads
1750 + (SchedulerOutput.has_sync_kv_loads), keeping load submission off
1751 + the critical path while preserving compute-I/O overlap.
1752 """
1753 if self._capacity_only:
1772 - return set(), set()
1754 + return
1755
1774 - # Issue async loads
1775 - for request in meta.requests:
1756 + for request in metadata.requests:
1757 load_spec = request.load_spec
1758 if load_spec is None or not load_spec.can_load:
1759 continue
@@ -1781,21 +1762,40 @@ class MooncakeStoreWorker:
1762 self.recv_request_queue.put(request)
1763
1764 assert self.load_async, "load_async must be True for better performance."
1784 - # Issue stores with CUDA event synchronization.
1785 - if self.can_put:
1786 - current_event = None
1787 - for request in meta.requests:
1788 - if request.can_save:
1789 - current_event = torch.cuda.Event()
1790 - current_event.record()
1791 - break
1765
1793 - for request in meta.requests:
1794 - if not request.can_save:
1795 - continue
1796 - request.current_event = current_event
1797 - assert self.kv_send_thread is not None
1798 - self.kv_send_thread.add_request(request)
1766 + def wait_for_save(self, metadata: MooncakeStoreConnectorMetadata):
1767 + """Issue async stores with CUDA event synchronization.
1768 +
1769 + Runs after the forward launch for compute-I/O overlap.
1770 + """
1771 + if self._capacity_only or not self.can_put:
1772 + return
1773 +
1774 + current_event = None
1775 + for request in metadata.requests:
1776 + if request.can_save:
1777 + current_event = torch.cuda.Event()
1778 + current_event.record()
1779 + break
1780 +
1781 + for request in metadata.requests:
1782 + if not request.can_save:
1783 + continue
1784 + request.current_event = current_event
1785 + assert self.kv_send_thread is not None
1786 + self.kv_send_thread.add_request(request)
1787 +
1788 + def get_finished(
1789 + self, finished_req_ids: set[str], meta: MooncakeStoreConnectorMetadata
1790 + ) -> tuple[set[str], set[str]]:
1791 + """Get completed send/recv request IDs.
1792 +
1793 + Loads are issued in start_load_kv() and stores in wait_for_save().
1794 + """
1795 + if self._capacity_only:
1796 + return set(), set()
1797 +
1798 + if self.can_put:
1799 self._close_ended_store_requests(finished_req_ids, meta)
1800
1801 # Blocks read by a store job are released by the scheduler when the job
vllm/distributed/kv_transfer/kv_connector/v1/simple_cpu_offload_connector.py
+6 -4
@@ -196,10 +196,11 @@ class SimpleCPUOffloadConnector(KVConnectorBase_V1, SupportsHMA):
196 self.worker_handler.handle_preemptions(kv_connector_metadata)
197
198 def start_load_kv(self, forward_context: "ForwardContext", **kwargs: Any) -> None:
199 - pass # Launch loads ops in get_finished() after launching model execution
199 + if self.worker_handler is not None:
200 + self.worker_handler.start_load_kv()
201
202 def wait_for_layer_load(self, layer_name: str) -> None:
202 - pass # Always load asynchronously and deferred to get_finished()
203 + pass # Always load asynchronously, issued in start_load_kv()
204
205 def save_kv_layer(
206 self,
@@ -208,10 +209,11 @@ class SimpleCPUOffloadConnector(KVConnectorBase_V1, SupportsHMA):
209 attn_metadata: "AttentionMetadata",
210 **kwargs: Any,
211 ) -> None:
211 - pass # Always save asynchronously and deferred to get_finished()
212 + pass # Always save asynchronously, issued in wait_for_save()
213
214 def wait_for_save(self) -> None:
214 - pass # All stores are driven by get_finished() and no wait needed
215 + if self.worker_handler is not None:
216 + self.worker_handler.wait_for_save()
217
218 def get_finished(
219 self,
vllm/v1/core/sched/output.py
+4
@@ -262,6 +262,10 @@ class SchedulerOutput:
262 # KV Cache Connector metadata.
263 kv_connector_metadata: KVConnectorMetadata | None = None
264
265 + # Whether any scheduled request consumes KV that the connector loads
266 + # synchronously during this step (load_async=False).
267 + has_sync_kv_loads: bool = False
268 +
269 # EC Cache Connector metadata
270 ec_connector_metadata: ECConnectorMetadata | None = None
271 # EC Cache Manager metadata
vllm/v1/core/sched/scheduler.py
+6
@@ -532,6 +532,8 @@ class Scheduler(SchedulerInterface):
532 scheduled_spec_decode_tokens: dict[str, list[int]] = {}
533 # Whether the running batch contains any prefill requests.
534 prefill_scheduled = False
535 + # Whether any scheduled request has a synchronous connector KV load.
536 + has_sync_kv_loads = False
537
538 # For logging.
539 scheduled_timestamp = time.monotonic()
@@ -1142,6 +1144,9 @@ class Scheduler(SchedulerInterface):
1144 continue
1145
1146 self.running.append(request)
1147 + if num_external_computed_tokens > 0:
1148 + # load_kv_async is False here
1149 + has_sync_kv_loads = True
1150 if self.log_stats:
1151 request.record_event(
1152 EngineCoreEventType.SCHEDULED, scheduled_timestamp
@@ -1318,6 +1323,7 @@ class Scheduler(SchedulerInterface):
1323 finished_req_ids=self.finished_req_ids,
1324 free_encoder_mm_hashes=self.encoder_cache_manager.get_freed_mm_hashes(),
1325 new_block_ids_to_zero=self._get_new_block_ids_to_zero(),
1326 + has_sync_kv_loads=has_sync_kv_loads,
1327 kv_cache_block_copies=pending_kv_cache_block_copies,
1328 partial_tail_offloads=pending_partial_tail_offloads,
1329 num_spec_tokens_to_schedule=num_spec_tokens_to_schedule,
vllm/v1/simple_kv_offload/worker.py
+40 -39
@@ -231,58 +231,59 @@ class SimpleCPUOffloadWorker:
231 self._connector_metadata = None
232
233 def start_load_kv(self) -> None:
234 - # NOTE: we defer launching both load and store to get_finished(),
235 - # which runs after model execution. This hides the CPU-side
236 - # block copy op overhead (~5ms) behind GPU compute.
237 - pass
234 + # Invoked after the forward launch on steps without sync loads
235 + # (SchedulerOutput.has_sync_kv_loads), so the CPU-side block copy
236 + # op overhead (~5ms) stays hidden behind GPU compute. Stores are
237 + # issued in wait_for_save().
238 + metadata = self._connector_metadata
239 + if metadata is not None and metadata.load_cpu_blocks:
240 + backend = self._backend
241 + assert backend is not None
242 + backend.launch_copy(
243 + metadata.load_cpu_blocks,
244 + metadata.load_gpu_blocks,
245 + is_store=False,
246 + event_idx=metadata.load_event,
247 + events_list=self._load_events,
248 + )
249
250 def wait_for_save(self) -> None:
240 - pass
241 -
242 - def get_finished(
243 - self,
244 - finished_req_ids: set[str],
245 - ) -> tuple[set[str] | None, set[str] | None]:
246 - """Submit transfers and report completed events to the scheduler.
251 + """Submit async stores.
252
253 Stores (GPU->CPU) read the live KV cache, which the compute stream may
254 still be writing under v1 overlapped execution, so they are ordered
250 - after a compute-done event recorded on the current stream. Loads
251 - (CPU->GPU) read stable pinned host memory and launch immediately. See
255 + after a compute-done event recorded on the current stream. See
256 #45704 for the bug and #39306 for the srcAccessOrder rationale.
257 + """
258 + metadata = self._connector_metadata
259 + if metadata is not None and metadata.store_gpu_blocks:
260 + backend = self._backend
261 + assert backend is not None
262 + if self._store_compute_done is None:
263 + self._store_compute_done = torch.Event()
264 + self._store_compute_done.record(torch.cuda.current_stream())
265 + backend.launch_copy(
266 + metadata.store_gpu_blocks,
267 + metadata.store_cpu_blocks,
268 + is_store=True,
269 + event_idx=metadata.store_event,
270 + events_list=self._store_events,
271 + wait_event=self._store_compute_done,
272 + )
273 +
274 + def get_finished(
275 + self, finished_req_ids: set[str]
276 + ) -> tuple[set[str] | None, set[str] | None]:
277 + """Report completed transfer events to the scheduler.
278 +
279 + Loads are submitted in start_load_kv() and stores in wait_for_save().
280
281 Returns:
282 tuple of (finished_sending, finished_recving).
283 - finished_sending: always None (stores use worker metadata).
284 - finished_recving: req_ids whose loads have completed.
285 """
259 - # (1) Submit transfers
286 metadata = self._connector_metadata
261 - if metadata is not None:
262 - backend = self._backend
263 - assert backend is not None
264 - if metadata.load_cpu_blocks:
265 - backend.launch_copy(
266 - metadata.load_cpu_blocks,
267 - metadata.load_gpu_blocks,
268 - is_store=False,
269 - event_idx=metadata.load_event,
270 - events_list=self._load_events,
271 - )
272 - if metadata.store_gpu_blocks:
273 - if self._store_compute_done is None:
274 - self._store_compute_done = torch.Event()
275 - self._store_compute_done.record(torch.cuda.current_stream())
276 - backend.launch_copy(
277 - metadata.store_gpu_blocks,
278 - metadata.store_cpu_blocks,
279 - is_store=True,
280 - event_idx=metadata.store_event,
281 - events_list=self._store_events,
282 - wait_event=self._store_compute_done,
283 - )
284 -
285 - # (2) Track completed transfer events
287 finished_recving: set[str] = set()
288
289 if self._pending_load_event_indices:
vllm/v1/worker/gpu/kv_connector.py
+14
@@ -54,6 +54,7 @@ class ActiveKVConnector(KVConnector):
54 self.kv_connector.register_kv_caches(kv_caches_dict)
55 self.kv_connector.set_host_xfer_buffer_ops(copy_kv_blocks)
56
57 + self._pending_load_start = False
58 self._disabled = False
59
60 def pre_forward(self, scheduler_output: "SchedulerOutput") -> None:
@@ -65,6 +66,16 @@ class ActiveKVConnector(KVConnector):
66 self.kv_connector.handle_preemptions(kv_connector_metadata)
67 self.kv_connector.bind_connector_metadata(kv_connector_metadata)
68
69 + if scheduler_output.has_sync_kv_loads:
70 + # Sync loads need to run before this step's forward.
71 + self._start_load_kv()
72 + else:
73 + # Start any async loads in post-forward instead, keeping
74 + # their host-side submission cost off the critical path.
75 + self._pending_load_start = True
76 +
77 + def _start_load_kv(self) -> None:
78 + self._pending_load_start = False
79 # TODO: sort out KV Connectors' use of forward_context
80 if is_forward_context_available():
81 self.kv_connector.start_load_kv(get_forward_context())
@@ -78,6 +89,9 @@ class ActiveKVConnector(KVConnector):
89 if self._disabled:
90 return None
91
92 + if self._pending_load_start:
93 + self._start_load_kv()
94 +
95 output = KVConnectorOutput()
96 if wait_for_save:
97 self.kv_connector.wait_for_save()
vllm/v1/worker/kv_connector_model_runner_mixin.py
+9 -5
@@ -79,14 +79,18 @@ class KVConnectorModelRunnerMixin:
79 assert scheduler_output.kv_connector_metadata is not None
80 kv_connector.bind_connector_metadata(scheduler_output.kv_connector_metadata)
81
82 - # Background KV cache transfers happen here.
83 - # These transfers are designed to be async and the requests
84 - # involved may be disjoint from the running requests.
85 - # Do this here to save a collective_rpc.
86 - kv_connector.start_load_kv(get_forward_context())
82 + # Start this step's KV loads, ordered after any in-flight KV block
83 + # zeroing. Sync loads feed this step's forward so must precede it;
84 + # otherwise start (async) loads after the forward launch, keeping
85 + # their host-side submission cost off the critical path.
86 + start_after_forward = not scheduler_output.has_sync_kv_loads
87 + if not start_after_forward:
88 + kv_connector.start_load_kv(get_forward_context())
89 try:
90 yield output
91 finally:
92 + if start_after_forward:
93 + kv_connector.start_load_kv(get_forward_context())
94 if wait_for_save and not defer_finalize:
95 kv_connector.wait_for_save()
96