A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 11, 12:30 UTC
CPU KV Offloading: Use more CUDA streams (#29013)
Signed-off-by: Or Ozeri <oro@il.ibm.com>
Or Ozeri committed
Dec 15, 2025 at 01:50 UTC
174e39ead7cb14a0511b0bbdc15dfd4a01ffb5fb
3 files changed
+192
-105
tests/v1/kv_offload/test_cpu_gpu.py
+10
-12
@@ -9,7 +9,7 @@ import torch
9
from vllm.platforms import current_platform
10
from vllm.v1.attention.backends.flash_attn import FlashAttentionBackend
11
from vllm.v1.kv_offload.mediums import CPULoadStoreSpec, GPULoadStoreSpec
12
-from vllm.v1.kv_offload.worker.cpu_gpu import CpuGpuOffloadingHandler
12
+from vllm.v1.kv_offload.worker.cpu_gpu import CpuGpuOffloadingHandlers
13
14
BACKENDS_TO_TEST = [FlashAttentionBackend]
15
@@ -82,7 +82,7 @@ def test_transfer(
82
83
# create handler
84
cpu_block_size = gpu_blocks_per_cpu_block * gpu_block_size
85
- handler = CpuGpuOffloadingHandler(
85
+ handlers = CpuGpuOffloadingHandlers(
86
attn_backends=attn_backends,
87
gpu_block_size=gpu_block_size,
88
cpu_block_size=cpu_block_size,
@@ -112,8 +112,7 @@ def test_transfer(
112
113
# set transfer direction
114
if gpu_to_cpu:
115
- src_kv_caches = handler.gpu_tensors
116
- dst_kv_caches = handler.cpu_tensors
115
+ handler = handlers.gpu_to_cpu_handler
116
src_spec_class = GPULoadStoreSpec
117
dst_spec_class = CPULoadStoreSpec
118
src_blocks = gpu_blocks
@@ -122,8 +121,7 @@ def test_transfer(
121
dst_blocks_in_gpu_block_size = cpu_blocks_in_gpu_block_size
122
dst_size_in_gpu_blocks = num_cpu_blocks * gpu_blocks_per_cpu_block
123
else:
125
- src_kv_caches = handler.cpu_tensors
126
- dst_kv_caches = handler.gpu_tensors
124
+ handler = handlers.cpu_to_gpu_handler
125
src_spec_class = CPULoadStoreSpec
126
dst_spec_class = GPULoadStoreSpec
127
src_blocks = cpu_blocks
@@ -144,12 +142,12 @@ def test_transfer(
142
dst_spec = dst_spec_class(dst_blocks)
143
144
# clone src and dst tensors before transfer
147
- orig_src_caches = [x.clone() for x in src_kv_caches]
148
- orig_dst_caches = [x.clone() for x in dst_kv_caches]
145
+ orig_src_caches = [x.clone() for x in handler.src_tensors]
146
+ orig_dst_caches = [x.clone() for x in handler.dst_tensors]
147
148
# call transfer function
149
assert handler.transfer_async(1, (src_spec, dst_spec))
152
- assert set(handler.transfer_events.keys()) == {1}
150
+ assert set({x[0] for x in handler._transfers}) == {1}
151
152
# wait for transfer to complete
153
end_time = time.time() + 10
@@ -161,15 +159,15 @@ def test_transfer(
159
time.sleep(0.1)
160
161
# verify src tensors did not change
164
- for orig_tensor, tensor in zip(orig_src_caches, src_kv_caches):
162
+ for orig_tensor, tensor in zip(orig_src_caches, handler.src_tensors):
163
assert torch.equal(orig_tensor, tensor)
164
165
# verify dst tensors
166
for dst_block in range(dst_size_in_gpu_blocks):
167
src_block_candidate = dst_to_src.get(dst_block)
168
for src_cache, dst_cache, orig_dst_cache, kv_dim in zip(
171
- src_kv_caches,
172
- dst_kv_caches,
169
+ handler.src_tensors,
170
+ handler.dst_tensors,
171
orig_dst_caches,
172
handler.kv_dim_before_num_blocks,
173
):
vllm/v1/kv_offload/cpu.py
+7
-7
@@ -13,7 +13,7 @@ from vllm.v1.kv_offload.backends.cpu import CPUBackend
13
from vllm.v1.kv_offload.lru_manager import LRUOffloadingManager
14
from vllm.v1.kv_offload.mediums import CPULoadStoreSpec, GPULoadStoreSpec
15
from vllm.v1.kv_offload.spec import OffloadingSpec
16
-from vllm.v1.kv_offload.worker.cpu_gpu import CpuGpuOffloadingHandler
16
+from vllm.v1.kv_offload.worker.cpu_gpu import CpuGpuOffloadingHandlers
17
from vllm.v1.kv_offload.worker.worker import OffloadingHandler
18
19
@@ -32,7 +32,7 @@ class CPUOffloadingSpec(OffloadingSpec):
32
self._manager: OffloadingManager | None = None
33
34
# worker-side
35
- self._handler: OffloadingHandler | None = None
35
+ self._handlers: CpuGpuOffloadingHandlers | None = None
36
37
self.eviction_policy: str = self.extra_config.get("eviction_policy", "lru")
38
@@ -67,13 +67,13 @@ class CPUOffloadingSpec(OffloadingSpec):
67
kv_caches: dict[str, torch.Tensor],
68
attn_backends: dict[str, type[AttentionBackend]],
69
) -> Iterator[tuple[type[LoadStoreSpec], type[LoadStoreSpec], OffloadingHandler]]:
70
- if not self._handler:
70
+ if not self._handlers:
71
if not current_platform.is_cuda_alike():
72
raise Exception(
73
"CPU Offloading is currently only supported on CUDA-alike GPUs"
74
)
75
76
- self._handler = CpuGpuOffloadingHandler(
76
+ self._handlers = CpuGpuOffloadingHandlers(
77
attn_backends=attn_backends,
78
gpu_block_size=self.gpu_block_size,
79
cpu_block_size=self.offloaded_block_size,
@@ -81,6 +81,6 @@ class CPUOffloadingSpec(OffloadingSpec):
81
gpu_caches=kv_caches,
82
)
83
84
- assert self._handler is not None
85
- yield GPULoadStoreSpec, CPULoadStoreSpec, self._handler
86
- yield CPULoadStoreSpec, GPULoadStoreSpec, self._handler
84
+ assert self._handlers is not None
85
+ yield GPULoadStoreSpec, CPULoadStoreSpec, self._handlers.gpu_to_cpu_handler
86
+ yield CPULoadStoreSpec, GPULoadStoreSpec, self._handlers.cpu_to_gpu_handler
vllm/v1/kv_offload/worker/cpu_gpu.py
+175
-86
@@ -1,5 +1,6 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from collections import deque
4
5
import numpy as np
6
import torch
@@ -8,7 +9,7 @@ from vllm import _custom_ops as ops
9
from vllm.attention.backends.abstract import AttentionBackend
10
from vllm.logger import init_logger
11
from vllm.utils.platform_utils import is_pin_memory_available
11
-from vllm.v1.kv_offload.mediums import CPULoadStoreSpec, GPULoadStoreSpec
12
+from vllm.v1.kv_offload.mediums import BlockIDsLoadStoreSpec
13
from vllm.v1.kv_offload.worker.worker import (
14
OffloadingHandler,
15
TransferResult,
@@ -51,7 +52,123 @@ def expand_block_ids(
52
output_idx = output_end_idx
53
54
54
-class CpuGpuOffloadingHandler(OffloadingHandler):
55
+class SingleDirectionOffloadingHandler(OffloadingHandler):
56
+ """
57
+ SingleDirectionOffloadingHandler handles transfers for a single direction,
58
+ either CPU->GPU or GPU->CPU.
59
+ Transfers are guaranteed to be executed in order of their submission.
60
+ Each transfer uses a unique CUDA stream, and its stream will start
61
+ executing only after the streams of previous transfers have finished.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ src_tensors: list[torch.Tensor],
67
+ dst_tensors: list[torch.Tensor],
68
+ kv_dim_before_num_blocks: list[bool],
69
+ src_block_size_factor: int,
70
+ dst_block_size_factor: int,
71
+ priority: int,
72
+ ):
73
+ """
74
+ Initialize a SingleDirectionOffloadingHandler.
75
+
76
+ Args:
77
+ src_tensors: list of KV cache tensors to copy from.
78
+ dst_tensors: list of KV cache tensors to copy to.
79
+ Order should match src_tensors.
80
+ kv_dim_before_num_blocks: list of bools, indicating
81
+ whether the respective KV cache tensor has a KV
82
+ dimension before its num_blocks dimension.
83
+ e.g. (2, num_blocks, ...)
84
+ src_block_size_factor: The number of kernel blocks
85
+ per KV block in a source tensor.
86
+ dst_block_size_factor: The number of kernel blocks
87
+ per KV block in a destination tensor.
88
+ priority: The priority of the backing CUDA streams.
89
+ Lower numbers indicate higher priority.
90
+ """
91
+ assert len(src_tensors) == len(dst_tensors) == len(kv_dim_before_num_blocks)
92
+
93
+ self.src_tensors: list[torch.Tensor] = src_tensors
94
+ self.dst_tensors: list[torch.Tensor] = dst_tensors
95
+ self.kv_dim_before_num_blocks: list[bool] = kv_dim_before_num_blocks
96
+ self.src_block_size_factor: int = src_block_size_factor
97
+ self.dst_block_size_factor: int = dst_block_size_factor
98
+ self.priority = priority
99
+
100
+ # queue of transfers (job_id, stream, event)
101
+ self._transfers: deque[tuple[int, torch.cuda.Stream, torch.Event]] = deque()
102
+ # list of CUDA streams available for re-use
103
+ self._stream_pool: list[torch.cuda.Stream] = []
104
+ # list of CUDA events available for re-use
105
+ self._event_pool: list[torch.Event] = []
106
+
107
+ def transfer_async(self, job_id: int, transfer_spec: TransferSpec) -> bool:
108
+ src_spec, dst_spec = transfer_spec
109
+ assert isinstance(src_spec, BlockIDsLoadStoreSpec)
110
+ assert isinstance(dst_spec, BlockIDsLoadStoreSpec)
111
+
112
+ src_blocks = src_spec.block_ids
113
+ dst_blocks = dst_spec.block_ids
114
+ assert src_blocks.ndim == 1
115
+ assert dst_blocks.ndim == 1
116
+
117
+ src_sub_block_count = src_blocks.size * self.src_block_size_factor
118
+ dst_sub_block_count = dst_blocks.size * self.dst_block_size_factor
119
+ src_sub_blocks_to_skip = -dst_blocks.size % self.src_block_size_factor
120
+
121
+ assert dst_sub_block_count == src_sub_block_count - src_sub_blocks_to_skip
122
+
123
+ src_to_dst = np.empty((dst_sub_block_count, 2), dtype=np.int64)
124
+ expand_block_ids(
125
+ src_blocks,
126
+ self.src_block_size_factor,
127
+ src_to_dst[:, 0],
128
+ skip_count=src_sub_blocks_to_skip,
129
+ )
130
+ expand_block_ids(dst_blocks, self.dst_block_size_factor, src_to_dst[:, 1])
131
+ src_to_dst_tensor = torch.from_numpy(src_to_dst)
132
+
133
+ stream = (
134
+ self._stream_pool.pop()
135
+ if self._stream_pool
136
+ else torch.cuda.Stream(priority=self.priority)
137
+ )
138
+ event = self._event_pool.pop() if self._event_pool else torch.Event()
139
+ if self._transfers:
140
+ _, _, last_event = self._transfers[-1]
141
+ # assure job will start only after the previous one completes
142
+ stream.wait_event(last_event)
143
+ with torch.cuda.stream(stream):
144
+ for src_tensor, dst_tensor, kv_dim in zip(
145
+ self.src_tensors, self.dst_tensors, self.kv_dim_before_num_blocks
146
+ ):
147
+ if kv_dim:
148
+ src_key_cache, src_value_cache = src_tensor
149
+ dst_key_cache, dst_value_cache = dst_tensor
150
+ ops.swap_blocks(src_key_cache, dst_key_cache, src_to_dst_tensor)
151
+ ops.swap_blocks(src_value_cache, dst_value_cache, src_to_dst_tensor)
152
+ else:
153
+ ops.swap_blocks(src_tensor, dst_tensor, src_to_dst_tensor)
154
+ event.record(stream)
155
+
156
+ self._transfers.append((job_id, stream, event))
157
+
158
+ # success
159
+ return True
160
+
161
+ def get_finished(self) -> list[TransferResult]:
162
+ results: list[TransferResult] = []
163
+ while self._transfers and self._transfers[0][2].query():
164
+ job_id, stream, event = self._transfers.popleft()
165
+ results.append((job_id, True))
166
+ self._stream_pool.append(stream)
167
+ self._event_pool.append(event)
168
+ return results
169
+
170
+
171
+class CpuGpuOffloadingHandlers:
172
def __init__(
173
self,
174
gpu_block_size: int,
@@ -60,27 +177,20 @@ class CpuGpuOffloadingHandler(OffloadingHandler):
177
gpu_caches: dict[str, torch.Tensor],
178
attn_backends: dict[str, type[AttentionBackend]],
179
):
180
+ assert gpu_caches
181
assert cpu_block_size % gpu_block_size == 0
64
- self.block_size_factor = cpu_block_size // gpu_block_size
65
-
66
- # cuda streams for gpu->cpu and cpu->gpu
67
- self.d2h_stream = torch.cuda.Stream()
68
- self.h2d_stream = torch.cuda.Stream()
69
-
70
- # job_id -> transfer cuda event
71
- self.transfer_events: dict[int, torch.Event] = {}
72
- # list of cuda events available for re-use
73
- self.events_pool: list[torch.Event] = []
182
+ block_size_factor = cpu_block_size // gpu_block_size
183
184
pin_memory = is_pin_memory_available()
185
186
# allocate cpu tensors
187
logger.info("Allocating %d CPU tensors...", len(gpu_caches))
79
- self.gpu_tensors: list[torch.Tensor] = []
80
- self.cpu_tensors: list[torch.Tensor] = []
81
- self.kv_dim_before_num_blocks: list[bool] = []
188
+ gpu_tensors: list[torch.Tensor] = []
189
+ cpu_tensors: list[torch.Tensor] = []
190
+ kv_dim_before_num_blocks: list[bool] = []
191
+ kernel_block_size: int | None = None
192
for layer_name, gpu_tensor in gpu_caches.items():
83
- self.gpu_tensors.append(gpu_tensor)
193
+ gpu_tensors.append(gpu_tensor)
194
195
gpu_shape = gpu_tensor.shape
196
attn_backend = attn_backends[layer_name]
@@ -88,16 +198,21 @@ class CpuGpuOffloadingHandler(OffloadingHandler):
198
num_blocks=1234, block_size=16, num_kv_heads=8, head_size=256
199
)
200
201
+ has_layers_dim = False
202
if len(gpu_shape) != len(test_shape):
203
# cross-layers tensor
204
# shape is (num_blocks, ...)
205
assert len(gpu_shape) == len(test_shape) + 1
206
num_blocks_idx = 0
96
- self.kv_dim_before_num_blocks.append(False)
207
+ has_layers_dim = True
208
+ kv_dim_before_num_blocks.append(False)
209
+
210
+ # prepend a dummy num_layers=80 to test_shape
211
+ test_shape = (80,) + test_shape
212
elif test_shape[0] == 1234:
213
# shape is (num_blocks, ...)
214
num_blocks_idx = 0
100
- self.kv_dim_before_num_blocks.append(False)
215
+ kv_dim_before_num_blocks.append(False)
216
else:
217
# shape should be (2, num_blocks, ...)
218
assert test_shape[0] == 2
@@ -105,13 +220,32 @@ class CpuGpuOffloadingHandler(OffloadingHandler):
220
assert gpu_shape[0] == 2
221
222
num_blocks_idx = 1
108
- self.kv_dim_before_num_blocks.append(True)
223
+ kv_dim_before_num_blocks.append(True)
224
+
225
+ try:
226
+ kv_cache_stride_order = attn_backend.get_kv_cache_stride_order(
227
+ include_num_layers_dimension=has_layers_dim
228
+ )
229
+ assert len(kv_cache_stride_order) == len(gpu_shape)
230
+ except (AttributeError, NotImplementedError):
231
+ kv_cache_stride_order = tuple(range(len(gpu_shape)))
232
+
233
+ # permute test_shape according to stride_order
234
+ test_shape = tuple(test_shape[i] for i in kv_cache_stride_order)
235
+
236
+ # find block_size (16) dimension index
237
+ block_size_idx = test_shape.index(16)
238
+ if kernel_block_size is not None:
239
+ assert kernel_block_size == gpu_shape[block_size_idx]
240
+ else:
241
+ kernel_block_size = gpu_shape[block_size_idx]
242
+ assert gpu_block_size % kernel_block_size == 0
243
244
cpu_shape = list(gpu_shape)
111
- cpu_shape[num_blocks_idx] = num_cpu_blocks * self.block_size_factor
245
+ cpu_shape[num_blocks_idx] = num_cpu_blocks * block_size_factor
246
247
logger.debug("Allocating CPU tensor of shape %r", cpu_shape)
114
- self.cpu_tensors.append(
248
+ cpu_tensors.append(
249
torch.zeros(
250
cpu_shape,
251
dtype=gpu_tensor.dtype,
@@ -120,72 +254,27 @@ class CpuGpuOffloadingHandler(OffloadingHandler):
254
)
255
)
256
123
- def transfer_async(self, job_id: int, spec: TransferSpec) -> bool:
124
- src_spec, dst_spec = spec
125
- if isinstance(src_spec, CPULoadStoreSpec):
126
- assert isinstance(dst_spec, GPULoadStoreSpec)
127
- stream = self.h2d_stream
128
- src_tensors = self.cpu_tensors
129
- dst_tensors = self.gpu_tensors
130
- src_block_size_factor = self.block_size_factor
131
- dst_block_size_factor = 1
132
- else:
133
- assert isinstance(src_spec, GPULoadStoreSpec)
134
- assert isinstance(dst_spec, CPULoadStoreSpec)
135
- stream = self.d2h_stream
136
- src_tensors = self.gpu_tensors
137
- dst_tensors = self.cpu_tensors
138
- src_block_size_factor = 1
139
- dst_block_size_factor = self.block_size_factor
140
-
141
- src_blocks = src_spec.block_ids
142
- dst_blocks = dst_spec.block_ids
143
- assert src_blocks.ndim == 1
144
- assert dst_blocks.ndim == 1
257
+ assert kernel_block_size is not None
258
+ gpu_block_size_factor = gpu_block_size // kernel_block_size
259
+ cpu_block_size_factor = cpu_block_size // kernel_block_size
260
146
- src_sub_block_count = src_blocks.size * src_block_size_factor
147
- dst_sub_block_count = dst_blocks.size * dst_block_size_factor
148
- src_sub_blocks_to_skip = -dst_blocks.size % src_block_size_factor
261
+ # TODO (orozery): adapt swap_blocks to support gpu_block_size_factor
262
+ assert gpu_block_size_factor == 1
263
150
- assert dst_sub_block_count == src_sub_block_count - src_sub_blocks_to_skip
151
-
152
- src_to_dst = np.empty((dst_sub_block_count, 2), dtype=np.int64)
153
- expand_block_ids(
154
- src_blocks,
155
- src_block_size_factor,
156
- src_to_dst[:, 0],
157
- skip_count=src_sub_blocks_to_skip,
264
+ self.gpu_to_cpu_handler = SingleDirectionOffloadingHandler(
265
+ src_tensors=gpu_tensors,
266
+ dst_tensors=cpu_tensors,
267
+ kv_dim_before_num_blocks=kv_dim_before_num_blocks,
268
+ src_block_size_factor=gpu_block_size_factor,
269
+ dst_block_size_factor=cpu_block_size_factor,
270
+ priority=1,
271
)
159
- expand_block_ids(dst_blocks, dst_block_size_factor, src_to_dst[:, 1])
160
- src_to_dst_tensor = torch.from_numpy(src_to_dst)
272
162
- event = self.events_pool.pop() if self.events_pool else torch.Event()
163
- with torch.cuda.stream(stream):
164
- for src_tensor, dst_tensor, kv_dim in zip(
165
- src_tensors, dst_tensors, self.kv_dim_before_num_blocks
166
- ):
167
- if kv_dim:
168
- src_key_cache = src_tensor[0]
169
- dst_key_cache = dst_tensor[0]
170
- ops.swap_blocks(src_key_cache, dst_key_cache, src_to_dst_tensor)
171
- src_value_cache = src_tensor[1]
172
- dst_value_cache = dst_tensor[1]
173
- ops.swap_blocks(src_value_cache, dst_value_cache, src_to_dst_tensor)
174
- else:
175
- ops.swap_blocks(src_tensor, dst_tensor, src_to_dst_tensor)
176
- event.record(stream)
177
-
178
- self.transfer_events[job_id] = event
179
-
180
- # success
181
- return True
182
-
183
- def get_finished(self) -> list[TransferResult]:
184
- results: list[TransferResult] = []
185
- for job_id, event in self.transfer_events.items():
186
- if event.query():
187
- results.append((job_id, True))
188
- self.events_pool.append(event)
189
- for job_id, _ in results:
190
- del self.transfer_events[job_id]
191
- return results
273
+ self.cpu_to_gpu_handler = SingleDirectionOffloadingHandler(
274
+ src_tensors=cpu_tensors,
275
+ dst_tensors=gpu_tensors,
276
+ kv_dim_before_num_blocks=kv_dim_before_num_blocks,
277
+ src_block_size_factor=cpu_block_size_factor,
278
+ dst_block_size_factor=gpu_block_size_factor,
279
+ priority=-1,
280
+ )