184 lines
6.0 KiB
Python
184 lines
6.0 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""Worker-side unit tests for SimpleCPUOffloadConnector.
|
|
|
|
Covers the GPU->CPU store cross-stream synchronization: the store copy must be
|
|
ordered after the compute stream that writes the KV blocks, otherwise it can
|
|
read partially written / stale blocks and silently corrupt the CPU cache.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
from vllm.platforms import current_platform
|
|
|
|
if not current_platform.is_cuda_alike():
|
|
pytest.skip("Requires CUDA or ROCm", allow_module_level=True)
|
|
|
|
from vllm.v1.simple_kv_offload.copy_backend import DmaCopyBackend
|
|
from vllm.v1.simple_kv_offload.cuda_mem_ops import (
|
|
CU_MEMCPY_SRC_ACCESS_ORDER_ANY,
|
|
CU_MEMCPY_SRC_ACCESS_ORDER_STREAM,
|
|
build_params,
|
|
pin_tensor,
|
|
)
|
|
from vllm.v1.simple_kv_offload.metadata import SimpleCPUOffloadMetadata
|
|
from vllm.v1.simple_kv_offload.worker import SimpleCPUOffloadWorker
|
|
|
|
NUM_BLOCKS = 64
|
|
BLOCK_BYTES = 4096
|
|
ITERS = 30
|
|
# Keep the compute stream busy so the KV write lands late; this makes the
|
|
# store-vs-compute race deterministic instead of timing-dependent.
|
|
SLEEP_CYCLES = 50_000_000
|
|
|
|
|
|
def _make_backend() -> tuple[DmaCopyBackend, torch.Tensor, torch.Tensor]:
|
|
gpu = {"k": torch.zeros((NUM_BLOCKS, BLOCK_BYTES), dtype=torch.int8, device="cuda")}
|
|
cpu = {"k": torch.zeros((NUM_BLOCKS, BLOCK_BYTES), dtype=torch.int8, device="cpu")}
|
|
pin_tensor(cpu["k"])
|
|
low_pri, _ = torch.cuda.Stream.priority_range()
|
|
backend = DmaCopyBackend()
|
|
backend.init(
|
|
gpu,
|
|
cpu,
|
|
gpu["k"].device,
|
|
torch.cuda.Stream(priority=low_pri),
|
|
torch.cuda.Stream(priority=low_pri),
|
|
)
|
|
return backend, gpu["k"], cpu["k"]
|
|
|
|
|
|
def _drive_store(
|
|
backend: DmaCopyBackend,
|
|
gpu: torch.Tensor,
|
|
cpu: torch.Tensor,
|
|
*,
|
|
with_barrier: bool,
|
|
) -> int:
|
|
"""Run ITERS store cycles; return how many landed corrupted in the CPU pool.
|
|
|
|
Each cycle writes a unique value on a compute stream (after a deliberate
|
|
delay) and then issues the GPU->CPU store. The store is issued *after* the
|
|
write in host program order, mirroring the connector's deferred-store
|
|
assumption. Only the compute-done event creates a real device-side
|
|
happens-before edge.
|
|
"""
|
|
block_ids = list(range(gpu.shape[0]))
|
|
compute_stream = torch.cuda.Stream()
|
|
corrupt = 0
|
|
for it in range(ITERS):
|
|
val = (it % 126) + 1 # 1..126; distinct from the zero-initialized pool
|
|
with torch.cuda.stream(compute_stream):
|
|
torch.cuda._sleep(SLEEP_CYCLES)
|
|
gpu.fill_(val)
|
|
|
|
wait_event = None
|
|
if with_barrier:
|
|
wait_event = torch.Event()
|
|
wait_event.record(compute_stream)
|
|
|
|
store_events: list[tuple[int, torch.Event]] = []
|
|
backend.launch_copy(
|
|
block_ids,
|
|
block_ids,
|
|
is_store=True,
|
|
event_idx=it,
|
|
events_list=store_events,
|
|
wait_event=wait_event,
|
|
)
|
|
|
|
deadline = time.time() + 10.0
|
|
while not store_events and time.time() < deadline:
|
|
time.sleep(0.0005)
|
|
assert store_events, "background copy was never enqueued"
|
|
store_events[0][1].synchronize()
|
|
|
|
if int((cpu[:, 0].to(torch.int32) != val).sum().item()):
|
|
corrupt += 1
|
|
return corrupt
|
|
|
|
|
|
def test_store_orders_after_compute_write():
|
|
"""The store must wait for the compute event; without it, it races.
|
|
|
|
Asserts both directions so the test is self-validating: the no-barrier
|
|
control must actually corrupt (proving the race window is exercised), and
|
|
the fixed path with the compute-done event must be clean.
|
|
"""
|
|
backend, gpu, cpu = _make_backend()
|
|
try:
|
|
control = _drive_store(backend, gpu, cpu, with_barrier=False)
|
|
fixed = _drive_store(backend, gpu, cpu, with_barrier=True)
|
|
finally:
|
|
backend.shutdown()
|
|
|
|
assert control > 0, (
|
|
"no-barrier store did not race the compute write; the test no longer "
|
|
"exercises the hazard it is meant to guard"
|
|
)
|
|
assert fixed == 0, f"store raced compute even with the barrier: {fixed} corrupt"
|
|
|
|
|
|
class _RecordingBackend:
|
|
"""Captures launch_copy calls without touching the GPU."""
|
|
|
|
def __init__(self) -> None:
|
|
self.calls: list[dict] = []
|
|
|
|
def launch_copy(
|
|
self,
|
|
src_blocks,
|
|
dst_blocks,
|
|
is_store,
|
|
event_idx,
|
|
events_list,
|
|
wait_event=None,
|
|
) -> None:
|
|
self.calls.append({"is_store": is_store, "wait_event": wait_event})
|
|
|
|
|
|
def test_get_finished_passes_wait_event_for_store_only():
|
|
"""get_finished gates stores on a compute-done event but not loads."""
|
|
worker = SimpleCPUOffloadWorker(
|
|
vllm_config=None, kv_cache_config=None, cpu_capacity_bytes=0
|
|
)
|
|
recording = _RecordingBackend()
|
|
worker._backend = recording
|
|
worker._connector_metadata = SimpleCPUOffloadMetadata(
|
|
load_event=0,
|
|
load_gpu_blocks=[0],
|
|
load_cpu_blocks=[0],
|
|
store_event=1,
|
|
store_gpu_blocks=[1],
|
|
store_cpu_blocks=[1],
|
|
)
|
|
|
|
worker.get_finished(set())
|
|
|
|
store_calls = [c for c in recording.calls if c["is_store"]]
|
|
load_calls = [c for c in recording.calls if not c["is_store"]]
|
|
assert len(store_calls) == 1
|
|
assert len(load_calls) == 1
|
|
assert isinstance(store_calls[0]["wait_event"], torch.Event)
|
|
assert load_calls[0]["wait_event"] is None
|
|
|
|
|
|
def test_build_params_src_access_order():
|
|
"""build_params defaults to ANY and honors an explicit STREAM override."""
|
|
gpu = {"k": torch.zeros((4, 64), dtype=torch.int8, device="cuda")}
|
|
cpu = {"k": torch.zeros((4, 64), dtype=torch.int8, device="cpu")}
|
|
stream = torch.cuda.Stream()
|
|
|
|
default = build_params(gpu, cpu, stream)
|
|
assert default.attrs.srcAccessOrder == CU_MEMCPY_SRC_ACCESS_ORDER_ANY
|
|
|
|
ordered = build_params(
|
|
gpu, cpu, stream, src_access_order=CU_MEMCPY_SRC_ACCESS_ORDER_STREAM
|
|
)
|
|
assert ordered.attrs.srcAccessOrder == CU_MEMCPY_SRC_ACCESS_ORDER_STREAM
|