[Bugfix][KV Offload] Register the offload region in chunks (#51081)

Signed-off-by: Mikhail Kostryukov <mike@triptrack.net> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Colton Ottley <coltonottley@gmail.com> Co-authored-by: Or Ozeri <oro@il.ibm.com>

drakosha committed Sep 17, 2026 at 13:19 UTC 40b40d1d39bb82563dd699be3456abaa95d0722a
5 files changed +198 -15
tests/v1/kv_offload/cpu/test_canonical_layout.py
+1
@@ -241,6 +241,7 @@ def test_cross_topology_roundtrip(writer_tp: int, reader_tp: int):
241 # The Triton load path dereferences CPU pointers on the GPU, which is
242 # only legal on pinned memory; production pins via CPUOffloadingWorker
243 pin_mmap_region(region)
244 + assert region.is_pinned
245 return region.create_next_canonical_view(_CANONICAL_PAGE)
246
247 try:
tests/v1/kv_offload/cpu/test_gpu_worker.py
+16
@@ -49,6 +49,22 @@ def test_rocm_cpu_to_gpu_uses_dma(monkeypatch: pytest.MonkeyPatch) -> None:
49 )
50
51
52 +@pytest.mark.skipif(not gpu_worker.HAS_TRITON, reason="Requires Triton")
53 +def test_unpinned_cpu_to_gpu_uses_dma(monkeypatch: pytest.MonkeyPatch) -> None:
54 + """The Triton load path dereferences CPU pointers on the GPU, so pageable
55 + host memory takes the DMA path even for pages where Triton would win."""
56 + monkeypatch.setattr(gpu_worker.current_platform, "is_xpu", lambda: False)
57 + monkeypatch.setattr(gpu_worker.current_platform, "is_rocm", lambda: False)
58 +
59 + refs = [[CanonicalKVCacheRef(tensor_idx=0, page_size_bytes=512)]]
60 + assert gpu_worker._select_swap_blocks_fn(refs, gpu_to_cpu=False) is not (
61 + ops.swap_blocks_batch
62 + )
63 + assert gpu_worker._select_swap_blocks_fn(
64 + refs, gpu_to_cpu=False, host_memory_is_pinned=False
65 + ) is (ops.swap_blocks_batch)
66 +
67 +
68 def test_worker_shutdown_releases_region_and_runs_both_handlers() -> None:
69 """Both directions drain before the worker releases its shared region."""
70 worker = CPUOffloadingWorker.__new__(CPUOffloadingWorker)
tests/v1/kv_offload/cpu/test_shared_offload_region.py
+82
@@ -15,6 +15,7 @@ from unittest.mock import MagicMock, call
15 import pytest
16
17 from vllm.utils.system_utils import get_mp_context
18 +from vllm.v1.kv_offload.cpu import gpu_worker
19 from vllm.v1.kv_offload.cpu import shared_offload_region as region_module
20 from vllm.v1.kv_offload.cpu.shared_offload_region import (
21 SharedOffloadRegion,
@@ -788,6 +789,87 @@ def test_cleanup_unregisters_every_pinned_chunk(iid, monkeypatch):
789 ]
790
791
792 +@pytest.fixture
793 +def host_register(monkeypatch):
794 + """Pretend to run on CUDA with host registrations capped at seven pages.
795 +
796 + Registration goes to a mock CudaRTLibrary and the region's cleanup to a
797 + separate mock torch runtime. Both stay installed through the cleanup that
798 + ``_region`` runs on exit.
799 + """
800 + cudart = MagicMock()
801 + cudart.cudaHostRegister.return_value = 0
802 + cudart.cudaHostUnregister.return_value = 0
803 + cudart.cudaGetLastError.return_value = 2
804 + torch_cudart = MagicMock()
805 + torch_cudart.cudaHostUnregister.return_value = MagicMock(value=0)
806 + monkeypatch.setattr(gpu_worker.current_platform, "is_cuda_alike", lambda: True)
807 + monkeypatch.setattr(gpu_worker, "MAX_HOST_REGISTER_CHUNK_BYTES", 7 * PAGE_SIZE)
808 + monkeypatch.setattr(gpu_worker, "CudaRTLibrary", lambda: cudart)
809 + monkeypatch.setattr(region_module.torch.cuda, "cudart", lambda: torch_cudart)
810 + return cudart, torch_cudart
811 +
812 +
813 +def test_pin_mmap_region_registers_row_aligned_chunks(iid, host_register):
814 + """Chunks end on row boundaries: 3-page rows under a 7-page cap register
815 + as 6 + 6 + 3 pages, where a raw byte cap would give 7 + 7 + 1."""
816 + cudart, torch_cudart = host_register
817 + with _region(iid, num_chunks=5, cpu_page_size=3 * PAGE_SIZE) as region:
818 + gpu_worker.pin_mmap_region(region)
819 + base = region._base.data_ptr()
820 + assert cudart.mock_calls == [
821 + call.cudaHostRegister(base, 6 * PAGE_SIZE),
822 + call.cudaHostRegister(base + 6 * PAGE_SIZE, 6 * PAGE_SIZE),
823 + call.cudaHostRegister(base + 12 * PAGE_SIZE, 3 * PAGE_SIZE),
824 + ]
825 + assert region.is_pinned
826 +
827 + assert torch_cudart.cudaHostUnregister.call_args_list == [
828 + call(base + 12 * PAGE_SIZE),
829 + call(base + 6 * PAGE_SIZE),
830 + call(base),
831 + ]
832 +
833 +
834 +@pytest.mark.parametrize("fail_at", [0, 1, 2])
835 +def test_pin_mmap_region_failure_leaves_region_pageable(iid, host_register, fail_at):
836 + """A failed chunk drains its pending error and unregisters the chunks
837 + before it on the same handle, so the region is pinned whole or not at all."""
838 + cudart, torch_cudart = host_register
839 + cudart.cudaHostRegister.side_effect = [0] * fail_at + [2]
840 + with _region(iid, num_chunks=5, cpu_page_size=3 * PAGE_SIZE) as region:
841 + gpu_worker.pin_mmap_region(region)
842 + base = region._base.data_ptr()
843 + assert not region.is_pinned
844 + assert region.pinned_addresses == []
845 +
846 + registered = [base + i * 6 * PAGE_SIZE for i in range(fail_at)]
847 + failed_size = min(6, 15 - 6 * fail_at) * PAGE_SIZE
848 + assert cudart.mock_calls == [
849 + *(call.cudaHostRegister(address, 6 * PAGE_SIZE) for address in registered),
850 + call.cudaHostRegister(base + fail_at * 6 * PAGE_SIZE, failed_size),
851 + call.cudaGetLastError(),
852 + *(call.cudaHostUnregister(address) for address in reversed(registered)),
853 + ]
854 + torch_cudart.cudaHostUnregister.assert_not_called()
855 +
856 +
857 +def test_pin_mmap_region_without_cuda_runtime_stays_pageable(
858 + iid, host_register, monkeypatch
859 +):
860 + """A CUDA runtime that cannot be loaded leaves the region pageable instead
861 + of failing worker startup."""
862 +
863 + def missing_runtime():
864 + raise AssertionError("libcudart is not loaded in the current process")
865 +
866 + monkeypatch.setattr(gpu_worker, "CudaRTLibrary", missing_runtime)
867 + with _region(iid) as region:
868 + gpu_worker.pin_mmap_region(region)
869 + assert not region.is_pinned
870 + assert region.pinned_addresses == []
871 +
872 +
873 def test_cleanup_after_create_next_worker_view_releases_mmap(iid):
874 """cleanup() must close the mmap even after create_next_worker_view was called.
875 create_next_worker_view returns a view that shares storage with _base; both must be
vllm/distributed/device_communicators/cuda_wrapper.py
+26
@@ -78,6 +78,16 @@ class CudaRTLibrary:
78 cudaError_t,
79 [ctypes.POINTER(ctypes.c_void_p), cudaIpcMemHandle_t, ctypes.c_uint],
80 ),
81 + # cudaError_t cudaHostRegister ( void* ptr, size_t size, unsigned int flags )
82 + Function(
83 + "cudaHostRegister",
84 + cudaError_t,
85 + [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_uint],
86 + ),
87 + # cudaError_t cudaHostUnregister ( void* ptr )
88 + Function("cudaHostUnregister", cudaError_t, [ctypes.c_void_p]),
89 + # cudaError_t cudaGetLastError ( void )
90 + Function("cudaGetLastError", cudaError_t, []),
91 ]
92
93 # https://rocm.docs.amd.com/projects/HIPIFY/en/latest/tables/CUDA_Runtime_API_functions_supported_by_HIP.html # noqa
@@ -92,6 +102,9 @@ class CudaRTLibrary:
102 "cudaMemcpy": "hipMemcpy",
103 "cudaIpcGetMemHandle": "hipIpcGetMemHandle",
104 "cudaIpcOpenMemHandle": "hipIpcOpenMemHandle",
105 + "cudaHostRegister": "hipHostRegister",
106 + "cudaHostUnregister": "hipHostUnregister",
107 + "cudaGetLastError": "hipGetLastError",
108 }
109
110 # class attribute to store the mapping from the path to the library
@@ -185,3 +198,16 @@ class CudaRTLibrary:
198 )
199 )
200 return devPtr
201 +
202 + def cudaHostRegister(self, ptr: int, size: int, flags: int = 0) -> int:
203 + """Return the raw error code instead of raising, since callers can
204 + fall back to pageable memory. Drain a failure with cudaGetLastError."""
205 + return self.funcs["cudaHostRegister"](ctypes.c_void_p(ptr), size, flags)
206 +
207 + def cudaHostUnregister(self, ptr: int) -> int:
208 + """Return the raw error code instead of raising."""
209 + return self.funcs["cudaHostUnregister"](ctypes.c_void_p(ptr))
210 +
211 + def cudaGetLastError(self) -> int:
212 + """Return and clear the error pending on this thread."""
213 + return self.funcs["cudaGetLastError"]()
vllm/v1/kv_offload/cpu/gpu_worker.py
+73 -15
@@ -11,6 +11,7 @@ import numpy as np
11 import torch
12
13 from vllm import _custom_ops as ops
14 +from vllm.distributed.device_communicators.cuda_wrapper import CudaRTLibrary
15 from vllm.logger import init_logger
16 from vllm.platforms import current_platform
17 from vllm.triton_utils import HAS_TRITON, triton
@@ -38,10 +39,13 @@ logger = init_logger(__name__)
39 def _select_swap_blocks_fn(
40 layer_refs_per_group: list[list[CanonicalKVCacheRef]],
41 gpu_to_cpu: bool,
42 + host_memory_is_pinned: bool = True,
43 ):
44 """Resolve the swap_blocks function for a handler at init time."""
45 # GPU->CPU is bandwidth-bound; the dedicated copy engine beats Triton.
44 - if gpu_to_cpu:
46 + # The Triton kernel dereferences CPU pointers on the GPU, which is only
47 + # valid for pinned host memory.
48 + if gpu_to_cpu or not host_memory_is_pinned:
49 return ops.swap_blocks_batch
50 # Fall back to the C++ DMA path on platforms where Triton isn't usable
51 # (e.g. ROCm host mappings) or where GPU kernels cannot directly
@@ -190,8 +194,12 @@ def _canonical_block_sizes(
194 return canonical_bytes_per_block
195
196
197 +# Bound registration size to avoid driver limits on large host allocations.
198 +MAX_HOST_REGISTER_CHUNK_BYTES = 64 * 1024**3
199 +
200 +
201 def pin_mmap_region(region: SharedOffloadRegion) -> None:
194 - """Register the entire mmap as CUDA pinned memory via cudaHostRegister."""
202 + """Register row-aligned chunks, rolling back on failure."""
203 if not current_platform.is_cuda_alike():
204 logger.info(
205 "Skipping mmap host registration on %s; cudaHostRegister is only "
@@ -201,23 +209,65 @@ def pin_mmap_region(region: SharedOffloadRegion) -> None:
209 return
210
211 rank = region.rank
212 + try:
213 + cudart = CudaRTLibrary()
214 + except (AssertionError, AttributeError, OSError):
215 + logger.warning(
216 + "Could not load the CUDA runtime for host registration on rank=%d; "
217 + "the offload region stays pageable",
218 + rank,
219 + exc_info=True,
220 + )
221 + return
222
223 base_ptr = region._base.data_ptr()
206 - result = torch.cuda.cudart().cudaHostRegister(base_ptr, region.total_size_bytes, 0)
207 - if result.value != 0:
224 + total_size = region.total_size_bytes
225 + # Chunks end on block-row boundaries, which are page aligned, so neither the
226 + # driver's page rounding nor any single block transfer straddles two
227 + # registrations.
228 + rows_per_chunk = max(MAX_HOST_REGISTER_CHUNK_BYTES // region._row_stride, 1)
229 + chunk_size = rows_per_chunk * region._row_stride
230 +
231 + # Register, drain and roll back through the same runtime handle, so a
232 + # failed chunk leaves neither a pending error nor a partly pinned region.
233 + addresses: list[int] = []
234 + for offset in range(0, total_size, chunk_size):
235 + address = base_ptr + offset
236 + size = min(chunk_size, total_size - offset)
237 + result = cudart.cudaHostRegister(address, size)
238 + if result == 0:
239 + addresses.append(address)
240 + continue
241 + cudart.cudaGetLastError()
242 logger.warning(
209 - "cudaHostRegister failed for rank=%d (code=%d) — "
210 - "transfers will still work but may be slower (unpinned DMA)",
243 + "cudaHostRegister failed for rank=%d at %.2f of %.2f GB (code=%d); "
244 + "the offload region stays pageable",
245 rank,
246 + offset / 1e9,
247 + total_size / 1e9,
248 result,
249 )
214 - else:
215 - logger.debug(
216 - "cudaHostRegister rank=%d %.2f GB",
217 - rank,
218 - region.total_size_bytes / 1e9,
219 - )
220 - region.is_pinned = True
250 + for registered in reversed(addresses):
251 + unregister_result = cudart.cudaHostUnregister(registered)
252 + if unregister_result != 0:
253 + cudart.cudaGetLastError()
254 + logger.warning(
255 + "cudaHostUnregister failed for rank=%d at %#x (code=%d); "
256 + "that chunk stays registered until the process exits",
257 + rank,
258 + registered,
259 + unregister_result,
260 + )
261 + return
262 +
263 + region.pinned_addresses.extend(addresses)
264 + region.is_pinned = True
265 + logger.debug(
266 + "cudaHostRegister rank=%d %.2f GB in %d chunk(s)",
267 + rank,
268 + total_size / 1e9,
269 + len(addresses),
270 + )
271
272
273 def _new_descriptor_buffers(
@@ -248,6 +298,7 @@ class SingleDirectionOffloadingHandler:
298 layer_refs_per_group: list[list[CanonicalKVCacheRef]],
299 gpu_to_cpu: bool,
300 canonical_layout: bool = False,
301 + host_memory_is_pinned: bool = True,
302 ):
303 """Initialize a SingleDirectionOffloadingHandler.
304
@@ -262,6 +313,8 @@ class SingleDirectionOffloadingHandler:
313 gpu_to_cpu: if True, transfer from GPU to CPU; otherwise CPU to GPU.
314 canonical_layout: if True, CPU pages use the canonical layout
315 described by the refs' mappings.
316 + host_memory_is_pinned: whether the CPU tensors are pinned, so GPU
317 + kernels may dereference them directly.
318
319 """
320 assert len(gpu_tensors) == len(cpu_tensors)
@@ -299,7 +352,7 @@ class SingleDirectionOffloadingHandler:
352 self.gpu_to_cpu: bool = gpu_to_cpu
353 self.layer_refs_per_group = layer_refs_per_group
354 self._swap_blocks_batch = _select_swap_blocks_fn(
302 - layer_refs_per_group, gpu_to_cpu
355 + layer_refs_per_group, gpu_to_cpu, host_memory_is_pinned
356 )
357
358 # GPU blocks may be smaller
@@ -646,7 +699,7 @@ class SingleDirectionOffloadingHandler:
699 last_event = last_transfer.end_event
700 # assure job will start only after the previous one completes
701 stream.wait_event(last_event)
649 - # CPU->GPU reads from host pinned memory, which is never written
702 + # CPU->GPU reads from host memory, which is never written
703 # by a concurrent GPU stream, so CU_MEMCPY_SRC_ACCESS_ORDER_ANY is
704 # safe and lets the driver pipeline source reads. GPU->CPU reads
705 # from the live GPU KV cache, which the compute stream keeps
@@ -764,6 +817,9 @@ class CPUOffloadingWorker(OffloadingWorker):
817 logger.info("Allocating %d CPU tensors...", len(kv_caches.tensors))
818 if mmap_region is not None and pin_memory:
819 pin_mmap_region(mmap_region)
820 + host_memory_is_pinned = pin_memory and (
821 + mmap_region is None or mmap_region.is_pinned
822 + )
823
824 canonical_bytes_per_block = (
825 _canonical_block_sizes(kv_caches.group_data_refs, len(kv_caches.tensors))
@@ -813,6 +869,7 @@ class CPUOffloadingWorker(OffloadingWorker):
869 layer_refs_per_group=kv_caches.group_data_refs,
870 gpu_to_cpu=True,
871 canonical_layout=canonical_layout,
872 + host_memory_is_pinned=host_memory_is_pinned,
873 )
874
875 self._load_handler = SingleDirectionOffloadingHandler(
@@ -822,6 +879,7 @@ class CPUOffloadingWorker(OffloadingWorker):
879 layer_refs_per_group=kv_caches.group_data_refs,
880 gpu_to_cpu=False,
881 canonical_layout=canonical_layout,
882 + host_memory_is_pinned=host_memory_is_pinned,
883 )
884
885 def submit_store(