[Feature] Release WorkspaceManager scratch on sleep (#59156)

Signed-off-by: aoshen02 <aoshen@inferact.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

aoshen02 committed Oct 2, 2026 at 10:14 UTC 866f02db8a6944f87541ee9e77a6da6865c0066a
6 files changed +140 -23
tests/basic_correctness/memory/cumem/test_cumem.py
+94
@@ -1,6 +1,8 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +import gc
5 +
6 import pytest
7 import torch
8
@@ -233,6 +235,98 @@ def test_discard_tags():
235 assert torch.allclose(weights, torch.ones_like(weights))
236
237
238 +@pytest.mark.parametrize("tag", ["workspace", None], ids=["workspace", "default"])
239 +@create_new_process_for_each_test("fork" if current_platform.is_cuda() else "spawn")
240 +def test_selective_wake_restores_internal_tags(tag):
241 + """A selective wake defers only weights/kv_cache; other tags always wake."""
242 + allocator = get_mem_allocator_instance()
243 + with allocator.use_memory_pool(tag):
244 + internal = torch.empty(16 << 20, dtype=torch.uint8, device=DEVICE_TYPE)
245 + with allocator.use_memory_pool("kv_cache"):
246 + kv = torch.empty(32 << 20, dtype=torch.uint8, device=DEVICE_TYPE)
247 +
248 + allocator.sleep(offload_tags=())
249 + assert mapped_usage(allocator) == 0
250 +
251 + allocator.wake_up(tags=["weights"])
252 + assert mapped_usage(allocator) == internal.nbytes
253 + allocator.wake_up(tags=["kv_cache"])
254 + assert mapped_usage(allocator) == internal.nbytes + kv.nbytes
255 +
256 +
257 +@create_new_process_for_each_test("fork" if current_platform.is_cuda() else "spawn")
258 +@pytest.mark.skipif(current_platform.is_xpu(), reason="Uses the CuMem allocator")
259 +def test_workspace_scratch_discarded_on_sleep():
260 + """Workspace scratch lives in the sleep-mode pool: sleep discards it, any
261 + (selective) wake remaps it at the same address so captured graphs replay,
262 + and a buffer outgrown by a resize stays usable until it is released."""
263 + from vllm.device_allocator.sleep_mode_backend import CuMemBackend
264 + from vllm.v1.worker.workspace import WorkspaceManager
265 +
266 + allocator = get_mem_allocator_instance()
267 + manager = WorkspaceManager(
268 + torch.device(DEVICE_TYPE),
269 + alloc_context=lambda: allocator.use_memory_pool("workspace"),
270 + )
271 + (small,) = manager.get_simultaneous(((32 << 20,), torch.uint8))
272 + # Resize while a view of the old buffer is alive: the tag is re-entered.
273 + (scratch,) = manager.get_simultaneous(((64 << 20,), torch.uint8))
274 + assert len(allocator.allocator_and_pools["workspace"]) == 2
275 + graph = torch.cuda.CUDAGraph()
276 + with torch.cuda.graph(graph):
277 + scratch.add_(1)
278 +
279 + backend = CuMemBackend()
280 + backend.suspend(level=1)
281 + assert mapped_usage(allocator) == 0
282 + # Discarded, not copied.
283 + assert all(
284 + d.tag == "workspace" and d.cpu_backup_tensor is None
285 + for d in allocator.pointer_to_data.values()
286 + )
287 +
288 + backend.resume(tags=["kv_cache"])
289 + assert mapped_usage(allocator) == small.nbytes + scratch.nbytes
290 + scratch.zero_()
291 + graph.replay()
292 + assert int(scratch.sum()) == scratch.numel()
293 + small.fill_(1)
294 +
295 + # The next resize releases the outgrown buffer; wake does not remap it.
296 + del small
297 + (big,) = manager.get_simultaneous(((128 << 20,), torch.uint8))
298 + assert mapped_usage(allocator) == scratch.nbytes + big.nbytes
299 + backend.suspend(level=1)
300 + backend.resume(tags=["weights"])
301 + assert mapped_usage(allocator) == scratch.nbytes + big.nbytes
302 +
303 +
304 +@pytest.mark.parametrize("free_x_early", [True, False], ids=["x-freed", "x-alive"])
305 +@create_new_process_for_each_test("fork" if current_platform.is_cuda() else "spawn")
306 +@pytest.mark.skipif(current_platform.is_xpu(), reason="Uses the CuMem allocator")
307 +def test_reentered_tag(free_x_early):
308 + """Enter a tag twice (two workspace ubatches): freeing X returns its memory
309 + by the next entry, without touching Y or breaking sleep/wake."""
310 + allocator = get_mem_allocator_instance()
311 + nbytes = 64 << 20
312 + with allocator.use_memory_pool("t"):
313 + xs = [torch.empty(nbytes, dtype=torch.uint8, device=DEVICE_TYPE)]
314 + if free_x_early:
315 + xs.clear()
316 + with allocator.use_memory_pool("t"):
317 + y = torch.empty(nbytes, dtype=torch.uint8, device=DEVICE_TYPE)
318 + xs.clear()
319 + gc.collect()
320 + with allocator.use_memory_pool("t"):
321 + pass
322 + assert mapped_usage(allocator) == nbytes
323 + y.fill_(1)
324 + allocator.sleep(offload_tags="t")
325 + allocator.wake_up()
326 + assert int(y.sum()) == y.numel()
327 + allocator.release_pools()
328 +
329 +
330 @create_new_process_for_each_test("fork" if current_platform.is_cuda() else "spawn")
331 @pytest.mark.skipif(current_platform.is_xpu(), reason="Uses the CuMem allocator")
332 def test_level2_discards_ordinary_tensor_with_weights_tag():
vllm/device_allocator/__init__.py
+3
@@ -13,6 +13,9 @@ from vllm.platforms import current_platform
13 # py_handle has type list[int] on ROCm and int otherwise
14 HandleType: TypeAlias = tuple[int, int, int, list[int] | int]
15
16 +# Selective wake may defer only these; any wake restores every other tag.
17 +DEFERRABLE_TAGS: tuple[str, ...] = ("weights", "kv_cache")
18 +
19
20 @dataclasses.dataclass
21 class AllocationData:
vllm/device_allocator/cumem.py
+20 -13
@@ -16,7 +16,7 @@ from typing import Any
16
17 import torch
18
19 -from vllm.device_allocator import AllocationData, HandleType
19 +from vllm.device_allocator import DEFERRABLE_TAGS, AllocationData, HandleType
20 from vllm.device_allocator.alloc_conf import (
21 EXPANDABLE_SEGMENTS,
22 conf_flag_enabled,
@@ -139,7 +139,7 @@ class CuMemAllocator:
139 def __init__(self):
140 self.pointer_to_data: dict[int, AllocationData] = {}
141 self.current_tag: str = CuMemAllocator.default_tag
142 - self.allocator_and_pools: dict[str, Any] = {}
142 + self.allocator_and_pools: dict[str, list[Any]] = {}
143 # Creating strong references to the two callbacks here to prevent
144 # these ephemeral bound-method objects being garbage collected.
145 # See discussions in https://github.com/vllm-project/vllm/pull/22724
@@ -167,7 +167,7 @@ class CuMemAllocator:
167 if not self.allocator_and_pools:
168 return
169
170 - pool_entries = list(self.allocator_and_pools.values())
170 + pool_entries = [e for es in self.allocator_and_pools.values() for e in es]
171 self.allocator_and_pools.clear()
172
173 mem_pools = [entry[0] for entry in pool_entries]
@@ -334,9 +334,9 @@ class CuMemAllocator:
334 memory, and the rest of the data will have empty memory.
335
336 Args:
337 - tags: The tags of the memory allocation that will be loaded
338 - back to GPU memory. If None, all memory allocation will be loaded
339 - back to GPU memory.
337 + tags: The deferrable tags (weights, kv_cache) to load back to GPU
338 + memory; every other tag is always loaded back. If None, all
339 + memory allocation will be loaded back to GPU memory.
340
341 """
342 gc.collect()
@@ -345,7 +345,7 @@ class CuMemAllocator:
345 for ptr, data in self.pointer_to_data.items():
346 if not data.is_asleep:
347 continue
348 - if tags is None or data.tag in tags:
348 + if tags is None or data.tag in tags or data.tag not in DEFERRABLE_TAGS:
349 handle = data.handle
350 create_and_map(handle)
351 data.is_asleep = False
@@ -392,6 +392,10 @@ class CuMemAllocator:
392 old_tag = self.current_tag
393 self.current_tag = tag
394 try:
395 + if tag != old_tag:
396 + # Older pools of this tag are never reused, so trim them.
397 + for pool, _ in self.allocator_and_pools.get(tag, []):
398 + self._trim(pool)
399 with use_memory_pool_with_allocator(
400 self.python_malloc_callback, self.python_free_callback
401 ) as data:
@@ -400,7 +404,7 @@ class CuMemAllocator:
404 # and the memory pool.
405 # to avoid the issue, we keep a reference of the data.
406 # see https://github.com/pytorch/pytorch/issues/146431 .
403 - self.allocator_and_pools[tag] = data
407 + self.allocator_and_pools.setdefault(tag, []).append(data)
408 yield
409 # PyTorch's bug, calling torch.cuda.empty_cache() will error
410 # when using pluggable allocator, see
@@ -413,16 +417,19 @@ class CuMemAllocator:
417 # TODO: we should expose `empty_cache` method in the memory
418 # pool.
419 # TODO: ask for help from PyTorch team to expose this method.
416 - allocations = data[0].snapshot()
417 - for allocation in allocations:
418 - if allocation["allocated_size"] == 0:
419 - handle = self._python_free_callback(allocation["address"])
420 - unmap_and_release(handle)
420 + self._trim(data[0])
421 finally:
422 self.current_tag = old_tag
423 if expandable_was_enabled:
424 set_alloc_conf(prev_conf)
425
426 + def _trim(self, pool: Any) -> None:
427 + """Release free segments of a pool that is never allocated from again."""
428 + for allocation in pool.snapshot():
429 + data = self.pointer_to_data.get(allocation["address"])
430 + if allocation["allocated_size"] == 0 and data and not data.is_asleep:
431 + unmap_and_release(self._python_free_callback(allocation["address"]))
432 +
433 def get_current_usage(self) -> int:
434 """Get the total number of bytes allocated in the memory pool."""
435 sum_bytes: int = 0
vllm/device_allocator/xpumem.py
+10 -5
@@ -9,7 +9,7 @@ from typing import Any
9
10 import torch
11
12 -from vllm.device_allocator import AllocationData, HandleType
12 +from vllm.device_allocator import DEFERRABLE_TAGS, AllocationData, HandleType
13 from vllm.logger import init_logger
14 from vllm.utils.torch_utils import PIN_MEMORY
15
@@ -145,7 +145,7 @@ class XpuMemAllocator:
145 def __init__(self):
146 self.pointer_to_data: dict[int, AllocationData] = {}
147 self.current_tag: str = XpuMemAllocator.default_tag
148 - self.allocator_and_pools: dict[str, Any] = {}
148 + self.allocator_and_pools: dict[str, list[Any]] = {}
149 self.python_malloc_callback = self._python_malloc_callback
150 self.python_free_callback = self._python_free_callback
151
@@ -267,7 +267,11 @@ class XpuMemAllocator:
267 for ptr, data in self.pointer_to_data.items():
268 if not data.is_asleep:
269 continue
270 - if tags is not None and data.tag not in tags:
270 + if (
271 + tags is not None
272 + and data.tag not in tags
273 + and data.tag in DEFERRABLE_TAGS
274 + ):
275 continue
276 create_and_allocate(data.handle)
277 data.is_asleep = False
@@ -300,7 +304,7 @@ class XpuMemAllocator:
304 # MemPool teardown may invoke allocator virtual methods (e.g. raw_delete)
305 # when releasing cached blocks. If allocator wrappers are dropped first,
306 # C++ can hit "pure virtual method called" during shutdown.
303 - pool_entries = list(self.allocator_and_pools.values())
307 + pool_entries = [e for es in self.allocator_and_pools.values() for e in es]
308 self.allocator_and_pools.clear()
309
310 mem_pools = [entry[0] for entry in pool_entries]
@@ -333,7 +337,8 @@ class XpuMemAllocator:
337 self.python_malloc_callback,
338 self.python_free_callback,
339 ) as data:
336 - self.allocator_and_pools[tag] = data
340 + # Keep every entry's pool alive (pytorch/pytorch#146431).
341 + self.allocator_and_pools.setdefault(tag, []).append(data)
342 yield
343 finally:
344 self.current_tag = old_tag
vllm/v1/worker/gpu_worker.py
+2
@@ -532,6 +532,8 @@ class Worker(WorkerBase):
532 self.device,
533 num_ubatches,
534 _num_workspace_lanes(self.vllm_config, self.use_v2_model_runner),
535 + # Scratch holds no state across steps: discard it on sleep.
536 + alloc_context=lambda: self._maybe_get_memory_pool_context("workspace"),
537 )
538 self.model_runner: GPUModelRunner = self._make_model_runner()
539 if self.rank == 0:
vllm/v1/worker/workspace.py
+11 -5
@@ -4,7 +4,7 @@
4 import inspect
5 import os
6 from collections.abc import Callable, Hashable, Iterator
7 -from contextlib import contextmanager
7 +from contextlib import AbstractContextManager, contextmanager, nullcontext
8 from contextvars import ContextVar
9 from itertools import accumulate
10 from math import prod
@@ -58,8 +58,10 @@ class WorkspaceManager:
58 device: torch.device,
59 num_ubatches: int | None = None,
60 num_lanes: int = 1,
61 + alloc_context: Callable[[], AbstractContextManager] = nullcontext,
62 ):
63 self._device = device
64 + self._alloc_context = alloc_context
65 # Cache num ubatches at init based on configuration (default to 1)
66 self._num_ubatches = num_ubatches if num_ubatches is not None else 1
67 if num_lanes < 1:
@@ -261,9 +263,11 @@ class WorkspaceManager:
263 # dead segment in reserved memory which can cause higher peak
264 # memory usage.
265 torch.accelerator.empty_cache()
264 - self._current_workspaces[workspace_id] = torch.empty(
265 - (required_bytes,), dtype=torch.uint8, device=self._device
266 - )
266 + # In sleep mode, entering the pool also trims outgrown buffers.
267 + with self._alloc_context():
268 + self._current_workspaces[workspace_id] = torch.empty(
269 + (required_bytes,), dtype=torch.uint8, device=self._device
270 + )
271 current_workspace = self._current_workspaces[workspace_id]
272
273 if envs.VLLM_DEBUG_WORKSPACE:
@@ -308,6 +312,7 @@ def init_workspace_manager(
312 device: torch.device,
313 num_ubatches: int | None = None,
314 num_lanes: int = 1,
315 + alloc_context: Callable[[], AbstractContextManager] = nullcontext,
316 ) -> None:
317 """Initialize the workspace manager with a device.
318
@@ -318,6 +323,7 @@ def init_workspace_manager(
323 device: The device to allocate workspace on.
324 num_ubatches: Number of workspace ubatch slots. Defaults to 1.
325 num_lanes: Number of independent execution lanes per ubatch. Defaults to 1.
326 + alloc_context: Factory for the context scratch is allocated in.
327
328 """
329 global _manager
@@ -328,7 +334,7 @@ def init_workspace_manager(
334 _manager._device,
335 device,
336 )
331 - _manager = WorkspaceManager(device, num_ubatches, num_lanes)
337 + _manager = WorkspaceManager(device, num_ubatches, num_lanes, alloc_context)
338
339
340 def lock_workspace() -> None: