Skip to content

Commit aad88f8

Browse files
[kv_offload+HMA][8/N]: Support multi-group worker transfer (vllm-project#38453)
Signed-off-by: Or Ozeri <oro@il.ibm.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
1 parent 0210024 commit aad88f8

4 files changed

Lines changed: 345 additions & 77 deletions

File tree

tests/v1/kv_offload/test_cpu_gpu.py

Lines changed: 233 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
DEVICE_TYPE = current_platform.device_type
2828
DEVICES = [f"{DEVICE_TYPE}:0"]
2929
NUM_MAPPINGS = [3]
30+
NUM_MAPPINGS_PER_GROUP = [2]
3031

3132

3233
@pytest.mark.parametrize("gpu_to_cpu", [True, False])
@@ -58,9 +59,7 @@ def test_transfer(
5859
# build CanonicalKVCacheTensor list: one per tensor
5960
kv_cache_tensors: list[CanonicalKVCacheTensor] = []
6061
for i in range(num_tensors):
61-
gpu_tensor = torch.randint(
62-
-128,
63-
127,
62+
gpu_tensor = torch.zeros(
6463
(num_gpu_blocks, gpu_page_size_bytes),
6564
dtype=torch.int8,
6665
device=device,
@@ -119,34 +118,44 @@ def test_transfer(
119118
for j in range(block_size_factor)
120119
]
121120

122-
# maybe skip some GPU blocks to test reading from the middle of a CPU block
123-
if not gpu_to_cpu:
124-
blocks_to_skip = block_size_factor - 1
121+
# maybe skip some GPU blocks to test reading/writing from the middle of a CPU block
122+
blocks_to_skip = block_size_factor - 1
123+
if blocks_to_skip > 0:
125124
gpu_blocks = gpu_blocks[blocks_to_skip:]
126125
cpu_blocks_expanded = cpu_blocks_expanded[blocks_to_skip:]
127126

128127
# set transfer direction
129128
if gpu_to_cpu:
130129
handler = handlers.gpu_to_cpu_handler
131-
src_spec = GPULoadStoreSpec(gpu_blocks, group_sizes=(len(gpu_blocks),))
130+
src_spec = GPULoadStoreSpec(
131+
gpu_blocks, group_sizes=(len(gpu_blocks),), block_indices=(blocks_to_skip,)
132+
)
132133
dst_spec = CPULoadStoreSpec(cpu_blocks)
133134
dst_to_src = dict(zip(cpu_blocks_expanded, gpu_blocks))
134-
num_dst_sub_blocks = num_cpu_blocks * block_size_factor
135+
num_dst_sub_blocks = num_gpu_blocks
135136
else:
136137
handler = handlers.cpu_to_gpu_handler
137138
src_spec = CPULoadStoreSpec(cpu_blocks)
138-
dst_spec = GPULoadStoreSpec(gpu_blocks, group_sizes=(len(gpu_blocks),))
139+
dst_spec = GPULoadStoreSpec(
140+
gpu_blocks, group_sizes=(len(gpu_blocks),), block_indices=(blocks_to_skip,)
141+
)
139142
dst_to_src = dict(zip(gpu_blocks, cpu_blocks_expanded))
140143
num_dst_sub_blocks = num_gpu_blocks
141144

145+
# randomize src and dst tensors before transfer
146+
for tensor in handler.src_tensors:
147+
tensor.random_()
148+
for tensor in handler.dst_tensors:
149+
tensor.random_()
150+
142151
# clone src and dst tensors before transfer
143152
orig_src_tensors = [x.clone() for x in handler.src_tensors]
144153
orig_dst_tensors = [x.clone() for x in handler.dst_tensors]
145154

146155
# call transfer function
147156
start_time = time.time()
148157
assert handler.transfer_async(1, (src_spec, dst_spec))
149-
assert set({x.job_id for x in handler._transfers}) == {1}
158+
assert {x.job_id for x in handler._transfers} == {1}
150159

151160
# wait for transfer to complete
152161
end_time = time.time() + 10
@@ -155,11 +164,14 @@ def test_transfer(
155164
if finished:
156165
assert finished[0].job_id == 1
157166
assert finished[0].success
158-
assert finished[0].transfer_type == (
159-
("GPU", "CPU") if gpu_to_cpu else ("CPU", "GPU")
167+
assert (
168+
finished[0].transfer_type == ("GPU", "CPU")
169+
if gpu_to_cpu
170+
else ("CPU", "GPU")
160171
)
161172
assert finished[0].transfer_size == (
162-
len(gpu_blocks) * handler.group_block_size_in_bytes[0]
173+
len(gpu_blocks)
174+
* sum([x.page_size_bytes for x in handler.kv_cache_groups_data_refs[0]])
163175
)
164176
assert finished[0].transfer_time > 0
165177
assert finished[0].transfer_time < (time.time() - start_time)
@@ -196,3 +208,211 @@ def test_transfer(
196208
handlers.gpu_to_cpu_handler.shutdown()
197209
if mmap_region:
198210
mmap_region.cleanup()
211+
212+
213+
@pytest.mark.parametrize("gpu_to_cpu", [True, False])
214+
@pytest.mark.parametrize("num_mappings_per_group", NUM_MAPPINGS_PER_GROUP)
215+
@pytest.mark.parametrize("gpu_page_size_bytes", GPU_PAGE_SIZES)
216+
@pytest.mark.parametrize("block_size_factor", BLOCK_SIZE_FACTORS)
217+
@pytest.mark.parametrize("num_gpu_blocks", NUM_GPU_BLOCKS)
218+
@pytest.mark.parametrize("num_cpu_blocks", NUM_CPU_BLOCKS)
219+
@pytest.mark.parametrize("seed", SEEDS)
220+
@pytest.mark.parametrize("device", DEVICES)
221+
@torch.inference_mode()
222+
def test_transfer_multi_group(
223+
default_vllm_config,
224+
gpu_to_cpu: bool,
225+
num_mappings_per_group: int,
226+
gpu_page_size_bytes: int,
227+
block_size_factor: int,
228+
num_gpu_blocks: int,
229+
num_cpu_blocks: int,
230+
seed: int,
231+
device: str,
232+
) -> None:
233+
"""Test transfers with three KV cache groups:
234+
- Group 0: aligned transfer with num_mappings_per_group blocks
235+
- Group 1: zero blocks (empty group)
236+
- Group 2: unaligned CPU->GPU transfer (logical_offset=block_size_factor-1,
237+
causing the implementation to skip source sub-blocks) with
238+
num_mappings_per_group blocks
239+
"""
240+
set_random_seed(seed)
241+
242+
# 3 groups, each with 2 tensors
243+
num_groups = 3
244+
tensors_per_group = 2
245+
num_tensors = num_groups * tensors_per_group
246+
kv_cache_tensors: list[CanonicalKVCacheTensor] = []
247+
for _ in range(num_tensors):
248+
gpu_tensor = torch.zeros(
249+
(num_gpu_blocks, gpu_page_size_bytes),
250+
dtype=torch.int8,
251+
device=device,
252+
)
253+
kv_cache_tensors.append(
254+
CanonicalKVCacheTensor(
255+
tensor=gpu_tensor,
256+
page_size_bytes=gpu_page_size_bytes,
257+
)
258+
)
259+
260+
kv_cache_groups_data_refs: list[list[CanonicalKVCacheRef]] = [
261+
[
262+
CanonicalKVCacheRef(
263+
tensor_idx=g * tensors_per_group + i,
264+
page_size_bytes=gpu_page_size_bytes,
265+
)
266+
for i in range(tensors_per_group)
267+
]
268+
for g in range(num_groups)
269+
]
270+
271+
canonical_kv_caches = CanonicalKVCaches(
272+
tensors=kv_cache_tensors, group_data_refs=kv_cache_groups_data_refs
273+
)
274+
275+
handlers = CpuGpuOffloadingHandlers(
276+
kv_caches=canonical_kv_caches,
277+
block_size_factor=block_size_factor,
278+
num_cpu_blocks=num_cpu_blocks,
279+
)
280+
281+
# group 0: aligned, group 1: empty, group 2: unaligned on CPU->GPU
282+
group_sizes_in_cpu_blocks = [num_mappings_per_group, 0, num_mappings_per_group]
283+
284+
total_cpu_blocks = sum(group_sizes_in_cpu_blocks)
285+
total_gpu_blocks_needed = total_cpu_blocks * block_size_factor
286+
gpu_blocks_all = random.sample(range(num_gpu_blocks), total_gpu_blocks_needed)
287+
cpu_blocks_all = random.sample(range(num_cpu_blocks), total_cpu_blocks)
288+
289+
# split gpu/cpu blocks per group
290+
gpu_blocks_per_group: list[list[int]] = []
291+
cpu_blocks_per_group: list[list[int]] = []
292+
gpu_offset = 0
293+
cpu_offset = 0
294+
for size in group_sizes_in_cpu_blocks:
295+
gpu_count = size * block_size_factor
296+
gpu_blocks_per_group.append(gpu_blocks_all[gpu_offset : gpu_offset + gpu_count])
297+
cpu_blocks_per_group.append(cpu_blocks_all[cpu_offset : cpu_offset + size])
298+
gpu_offset += gpu_count
299+
cpu_offset += size
300+
301+
# expand cpu blocks to gpu-page granularity
302+
cpu_blocks_expanded_per_group = [
303+
[
304+
cpu_block * block_size_factor + j
305+
for cpu_block in cpu_blocks
306+
for j in range(block_size_factor)
307+
]
308+
for cpu_blocks in cpu_blocks_per_group
309+
]
310+
311+
# skip sub-blocks from group 2 to test unaligned transfers.
312+
sub_blocks_to_skip = block_size_factor - 1 # e.g. 2 when block_size_factor=3
313+
if sub_blocks_to_skip > 0:
314+
gpu_blocks_per_group[2] = gpu_blocks_per_group[2][
315+
sub_blocks_to_skip:-sub_blocks_to_skip
316+
]
317+
cpu_blocks_expanded_per_group[2] = cpu_blocks_expanded_per_group[2][
318+
sub_blocks_to_skip:-sub_blocks_to_skip
319+
]
320+
321+
# build flat gpu_blocks list and group_sizes in GPU blocks
322+
gpu_blocks: list[int] = []
323+
group_sizes: list[int] = []
324+
for gpu_blks in gpu_blocks_per_group:
325+
gpu_blocks.extend(gpu_blks)
326+
group_sizes.append(len(gpu_blks))
327+
328+
# build flat cpu_blocks list
329+
cpu_blocks = []
330+
for cpu_blks in cpu_blocks_per_group:
331+
cpu_blocks.extend(cpu_blks)
332+
333+
# block_indices: only relevant for unaligned transfers
334+
block_indices: list[int] = [0, 0, sub_blocks_to_skip]
335+
336+
if gpu_to_cpu:
337+
handler = handlers.gpu_to_cpu_handler
338+
src_spec = GPULoadStoreSpec(
339+
gpu_blocks, group_sizes=group_sizes, block_indices=block_indices
340+
)
341+
dst_spec = CPULoadStoreSpec(cpu_blocks)
342+
# per-group mapping: cpu sub-block -> gpu sub-block
343+
dst_to_src_per_group = [
344+
dict(zip(expanded, gpu_blks))
345+
for expanded, gpu_blks in zip(
346+
cpu_blocks_expanded_per_group, gpu_blocks_per_group
347+
)
348+
]
349+
num_dst_sub_blocks = num_cpu_blocks * block_size_factor
350+
else:
351+
handler = handlers.cpu_to_gpu_handler
352+
src_spec = CPULoadStoreSpec(cpu_blocks)
353+
dst_spec = GPULoadStoreSpec(
354+
gpu_blocks, group_sizes=group_sizes, block_indices=block_indices
355+
)
356+
# per-group mapping: gpu sub-block -> cpu sub-block
357+
dst_to_src_per_group = [
358+
dict(zip(gpu_blks, expanded))
359+
for gpu_blks, expanded in zip(
360+
gpu_blocks_per_group, cpu_blocks_expanded_per_group
361+
)
362+
]
363+
num_dst_sub_blocks = num_gpu_blocks
364+
365+
# randomize src and dst tensors before transfer
366+
for tensor in handler.src_tensors:
367+
tensor.random_()
368+
for tensor in handler.dst_tensors:
369+
tensor.random_()
370+
371+
orig_src_tensors = [x.clone() for x in handler.src_tensors]
372+
orig_dst_tensors = [x.clone() for x in handler.dst_tensors]
373+
374+
assert handler.transfer_async(1, (src_spec, dst_spec))
375+
assert {x.job_id for x in handler._transfers} == {1}
376+
377+
end_time = time.time() + 10
378+
while time.time() < end_time:
379+
finished = handler.get_finished()
380+
if finished:
381+
assert finished[0].job_id == 1
382+
assert finished[0].success
383+
expected_bytes = sum(
384+
group_size * sum([x.page_size_bytes for x in data_refs])
385+
for group_size, data_refs in zip(
386+
group_sizes, handler.kv_cache_groups_data_refs
387+
)
388+
)
389+
assert finished[0].transfer_size == expected_bytes
390+
break
391+
time.sleep(0.1)
392+
393+
# verify src tensors did not change
394+
for orig_tensor, tensor in zip(orig_src_tensors, handler.src_tensors):
395+
assert torch.equal(orig_tensor, tensor)
396+
397+
# verify dst tensors at gpu-page granularity
398+
for group_idx, dst_to_src in enumerate(dst_to_src_per_group):
399+
group_tensor_offset = group_idx * tensors_per_group
400+
for tensor_idx in range(tensors_per_group):
401+
src_tensor = handler.src_tensors[group_tensor_offset + tensor_idx]
402+
dst_tensor = handler.dst_tensors[group_tensor_offset + tensor_idx]
403+
orig_dst_tensor = orig_dst_tensors[group_tensor_offset + tensor_idx]
404+
src_view = src_tensor.view(-1, gpu_page_size_bytes)
405+
dst_view = dst_tensor.view(-1, gpu_page_size_bytes)
406+
orig_dst_view = orig_dst_tensor.view(-1, gpu_page_size_bytes)
407+
for dst_sub_block in range(num_dst_sub_blocks):
408+
src_sub_block = dst_to_src.get(dst_sub_block)
409+
if src_sub_block is not None:
410+
expected = src_view[src_sub_block]
411+
else:
412+
expected = orig_dst_view[dst_sub_block]
413+
torch.testing.assert_close(
414+
dst_view[dst_sub_block].cpu(), expected.cpu()
415+
)
416+
417+
handlers.cpu_to_gpu_handler.shutdown()
418+
handlers.gpu_to_cpu_handler.shutdown()

vllm/distributed/kv_transfer/kv_connector/v1/offloading/scheduler.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,9 @@ def _get_reqs_to_store(self, scheduler_output: SchedulerOutput):
381381
for i in range(self.config.block_size_factor):
382382
src_block_ids.append(block_ids[gpu_block_idx + i])
383383
src_spec = GPULoadStoreSpec(
384-
src_block_ids, group_sizes=(len(src_block_ids),)
384+
src_block_ids,
385+
group_sizes=(len(src_block_ids),),
386+
block_indices=(0,),
385387
)
386388

387389
reqs_to_store[req_id] = (src_spec, dst_spec)

vllm/v1/kv_offload/mediums.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,26 +34,24 @@ class GPULoadStoreSpec(BlockIDsLoadStoreSpec):
3434
will correspond to logically contiguous blocks, e.g. blocks 5-10 of a some request.
3535
block_indices[i] will represent the block index of the first block in group #i.
3636
Thus, len(block_indices) == len(group_sizes) = number of KV cache groups.
37-
This information is required in order to support loading from offloaded blocks
37+
This information is required in order to support off/loading from offloaded blocks
3838
which are larger than GPU blocks.
3939
In such cases, the first GPU block per each group may be unaligned to the offloaded
4040
block size, and so knowing block_indices[i] allows the worker to correctly
4141
skip part of the first matching offloaded block.
42-
Offloading from GPU is always aligned to offloaded block size, and so
43-
block_indices will only be set by the offloading connector when loading into GPU.
4442
"""
4543

4644
def __init__(
4745
self,
4846
block_ids: list[int],
4947
group_sizes: Sequence[int],
50-
block_indices: Sequence[int] | None = None,
48+
block_indices: Sequence[int],
5149
):
5250
super().__init__(block_ids)
5351
assert sum(group_sizes) == len(block_ids)
54-
assert block_indices is None or len(block_indices) == len(group_sizes)
52+
assert len(block_indices) == len(group_sizes)
5553
self.group_sizes: Sequence[int] = group_sizes
56-
self.block_indices: Sequence[int] | None = block_indices
54+
self.block_indices: Sequence[int] = block_indices
5755

5856
@staticmethod
5957
def medium() -> str:

0 commit comments

Comments
 (0)