[Core] Make the GPU sync check thread-local and fix its suppressors (#51455)
Signed-off-by: Nick Hill <nickhill123@gmail.com>
Nick Hill committed
Aug 8, 2026 at 17:13 UTC
fbff187d593c322c4a221864ce5756bd849e174e
2 files changed
+288
-120
tests/utils_/test_gpu_sync_debug.py
+101
-2
@@ -1,5 +1,8 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+import threading
4
+import warnings
5
+
6
import pytest
7
import torch
8
@@ -36,7 +39,9 @@ def _causes_sync():
39
@create_new_process_for_each_test()
40
def test_with_env_set(monkeypatch, mode):
41
# Env set + gate flipped on: the unguarded sync is detected.
39
- monkeypatch.setenv("VLLM_GPU_SYNC_CHECK", mode)
42
+ # `_SYNC_CHECK_MODE` is read from the env once at import, so patch the
43
+ # module attribute rather than the environment.
44
+ monkeypatch.setattr(gsd, "_SYNC_CHECK_MODE", mode)
45
monkeypatch.setattr(gsd, "_sync_check_enabled", True)
46
47
# Guarded syncs always pass.
@@ -51,10 +56,104 @@ def test_with_env_set(monkeypatch, mode):
56
with_gpu_sync_check(_causes_sync)()
57
58
59
+@create_new_process_for_each_test()
60
+def test_other_threads_are_not_policed(monkeypatch):
61
+ """A background thread that syncs deliberately must not be broken by the
62
+ check being armed on the thread running the decorated function."""
63
+ monkeypatch.setattr(gsd, "_SYNC_CHECK_MODE", "error")
64
+ monkeypatch.setattr(gsd, "_sync_check_enabled", True)
65
+
66
+ def sync_on_worker():
67
+ failure: list[BaseException] = []
68
+
69
+ def worker():
70
+ try:
71
+ torch.ones(4, device="cuda").cpu()
72
+ except BaseException as exc: # pragma: no cover - failure path
73
+ failure.append(exc)
74
+
75
+ thread = threading.Thread(target=worker)
76
+ thread.start()
77
+ thread.join()
78
+ assert not failure, f"background thread raised: {failure[0]!r}"
79
+
80
+ with_gpu_sync_check(sync_on_worker)()
81
+
82
+
83
+@create_new_process_for_each_test()
84
+def test_allow_on_other_thread_does_not_disarm(monkeypatch):
85
+ """`gpu_sync_allowed()` on one thread must not suppress the check on
86
+ another. It is scoped by ContextVar rather than torch's process-global
87
+ sync debug mode, which a previous implementation mutated."""
88
+ monkeypatch.setattr(gsd, "_SYNC_CHECK_MODE", "error")
89
+ monkeypatch.setattr(gsd, "_sync_check_enabled", True)
90
+
91
+ def main_syncs_while_worker_allows():
92
+ stop = threading.Event()
93
+
94
+ def worker():
95
+ with gpu_sync_allowed():
96
+ while not stop.is_set():
97
+ torch.ones(4, device="cuda").cpu()
98
+
99
+ thread = threading.Thread(target=worker)
100
+ thread.start()
101
+ try:
102
+ # Must still be reported despite the worker's open allow region.
103
+ torch.ones(4, device="cuda").cpu()
104
+ finally:
105
+ stop.set()
106
+ thread.join()
107
+
108
+ with pytest.raises(RuntimeError, match=SYNC_ERROR_MESSAGE):
109
+ with_gpu_sync_check(main_syncs_while_worker_allows)()
110
+
111
+
112
+@create_new_process_for_each_test()
113
+def test_suppressing_works_while_compiling(monkeypatch):
114
+ """`_suppressing` wraps torch compile entry points, which run with
115
+ `torch.compiler.is_compiling()` true. `gpu_sync_allowed()` deliberately
116
+ no-ops in that state, so `_suppressing` must not route through it."""
117
+ monkeypatch.setattr(gsd, "_SYNC_CHECK_MODE", "error")
118
+ monkeypatch.setattr(gsd, "_sync_check_enabled", True)
119
+ # Emulate being inside a torch compile, as inductor passes are.
120
+ monkeypatch.setattr(torch.compiler, "is_compiling", lambda: True)
121
+
122
+ suppressed = gsd._suppressing(lambda: torch.ones(4, device="cuda").cpu())
123
+ with_gpu_sync_check(suppressed)()
124
+
125
+
126
+@create_new_process_for_each_test()
127
+def test_sync_debug_mode_restored_after_checked_call(monkeypatch):
128
+ """The mode is armed only for the duration of a checked call. Leaving it
129
+ on process-wide made every sync outside a checked region emit a
130
+ `UserWarning` whenever our handler was not the installed one."""
131
+ monkeypatch.setattr(gsd, "_SYNC_CHECK_MODE", "error")
132
+ monkeypatch.setattr(gsd, "_sync_check_enabled", True)
133
+
134
+ before = torch.cuda.get_sync_debug_mode()
135
+
136
+ def nested():
137
+ # `execute_model` -> `sample_tokens` both carry the decorator.
138
+ assert torch.cuda.get_sync_debug_mode() != 0, "armed inside"
139
+ with_gpu_sync_check(lambda: None)()
140
+ assert torch.cuda.get_sync_debug_mode() != 0, "still armed after inner"
141
+
142
+ with_gpu_sync_check(nested)()
143
+ assert torch.cuda.get_sync_debug_mode() == before
144
+
145
+ # With the mode back to its original value, torch emits nothing for a
146
+ # sync outside a checked region.
147
+ with warnings.catch_warnings(record=True) as caught:
148
+ warnings.simplefilter("always")
149
+ torch.ones(4, device="cuda").cpu()
150
+ assert not [r for r in caught if gsd._TORCH_SYNC_WARNING in str(r.message)]
151
+
152
+
153
@create_new_process_for_each_test()
154
def test_without_env_set(monkeypatch):
155
# Env unset: the decorator is a pass-through, no sync is detected.
57
- monkeypatch.delenv("VLLM_GPU_SYNC_CHECK", raising=False)
156
+ monkeypatch.setattr(gsd, "_SYNC_CHECK_MODE", None)
157
monkeypatch.setattr(gsd, "_sync_check_enabled", True)
158
159
with_gpu_sync_check(_no_sync)()
vllm/utils/gpu_sync_debug.py
+187
-118
@@ -1,8 +1,20 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Detect unintended GPU<->CPU syncs in the hot path.
4
+
5
+`torch.cuda.set_sync_debug_mode` is process-global, so we arm it at "warn"
6
+(which never raises) and decide in `_sync_warning_hook` whether a given sync
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
+
12
import functools
13
import sys
5
-from contextlib import contextmanager
14
+import threading
15
+import warnings
16
+from contextlib import contextmanager, nullcontext
17
+from contextvars import ContextVar
18
19
import torch
20
@@ -13,153 +25,210 @@ SYNC_ERROR_MESSAGE = (
25
"GPU<->CPU sync detected - avoid it or wrap with gpu_sync_allowed()"
26
)
27
28
+# Warning text torch emits in "warn" mode.
29
+_TORCH_SYNC_WARNING = "called a synchronizing CUDA operation"
30
+
31
_GPU_SYNC_ALLOWED_FIRST_SEEN: set[tuple[str, int]] = set()
32
18
-# Global sync-check gate. Off during engine setup (model load, KV cache
19
-# init, warmup/compile) so first-compile and lazy-init syncs pass through;
20
-# flipped on by `enable_gpu_sync_check()` at the end of
21
-# `GPUWorker.compile_or_warm_up_model`, after which `with_gpu_sync_check`-
22
-# decorated functions activate the configured debug mode.
33
+# Read once: `envs.__getattr__` re-evaluates the env lambda on every access,
34
+# which dominates the cost of a disabled `gpu_sync_allowed()`. Tests toggling
35
+# the env var must patch this attribute instead.
36
+_SYNC_CHECK_MODE: str | None = (
37
+ envs.VLLM_GPU_SYNC_CHECK if current_platform.is_cuda_alike() else None
38
+)
39
+
40
+# Configured mode while a thread is inside a checked call, else None.
41
+_checking: ContextVar[str | None] = ContextVar("vllm_gpu_sync_checking", default=None)
42
+# Depth of nested `gpu_sync_allowed()` regions on this thread.
43
+_allow_depth: ContextVar[int] = ContextVar("vllm_gpu_sync_allow_depth", default=0)
44
+
45
+# Off during engine setup so model load, KV cache init and warmup may sync
46
+# freely; flipped on by `enable_gpu_sync_check()` once warmup completes.
47
_sync_check_enabled: bool = False
48
49
50
def enable_gpu_sync_check() -> None:
27
- """Flip the sync-check gate on. Call once per worker, after warmup /
28
- first-compile is complete. No-op unless `VLLM_GPU_SYNC_CHECK` is set."""
29
- if envs.VLLM_GPU_SYNC_CHECK is None:
51
+ """Flip the sync-check gate on, once per worker, after warmup."""
52
+ if _SYNC_CHECK_MODE is None:
53
return
54
global _sync_check_enabled
55
_sync_check_enabled = True
56
_install_compile_time_sync_suppressors()
57
58
59
+_arm_lock = threading.Lock()
60
+_arm_count: int = 0
61
+_saved_sync_debug_mode: int = 0
62
+_prev_showwarning = warnings.showwarning
63
+_sync_filter_head: tuple | None = None
64
+
65
+
66
+def _sync_warning_hook(message, category, filename, lineno, file=None, line=None):
67
+ """Turn torch's sync warning into an error, but only on a thread that is
68
+ being checked and is outside any allow region."""
69
+ if _TORCH_SYNC_WARNING in str(message):
70
+ mode = _checking.get()
71
+ if mode is None or _allow_depth.get():
72
+ return None
73
+ if mode == "error":
74
+ raise RuntimeError(SYNC_ERROR_MESSAGE)
75
+ return _prev_showwarning(message, category, filename, lineno, file, line)
76
+
77
+
78
+def _install_warning_hook() -> None:
79
+ """(Re)install the hook and a filter that lets torch's warning reach it.
80
+
81
+ Done per checked call because pytest runs each test inside
82
+ `warnings.catch_warnings()`, which restores both `showwarning` and
83
+ `filters`. The hook is left in place afterwards: outside a checked call
84
+ the debug mode is disarmed, so torch emits nothing for it to see.
85
+ """
86
+ global _prev_showwarning, _sync_filter_head
87
+ if warnings.showwarning is not _sync_warning_hook:
88
+ _prev_showwarning = warnings.showwarning
89
+ warnings.showwarning = _sync_warning_hook
90
+ # "always" so the warning survives filtering and isn't deduplicated by
91
+ # `__warningregistry__`. `filterwarnings` prepends, so only re-assert it
92
+ # once ours is no longer in front.
93
+ if warnings.filters[:1] != [_sync_filter_head]:
94
+ warnings.filterwarnings(
95
+ "always", message=_TORCH_SYNC_WARNING, category=UserWarning
96
+ )
97
+ _sync_filter_head = warnings.filters[0]
98
+
99
+
100
+@contextmanager
101
+def _checked_region(mode: str):
102
+ """Police syncs on this thread for the duration of the block.
103
+
104
+ The debug mode is armed per call so that syncs outside a checked region
105
+ emit nothing, and refcounted because `execute_model` and `sample_tokens`
106
+ nest. "warn" rather than "error" because torch's error mode raises on
107
+ whichever thread synced, with no way to exempt one.
108
+ """
109
+ global _arm_count, _saved_sync_debug_mode
110
+ _install_warning_hook()
111
+ with _arm_lock:
112
+ if _arm_count == 0:
113
+ _saved_sync_debug_mode = torch.cuda.get_sync_debug_mode()
114
+ torch.cuda.set_sync_debug_mode("warn")
115
+ _arm_count += 1
116
+ token = _checking.set(mode)
117
+ try:
118
+ yield
119
+ finally:
120
+ _checking.reset(token)
121
+ with _arm_lock:
122
+ _arm_count -= 1
123
+ if _arm_count == 0:
124
+ torch.cuda.set_sync_debug_mode(_saved_sync_debug_mode)
125
+
126
+
127
+@contextmanager
128
+def _allow_syncs():
129
+ token = _allow_depth.set(_allow_depth.get() + 1)
130
+ try:
131
+ yield
132
+ finally:
133
+ _allow_depth.reset(token)
134
+
135
+
136
+# Shared, since `nullcontext` is stateless: reusing one instance avoids an
137
+# allocation per call, which is most of the cost when the check is disabled.
138
+_NOOP_CM = nullcontext()
139
+
140
_compile_time_suppressors_installed: bool = False
141
142
143
+def _suppressing(fn):
144
+ """Allow the syncs `fn` performs on its calling thread."""
145
+
146
+ @functools.wraps(fn)
147
+ def wrapper(*args, **kwargs):
148
+ # Not `gpu_sync_allowed()`, which no-ops while
149
+ # `torch.compiler.is_compiling()` -- exactly when these run.
150
+ with _allow_syncs():
151
+ return fn(*args, **kwargs)
152
+
153
+ return wrapper
154
+
155
+
156
def _install_compile_time_sync_suppressors() -> None:
40
- """Wrap torch inductor/aot_autograd compile entry points so the
41
- synchronizing ops those passes perform don't trip the
42
- sync-check mode we set around `execute_model` / `sample_tokens`.
157
+ """Allow the syncs torch's compile passes perform.
158
44
- Warmup-time compiles already run under the gate (before
45
- `enable_gpu_sync_check`), but post-warmup compiles fire inside
46
- `execute_model` and we want to avoid this tripping the sync check.
159
+ Warmup-time compiles run before the gate flips, but lazy ones fire inside
160
+ `execute_model`.
161
"""
162
global _compile_time_suppressors_installed
163
if _compile_time_suppressors_installed:
164
return
165
_compile_time_suppressors_installed = True
166
53
- try: # noqa: BLE001
167
+ try:
168
from torch._inductor.fx_passes import joint_graph as _jg
169
56
- _orig_joint = _jg.joint_graph_passes
57
-
58
- @functools.wraps(_orig_joint)
59
- def _wrapped_joint(*args, **kwargs):
60
- prev_mode = torch.cuda.get_sync_debug_mode()
61
- if not prev_mode:
62
- return _orig_joint(*args, **kwargs)
63
- torch.cuda.set_sync_debug_mode(0)
64
- try:
65
- return _orig_joint(*args, **kwargs)
66
- finally:
67
- torch.cuda.set_sync_debug_mode(prev_mode)
68
-
69
- # `compile_fx` does `from .fx_passes.joint_graph import
70
- # joint_graph_passes`, which binds the *function object* at import
71
- # time. Patching just the module attribute won't update that rebind,
72
- # so patch every already-imported reference we can find. Restrict
73
- # the scan to torch's compile-time modules.
74
- import sys as _sys
75
-
76
- setattr(_jg, "joint_graph_passes", _wrapped_joint) # noqa: B010
77
- for _name, _mod in list(_sys.modules.items()):
78
- if _mod is None:
79
- continue
80
- if not (
81
- _name.startswith("torch._inductor")
82
- or _name.startswith("torch._functorch")
83
- or _name.startswith("torch._dynamo")
170
+ orig = _jg.joint_graph_passes
171
+ wrapped = _suppressing(orig)
172
+ # `compile_fx` imports this by value, so patching the defining module
173
+ # alone misses that rebind; patch every compile-time module still
174
+ # holding the original.
175
+ _jg.joint_graph_passes = wrapped
176
+ for name, mod in list(sys.modules.items()):
177
+ if (
178
+ mod is not None
179
+ and name.startswith(
180
+ ("torch._inductor", "torch._functorch", "torch._dynamo")
181
+ )
182
+ and getattr(mod, "joint_graph_passes", None) is orig
183
):
85
- continue
86
- if getattr(_mod, "joint_graph_passes", None) is _orig_joint:
87
- setattr(_mod, "joint_graph_passes", _wrapped_joint) # noqa: B010
184
+ setattr(mod, "joint_graph_passes", wrapped) # noqa: B010
185
except Exception: # pragma: no cover
186
pass
187
91
-
92
-@contextmanager
93
-def _suppress_gpu_sync_check(prev_mode: int):
94
- torch.cuda.set_sync_debug_mode(0)
188
try:
96
- yield
97
- finally:
98
- torch.cuda.set_sync_debug_mode(prev_mode)
189
+ # Inductor builds its cudagraph tree lazily, so `deferred_cudagraphify`
190
+ # and the `capture_begin` sync inside it can fire during
191
+ # `execute_model`. It resolves `cudagraphify` as a module global at
192
+ # call time, so patching the attribute is enough.
193
+ from torch._inductor import cudagraph_trees as _ct
194
+
195
+ _ct.cudagraphify = _suppressing(_ct.cudagraphify)
196
+ except Exception: # pragma: no cover
197
+ pass
198
199
101
-@contextmanager
102
-def _noop_cm():
103
- yield
104
-
105
-
106
-if current_platform.is_cuda_alike():
107
-
108
- def gpu_sync_allowed(first_only: bool = False):
109
- """Context manager that suppresses `torch.cuda.set_sync_debug_mode` for the
110
- duration of the `with` block.
111
-
112
- If `first_only` is True, only the first entry from this call site
113
- suppresses the sync check; subsequent entries from the same site are
114
- no-ops so any further GPU syncs will be reported. The "site" is the
115
- caller's (filename, lineno), so different
116
- `with gpu_sync_allowed(first_only=True):` lines track independently.
117
- """
118
- if envs.VLLM_GPU_SYNC_CHECK is None or torch.compiler.is_compiling():
119
- return _noop_cm()
120
- prev_mode = torch.cuda.get_sync_debug_mode()
121
- if not prev_mode:
122
- return _noop_cm()
123
- if first_only:
124
- frame = sys._getframe(1)
125
- key = (frame.f_code.co_filename, frame.f_lineno)
126
- if key in _GPU_SYNC_ALLOWED_FIRST_SEEN:
127
- return _noop_cm()
128
- _GPU_SYNC_ALLOWED_FIRST_SEEN.add(key)
129
- return _suppress_gpu_sync_check(prev_mode)
130
-
131
- def with_gpu_sync_check(fn):
132
- """Decorator that enables `torch.cuda.set_sync_debug_mode` around `fn`
133
- when `VLLM_GPU_SYNC_CHECK` is set *and* the gate has been flipped by
134
- `enable_gpu_sync_check()`. Before the gate flips (i.e. during
135
- engine setup / warmup) the decorated function runs as-is.
136
- """
137
- mode = envs.VLLM_GPU_SYNC_CHECK
138
- if mode is None:
139
- return fn
140
-
141
- @functools.wraps(fn)
142
- def wrapper(*args, **kwargs):
143
- if not _sync_check_enabled:
144
- return fn(*args, **kwargs)
145
- prev_mode = torch.cuda.get_sync_debug_mode()
146
- torch.cuda.set_sync_debug_mode(mode)
147
- try:
148
- return fn(*args, **kwargs)
149
- except RuntimeError as re:
150
- if str(re) == "called a synchronizing CUDA operation":
151
- raise RuntimeError(SYNC_ERROR_MESSAGE) from re
152
- raise re
153
- finally:
154
- torch.cuda.set_sync_debug_mode(prev_mode)
155
-
156
- return wrapper
157
-
158
-else:
159
- # No-op the methods in non-CUDA cases.
160
-
161
- def gpu_sync_allowed(first_only: bool = False):
162
- return _noop_cm()
163
-
164
- def with_gpu_sync_check(fn):
200
+def gpu_sync_allowed(first_only: bool = False):
201
+ """Allow GPU<->CPU syncs inside the `with` block, on this thread only.
202
+
203
+ With `first_only`, only the first entry from a given call site
204
+ (filename, lineno) is allowed, so later syncs there are still reported.
205
+ """
206
+ if _SYNC_CHECK_MODE is None or torch.compiler.is_compiling():
207
+ return _NOOP_CM
208
+ if first_only:
209
+ frame = sys._getframe(1)
210
+ key = (frame.f_code.co_filename, frame.f_lineno)
211
+ if key in _GPU_SYNC_ALLOWED_FIRST_SEEN:
212
+ return _NOOP_CM
213
+ _GPU_SYNC_ALLOWED_FIRST_SEEN.add(key)
214
+ return _allow_syncs()
215
+
216
+
217
+def with_gpu_sync_check(fn):
218
+ """Report GPU<->CPU syncs performed by `fn` on its calling thread.
219
+
220
+ Active only once `enable_gpu_sync_check()` has flipped the gate. Other
221
+ threads are never policed, so deliberate syncs there (e.g. the EPLB
222
+ transfer worker) are unaffected.
223
+ """
224
+ if (mode := _SYNC_CHECK_MODE) is None:
225
return fn
226
+
227
+ @functools.wraps(fn)
228
+ def wrapper(*args, **kwargs):
229
+ if not _sync_check_enabled:
230
+ return fn(*args, **kwargs)
231
+ with _checked_region(mode):
232
+ return fn(*args, **kwargs)
233
+
234
+ return wrapper