[Bugfix][KVConnector] Fix SimpleCPUOffloadConnector GPU->CPU store race (#46278)

Saddss committed Jun 23, 2026 at 04:08 UTC 82ede09a5a26c3b529a1e13adb68e5ecc0558790
4 files changed +244 -16
tests/v1/simple_kv_offload/test_worker.py new
+183
@@ -0,0 +1,183 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Worker-side unit tests for SimpleCPUOffloadConnector.
4 +
5 +Covers the GPU->CPU store cross-stream synchronization: the store copy must be
6 +ordered after the compute stream that writes the KV blocks, otherwise it can
7 +read partially written / stale blocks and silently corrupt the CPU cache.
8 +"""
9 +
10 +from __future__ import annotations
11 +
12 +import time
13 +
14 +import pytest
15 +import torch
16 +
17 +from vllm.platforms import current_platform
18 +
19 +if not current_platform.is_cuda_alike():
20 + pytest.skip("Requires CUDA or ROCm", allow_module_level=True)
21 +
22 +from vllm.v1.simple_kv_offload.copy_backend import DmaCopyBackend
23 +from vllm.v1.simple_kv_offload.cuda_mem_ops import (
24 + CU_MEMCPY_SRC_ACCESS_ORDER_ANY,
25 + CU_MEMCPY_SRC_ACCESS_ORDER_STREAM,
26 + build_params,
27 + pin_tensor,
28 +)
29 +from vllm.v1.simple_kv_offload.metadata import SimpleCPUOffloadMetadata
30 +from vllm.v1.simple_kv_offload.worker import SimpleCPUOffloadWorker
31 +
32 +NUM_BLOCKS = 64
33 +BLOCK_BYTES = 4096
34 +ITERS = 30
35 +# Keep the compute stream busy so the KV write lands late; this makes the
36 +# store-vs-compute race deterministic instead of timing-dependent.
37 +SLEEP_CYCLES = 50_000_000
38 +
39 +
40 +def _make_backend() -> tuple[DmaCopyBackend, torch.Tensor, torch.Tensor]:
41 + gpu = {"k": torch.zeros((NUM_BLOCKS, BLOCK_BYTES), dtype=torch.int8, device="cuda")}
42 + cpu = {"k": torch.zeros((NUM_BLOCKS, BLOCK_BYTES), dtype=torch.int8, device="cpu")}
43 + pin_tensor(cpu["k"])
44 + low_pri, _ = torch.cuda.Stream.priority_range()
45 + backend = DmaCopyBackend()
46 + backend.init(
47 + gpu,
48 + cpu,
49 + gpu["k"].device,
50 + torch.cuda.Stream(priority=low_pri),
51 + torch.cuda.Stream(priority=low_pri),
52 + )
53 + return backend, gpu["k"], cpu["k"]
54 +
55 +
56 +def _drive_store(
57 + backend: DmaCopyBackend,
58 + gpu: torch.Tensor,
59 + cpu: torch.Tensor,
60 + *,
61 + with_barrier: bool,
62 +) -> int:
63 + """Run ITERS store cycles; return how many landed corrupted in the CPU pool.
64 +
65 + Each cycle writes a unique value on a compute stream (after a deliberate
66 + delay) and then issues the GPU->CPU store. The store is issued *after* the
67 + write in host program order, mirroring the connector's deferred-store
68 + assumption. Only the compute-done event creates a real device-side
69 + happens-before edge.
70 + """
71 + block_ids = list(range(gpu.shape[0]))
72 + compute_stream = torch.cuda.Stream()
73 + corrupt = 0
74 + for it in range(ITERS):
75 + val = (it % 126) + 1 # 1..126; distinct from the zero-initialized pool
76 + with torch.cuda.stream(compute_stream):
77 + torch.cuda._sleep(SLEEP_CYCLES)
78 + gpu.fill_(val)
79 +
80 + wait_event = None
81 + if with_barrier:
82 + wait_event = torch.Event()
83 + wait_event.record(compute_stream)
84 +
85 + store_events: list[tuple[int, torch.Event]] = []
86 + backend.launch_copy(
87 + block_ids,
88 + block_ids,
89 + is_store=True,
90 + event_idx=it,
91 + events_list=store_events,
92 + wait_event=wait_event,
93 + )
94 +
95 + deadline = time.time() + 10.0
96 + while not store_events and time.time() < deadline:
97 + time.sleep(0.0005)
98 + assert store_events, "background copy was never enqueued"
99 + store_events[0][1].synchronize()
100 +
101 + if int((cpu[:, 0].to(torch.int32) != val).sum().item()):
102 + corrupt += 1
103 + return corrupt
104 +
105 +
106 +def test_store_orders_after_compute_write():
107 + """The store must wait for the compute event; without it, it races.
108 +
109 + Asserts both directions so the test is self-validating: the no-barrier
110 + control must actually corrupt (proving the race window is exercised), and
111 + the fixed path with the compute-done event must be clean.
112 + """
113 + backend, gpu, cpu = _make_backend()
114 + try:
115 + control = _drive_store(backend, gpu, cpu, with_barrier=False)
116 + fixed = _drive_store(backend, gpu, cpu, with_barrier=True)
117 + finally:
118 + backend.shutdown()
119 +
120 + assert control > 0, (
121 + "no-barrier store did not race the compute write; the test no longer "
122 + "exercises the hazard it is meant to guard"
123 + )
124 + assert fixed == 0, f"store raced compute even with the barrier: {fixed} corrupt"
125 +
126 +
127 +class _RecordingBackend:
128 + """Captures launch_copy calls without touching the GPU."""
129 +
130 + def __init__(self) -> None:
131 + self.calls: list[dict] = []
132 +
133 + def launch_copy(
134 + self,
135 + src_blocks,
136 + dst_blocks,
137 + is_store,
138 + event_idx,
139 + events_list,
140 + wait_event=None,
141 + ) -> None:
142 + self.calls.append({"is_store": is_store, "wait_event": wait_event})
143 +
144 +
145 +def test_get_finished_passes_wait_event_for_store_only():
146 + """get_finished gates stores on a compute-done event but not loads."""
147 + worker = SimpleCPUOffloadWorker(
148 + vllm_config=None, kv_cache_config=None, cpu_capacity_bytes=0
149 + )
150 + recording = _RecordingBackend()
151 + worker._backend = recording
152 + worker._connector_metadata = SimpleCPUOffloadMetadata(
153 + load_event=0,
154 + load_gpu_blocks=[0],
155 + load_cpu_blocks=[0],
156 + store_event=1,
157 + store_gpu_blocks=[1],
158 + store_cpu_blocks=[1],
159 + )
160 +
161 + worker.get_finished(set())
162 +
163 + store_calls = [c for c in recording.calls if c["is_store"]]
164 + load_calls = [c for c in recording.calls if not c["is_store"]]
165 + assert len(store_calls) == 1
166 + assert len(load_calls) == 1
167 + assert isinstance(store_calls[0]["wait_event"], torch.Event)
168 + assert load_calls[0]["wait_event"] is None
169 +
170 +
171 +def test_build_params_src_access_order():
172 + """build_params defaults to ANY and honors an explicit STREAM override."""
173 + gpu = {"k": torch.zeros((4, 64), dtype=torch.int8, device="cuda")}
174 + cpu = {"k": torch.zeros((4, 64), dtype=torch.int8, device="cpu")}
175 + stream = torch.cuda.Stream()
176 +
177 + default = build_params(gpu, cpu, stream)
178 + assert default.attrs.srcAccessOrder == CU_MEMCPY_SRC_ACCESS_ORDER_ANY
179 +
180 + ordered = build_params(
181 + gpu, cpu, stream, src_access_order=CU_MEMCPY_SRC_ACCESS_ORDER_STREAM
182 + )
183 + assert ordered.attrs.srcAccessOrder == CU_MEMCPY_SRC_ACCESS_ORDER_STREAM
vllm/v1/simple_kv_offload/copy_backend.py
+38 -5
@@ -12,6 +12,8 @@ import torch
12 from vllm.logger import init_logger
13 from vllm.platforms import current_platform
14 from vllm.v1.simple_kv_offload.cuda_mem_ops import (
15 + CU_MEMCPY_SRC_ACCESS_ORDER_ANY,
16 + CU_MEMCPY_SRC_ACCESS_ORDER_STREAM,
17 BatchMemcpyParams,
18 build_params,
19 copy_blocks,
@@ -43,8 +45,20 @@ class DmaCopyBackend:
45 self._load_stream = load_stream
46 self._store_stream = store_stream
47
46 - self._store_params = build_params(gpu_caches, cpu_caches, store_stream)
47 - self._load_params = build_params(cpu_caches, gpu_caches, load_stream)
48 + # Stores read the live KV cache -> STREAM (paired with the compute-done
49 + # wait in get_finished); loads read stable pinned host memory -> ANY.
50 + self._store_params = build_params(
51 + gpu_caches,
52 + cpu_caches,
53 + store_stream,
54 + src_access_order=CU_MEMCPY_SRC_ACCESS_ORDER_STREAM,
55 + )
56 + self._load_params = build_params(
57 + cpu_caches,
58 + gpu_caches,
59 + load_stream,
60 + src_access_order=CU_MEMCPY_SRC_ACCESS_ORDER_ANY,
61 + )
62
63 self._queue = queue.SimpleQueue()
64 self._thread = threading.Thread(
@@ -61,11 +75,20 @@ class DmaCopyBackend:
75 is_store: bool,
76 event_idx: int,
77 events_list: list[tuple[int, torch.Event]],
78 + wait_event: torch.Event | None = None,
79 ) -> None:
80 params = self._store_params if is_store else self._load_params
81 assert params is not None and self._queue is not None
82 self._queue.put(
68 - (src_blocks, dst_blocks, params, is_store, event_idx, events_list)
83 + (
84 + src_blocks,
85 + dst_blocks,
86 + params,
87 + is_store,
88 + event_idx,
89 + events_list,
90 + wait_event,
91 + )
92 )
93
94 def shutdown(self) -> None:
@@ -89,9 +112,19 @@ class DmaCopyBackend:
112 item = q.get()
113 if item is None:
114 return
92 - src_blocks, dst_blocks, params, is_store, event_idx, events_list = item
93 - copy_blocks(src_blocks, dst_blocks, params)
115 + (
116 + src_blocks,
117 + dst_blocks,
118 + params,
119 + is_store,
120 + event_idx,
121 + events_list,
122 + wait_event,
123 + ) = item
124 stream = store_stream if is_store else load_stream
125 + if wait_event is not None:
126 + stream.wait_event(wait_event)
127 + copy_blocks(src_blocks, dst_blocks, params)
128 event = torch.Event()
129 event.record(stream)
130 events_list.append((event_idx, event))
vllm/v1/simple_kv_offload/cuda_mem_ops.py
+10 -6
@@ -13,6 +13,12 @@ from vllm.platforms import current_platform
13
14 logger = init_logger(__name__)
15
16 +# CUmemcpySrcAccessOrder values (CUDA driver API). STREAM(1): source read in
17 +# stream order, safe when the source may still be written. ANY(3): source may
18 +# be read early, only safe for a stable source (e.g. pinned host memory).
19 +CU_MEMCPY_SRC_ACCESS_ORDER_STREAM = 1
20 +CU_MEMCPY_SRC_ACCESS_ORDER_ANY = 3
21 +
22
23 def pin_tensor(tensor: torch.Tensor) -> None:
24 """Pin a CPU tensor via cudaHostRegister.
@@ -106,8 +112,8 @@ class BatchMemcpyParams(NamedTuple):
112 dst_bases: np.ndarray # [num_layers] uint64
113 bpb: np.ndarray # [num_layers] uint64 — bytes per block
114 num_layers: int
109 - # CUDA only: one attributes entry with srcAccessOrder=ANY. Unused on
110 - # ROCm (7.2.1 or 7.2.2) because the current runtime rejects numAttrs > 0.
115 + # CUDA only: one attributes entry carrying srcAccessOrder. Unused on ROCm
116 + # (7.2.1 or 7.2.2) because the current runtime rejects numAttrs > 0.
117 attrs: _CUmemcpyAttributes
118 attrs_idx: ctypes.c_size_t
119 # NOTE: cuMemcpyBatchAsync_v2() removed fail_idx field, but we use
@@ -120,6 +126,7 @@ def build_params(
126 src_caches: dict[str, torch.Tensor],
127 dst_caches: dict[str, torch.Tensor],
128 stream: torch.cuda.Stream,
129 + src_access_order: int = CU_MEMCPY_SRC_ACCESS_ORDER_ANY,
130 ) -> BatchMemcpyParams:
131 global _batch_memcpy_fn
132 if _batch_memcpy_fn is None:
@@ -137,10 +144,7 @@ def build_params(
144 dst_bases.append(d.data_ptr())
145 bpb.append(s_bpb)
146
140 - # ``srcAccessOrder=3`` == CU_MEMCPY_SRC_ACCESS_ORDER_ANY /
141 - # hipMemcpySrcAccessOrderAny. See
142 - # https://docs.nvidia.com/cuda/cuda-driver-api/group__CUDA__MEM.html#group__CUDA__MEM_1g6f1ff58e3065df3eb4b573dba77ad31f # noqa: E501
143 - attrs = _CUmemcpyAttributes(srcAccessOrder=3)
147 + attrs = _CUmemcpyAttributes(srcAccessOrder=src_access_order)
148
149 return BatchMemcpyParams(
150 src_bases=np.array(src_bases, dtype=np.uint64),
vllm/v1/simple_kv_offload/worker.py
+13 -5
@@ -57,6 +57,10 @@ class SimpleCPUOffloadWorker:
57 # Metadata for the current step
58 self._connector_metadata: SimpleCPUOffloadMetadata | None = None
59
60 + # Compute-done event recorded before each store; reused across steps
61 + # (get_finished runs once per step, copy queue is FIFO).
62 + self._store_compute_done: torch.Event | None = None
63 +
64 # Pending event index sets, populated in bind_connector_metadata
65 self._pending_load_event_indices: set[int] = set()
66 self._pending_store_event_indices: set[int] = set()
@@ -206,9 +210,11 @@ class SimpleCPUOffloadWorker:
210 ) -> tuple[set[str] | None, set[str] | None]:
211 """Submit transfers and report completed events to the scheduler.
212
209 - Called after model execution. The manager only schedules stores for
210 - blocks whose KV data is confirmed computed, so we launch both loads
211 - and stores immediately — no deferral or cross-stream sync needed.
213 + Stores (GPU->CPU) read the live KV cache, which the compute stream may
214 + still be writing under v1 overlapped execution, so they are ordered
215 + after a compute-done event recorded on the current stream. Loads
216 + (CPU->GPU) read stable pinned host memory and launch immediately. See
217 + #45704 for the bug and #39306 for the srcAccessOrder rationale.
218
219 Returns:
220 tuple of (finished_sending, finished_recving).
@@ -218,7 +224,6 @@ class SimpleCPUOffloadWorker:
224 # (1) Submit transfers
225 metadata = self._connector_metadata
226 if metadata is not None:
221 - # Launch loads (CPU->GPU).
227 if metadata.load_cpu_blocks:
228 self._backend.launch_copy(
229 metadata.load_cpu_blocks,
@@ -227,14 +232,17 @@ class SimpleCPUOffloadWorker:
232 event_idx=metadata.load_event,
233 events_list=self._load_events,
234 )
230 - # Launch stores (GPU->CPU).
235 if metadata.store_gpu_blocks:
236 + if self._store_compute_done is None:
237 + self._store_compute_done = torch.Event()
238 + self._store_compute_done.record(torch.cuda.current_stream())
239 self._backend.launch_copy(
240 metadata.store_gpu_blocks,
241 metadata.store_cpu_blocks,
242 is_store=True,
243 event_idx=metadata.store_event,
244 events_list=self._store_events,
245 + wait_event=self._store_compute_done,
246 )
247
248 # (2) Track completed transfer events