main
py 700 lines 24.8 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import logging
4 import random
5 import time
6 import uuid
7 from unittest.mock import MagicMock
8
9 import pytest
10 import torch
11
12 from vllm import _custom_ops as ops
13 from vllm.platforms import current_platform
14 from vllm.utils.math_utils import round_up
15 from vllm.utils.torch_utils import set_random_seed
16 from vllm.v1.kv_offload.base import (
17 CanonicalKVCacheRef,
18 CanonicalKVCaches,
19 CanonicalKVCacheTensor,
20 GPULoadStoreSpec,
21 TransferResult,
22 )
23 from vllm.v1.kv_offload.cpu import gpu_worker
24 from vllm.v1.kv_offload.cpu.common import CPULoadStoreSpec
25 from vllm.v1.kv_offload.cpu.gpu_worker import CPUOffloadingWorker
26 from vllm.v1.kv_offload.cpu.shared_offload_region import SharedOffloadRegion
27
28 NUM_GPU_BLOCKS = [64]
29 NUM_CPU_CHUNKS = [256]
30 GPU_PAGE_SIZES = [512, 1024]
31 BLOCKS_PER_CHUNK_VALUES = [1, 3]
32 NUM_TENSORS = [4]
33 SEEDS = [0]
34 DEVICE_TYPE = current_platform.device_type
35 DEVICES = [f"{DEVICE_TYPE}:0"]
36 NUM_MAPPINGS = [3]
37 NUM_MAPPINGS_PER_GROUP = [2]
38
39
40 @pytest.mark.skipif(not current_platform.is_rocm(), reason="ROCm-specific test")
41 def test_rocm_cpu_to_gpu_uses_dma(monkeypatch: pytest.MonkeyPatch) -> None:
42 monkeypatch.setattr(gpu_worker, "HAS_TRITON", True)
43 monkeypatch.setattr(gpu_worker.current_platform, "is_xpu", lambda: False)
44 monkeypatch.setattr(gpu_worker.current_platform, "is_rocm", lambda: True)
45
46 refs = [[CanonicalKVCacheRef(tensor_idx=0, page_size_bytes=512)]]
47 assert gpu_worker._select_swap_blocks_fn(refs, gpu_to_cpu=False) is (
48 ops.swap_blocks_batch
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)
71 calls: list[str] = []
72
73 def record_store_shutdown() -> bool:
74 calls.append("store")
75 return True
76
77 def record_load_shutdown() -> bool:
78 calls.append("load")
79 return True
80
81 store_handler = MagicMock()
82 load_handler = MagicMock()
83 store_handler.shutdown.side_effect = record_store_shutdown
84 load_handler.shutdown.side_effect = record_load_shutdown
85
86 def record_region_cleanup(**_: bool) -> bool:
87 calls.append("region")
88 return True
89
90 mmap_region = MagicMock()
91 mmap_region.cleanup.side_effect = record_region_cleanup
92 worker._store_handler = store_handler
93 worker._load_handler = load_handler
94 worker._mmap_region = mmap_region
95
96 worker.shutdown()
97
98 assert calls == ["store", "load", "region"]
99 store_handler.shutdown.assert_called_once_with()
100 load_handler.shutdown.assert_called_once_with()
101 mmap_region.cleanup.assert_called_once_with()
102 assert worker._mmap_region is None
103
104
105 @pytest.mark.parametrize("failing_handler", ["store", "load"])
106 def test_worker_logs_handler_error_and_cleans_region(
107 caplog_vllm, monkeypatch: pytest.MonkeyPatch, failing_handler
108 ) -> None:
109 worker = CPUOffloadingWorker.__new__(CPUOffloadingWorker)
110 calls: list[str] = []
111 store_handler = MagicMock()
112 load_handler = MagicMock()
113 if failing_handler == "store":
114
115 def fail_store_shutdown() -> bool:
116 calls.append("store")
117 raise RuntimeError("transfer did not drain")
118
119 store_handler.shutdown.side_effect = fail_store_shutdown
120 else:
121
122 def fail_load_shutdown() -> bool:
123 calls.append("load")
124 raise RuntimeError("transfer did not drain")
125
126 load_handler.shutdown.side_effect = fail_load_shutdown
127
128 def record_other_shutdown() -> bool:
129 calls.append("load" if failing_handler == "store" else "store")
130 return True
131
132 if failing_handler == "store":
133 load_handler.shutdown.side_effect = record_other_shutdown
134 else:
135 store_handler.shutdown.side_effect = record_other_shutdown
136
137 def record_device_sync() -> None:
138 calls.append("sync")
139
140 monkeypatch.setattr(torch.accelerator, "synchronize", record_device_sync)
141
142 def record_region_cleanup() -> None:
143 calls.append("region")
144
145 mmap_region = MagicMock()
146 mmap_region.cleanup.side_effect = record_region_cleanup
147 worker._store_handler = store_handler
148 worker._load_handler = load_handler
149 worker._mmap_region = mmap_region
150
151 with caplog_vllm.at_level(
152 logging.ERROR, logger="vllm.v1.kv_offload.cpu.gpu_worker"
153 ):
154 worker.shutdown()
155
156 other = "load" if failing_handler == "store" else "store"
157 getattr(worker, f"_{other}_handler").shutdown.assert_called_once_with()
158 mmap_region.cleanup.assert_called_once_with()
159 assert worker._mmap_region is None
160 assert (
161 f"Failed to shut down {failing_handler} offloading handler" in caplog_vllm.text
162 )
163 assert calls[-2:] == ["sync", "region"]
164
165
166 def test_handler_shutdown_skips_transfers_after_event_sync_failure() -> None:
167 handler = gpu_worker.SingleDirectionOffloadingHandler.__new__(
168 gpu_worker.SingleDirectionOffloadingHandler
169 )
170 failed_event = MagicMock()
171 failed_event.synchronize.side_effect = RuntimeError("device lost")
172 skipped_event = MagicMock()
173 handler._transfers = gpu_worker.deque(
174 [
175 MagicMock(end_event=failed_event),
176 MagicMock(end_event=skipped_event),
177 ]
178 )
179 handler._transfer_events = {1: failed_event, 2: skipped_event}
180 handler._stream_pool = [MagicMock()]
181 handler._event_pool = [MagicMock()]
182 handler._buffer_pool = [(MagicMock(), MagicMock(), MagicMock())]
183 handler.src_tensors = [MagicMock()]
184 handler.dst_tensors = [MagicMock()]
185
186 with pytest.raises(RuntimeError, match="device lost"):
187 handler.shutdown()
188 failed_event.synchronize.assert_called_once_with()
189 skipped_event.synchronize.assert_not_called()
190 assert not handler._transfers
191 assert not handler._transfer_events
192 assert not handler._stream_pool
193 assert not handler._event_pool
194 assert not handler._buffer_pool
195 assert not handler.src_tensors
196 assert not handler.dst_tensors
197
198
199 @pytest.mark.parametrize("device_sync_fails", [False, True])
200 def test_worker_syncs_before_cleanup_after_handler_failure(
201 caplog_vllm, monkeypatch: pytest.MonkeyPatch, device_sync_fails: bool
202 ) -> None:
203 worker = CPUOffloadingWorker.__new__(CPUOffloadingWorker)
204 calls: list[str] = []
205 store_handler = MagicMock()
206
207 def fail_store_shutdown() -> None:
208 raise RuntimeError("device lost")
209
210 store_handler.shutdown.side_effect = fail_store_shutdown
211 load_handler = MagicMock()
212
213 def record_device_sync() -> None:
214 calls.append("sync")
215 if device_sync_fails:
216 raise RuntimeError("device lost")
217
218 def record_region_cleanup() -> None:
219 calls.append("region")
220
221 monkeypatch.setattr(torch.accelerator, "synchronize", record_device_sync)
222 mmap_region = MagicMock()
223 mmap_region.cleanup.side_effect = record_region_cleanup
224 worker._store_handler = store_handler
225 worker._load_handler = load_handler
226 worker._mmap_region = mmap_region
227
228 with caplog_vllm.at_level(
229 logging.WARNING, logger="vllm.v1.kv_offload.cpu.gpu_worker"
230 ):
231 worker.shutdown()
232
233 assert calls == ["sync", "region"]
234 mmap_region.cleanup.assert_called_once_with()
235 if device_sync_fails:
236 assert "Device sync before mmap cleanup failed" in caplog_vllm.text
237
238
239 @pytest.mark.parametrize("gpu_to_cpu", [True, False])
240 @pytest.mark.parametrize("num_mappings", NUM_MAPPINGS)
241 @pytest.mark.parametrize("gpu_page_size_bytes", GPU_PAGE_SIZES)
242 @pytest.mark.parametrize("blocks_per_chunk", BLOCKS_PER_CHUNK_VALUES)
243 @pytest.mark.parametrize("num_gpu_blocks", NUM_GPU_BLOCKS)
244 @pytest.mark.parametrize("num_cpu_chunks", NUM_CPU_CHUNKS)
245 @pytest.mark.parametrize("num_tensors", NUM_TENSORS)
246 @pytest.mark.parametrize("seed", SEEDS)
247 @pytest.mark.parametrize("device", DEVICES)
248 @pytest.mark.parametrize(
249 ("use_shared_memory", "replicated_layout"),
250 [(False, False), (True, False), (True, True)],
251 )
252 @torch.inference_mode()
253 def test_transfer(
254 default_vllm_config,
255 gpu_to_cpu: bool,
256 num_mappings: int,
257 gpu_page_size_bytes: int,
258 blocks_per_chunk: int,
259 num_gpu_blocks: int,
260 num_cpu_chunks: int,
261 num_tensors: int,
262 seed: int,
263 device: str,
264 use_shared_memory: bool,
265 replicated_layout: bool,
266 ) -> None:
267 set_random_seed(seed)
268
269 # build CanonicalKVCacheTensor list: one per tensor
270 kv_cache_tensors: list[CanonicalKVCacheTensor] = []
271 for i in range(num_tensors):
272 gpu_tensor = torch.zeros(
273 (num_gpu_blocks, gpu_page_size_bytes),
274 dtype=torch.int8,
275 device=device,
276 )
277 kv_cache_tensors.append(
278 CanonicalKVCacheTensor(
279 tensor=gpu_tensor,
280 page_size_bytes=gpu_page_size_bytes,
281 )
282 )
283
284 # one group containing all tensors, one data ref per tensor
285 kv_cache_groups_data_refs: list[list[CanonicalKVCacheRef]] = [
286 [
287 CanonicalKVCacheRef(
288 tensor_idx=i,
289 page_size_bytes=gpu_page_size_bytes,
290 )
291 for i in range(num_tensors)
292 ]
293 ]
294
295 kv_caches = CanonicalKVCaches(
296 tensors=kv_cache_tensors,
297 group_data_refs=kv_cache_groups_data_refs,
298 )
299
300 mmap_region: SharedOffloadRegion | None = None
301 if use_shared_memory:
302 cpu_page_size = round_up(
303 gpu_page_size_bytes * num_tensors * blocks_per_chunk,
304 SharedOffloadRegion.BLOCK_SIZE_ALIGNMENT,
305 )
306 simulated_world_size = 2
307 kv_bytes_per_chunk = (
308 cpu_page_size if replicated_layout else cpu_page_size * simulated_world_size
309 )
310 mmap_region = SharedOffloadRegion(
311 engine_id=str(uuid.uuid4()),
312 num_chunks=num_cpu_chunks,
313 rank=0,
314 kv_bytes_per_chunk=kv_bytes_per_chunk,
315 cpu_page_size=cpu_page_size,
316 )
317
318 worker = CPUOffloadingWorker(
319 kv_caches=kv_caches,
320 blocks_per_chunk=blocks_per_chunk,
321 num_cpu_chunks=num_cpu_chunks,
322 mmap_region=mmap_region,
323 )
324
325 # select block mappings
326 gpu_blocks = random.sample(range(num_gpu_blocks), num_mappings * blocks_per_chunk)
327 cpu_chunks = random.sample(range(num_cpu_chunks), num_mappings)
328
329 # expand cpu chunks to gpu-page granularity for uniform comparison:
330 # each cpu chunk maps to blocks_per_chunk consecutive sub-blocks
331 cpu_chunks_expanded = [
332 cpu_chunk * blocks_per_chunk + j
333 for cpu_chunk in cpu_chunks
334 for j in range(blocks_per_chunk)
335 ]
336
337 # maybe skip some GPU blocks to test reading/writing from the middle of a CPU chunk
338 blocks_to_skip = blocks_per_chunk - 1
339 if blocks_to_skip > 0:
340 gpu_blocks = gpu_blocks[blocks_to_skip:]
341 cpu_chunks_expanded = cpu_chunks_expanded[blocks_to_skip:]
342
343 # set transfer direction
344 if gpu_to_cpu:
345 handler = worker._store_handler
346 src_spec = GPULoadStoreSpec(
347 gpu_blocks, group_sizes=(len(gpu_blocks),), block_indices=(blocks_to_skip,)
348 )
349 dst_spec = CPULoadStoreSpec(cpu_chunks)
350 dst_to_src = dict(zip(cpu_chunks_expanded, gpu_blocks))
351 num_dst_sub_blocks = num_gpu_blocks
352 else:
353 handler = worker._load_handler
354 src_spec = CPULoadStoreSpec(cpu_chunks)
355 dst_spec = GPULoadStoreSpec(
356 gpu_blocks, group_sizes=(len(gpu_blocks),), block_indices=(blocks_to_skip,)
357 )
358 dst_to_src = dict(zip(gpu_blocks, cpu_chunks_expanded))
359 num_dst_sub_blocks = num_gpu_blocks
360
361 # randomize src and dst tensors before transfer
362 for tensor in handler.src_tensors:
363 tensor.random_()
364 for tensor in handler.dst_tensors:
365 tensor.random_()
366
367 # clone src and dst tensors before transfer
368 orig_src_tensors = [x.clone() for x in handler.src_tensors]
369 orig_dst_tensors = [x.clone() for x in handler.dst_tensors]
370
371 # call transfer function via public API
372 start_time = time.time()
373 if gpu_to_cpu:
374 assert worker.submit_store(1, src_spec, dst_spec)
375 else:
376 assert worker.submit_load(1, src_spec, dst_spec)
377 assert {x.job_id for x in handler._transfers} == {1}
378
379 # wait for transfer to complete
380 end_time = time.time() + 10
381 while time.time() < end_time:
382 finished = worker.get_finished()
383 if finished:
384 assert finished[0].job_id == 1
385 assert finished[0].success
386 assert finished[0].transfer_size == (
387 len(gpu_blocks)
388 * sum([x.page_size_bytes for x in handler.layer_refs_per_group[0]])
389 )
390 assert finished[0].transfer_time > 0
391 assert finished[0].transfer_time < (time.time() - start_time)
392 break
393 time.sleep(0.1)
394
395 # verify src tensors did not change
396 for orig_tensor, tensor in zip(orig_src_tensors, handler.src_tensors):
397 assert torch.equal(orig_tensor, tensor)
398
399 # verify dst tensors at gpu-page granularity.
400 for src_tensor, dst_tensor, orig_dst_tensor in zip(
401 handler.src_tensors,
402 handler.dst_tensors,
403 orig_dst_tensors,
404 ):
405 # view both GPU and CPU tensors as (n, gpu_page_size_bytes) for comparison.
406 src_view = src_tensor.reshape(-1, gpu_page_size_bytes)
407 dst_view = dst_tensor.reshape(-1, gpu_page_size_bytes)
408 orig_dst_view = orig_dst_tensor.reshape(-1, gpu_page_size_bytes)
409 for dst_sub_block in range(num_dst_sub_blocks):
410 src_sub_block = dst_to_src.get(dst_sub_block)
411 if src_sub_block is not None:
412 expected = src_view[src_sub_block]
413 else:
414 expected = orig_dst_view[dst_sub_block]
415 torch.testing.assert_close(dst_view[dst_sub_block].cpu(), expected.cpu())
416
417 # Drop loop-variable refs so mmap_obj has no exported buffers at cleanup.
418 del orig_tensor, tensor, src_tensor, dst_tensor, orig_dst_tensor
419 del src_view, dst_view, orig_dst_view, expected
420
421 worker.shutdown()
422
423
424 @pytest.mark.parametrize("gpu_to_cpu", [True, False])
425 @pytest.mark.parametrize("num_mappings_per_group", NUM_MAPPINGS_PER_GROUP)
426 @pytest.mark.parametrize("gpu_page_size_bytes", GPU_PAGE_SIZES)
427 @pytest.mark.parametrize("blocks_per_chunk", BLOCKS_PER_CHUNK_VALUES)
428 @pytest.mark.parametrize("num_gpu_blocks", NUM_GPU_BLOCKS)
429 @pytest.mark.parametrize("num_cpu_chunks", NUM_CPU_CHUNKS)
430 @pytest.mark.parametrize("seed", SEEDS)
431 @pytest.mark.parametrize("device", DEVICES)
432 @torch.inference_mode()
433 def test_transfer_multi_group(
434 default_vllm_config,
435 gpu_to_cpu: bool,
436 num_mappings_per_group: int,
437 gpu_page_size_bytes: int,
438 blocks_per_chunk: int,
439 num_gpu_blocks: int,
440 num_cpu_chunks: int,
441 seed: int,
442 device: str,
443 ) -> None:
444 """Test transfers with three KV cache groups:
445 - Group 0: aligned transfer with num_mappings_per_group blocks
446 - Group 1: zero blocks (empty group)
447 - Group 2: unaligned CPU->GPU transfer (logical_offset=blocks_per_chunk-1,
448 causing the implementation to skip source sub-blocks) with
449 num_mappings_per_group blocks
450 """
451 set_random_seed(seed)
452
453 # 3 groups, each with 2 tensors
454 num_groups = 3
455 tensors_per_group = 2
456 num_tensors = num_groups * tensors_per_group
457 kv_cache_tensors: list[CanonicalKVCacheTensor] = []
458 for _ in range(num_tensors):
459 gpu_tensor = torch.zeros(
460 (num_gpu_blocks, gpu_page_size_bytes),
461 dtype=torch.int8,
462 device=device,
463 )
464 kv_cache_tensors.append(
465 CanonicalKVCacheTensor(
466 tensor=gpu_tensor,
467 page_size_bytes=gpu_page_size_bytes,
468 )
469 )
470
471 kv_cache_groups_data_refs: list[list[CanonicalKVCacheRef]] = [
472 [
473 CanonicalKVCacheRef(
474 tensor_idx=g * tensors_per_group + i,
475 page_size_bytes=gpu_page_size_bytes,
476 )
477 for i in range(tensors_per_group)
478 ]
479 for g in range(num_groups)
480 ]
481
482 canonical_kv_caches = CanonicalKVCaches(
483 tensors=kv_cache_tensors, group_data_refs=kv_cache_groups_data_refs
484 )
485
486 worker = CPUOffloadingWorker(
487 kv_caches=canonical_kv_caches,
488 blocks_per_chunk=blocks_per_chunk,
489 num_cpu_chunks=num_cpu_chunks,
490 )
491
492 # group 0: aligned, group 1: empty, group 2: unaligned on CPU->GPU
493 group_sizes_in_cpu_chunks = [num_mappings_per_group, 0, num_mappings_per_group]
494
495 total_cpu_chunks = sum(group_sizes_in_cpu_chunks)
496 total_gpu_blocks_needed = total_cpu_chunks * blocks_per_chunk
497 gpu_blocks_all = random.sample(range(num_gpu_blocks), total_gpu_blocks_needed)
498 cpu_chunks_all = random.sample(range(num_cpu_chunks), total_cpu_chunks)
499
500 # split gpu blocks / cpu chunks per group
501 gpu_blocks_per_group: list[list[int]] = []
502 cpu_chunks_per_group: list[list[int]] = []
503 gpu_offset = 0
504 cpu_offset = 0
505 for size in group_sizes_in_cpu_chunks:
506 gpu_count = size * blocks_per_chunk
507 gpu_blocks_per_group.append(gpu_blocks_all[gpu_offset : gpu_offset + gpu_count])
508 cpu_chunks_per_group.append(cpu_chunks_all[cpu_offset : cpu_offset + size])
509 gpu_offset += gpu_count
510 cpu_offset += size
511
512 # expand cpu chunks to gpu-page granularity
513 cpu_chunks_expanded_per_group = [
514 [
515 cpu_chunk * blocks_per_chunk + j
516 for cpu_chunk in cpu_chunks
517 for j in range(blocks_per_chunk)
518 ]
519 for cpu_chunks in cpu_chunks_per_group
520 ]
521
522 # skip sub-blocks from group 2 to test unaligned transfers.
523 sub_blocks_to_skip = blocks_per_chunk - 1 # e.g. 2 when blocks_per_chunk=3
524 if sub_blocks_to_skip > 0:
525 gpu_blocks_per_group[2] = gpu_blocks_per_group[2][
526 sub_blocks_to_skip:-sub_blocks_to_skip
527 ]
528 cpu_chunks_expanded_per_group[2] = cpu_chunks_expanded_per_group[2][
529 sub_blocks_to_skip:-sub_blocks_to_skip
530 ]
531
532 # build flat gpu_blocks list and group_sizes in GPU blocks
533 gpu_blocks: list[int] = []
534 group_sizes: list[int] = []
535 for gpu_blks in gpu_blocks_per_group:
536 gpu_blocks.extend(gpu_blks)
537 group_sizes.append(len(gpu_blks))
538
539 # build flat cpu_chunks list
540 cpu_chunks = []
541 for cpu_chnks in cpu_chunks_per_group:
542 cpu_chunks.extend(cpu_chnks)
543
544 # block_indices: only relevant for unaligned transfers
545 block_indices: list[int] = [0, 0, sub_blocks_to_skip]
546
547 if gpu_to_cpu:
548 handler = worker._store_handler
549 src_spec = GPULoadStoreSpec(
550 gpu_blocks, group_sizes=group_sizes, block_indices=block_indices
551 )
552 dst_spec = CPULoadStoreSpec(cpu_chunks)
553 # per-group mapping: cpu sub-block -> gpu sub-block
554 dst_to_src_per_group = [
555 dict(zip(expanded, gpu_blks))
556 for expanded, gpu_blks in zip(
557 cpu_chunks_expanded_per_group, gpu_blocks_per_group
558 )
559 ]
560 num_dst_sub_blocks = num_cpu_chunks * blocks_per_chunk
561 else:
562 handler = worker._load_handler
563 src_spec = CPULoadStoreSpec(cpu_chunks)
564 dst_spec = GPULoadStoreSpec(
565 gpu_blocks, group_sizes=group_sizes, block_indices=block_indices
566 )
567 # per-group mapping: gpu sub-block -> cpu sub-block
568 dst_to_src_per_group = [
569 dict(zip(gpu_blks, expanded))
570 for gpu_blks, expanded in zip(
571 gpu_blocks_per_group, cpu_chunks_expanded_per_group
572 )
573 ]
574 num_dst_sub_blocks = num_gpu_blocks
575
576 # randomize src and dst tensors before transfer
577 for tensor in handler.src_tensors:
578 tensor.random_()
579 for tensor in handler.dst_tensors:
580 tensor.random_()
581
582 orig_src_tensors = [x.clone() for x in handler.src_tensors]
583 orig_dst_tensors = [x.clone() for x in handler.dst_tensors]
584
585 if gpu_to_cpu:
586 assert worker.submit_store(1, src_spec, dst_spec)
587 else:
588 assert worker.submit_load(1, src_spec, dst_spec)
589 assert {x.job_id for x in handler._transfers} == {1}
590
591 end_time = time.time() + 10
592 while time.time() < end_time:
593 finished = worker.get_finished()
594 if finished:
595 assert finished[0].job_id == 1
596 assert finished[0].success
597 expected_bytes = sum(
598 group_size * sum([x.page_size_bytes for x in data_refs])
599 for group_size, data_refs in zip(
600 group_sizes, handler.layer_refs_per_group
601 )
602 )
603 assert finished[0].transfer_size == expected_bytes
604 break
605 time.sleep(0.1)
606
607 # verify src tensors did not change
608 for orig_tensor, tensor in zip(orig_src_tensors, handler.src_tensors):
609 assert torch.equal(orig_tensor, tensor)
610
611 # verify dst tensors at gpu-page granularity
612 for group_idx, dst_to_src in enumerate(dst_to_src_per_group):
613 group_tensor_offset = group_idx * tensors_per_group
614 for tensor_idx in range(tensors_per_group):
615 src_tensor = handler.src_tensors[group_tensor_offset + tensor_idx]
616 dst_tensor = handler.dst_tensors[group_tensor_offset + tensor_idx]
617 orig_dst_tensor = orig_dst_tensors[group_tensor_offset + tensor_idx]
618 src_view = src_tensor.view(-1, gpu_page_size_bytes)
619 dst_view = dst_tensor.view(-1, gpu_page_size_bytes)
620 orig_dst_view = orig_dst_tensor.view(-1, gpu_page_size_bytes)
621 for dst_sub_block in range(num_dst_sub_blocks):
622 src_sub_block = dst_to_src.get(dst_sub_block)
623 if src_sub_block is not None:
624 expected = src_view[src_sub_block]
625 else:
626 expected = orig_dst_view[dst_sub_block]
627 torch.testing.assert_close(
628 dst_view[dst_sub_block].cpu(), expected.cpu()
629 )
630
631 worker.shutdown()
632
633
634 @pytest.mark.skipif(
635 not current_platform.is_cuda_alike(),
636 reason="stream ordering test requires a CUDA-like platform",
637 )
638 @torch.inference_mode()
639 def test_load_waits_for_pending_compute_stream_writes(default_vllm_config) -> None:
640 """A CPU load must land after pending writes to its GPU destination."""
641 device = DEVICES[0]
642 page_size_bytes = 128 * 1024
643 num_blocks = 64
644 loaded_blocks = list(range(32))
645 sentinel = 0x5A
646
647 gpu_tensor = torch.zeros(
648 (num_blocks, page_size_bytes), dtype=torch.int8, device=device
649 )
650 loaded_block_ids = torch.tensor(loaded_blocks, dtype=torch.long, device=device)
651 worker = CPUOffloadingWorker(
652 kv_caches=CanonicalKVCaches(
653 tensors=[
654 CanonicalKVCacheTensor(
655 tensor=gpu_tensor, page_size_bytes=page_size_bytes
656 )
657 ],
658 group_data_refs=[
659 [CanonicalKVCacheRef(tensor_idx=0, page_size_bytes=page_size_bytes)]
660 ],
661 ),
662 blocks_per_chunk=1,
663 num_cpu_chunks=num_blocks,
664 )
665 worker._load_handler.src_tensors[0].fill_(sentinel)
666 expected = torch.full((page_size_bytes,), sentinel, dtype=torch.int8)
667
668 try:
669 for trial in range(3):
670 gpu_tensor.fill_(0x11)
671 torch.accelerator.synchronize()
672
673 # Model a delayed zero of a freshly allocated KV block. Without a
674 # compute-stream dependency, the DMA can finish during the sleep
675 # and this later fill wipes out the loaded cache contents.
676 torch.cuda._sleep(50_000_000)
677 gpu_tensor.index_fill_(0, loaded_block_ids, 0)
678
679 assert worker.submit_load(
680 trial + 1,
681 CPULoadStoreSpec(loaded_blocks),
682 GPULoadStoreSpec(
683 loaded_blocks,
684 group_sizes=(len(loaded_blocks),),
685 block_indices=(0,),
686 ),
687 )
688 deadline = time.time() + 10
689 finished: list[TransferResult] = []
690 while time.time() < deadline and not finished:
691 finished = worker.get_finished()
692 if not finished:
693 time.sleep(0.001)
694 assert finished and finished[0].success, f"load {trial} did not finish"
695
696 torch.accelerator.synchronize()
697 for block_id in loaded_blocks:
698 torch.testing.assert_close(gpu_tensor[block_id].cpu(), expected)
699 finally:
700 worker.shutdown()