Files
vllm-project--vllm/tests/v1/simple_kv_offload/test_worker.py
T
wehub-resource-sync 7ce4c8e27e
pre-commit / pre-run-check (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:55:37 +08:00

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