[Core] Enhance cpu<->gpu sync checking to include paged async copies (#53491)
Signed-off-by: Nick Hill <nickhill123@gmail.com>
Nick Hill committed
Sep 8, 2026 at 17:26 UTC
4aadb0f14d4c0d6397cfba271f11906e980b18c1
5 files changed
+332
-14
tests/utils_/test_gpu_sync_debug.py
+146
@@ -158,3 +158,149 @@ def test_without_env_set(monkeypatch):
158
159
with_gpu_sync_check(_no_sync)()
160
with_gpu_sync_check(_causes_sync)()
161
+
162
+
163
+def _pageable_h2d_nonblocking():
164
+ # Pageable source: the "async" copy is staged through pageable memory.
165
+ torch.zeros(4).to("cuda", non_blocking=True)
166
+
167
+
168
+def _pinned_h2d_nonblocking():
169
+ # Pinned, contiguous, same dtype: genuinely asynchronous.
170
+ torch.zeros(4).pin_memory().to("cuda", non_blocking=True)
171
+
172
+
173
+def _noncontiguous_h2d_nonblocking():
174
+ # Pinned but gapped layout (strided slice): staged through a pageable temp.
175
+ torch.zeros(8).pin_memory()[::2].to("cuda", non_blocking=True)
176
+
177
+
178
+def _transposed_h2d_nonblocking():
179
+ # Dense permuted layout: copied with pitched cudaMemcpy2DAsync, stays async.
180
+ torch.zeros(4, 4).pin_memory().t().to("cuda", non_blocking=True)
181
+
182
+
183
+def _dtype_converting_h2d_nonblocking():
184
+ # Pinned and contiguous: the conversion runs GPU-side, staying async.
185
+ torch.zeros(4, dtype=torch.float64).pin_memory().to(
186
+ "cuda", dtype=torch.float32, non_blocking=True
187
+ )
188
+
189
+
190
+def _pageable_h2d_via_cuda():
191
+ torch.zeros(4).cuda(non_blocking=True)
192
+
193
+
194
+def _pageable_h2d_via_copy_():
195
+ torch.zeros(4, device="cuda").copy_(torch.zeros(4), non_blocking=True)
196
+
197
+
198
+def _d2h_via_to():
199
+ # Genuinely asynchronous: torch allocates the destination pinned.
200
+ torch.zeros(4, device="cuda").to("cpu", non_blocking=True)
201
+
202
+
203
+def _pageable_d2h_via_copy_():
204
+ torch.zeros(4).copy_(torch.zeros(4, device="cuda"), non_blocking=True)
205
+
206
+
207
+def _pinned_d2h_via_copy_():
208
+ # Pinned, contiguous, same dtype: genuinely asynchronous.
209
+ torch.zeros(4).pin_memory().copy_(torch.zeros(4, device="cuda"), non_blocking=True)
210
+
211
+
212
+def _noncontiguous_d2h_via_copy_():
213
+ # Pinned but gapped (strided slice) destination.
214
+ torch.zeros(8).pin_memory()[::2].copy_(
215
+ torch.zeros(4, device="cuda"), non_blocking=True
216
+ )
217
+
218
+
219
+def _transposed_d2h_via_copy_():
220
+ # Dense permuted destination: pitched cudaMemcpy2DAsync, stays async.
221
+ torch.zeros(4, 4).pin_memory().t().copy_(
222
+ torch.zeros(4, 4, device="cuda"), non_blocking=True
223
+ )
224
+
225
+
226
+def _empty_h2d_nonblocking():
227
+ # Empty (e.g. first-step penalties): no CUDA call is issued at all.
228
+ torch.zeros(0).to("cuda", non_blocking=True)
229
+
230
+
231
+def _dtype_converting_d2h_via_copy_():
232
+ # Pinned and contiguous: the conversion runs GPU-side, staying async.
233
+ torch.zeros(4).pin_memory().copy_(
234
+ torch.zeros(4, dtype=torch.float64, device="cuda"), non_blocking=True
235
+ )
236
+
237
+
238
+@pytest.mark.parametrize("mode", ["warn", "error"])
239
+@pytest.mark.parametrize(
240
+ "fn",
241
+ [
242
+ _pageable_h2d_nonblocking,
243
+ _noncontiguous_h2d_nonblocking,
244
+ _pageable_h2d_via_cuda,
245
+ _pageable_h2d_via_copy_,
246
+ _pageable_d2h_via_copy_,
247
+ _noncontiguous_d2h_via_copy_,
248
+ ],
249
+)
250
+@create_new_process_for_each_test()
251
+def test_implicit_copy_sync_detected(monkeypatch, mode, fn):
252
+ """`non_blocking=True` CPU<->CUDA copies with a pageable or
253
+ non-densely-laid-out CPU tensor may block the host without tripping
254
+ torch's sync debug mode; the `Tensor.to`/`cuda`/`copy_` wrappers must
255
+ flag them.
256
+ """
257
+ monkeypatch.setattr(gsd, "_SYNC_CHECK_MODE", mode)
258
+ monkeypatch.setattr(gsd, "_sync_check_enabled", True)
259
+ gsd._install_copy_checkers()
260
+
261
+ if mode == "error":
262
+ with pytest.raises(RuntimeError, match="Implicit GPU<->CPU sync"):
263
+ with_gpu_sync_check(fn)()
264
+ else:
265
+ with pytest.warns(UserWarning, match="Implicit GPU<->CPU sync"):
266
+ with_gpu_sync_check(fn)()
267
+
268
+
269
+@create_new_process_for_each_test()
270
+def test_genuinely_async_transfers_pass(monkeypatch):
271
+ """Pinned, densely laid out CPU tensors make `non_blocking=True` truly
272
+ asynchronous in both directions -- even with a dtype conversion, which
273
+ runs GPU-side, and even permuted (e.g. transposed), which uses pitched
274
+ cudaMemcpy2D/3DAsync; D2H `Tensor.to` allocates a pinned destination.
275
+ None of these may be flagged."""
276
+ monkeypatch.setattr(gsd, "_SYNC_CHECK_MODE", "error")
277
+ monkeypatch.setattr(gsd, "_sync_check_enabled", True)
278
+ gsd._install_copy_checkers()
279
+
280
+ for fn in (
281
+ _pinned_h2d_nonblocking,
282
+ _pinned_d2h_via_copy_,
283
+ _d2h_via_to,
284
+ _dtype_converting_h2d_nonblocking,
285
+ _dtype_converting_d2h_via_copy_,
286
+ _transposed_h2d_nonblocking,
287
+ _transposed_d2h_via_copy_,
288
+ _empty_h2d_nonblocking,
289
+ ):
290
+ fn() # Warm up outside the checked region.
291
+ with_gpu_sync_check(fn)()
292
+
293
+
294
+@create_new_process_for_each_test()
295
+def test_implicit_copy_sync_can_be_allowed(monkeypatch):
296
+ """`gpu_sync_allowed()` must exempt implicit copy syncs too."""
297
+ monkeypatch.setattr(gsd, "_SYNC_CHECK_MODE", "error")
298
+ monkeypatch.setattr(gsd, "_sync_check_enabled", True)
299
+ gsd._install_copy_checkers()
300
+
301
+ def allowed_pageable_copies():
302
+ with gpu_sync_allowed():
303
+ _pageable_h2d_nonblocking()
304
+ _pageable_d2h_via_copy_()
305
+
306
+ with_gpu_sync_check(allowed_pageable_copies)()
vllm/distributed/kv_transfer/kv_connector/v1/example_connector.py
+12
-4
@@ -135,7 +135,11 @@ class ExampleConnector(KVConnectorBase_V1):
135
slot_mapping (torch.Tensor): the slot mapping. In shape
136
[num_tokens].
137
"""
138
- slot_mapping = slot_mapping.to(dst_kv_cache_layer.device, non_blocking=True)
138
+ # Deliberate bulk transfers in this reference connector.
139
+ with gpu_sync_allowed():
140
+ slot_mapping = slot_mapping.to(
141
+ dst_kv_cache_layer.device, non_blocking=True
142
+ )
143
if isinstance(attn_metadata, MLACommonMetadata):
144
# [B, 1, N, C] -> [B * N, C]; slot_mapping indexes B * N slots.
145
dst_kv_cache_layer = dst_kv_cache_layer.reshape(
@@ -177,8 +181,10 @@ class ExampleConnector(KVConnectorBase_V1):
181
filename = self._generate_filename_debug(
182
layer_name, request.token_ids, request.mm_hashes
183
)
180
- kv_cache_cpu = safetensors.torch.load_file(filename)["kv_cache"]
181
- kv_cache = kv_cache_cpu.to(kv_cache_layer.device, non_blocking=True)
184
+ # Deliberate bulk load in this reference connector.
185
+ with gpu_sync_allowed():
186
+ kv_cache_cpu = safetensors.torch.load_file(filename)["kv_cache"]
187
+ kv_cache = kv_cache_cpu.to(kv_cache_layer.device, non_blocking=True)
188
if isinstance(attn_metadata, dict):
189
inject_kv_into_layer(
190
kv_cache_layer,
@@ -224,7 +230,9 @@ class ExampleConnector(KVConnectorBase_V1):
230
231
The layer is a standardized [B, H, N, C] per-layer view (H == 1 for MLA).
232
"""
227
- slot_mapping = slot_mapping.to(layer.device, non_blocking=True)
233
+ # Deliberate bulk transfer in this reference connector.
234
+ with gpu_sync_allowed():
235
+ slot_mapping = slot_mapping.to(layer.device, non_blocking=True)
236
if isinstance(attn_metadata, MLACommonMetadata):
237
# [B, 1, N, C] -> [B * N, C]; slot_mapping indexes B * N slots.
238
return layer.reshape(-1, layer.shape[-1])[slot_mapping, ...]
vllm/envs.py
+5
-3
@@ -642,9 +642,11 @@ environment_variables: dict[str, Callable[[], Any]] = {
642
"VLLM_TRITON_USE_TD": lambda: {"1": True, "0": False}.get(
643
os.getenv("VLLM_TRITON_USE_TD", "").strip()
644
),
645
- # If set, enable PyTorch's GPU<->CPU synchronization debug mode around
646
- # the worker's `execute_model` and `sample_tokens` calls. Valid values
647
- # are "warn" (print a warning on each sync) or "error" (raise on sync).
645
+ # If set, enable GPU<->CPU synchronization checking around the worker's
646
+ # `execute_model` and `sample_tokens` calls, via PyTorch's sync debug mode
647
+ # plus wrappers flagging `non_blocking` CPU<->CUDA copies that silently
648
+ # block the host (CPU tensors that are not pinned or not densely laid out).
649
+ # Valid values are "warn" (warn on each sync) or "error" (raise on sync).
650
# Unset disables the check. See `torch.cuda.set_sync_debug_mode`.
651
"VLLM_GPU_SYNC_CHECK": env_with_choices(
652
"VLLM_GPU_SYNC_CHECK", None, ["warn", "error"]
vllm/model_executor/offloader/uva.py
+10
-7
@@ -3,6 +3,7 @@
3
"""UVA-based CPU offloading using Unified Virtual Addressing."""
4
5
from collections.abc import Generator
6
+from contextlib import nullcontext
7
8
import torch
9
import torch.nn as nn
@@ -11,6 +12,7 @@ from torch.func import functional_call
12
import vllm.envs as envs
13
from vllm.logger import init_logger
14
from vllm.model_executor.offloader.base import BaseOffloader, should_pin_memory
15
+from vllm.utils.gpu_sync_debug import gpu_sync_allowed
16
from vllm.utils.mem_utils import format_gib
17
from vllm.utils.platform_utils import is_uva_available
18
from vllm.utils.torch_utils import get_accelerator_view_from_cpu_tensor
@@ -126,13 +128,14 @@ class UVAOffloader(BaseOffloader):
128
129
def forward(*args, **kwargs):
130
module.forward = original_forward
129
- device_state = {
130
- # here we blindly call `to(device)`
131
- # if the parameter is already on the device,
132
- # it will be a no-op
133
- k: v.to(device, non_blocking=True)
134
- for k, v in module.state_dict().items()
135
- }
131
+ with nullcontext() if self.pin_memory else gpu_sync_allowed():
132
+ device_state = {
133
+ # here we blindly call `to(device)`
134
+ # if the parameter is already on the device,
135
+ # it will be a no-op
136
+ k: v.to(device, non_blocking=True)
137
+ for k, v in module.state_dict().items()
138
+ }
139
140
# set `tie_weights=False` as tied weights in original model
141
# become untied when calling .to(device) individually
vllm/utils/gpu_sync_debug.py
+159
@@ -7,6 +7,12 @@
7
is a failure. The scoping lives in ContextVars, which are per-thread and
8
per-asyncio-task, so an allow region opened on one thread is invisible to
9
every other one.
10
+
11
+torch's mode only instruments explicit synchronizing calls, so it misses
12
+`non_blocking=True` CPU<->CUDA copies that the CUDA driver silently stages
13
+through pageable memory (when the CPU tensor is not pinned or not densely
14
+laid out). Those are caught by the wrappers on `Tensor.to`/`Tensor.cuda`/
15
+`Tensor.copy_` installed by `_install_copy_checkers()`.
16
"""
17
18
import functools
@@ -54,6 +60,7 @@ def enable_gpu_sync_check() -> None:
60
global _sync_check_enabled
61
_sync_check_enabled = True
62
_install_compile_time_sync_suppressors()
63
+ _install_copy_checkers()
64
65
66
_arm_lock = threading.Lock()
@@ -197,6 +204,158 @@ def _install_compile_time_sync_suppressors() -> None:
204
pass
205
206
207
+# ---------------------------------------------------------------------------
208
+# Implicit sync detection in `non_blocking` CPU<->CUDA copies.
209
+#
210
+# A `non_blocking=True` copy between CPU and CUDA still blocks the host when
211
+# the CPU side is not pinned or not densely laid out (gapped strided slices
212
+# stage through a pageable temp): D2H copies into such memory wait for the
213
+# stream, and H2D copies from it do too beyond a driver size threshold.
214
+# None of that goes through an API torch's sync debug mode instruments, so
215
+# wrap the Python-level transfer entry points and flag such copies on threads
216
+# in a checked region.
217
+
218
+IMPLICIT_SYNC_ERROR_MESSAGE = (
219
+ "Implicit GPU<->CPU sync detected - use a pinned, densely laid out CPU "
220
+ "tensor, or wrap with gpu_sync_allowed()"
221
+)
222
+
223
+# Captured at import; `_install_copy_checkers` replaces these attributes.
224
+_torch_to = torch.Tensor.to
225
+_torch_cuda = torch.Tensor.cuda
226
+_torch_copy_ = torch.Tensor.copy_
227
+
228
+# torch's own parser for `Tensor.to`'s overloaded signature, returning
229
+# (device, dtype, non_blocking, memory_format) (also used by torch._dynamo).
230
+_parse_to = getattr(torch._C._nn, "_parse_to", None)
231
+
232
+
233
+def _active_check_mode() -> str | None:
234
+ """The mode the calling thread is being checked in, if any."""
235
+ if not _sync_check_enabled or _allow_depth.get() or torch.compiler.is_compiling():
236
+ return None
237
+ return _checking.get()
238
+
239
+
240
+def _is_dense(t: torch.Tensor) -> bool:
241
+ """Whether `t`'s elements fill a contiguous storage range, possibly
242
+ permuted (e.g. a transposed matrix). Such layouts can be copied with
243
+ pitched cudaMemcpy2D/3DAsync directly from/to pinned memory, whereas
244
+ gapped layouts (e.g. strided slices) stage through a pageable temp."""
245
+ if t.is_contiguous():
246
+ # The common case, answered by a memoized TensorImpl flag.
247
+ return True
248
+ expected_stride = 1
249
+ for stride, size in sorted(zip(t.stride(), t.shape)):
250
+ if size <= 1:
251
+ continue
252
+ if stride != expected_stride:
253
+ return False
254
+ expected_stride *= size
255
+ return True
256
+
257
+
258
+def _cpu_copy_stall_reason(
259
+ cpu_tensor: torch.Tensor, device: torch.device | None
260
+) -> str | None:
261
+ """Why a `non_blocking` copy between `cpu_tensor` and a CUDA tensor would
262
+ silently block the host, or None if it is genuinely asynchronous.
263
+
264
+ `device` describes the other side of the copy; a non-CUDA `device` means
265
+ the copy does not involve the GPU at all.
266
+ """
267
+ if device is None or device.type != "cuda" or cpu_tensor.device.type != "cpu":
268
+ return None
269
+ if cpu_tensor.numel() == 0:
270
+ return None # Nothing to copy; torch issues no CUDA call at all.
271
+ if not _is_dense(cpu_tensor):
272
+ return "the CPU tensor is not densely laid out"
273
+ if not cpu_tensor.is_pinned():
274
+ return "the CPU tensor is not pinned"
275
+ return None
276
+
277
+
278
+def _report_implicit_sync(op: str, direction: str, reason: str, mode: str) -> None:
279
+ msg = (
280
+ f"Tensor.{op}: a non_blocking {direction} copy that may be silently "
281
+ f"synchronous ({reason}). {IMPLICIT_SYNC_ERROR_MESSAGE}"
282
+ )
283
+ if mode == "error":
284
+ raise RuntimeError(msg)
285
+ warnings.warn(msg, stacklevel=3)
286
+
287
+
288
+def _checked_to(self, *args, **kwargs):
289
+ # Only H2D is checked: D2H `to` allocates a pinned destination in
290
+ # supported torch versions, so it is genuinely asynchronous.
291
+ if (mode := _active_check_mode()) is not None and _parse_to is not None:
292
+ try:
293
+ device, _, non_blocking, _ = _parse_to(*args, **kwargs)
294
+ except Exception:
295
+ pass # Invalid args; let the original call raise.
296
+ else:
297
+ if non_blocking and (reason := _cpu_copy_stall_reason(self, device)):
298
+ _report_implicit_sync("to", "H2D", reason, mode)
299
+ return _torch_to(self, *args, **kwargs)
300
+
301
+
302
+def _checked_cuda(self, *args, **kwargs):
303
+ # Signature: cuda(device=None, non_blocking=False, memory_format=None).
304
+ if (mode := _active_check_mode()) is not None:
305
+ device = args[0] if args else kwargs.get("device")
306
+ non_blocking = args[1] if len(args) > 1 else kwargs.get("non_blocking", False)
307
+ if non_blocking:
308
+ if device is None:
309
+ device = torch.device("cuda")
310
+ elif isinstance(device, int):
311
+ device = torch.device("cuda", device)
312
+ elif not isinstance(device, torch.device):
313
+ device = torch.device(device)
314
+ if reason := _cpu_copy_stall_reason(self, device):
315
+ _report_implicit_sync("cuda", "H2D", reason, mode)
316
+ return _torch_cuda(self, *args, **kwargs)
317
+
318
+
319
+def _checked_copy_(self, *args, **kwargs):
320
+ # Signature: copy_(src, non_blocking=False).
321
+ if (
322
+ (mode := _active_check_mode()) is not None
323
+ and args
324
+ and isinstance(src := args[0], torch.Tensor)
325
+ ):
326
+ non_blocking = args[1] if len(args) > 1 else kwargs.get("non_blocking", False)
327
+ if non_blocking:
328
+ if self.device.type == "cuda":
329
+ reason = _cpu_copy_stall_reason(src, self.device)
330
+ direction = "H2D"
331
+ else:
332
+ reason = _cpu_copy_stall_reason(self, src.device)
333
+ direction = "D2H"
334
+ if reason:
335
+ _report_implicit_sync("copy_", direction, reason, mode)
336
+ return _torch_copy_(self, *args, **kwargs)
337
+
338
+
339
+_copy_checkers_installed: bool = False
340
+
341
+
342
+def _install_copy_checkers() -> None:
343
+ """Wrap the Python-level CPU<->CUDA transfer entry points with the checks
344
+ above.
345
+
346
+ The patch is process-global, but it only reports on threads inside a
347
+ checked region, matching the scoping of the torch-level check. Copies
348
+ issued from C++ (e.g. inside compiled graphs) bypass it.
349
+ """
350
+ global _copy_checkers_installed
351
+ if _copy_checkers_installed:
352
+ return
353
+ _copy_checkers_installed = True
354
+ torch.Tensor.to = _checked_to # type: ignore[method-assign]
355
+ torch.Tensor.cuda = _checked_cuda # type: ignore[method-assign]
356
+ torch.Tensor.copy_ = _checked_copy_ # type: ignore[method-assign]
357
+
358
+
359
def gpu_sync_allowed(first_only: bool = False):
360
"""Allow GPU<->CPU syncs inside the `with` block, on this thread only.
361