[Bugfix][ROCm] Fix cumem sleep and teardown (#46203)
Signed-off-by: pei.zhang <pei.zhang@amd.com> Signed-off-by: Matthew Wong <Matthew.Wong2@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Matthew Wong <Matthew.Wong2@amd.com> Co-authored-by: Andreas Karatzas <akaratza@amd.com>
peizhang56 committed
Jun 23, 2026 at 11:45 UTC
68afd7889723c1538af7993b6e568b01945ce43a
7 files changed
+130
-18
.buildkite/test-amd.yaml
+1
-1
@@ -632,7 +632,7 @@ steps:
632
#----------------------------------------------------- mi300 · basic_correctness -----------------------------------------------------#
633
634
- label: Basic Correctness # TBD
635
- timeout_in_minutes: 180
635
+ timeout_in_minutes: 40
636
mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300]
637
agent_pool: mi300_1
638
fast_check: true
.buildkite/test_areas/basic_correctness.yaml
+6
@@ -16,3 +16,9 @@ steps:
16
- pytest -v -s basic_correctness/test_mem.py
17
- pytest -v -s basic_correctness/test_basic_correctness.py
18
- pytest -v -s basic_correctness/test_cpu_offload.py
19
+ mirror:
20
+ amd:
21
+ device: mi325_1
22
+ timeout_in_minutes: 40
23
+ depends_on:
24
+ - image-build-amd
csrc/cumem_allocator.cpp
+38
-5
@@ -48,8 +48,8 @@ static inline unsigned long long my_min(unsigned long long a,
48
}
49
50
static CUresult reserve_rocm_address(CUdeviceptr* d_mem, size_t size,
51
- size_t alignment) {
52
- CUresult status = cuMemAddressReserve(d_mem, size, alignment, 0, 0);
51
+ size_t alignment, CUdeviceptr addr = 0) {
52
+ CUresult status = cuMemAddressReserve(d_mem, size, alignment, addr, 0);
53
if (status == CUresult(0) || alignment == 0) {
54
return status;
55
}
@@ -58,7 +58,7 @@ static CUresult reserve_rocm_address(CUdeviceptr* d_mem, size_t size,
58
// alignment even when physical VRAM is free. Let HIP choose the default
59
// alignment, then verify that the returned address still satisfies the
60
// requested alignment before accepting it.
61
- status = cuMemAddressReserve(d_mem, size, 0, 0, 0);
61
+ status = cuMemAddressReserve(d_mem, size, 0, addr, 0);
62
if (status != CUresult(0)) {
63
return status;
64
}
@@ -535,7 +535,14 @@ void my_free(void* ptr, ssize_t size, int device, CUstream stream) {
535
Py_DECREF(py_result);
536
PyGILState_Release(gstate);
537
538
- unmap_and_release(device, size, d_mem, p_memHandle, chunk_sizes, num_chunks);
538
+ // An empty chunk list means this allocation is asleep: its physical chunks
539
+ // were already unmapped and released by sleep(), but the virtual address is
540
+ // still held as a placeholder reservation. Skip unmap/release (freeing the
541
+ // placeholder address happens below).
542
+ if (num_chunks > 0) {
543
+ unmap_and_release(device, size, d_mem, p_memHandle, chunk_sizes,
544
+ num_chunks);
545
+ }
546
#else
547
// Non-ROCm path: simple integer handle already extracted; drop temporary
548
// Python refs while still holding the GIL, then release it.
@@ -548,11 +555,13 @@ void my_free(void* ptr, ssize_t size, int device, CUstream stream) {
555
unmap_and_release(device, size, d_mem, p_memHandle);
556
#endif
557
551
- // free address and the handle
558
+ // Free the virtual address. On ROCm this also covers an asleep allocation,
559
+ // whose placeholder reservation made by sleep() is still held here.
560
CUDA_CHECK(cuMemAddressFree(d_mem, size));
561
#ifndef USE_ROCM
562
free(p_memHandle);
563
#else
564
+ // Only awake allocations have per-chunk handles to free.
565
for (auto i = 0; i < num_chunks; ++i) {
566
free(p_memHandle[i]);
567
}
@@ -672,6 +681,29 @@ static PyObject* python_unmap_and_release(PyObject* self, PyObject* args) {
681
unmap_and_release(recv_device, recv_size, d_mem_ptr, p_memHandle, chunk_sizes,
682
num_chunks);
683
684
+ // On ROCm/Linux, physical VRAM is only reclaimed once the virtual address
685
+ // range is freed; hipMemUnmap + hipMemRelease alone leave the memory
686
+ // resident (see ROCm#6021). Free the address to release physical memory,
687
+ // then immediately re-reserve the SAME address as an empty placeholder so
688
+ // the regular allocator cannot hand it out while we sleep. wake_up remaps
689
+ // physical chunks into this placeholder.
690
+ if (error_code == no_error) {
691
+ CUDA_CHECK(cuMemAddressFree(d_mem_ptr, recv_size));
692
+ if (error_code == no_error) {
693
+ CUdeviceptr reserved = 0;
694
+ CUDA_CHECK(reserve_rocm_address(&reserved, recv_size, /*alignment=*/0,
695
+ d_mem_ptr));
696
+ if (error_code == no_error && reserved != d_mem_ptr) {
697
+ (void)cuMemAddressFree(reserved, recv_size);
698
+ snprintf(error_msg, sizeof(error_msg),
699
+ "failed to re-reserve placeholder address on sleep "
700
+ "(requested %#llx, got %#llx)",
701
+ (unsigned long long)d_mem_ptr, (unsigned long long)reserved);
702
+ error_code = CUresult(1);
703
+ }
704
+ }
705
+ }
706
+
707
free(p_memHandle);
708
free(chunk_sizes);
709
#endif
@@ -736,6 +768,7 @@ static PyObject* python_create_and_map(PyObject* self, PyObject* args) {
768
chunk_sizes[i] = PyLong_AsUnsignedLongLong(size_py);
769
}
770
771
+ // Address already reserved as a placeholder by sleep(); just remap chunks.
772
create_and_map(recv_device, recv_size, d_mem_ptr, p_memHandle, chunk_sizes,
773
num_chunks);
774
vllm/device_allocator/__init__.py
+4
-2
@@ -3,14 +3,15 @@
3
4
import dataclasses
5
from contextlib import AbstractContextManager
6
-from typing import Protocol
6
+from typing import Protocol, TypeAlias
7
8
import torch
9
10
from vllm.platforms import current_platform
11
12
# py_device, py_size_or_aligned_size, py_ptr, py_handle
13
-HandleType = tuple[int, int, int, int]
13
+# py_handle has type list[int] on ROCm and int otherwise
14
+HandleType: TypeAlias = tuple[int, int, int, list[int] | int]
15
16
17
@dataclasses.dataclass
@@ -18,6 +19,7 @@ class AllocationData:
19
handle: HandleType
20
tag: str
21
cpu_backup_tensor: torch.Tensor | None = None
22
+ is_asleep: bool = False
23
24
25
class MemAllocator(Protocol):
vllm/device_allocator/cumem.py
+67
-1
@@ -8,6 +8,7 @@
8
# both of them failed because of cuda context mismatch.
9
# not sure why, they are created from a different context.
10
# the only successful approach is to call cuda driver API in C.
11
+import atexit
12
import gc
13
import os
14
from collections.abc import Callable, Iterator
@@ -18,6 +19,7 @@ import torch
19
20
from vllm.device_allocator import AllocationData, HandleType
21
from vllm.logger import init_logger
22
+from vllm.platforms import current_platform
23
from vllm.utils.system_utils import find_loaded_library
24
from vllm.utils.torch_utils import PIN_MEMORY
25
@@ -115,8 +117,21 @@ class CuMemAllocator:
117
assert cumem_available, "cumem allocator is not available"
118
if CuMemAllocator.instance is None:
119
CuMemAllocator.instance = CuMemAllocator()
120
+ # Ensure MemPool/allocator wrappers are released before interpreter
121
+ # finalization tears down PyTorch allocator internals.
122
+ atexit.register(CuMemAllocator._shutdown_singleton)
123
return CuMemAllocator.instance
124
125
+ @staticmethod
126
+ def _shutdown_singleton() -> None:
127
+ instance = CuMemAllocator.instance
128
+ if instance is None:
129
+ return
130
+ try:
131
+ instance.release_pools()
132
+ except Exception:
133
+ logger.exception("CuMemAllocator singleton shutdown failed")
134
+
135
def __init__(self):
136
self.pointer_to_data: dict[int, AllocationData] = {}
137
self.current_tag: str = CuMemAllocator.default_tag
@@ -127,6 +142,45 @@ class CuMemAllocator:
142
self.python_malloc_callback = self._python_malloc_callback
143
self.python_free_callback = self._python_free_callback
144
145
+ def release_pools(self) -> None:
146
+ """Drop Python references to MemPool/pluggable allocators eagerly.
147
+
148
+ A cumem ``MemPool`` outlives the ``use_memory_pool`` context (a strong
149
+ reference is kept in ``allocator_and_pools`` to work around
150
+ pytorch/pytorch#146431), and a captured CUDA graph can keep it alive
151
+ longer still. ``MemPool`` only holds a non-owning pointer to the
152
+ allocator, whose owning reference lives in the Python
153
+ ``CUDAPluggableAllocator``. If both are instead dropped during
154
+ interpreter shutdown, GC may finalize the allocator first; the eventual
155
+ ``~MemPool`` -> ``emptyCache`` -> ``release_block`` then makes a virtual
156
+ call into the freed allocator -- aborting the process with "pure virtual
157
+ method called" (pytorch/pytorch#145168).
158
+
159
+ Release the kept-alive pools before interpreter finalization, and keep
160
+ the pluggable allocator wrappers alive while MemPool destructors run.
161
+ This is safe to call more than once.
162
+ """
163
+ if not self.allocator_and_pools:
164
+ return
165
+
166
+ pool_entries = list(self.allocator_and_pools.values())
167
+ self.allocator_and_pools.clear()
168
+
169
+ mem_pools = [entry[0] for entry in pool_entries]
170
+ allocators = [entry[1] for entry in pool_entries]
171
+ pool_entries.clear()
172
+
173
+ # Phase 1: drop MemPool refs while allocators are still strongly held.
174
+ mem_pools.clear()
175
+ gc.collect()
176
+
177
+ # Phase 2: now it is safe to release allocator wrappers.
178
+ allocators.clear()
179
+
180
+ def close(self) -> None:
181
+ """Compatibility alias for deterministic pool release."""
182
+ self.release_pools()
183
+
184
def _python_malloc_callback(self, allocation_handle: HandleType) -> None:
185
"""
186
Internal method to store the allocation data
@@ -150,6 +204,14 @@ class CuMemAllocator:
204
data = self.pointer_to_data.pop(ptr)
205
if data.cpu_backup_tensor is not None:
206
data.cpu_backup_tensor = None
207
+ if data.is_asleep and current_platform.is_rocm():
208
+ # On ROCm, sleep() already unmapped and released this allocation's
209
+ # physical chunks and holds its virtual address as a placeholder
210
+ # reservation. Return a handle with an empty chunk list so the C
211
+ # extension skips unmap/release (avoiding a double-free) while
212
+ # still freeing the placeholder address.
213
+ device, size, d_mem, _ = data.handle
214
+ return (device, size, d_mem, [])
215
# Drain pending kernels before the C extension's cuMemUnmap.
216
# The pluggable allocator path doesn't defer reclaim like the
217
# regular caching allocator, so without this, in-flight work
@@ -201,7 +263,10 @@ class CuMemAllocator:
263
cpu_ptr = cpu_backup_tensor.data_ptr()
264
libcudart.cudaMemcpy(cpu_ptr, ptr, size_in_bytes)
265
data.cpu_backup_tensor = cpu_backup_tensor
204
- unmap_and_release(handle)
266
+ try:
267
+ unmap_and_release(handle)
268
+ finally:
269
+ data.is_asleep = True
270
271
logger.info(
272
"CuMemAllocator: sleep freed %.2f GiB memory in total, of which "
@@ -230,6 +295,7 @@ class CuMemAllocator:
295
if tags is None or data.tag in tags:
296
handle = data.handle
297
create_and_map(handle)
298
+ data.is_asleep = False
299
if data.cpu_backup_tensor is not None:
300
cpu_backup_tensor = data.cpu_backup_tensor
301
if cpu_backup_tensor is not None:
vllm/distributed/device_communicators/cuda_wrapper.py
+6
-9
@@ -104,15 +104,12 @@ class CudaRTLibrary:
104
105
def __init__(self, so_file: str | None = None):
106
if so_file is None:
107
- so_file = find_loaded_library("libcudart")
108
- if so_file is None:
109
- # libcudart is not loaded in the current process, try hip
110
- so_file = find_loaded_library("libamdhip64")
111
- # should be safe to assume now that we are using ROCm
112
- # as the following assertion should error out if the
113
- # libhiprtc library is also not loaded
114
- if so_file is None:
115
- so_file = envs.VLLM_CUDART_SO_PATH # fallback to env var
107
+ so_file = (
108
+ find_loaded_library(
109
+ "libamdhip64" if current_platform.is_rocm() else "libcudart"
110
+ )
111
+ or envs.VLLM_CUDART_SO_PATH # fallback to env var
112
+ )
113
assert so_file is not None, (
114
"libcudart is not loaded in the current process, "
115
"try setting VLLM_CUDART_SO_PATH"
vllm/v1/worker/gpu_worker.py
+8
@@ -1185,6 +1185,14 @@ class Worker(WorkerBase):
1185
if model_runner := getattr(self, "model_runner", None):
1186
model_runner.shutdown()
1187
1188
+ # Release kept-alive cumem pools while the pluggable allocator wrappers
1189
+ # and callbacks are still alive, so MemPool teardown is not deferred to
1190
+ # interpreter finalization (pytorch/pytorch#145168).
1191
+ from vllm.device_allocator.cumem import CuMemAllocator
1192
+
1193
+ if CuMemAllocator.instance is not None:
1194
+ CuMemAllocator.instance.release_pools()
1195
+
1196
def elastic_ep_execute(self, execute_method: str, *args, **kwargs):
1197
return self.elastic_ep_executor.execute(execute_method, *args, **kwargs)
1198