[Core] Release NCCL communicator memory in sleep mode (#51485)

Signed-off-by: aoshen02 <aoshen02@users.noreply.github.com> Co-authored-by: aoshen02 <aoshen02@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

aoshen02 committed Sep 1, 2026 at 14:31 UTC 4ac452ad9883864bb3ab0412a45a63cbf62d730a
12 files changed +182 -6
tests/distributed/test_pynccl.py
+39
@@ -472,3 +472,42 @@ def test_ncclGetUniqueId():
472 # 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
473 # as long as the function doesn't raise an exception, we're good
474 assert unique_id is not None
475 +
476 +
477 +def test_pynccl_suspend_resume_idempotent():
478 + """Repeated suspend/resume (e.g. staged wake-ups) reach NCCL once each."""
479 + from unittest.mock import Mock
480 +
481 + comm = object.__new__(PyNcclCommunicator)
482 + comm.disabled = False
483 + comm._suspended = False
484 + comm.comm = object()
485 + comm.nccl = Mock()
486 +
487 + comm.suspend()
488 + comm.suspend()
489 + comm.resume()
490 + comm.resume()
491 +
492 + assert comm.nccl.ncclCommSuspend.call_count == 1
493 + assert comm.nccl.ncclCommResume.call_count == 1
494 +
495 +
496 +def test_pynccl_suspend_noop_without_symbol():
497 + """RCCL / NCCL < 2.29.7 lack ncclCommSuspend; suspend() must no-op, not
498 + crash, and resume() must then no-op too since nothing was suspended."""
499 + from unittest.mock import Mock
500 +
501 + comm = object.__new__(PyNcclCommunicator)
502 + comm.disabled = False
503 + comm._suspended = False
504 + comm.comm = object()
505 + comm.nccl = Mock()
506 + comm.nccl.has_symbol.return_value = False
507 +
508 + comm.suspend()
509 + comm.resume()
510 +
511 + comm.nccl.ncclCommSuspend.assert_not_called()
512 + comm.nccl.ncclCommResume.assert_not_called()
513 + assert comm._suspended is False
tests/v1/worker/test_kv_cache_allocation_scope.py
+4 -1
@@ -113,11 +113,14 @@ def test_kv_wake_does_not_run_model_runner_recovery() -> None:
113 worker = cast(
114 Worker,
115 SimpleNamespace(
116 - _get_sleep_mode_backend=lambda: SimpleNamespace(resume=lambda tags: None),
116 + sleep_mode_backend=SimpleNamespace(resume=lambda tags: None),
117 _sleep_saved_buffers={},
118 _sleep_saved_draft_buffers={},
119 model_runner=runner,
120 synchronize_device=lambda: None,
121 + vllm_config=SimpleNamespace(
122 + model_config=SimpleNamespace(enable_nccl_comm_suspend=False)
123 + ),
124 ),
125 )
126 layout_tensors = runner.layout_tensors
tests/v1/worker/test_sleep_mode_backend.py
+49
@@ -38,6 +38,55 @@ def test_new_backend_starts_in_running_state():
38 assert CuMemBackend().state() == "RUNNING"
39
40
41 +@pytest.mark.parametrize("enable_nccl_comm_suspend", [True, False])
42 +def test_worker_drives_communicator_suspension(monkeypatch, enable_nccl_comm_suspend):
43 + """Comm walkers run around sleep/wake only when explicitly enabled."""
44 + from types import SimpleNamespace
45 +
46 + from vllm.v1.worker.gpu_worker import Worker
47 +
48 + calls: list[tuple[str, object]] = []
49 +
50 + class Backend:
51 + def suspend(self, level: int = 1) -> None:
52 + calls.append(("backend.suspend", level))
53 +
54 + def resume(self, tags: list[str] | None = None) -> None:
55 + calls.append(("backend.resume", tuple(tags) if tags else None))
56 +
57 + worker = object.__new__(Worker)
58 + worker._sleep_mode_backend = Backend()
59 + worker._sleep_saved_buffers = {}
60 + worker._sleep_saved_draft_buffers = {}
61 + worker.vllm_config = SimpleNamespace(
62 + model_config=SimpleNamespace(enable_nccl_comm_suspend=enable_nccl_comm_suspend)
63 + )
64 +
65 + monkeypatch.setattr("torch.accelerator.synchronize", lambda: None)
66 + monkeypatch.setattr("torch.accelerator.get_memory_info", lambda: (0, 0))
67 + monkeypatch.setattr(
68 + "vllm.v1.worker.gpu_worker.suspend_device_comms",
69 + lambda: calls.append(("comms.suspend", None)),
70 + )
71 + monkeypatch.setattr(
72 + "vllm.v1.worker.gpu_worker.resume_device_comms",
73 + lambda: calls.append(("comms.resume", None)),
74 + )
75 +
76 + worker.sleep(level=1)
77 + worker.wake_up(tags=["weights"])
78 +
79 + expected = [
80 + ("backend.suspend", 1),
81 + ("comms.suspend", None),
82 + ("backend.resume", ("weights",)),
83 + ("comms.resume", None),
84 + ]
85 + if not enable_nccl_comm_suspend:
86 + expected = [c for c in expected if not c[0].startswith("comms.")]
87 + assert calls == expected
88 +
89 +
90 def test_unknown_backend_raises():
91 with pytest.raises(ValueError, match="Unsupported sleep-mode backend"):
92 SleepModeBackendFactory.get_backend_class("does-not-exist")
vllm/config/model.py
+4
@@ -343,6 +343,10 @@ class ModelConfig:
343 (default) uses the built-in ``CuMemAllocator`` and is behavior-compatible
344 with prior releases. Additional backends (CUDA checkpoint, CRIU, durable
345 snapshot) may be registered in-tree or by plugins (RFC #34303)."""
346 + enable_nccl_comm_suspend: bool = False
347 + """Enable releasing NCCL communicator memory during sleep mode
348 + (``ncclCommSuspend``/``ncclCommResume``). Experimental; when disabled
349 + (the default) sleep still releases weights/KV-cache memory as before."""
350 enable_cumem_allocator: bool = False
351 """Enable the custom cumem allocator to leverage advanced GPU memory
352 allocation features such as multi-node NVLink support.
vllm/device_allocator/sleep_mode_backend.py
+1 -2
@@ -134,8 +134,7 @@ class CuMemBackend(SleepModeBackend):
134
135 @classmethod
136 def preserves_communicators(cls) -> bool:
137 - # Communicator buffers (e.g. NCCL) live outside CuMemAllocator's pool, so
138 - # an allocator-level sleep leaves them intact (no reinit needed on resume).
137 + # Comm identity/topology survive memory suspension; no reinit needed.
138 return True
139
140
vllm/distributed/device_communicators/base_device_communicator.py
+6
@@ -226,6 +226,12 @@ class DeviceCommunicatorBase:
226 def checkpoint_restore(self) -> None:
227 """Restore communicator state after checkpoint (default: no-op)."""
228
229 + def suspend(self) -> None:
230 + """Release reclaimable communicator memory (default: no-op)."""
231 +
232 + def resume(self) -> None:
233 + """Restore memory released by ``suspend`` (default: no-op)."""
234 +
235 def all_gather(self, input_: torch.Tensor, dim: int = -1) -> torch.Tensor:
236 if dim < 0:
237 # Convert negative dim to positive.
vllm/distributed/device_communicators/cuda_communicator.py
+8
@@ -631,6 +631,14 @@ class CudaCommunicator(DeviceCommunicatorBase):
631 self.all2all_manager.destroy()
632 self.all2all_manager = None # type: ignore[assignment]
633
634 + def suspend(self) -> None:
635 + if self.pynccl_comm is not None:
636 + self.pynccl_comm.suspend()
637 +
638 + def resume(self) -> None:
639 + if self.pynccl_comm is not None:
640 + self.pynccl_comm.resume()
641 +
642 def checkpoint_prepare(self) -> None:
643 # Only FlashInfer all-reduce and FlashInfer all2all are supported for now.
644 from .flashinfer_all_reduce import checkpoint_prepare_fi_ar_workspaces
vllm/distributed/device_communicators/pynccl.py
+24
@@ -27,6 +27,8 @@ logger = init_logger(__name__)
27
28 _NCCL_SYMM_OPS_REGISTERED = False
29
30 +_NCCL_SUSPEND_MEM = 0x01
31 +
32
33 def register_nccl_symmetric_ops(pynccl_comm):
34 from vllm.distributed.device_communicators.pynccl_allocator import (
@@ -105,6 +107,7 @@ class PyNcclCommunicator:
107
108 self.available = True
109 self.disabled = False
110 + self._suspended = False
111
112 self.nccl_version = self.nccl.ncclGetRawVersion()
113 if self.rank == 0:
@@ -479,6 +482,27 @@ class PyNcclCommunicator:
482 def deregister_comm_window(self, window):
483 return self.nccl.ncclCommWindowDeregister(self.comm, window)
484
485 + def suspend(self):
486 + """Release comm GPU memory (collective, idempotent); keeps topology."""
487 + if self.disabled or self._suspended:
488 + return
489 + if not self.nccl.has_symbol("ncclCommSuspend"):
490 + logger.warning_once(
491 + "ncclCommSuspend is not available in the loaded NCCL/RCCL "
492 + "library (requires NCCL >= 2.29.7); skipping communicator "
493 + "memory suspension."
494 + )
495 + return
496 + self.nccl.ncclCommSuspend(self.comm, _NCCL_SUSPEND_MEM)
497 + self._suspended = True
498 +
499 + def resume(self):
500 + """Restore a suspended comm (collective); no-op unless suspended."""
501 + if self.disabled or not self._suspended:
502 + return
503 + self.nccl.ncclCommResume(self.comm)
504 + self._suspended = False
505 +
506 def batch_isend_irecv(self, p2p_ops: list, stream=None):
507 if self.disabled:
508 return
vllm/distributed/device_communicators/pynccl_wrapper.py
+21
@@ -329,6 +329,10 @@ class NCCLLibrary:
329 # shutdown when peer ranks may already be gone.
330 # ncclResult_t ncclCommAbort(ncclComm_t comm);
331 Function("ncclCommAbort", ncclResult_t, [ncclComm_t]),
332 + # ncclResult_t ncclCommSuspend(ncclComm_t comm, int flags);
333 + Function("ncclCommSuspend", ncclResult_t, [ncclComm_t, ctypes.c_int]),
334 + # ncclResult_t ncclCommResume(ncclComm_t comm);
335 + Function("ncclCommResume", ncclResult_t, [ncclComm_t]),
336 # ncclResult_t ncclGroupStart();
337 Function("ncclGroupStart", ncclResult_t, []),
338 # ncclResult_t ncclGroupEnd();
@@ -417,10 +421,21 @@ class NCCLLibrary:
421 elif func.name == "ncclCommQueryProperties":
422 # Optional on NCCL versions older than 2.29.
423 continue
424 + elif func.name in ("ncclCommSuspend", "ncclCommResume"):
425 + # RCCL doesn't export these; NCCL >= 2.29.7 does, and
426 + # vLLM's CUDA path already requires that version.
427 + # PyNcclCommunicator checks has_symbol() before
428 + # calling either.
429 + if current_platform.is_rocm():
430 + continue
431 raise
432 NCCLLibrary.path_to_dict_mapping[so_file] = _funcs
433 self._funcs = NCCLLibrary.path_to_dict_mapping[so_file]
434
435 + def has_symbol(self, name: str) -> bool:
436 + """Whether the loaded NCCL/RCCL library exports the given symbol."""
437 + return name in self._funcs
438 +
439 def ncclGetErrorString(self, result: ncclResult_t) -> str:
440 return self._funcs["ncclGetErrorString"](result).decode("utf-8")
441
@@ -599,6 +614,12 @@ class NCCLLibrary:
614 def ncclCommAbort(self, comm: ncclComm_t) -> None:
615 self.NCCL_CHECK(self._funcs["ncclCommAbort"](comm))
616
617 + def ncclCommSuspend(self, comm: ncclComm_t, flags: int) -> None:
618 + self.NCCL_CHECK(self._funcs["ncclCommSuspend"](comm, flags))
619 +
620 + def ncclCommResume(self, comm: ncclComm_t) -> None:
621 + self.NCCL_CHECK(self._funcs["ncclCommResume"](comm))
622 +
623 def ncclGroupStart(self) -> None:
624 self.NCCL_CHECK(self._funcs["ncclGroupStart"]())
625
vllm/distributed/parallel_state.py
+10
@@ -180,6 +180,16 @@ def _apply_to_device_comms(
180 action(dc)
181
182
183 +def suspend_device_comms() -> None:
184 + """Release device communicator memory (collective; comms must be idle)."""
185 + _apply_to_device_comms(lambda comm: comm.suspend())
186 +
187 +
188 +def resume_device_comms() -> None:
189 + """Restore suspended device communicators (collective)."""
190 + _apply_to_device_comms(lambda comm: comm.resume())
191 +
192 +
193 def all_reduce(tensor: torch.Tensor, group_name: str) -> torch.Tensor:
194 assert group_name in _groups, f"Group {group_name} is not found."
195 group = _groups[group_name]()
vllm/engine/arg_utils.py
+6
@@ -710,6 +710,7 @@ class EngineArgs:
710 generation_config: str = ModelConfig.generation_config
711 enable_sleep_mode: bool = ModelConfig.enable_sleep_mode
712 enable_cumem_allocator: bool = ModelConfig.enable_cumem_allocator
713 + enable_nccl_comm_suspend: bool = ModelConfig.enable_nccl_comm_suspend
714 override_generation_config: dict[str, Any] = get_field(
715 ModelConfig, "override_generation_config"
716 )
@@ -932,6 +933,10 @@ class EngineArgs:
933 model_group.add_argument(
934 "--enable-cumem-allocator", **model_kwargs["enable_cumem_allocator"]
935 )
936 + model_group.add_argument(
937 + "--enable-nccl-comm-suspend",
938 + **model_kwargs["enable_nccl_comm_suspend"],
939 + )
940 model_group.add_argument("--model-impl", **model_kwargs["model_impl"])
941 model_group.add_argument(
942 "--logits-processors", **model_kwargs["logits_processors"]
@@ -1823,6 +1828,7 @@ class EngineArgs:
1828 override_generation_config=self.override_generation_config,
1829 enable_sleep_mode=self.enable_sleep_mode,
1830 enable_cumem_allocator=self.enable_cumem_allocator,
1831 + enable_nccl_comm_suspend=self.enable_nccl_comm_suspend,
1832 model_impl=self.model_impl,
1833 logits_processors=self.logits_processors,
1834 video_pruning_rate=self.video_pruning_rate,
vllm/v1/worker/gpu_worker.py
+10 -3
@@ -45,6 +45,8 @@ from vllm.distributed.parallel_state import (
45 checkpoint_restore_distributed_state,
46 get_pp_group,
47 get_tp_group,
48 + resume_device_comms,
49 + suspend_device_comms,
50 )
51 from vllm.distributed.weight_transfer import (
52 WeightTransferEngine,
@@ -220,7 +222,8 @@ class Worker(WorkerBase):
222 # Resolved lazily on first sleep/wake; persists worker-process state.
223 self._sleep_mode_backend: SleepModeBackend | None = None
224
223 - def _get_sleep_mode_backend(self) -> "SleepModeBackend":
225 + @property
226 + def sleep_mode_backend(self) -> "SleepModeBackend":
227 if self._sleep_mode_backend is None:
228 from vllm.device_allocator.sleep_mode_backend import (
229 SleepModeBackendFactory,
@@ -247,7 +250,9 @@ class Worker(WorkerBase):
250 name: buffer.cpu().clone() for name, buffer in draft.named_buffers()
251 }
252
250 - self._get_sleep_mode_backend().suspend(level)
253 + self.sleep_mode_backend.suspend(level)
254 + if self.vllm_config.model_config.enable_nccl_comm_suspend:
255 + suspend_device_comms()
256
257 torch.accelerator.synchronize()
258 deadline = time.monotonic() + (5.0 if current_platform.is_rocm() else 0)
@@ -267,7 +272,9 @@ class Worker(WorkerBase):
272 )
273
274 def wake_up(self, tags: list[str] | None = None) -> None:
270 - self._get_sleep_mode_backend().resume(tags)
275 + self.sleep_mode_backend.resume(tags)
276 + if self.vllm_config.model_config.enable_nccl_comm_suspend:
277 + resume_device_comms()
278
279 # Restore the buffers after level 2 sleep
280 wake_weights = tags is None or "weights" in tags