Files
lmcache--lmcache/tests/v1/multiprocess/test_cache_server.py
T
2026-07-13 12:24:33 +08:00

754 lines
23 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Standard
from typing import Generator
import multiprocessing as mp
import os
import time
# Third Party
import pytest
import torch
import zmq
# First Party
from lmcache.utils import EngineType
from lmcache.v1.distributed.config import (
EvictionConfig,
L1ManagerConfig,
L1MemoryManagerConfig,
StorageManagerConfig,
)
from lmcache.v1.mp_observability.config import DEFAULT_OBSERVABILITY_CONFIG
from lmcache.v1.multiprocess.config import MPServerConfig
from lmcache.v1.multiprocess.custom_types import (
IPCCacheServerKey,
KVCache,
)
from lmcache.v1.multiprocess.mq import MessageQueueClient
from lmcache.v1.multiprocess.protocol import (
RequestType,
get_response_class,
)
from lmcache.v1.multiprocess.server import run_cache_server
from lmcache.v1.platform.cuda.ipc_wrapper import CudaIPCWrapper
# Configuration constants
SERVER_HOST = "localhost"
SERVER_PORT = 5599
SERVER_URL = f"tcp://{SERVER_HOST}:{SERVER_PORT}"
CHUNK_SIZE = 256
CPU_BUFFER_SIZE = 5.0
DEFAULT_TIMEOUT = 20.0
def _has_working_new_shared_cuda() -> bool:
if not torch.cuda.is_available():
print("CUDA is not available, skipping tests that require new_shared_cuda")
return False
try:
# Minimal sanity check — adapt to your real API
buf = torch.empty(1024, device="cuda")
shared = buf.untyped_storage()._share_cuda_() # or your exact call
return shared is not None
except Exception:
return False
if not _has_working_new_shared_cuda():
pytest.skip(
"new_shared_cuda is not available or not working on this system",
allow_module_level=True,
)
def initialize_kv_cache(
device: torch.device,
num_pages: int = 1024,
num_layers: int = 32,
page_size: int = 16,
num_heads: int = 8,
head_size: int = 128,
dtype: torch.dtype = torch.bfloat16,
) -> list[torch.Tensor]:
"""
Initialize KV cache tensors on GPU for testing.
"""
torch.random.manual_seed(42)
gpu_tensors = [
torch.rand(
(2, num_pages, page_size, num_heads, head_size),
dtype=dtype,
device=device,
)
for _ in range(num_layers)
]
return gpu_tensors
class ClientContext:
"""
Client context that manages GPU KV cache tensors.
"""
def __init__(
self,
device: torch.device,
num_pages: int = 1024,
num_layers: int = 32,
page_size: int = 16,
num_heads: int = 8,
head_size: int = 128,
dtype: torch.dtype = torch.bfloat16,
):
self.device = device
self.num_pages = num_pages
self.num_layers = num_layers
self.page_size = page_size
self.num_heads = num_heads
self.head_size = head_size
self.dtype = dtype
self.gpu_kv_caches = initialize_kv_cache(
device, num_pages, num_layers, page_size, num_heads, head_size, dtype
)
def get_kv_cache(self) -> KVCache:
"""
Wrap GPU tensors in CudaIPCWrapper for IPC communication.
"""
return [CudaIPCWrapper(tensor) for tensor in self.gpu_kv_caches]
def get_tensor_slice(
self, layer: int, start_page: int, num_pages: int
) -> torch.Tensor:
"""
Get a slice of the KV cache tensor for a specific layer.
"""
return self.gpu_kv_caches[layer][:, start_page : start_page + num_pages]
def create_cache_key(index: int, model: str = "testmodel") -> IPCCacheServerKey:
"""
Create a cache key for testing.
"""
global CHUNK_SIZE
token_ids = [index] * CHUNK_SIZE
return IPCCacheServerKey.from_token_ids(
model,
1,
0,
token_ids,
start=0,
end=CHUNK_SIZE,
request_id=f"test_request_{index}",
)
BLOCKS_PER_KEY = 16
def lookup_all(
client: MessageQueueClient,
keys: list[IPCCacheServerKey],
timeout: float = DEFAULT_TIMEOUT,
) -> int:
"""Lookup all keys individually and return total found count.
Uses the two-phase lookup protocol: LOOKUP registers the job server-side,
then QUERY_PREFETCH_STATUS is polled by request_id until the result is ready.
"""
total = 0
for key in keys:
lookup_key = key.no_worker_id_version()
# Phase 1: Submit lookup (server tracks by request_id, returns None)
client.submit_request(
RequestType.LOOKUP,
[lookup_key, 1],
get_response_class(RequestType.LOOKUP),
).result(timeout=timeout)
# Phase 2: Poll by request_id until done
while True:
result = client.submit_request(
RequestType.QUERY_PREFETCH_STATUS,
[lookup_key.request_id],
get_response_class(RequestType.QUERY_PREFETCH_STATUS),
).result(timeout=timeout)
if result is not None:
total += result
break
return total
def store_keys(
client: MessageQueueClient,
keys: list[IPCCacheServerKey],
instance_id: int,
gpu_block_ids: list[int],
event: torch.cuda.Event,
timeout: float = DEFAULT_TIMEOUT,
) -> None:
"""Store keys one at a time using the single-key API."""
for i, key in enumerate(keys):
start = i * BLOCKS_PER_KEY
end = start + BLOCKS_PER_KEY
block_ids = gpu_block_ids[start:end]
future = client.submit_request(
RequestType.STORE,
[key, instance_id, [block_ids], event.ipc_handle()],
get_response_class(RequestType.STORE),
)
result = future.to_cuda_future().result(timeout=timeout)
assert result is True, f"Store should succeed for key {i}"
def retrieve_keys(
client: MessageQueueClient,
keys: list[IPCCacheServerKey],
instance_id: int,
gpu_block_ids: list[int],
event: torch.cuda.Event,
timeout: float = DEFAULT_TIMEOUT,
) -> list[bool]:
"""Retrieve keys one at a time using the single-key API."""
results = []
for i, key in enumerate(keys):
start = i * BLOCKS_PER_KEY
end = start + BLOCKS_PER_KEY
block_ids = gpu_block_ids[start:end]
future = client.submit_request(
RequestType.RETRIEVE,
[key, instance_id, [block_ids], event.ipc_handle(), 0],
get_response_class(RequestType.RETRIEVE),
)
result = future.to_cuda_future().result(timeout=timeout)
results.append(result)
return results
def server_process_runner(
host: str, port: int, chunk_size: int, cpu_buffer_size: float
):
"""
Entry point for the server process.
"""
mp_config = MPServerConfig(host=host, port=port, chunk_size=chunk_size)
storage_manager_config = StorageManagerConfig(
l1_manager_config=L1ManagerConfig(
memory_config=L1MemoryManagerConfig(
size_in_bytes=int(cpu_buffer_size * 1024**3),
use_lazy=True,
),
),
eviction_config=EvictionConfig(eviction_policy="LRU"),
)
run_cache_server(
mp_config=mp_config,
storage_manager_config=storage_manager_config,
obs_config=DEFAULT_OBSERVABILITY_CONFIG,
)
@pytest.fixture(scope="module")
def server_process() -> Generator[mp.Process, None, None]:
"""
Fixture that starts the cache server in a separate process.
The server runs for the entire test module.
"""
# Start server process
mp.set_start_method("spawn", force=True)
process = mp.Process(
target=server_process_runner,
args=(SERVER_HOST, SERVER_PORT, CHUNK_SIZE, CPU_BUFFER_SIZE),
daemon=True,
)
process.start()
# Wait for server to initialize
time.sleep(2)
yield process
# Cleanup: terminate the server process
if process.is_alive():
process.terminate()
process.join(timeout=5)
if process.is_alive():
process.kill()
process.join()
@pytest.fixture(scope="module")
def zmq_context() -> Generator[zmq.Context, None, None]:
"""
Fixture that provides a ZMQ context for the test module.
"""
context = zmq.Context.instance()
yield context
# Context cleanup is handled by ZMQ
@pytest.fixture(scope="function")
def client(
server_process: mp.Process, zmq_context: zmq.Context
) -> Generator[MessageQueueClient, None, None]:
"""
Fixture that provides a message queue client for each test function.
"""
client = MessageQueueClient(server_url=SERVER_URL, context=zmq_context)
yield client
# Client cleanup
client.close()
@pytest.fixture(scope="function")
def client_context() -> Generator[ClientContext, None, None]:
"""
Fixture that provides a client context with initialized KV cache.
"""
if not torch.cuda.is_available():
pytest.skip("CUDA is not available")
device = torch.device("cuda:0")
ctx = ClientContext(device=device)
yield ctx
# Cleanup GPU memory
del ctx.gpu_kv_caches
torch.cuda.empty_cache()
@pytest.fixture(scope="function")
def registered_instance(
client: MessageQueueClient, client_context: ClientContext
) -> Generator[int, None, None]:
"""
Fixture that registers a KV cache instance and returns the instance ID.
Automatically unregisters after the test.
"""
instance_id = os.getpid()
# Register KV cache. No engine group infos are sent, so the server
# detects ``slots_per_block`` from the tensors and treats every group
# as uncompressed (``compress_ratio == 1``).
future = client.submit_request(
RequestType.REGISTER_KV_CACHE,
[
instance_id,
client_context.get_kv_cache(),
"testmodel",
1,
EngineType.VLLM,
{},
[],
],
get_response_class(RequestType.REGISTER_KV_CACHE),
)
result = future.result(timeout=DEFAULT_TIMEOUT)
assert result is None, "Register should return None"
yield instance_id
# Unregister KV cache
try:
client.submit_request(
RequestType.CLEAR, [], get_response_class(RequestType.CLEAR)
).result(timeout=DEFAULT_TIMEOUT)
future = client.submit_request(
RequestType.UNREGISTER_KV_CACHE,
[instance_id],
get_response_class(RequestType.UNREGISTER_KV_CACHE),
)
future.result(timeout=DEFAULT_TIMEOUT)
except Exception as e:
print(f"Error during unregister: {e}")
# ============================================================================
# Test Functions
# ============================================================================
def test_server_running(server_process: mp.Process):
"""
Test that the server process is running.
"""
assert server_process.is_alive(), "Server process should be running"
@pytest.mark.skipif(
not torch.cuda.is_available(),
reason="Register/Unregister KV cache requires CUDA",
)
def test_register_unregister_kv_cache(
client: MessageQueueClient, client_context: ClientContext
):
"""
Test registering and unregistering a KV cache.
"""
instance_id = os.getpid()
# Register. No engine group infos: geometry is detected from the
# tensors (uncompressed).
future = client.submit_request(
RequestType.REGISTER_KV_CACHE,
[
instance_id,
client_context.get_kv_cache(),
"testmodel",
1,
EngineType.VLLM,
{},
[],
],
get_response_class(RequestType.REGISTER_KV_CACHE),
)
result = future.result(timeout=DEFAULT_TIMEOUT)
assert result is None
# Unregister
future = client.submit_request(
RequestType.UNREGISTER_KV_CACHE,
[instance_id],
get_response_class(RequestType.UNREGISTER_KV_CACHE),
)
result = future.result(timeout=DEFAULT_TIMEOUT)
assert result is None
@pytest.mark.skipif(
not torch.cuda.is_available(),
reason="Store and Lookup require CUDA",
)
def test_store_and_lookup(
client: MessageQueueClient,
client_context: ClientContext,
registered_instance: int,
):
"""
Test storing KV cache entries and looking them up.
"""
num_keys = 10
keys = [create_cache_key(i) for i in range(num_keys)]
gpu_block_ids = list(range(0, 16 * num_keys))
event = torch.cuda.Event(interprocess=True)
event.record()
# Store
store_keys(client, keys, registered_instance, gpu_block_ids, event)
# Lookup - keys that exist
lookup_result = lookup_all(client, keys)
assert lookup_result == num_keys, "All stored keys should exist"
# Lookup - keys that don't exist
non_existent_keys = [create_cache_key(i + 1000) for i in range(5)]
lookup_result2 = lookup_all(client, non_existent_keys)
assert lookup_result2 == 0, "Non-existent keys should not be found"
@pytest.mark.skipif(
not torch.cuda.is_available(),
reason="Store requires CUDA",
)
def test_store_fails_closed_on_incomplete_block_ids(
client: MessageQueueClient,
client_context: ClientContext,
registered_instance: int,
):
"""An under-length block-id list skips the whole store (fail-closed).
Regression guard for the all-or-nothing store contract: a ``gpu_block_ids``
list too short to fully cover a chunk (e.g. a caller/protocol bug) must skip
the store entirely (returning ``False``) and commit nothing — the previous
fail-open path raised internally but then ``finish_write``-committed the
reservation anyway, turning the key into a retrievable garbage entry (lookup
would find it).
The committed-state assertion is on a *miss* (lookup == 0), which is robust
to this harness's known store->lookup race (that race can only turn a true
hit into a miss, never the reverse).
"""
# One-chunk key (256 tokens == BLOCKS_PER_KEY blocks) but only half the
# block IDs needed, so the chunk is not fully covered.
key = create_cache_key(90001)
event = torch.cuda.Event(interprocess=True)
event.record()
result = (
client.submit_request(
RequestType.STORE,
[
key,
registered_instance,
[list(range(BLOCKS_PER_KEY // 2))],
event.ipc_handle(),
],
get_response_class(RequestType.STORE),
)
.to_cuda_future()
.result(timeout=DEFAULT_TIMEOUT)
)
assert result is False, "Store should fail closed (skip) on a short list"
assert lookup_all(client, [key]) == 0, "An uncovered chunk must not be committed"
@pytest.mark.skipif(
not torch.cuda.is_available(),
reason="Store, Retrieve, and Verify require CUDA",
)
def test_store_retrieve_verify(
client: MessageQueueClient,
client_context: ClientContext,
registered_instance: int,
):
"""
Test storing and retrieving KV cache entries, verifying correctness.
"""
num_keys = 20
keys = [create_cache_key(i) for i in range(num_keys)]
event = torch.cuda.Event(interprocess=True)
event.record()
# Store at the beginning of the cache
store_block_ids = list(range(0, 16 * num_keys))
store_keys(client, keys, registered_instance, store_block_ids, event)
event = torch.cuda.Event(interprocess=True)
event.record()
# Call look up to ensure the data is ready to be retrieved
lookup_result = lookup_all(client, keys)
assert lookup_result == num_keys
# Retrieve to a different location in the cache
# Use offset of 40 blocks (640 pages total needed: 320 + 320)
retrieve_offset = 40 * 16
retrieve_block_ids = list(range(retrieve_offset, retrieve_offset + 16 * num_keys))
retrieve_result = retrieve_keys(
client, keys, registered_instance, retrieve_block_ids, event
)
assert len(retrieve_result) == num_keys
assert all(retrieve_result), "All keys should be retrieved successfully"
# Verify correctness by comparing tensors
for i in range(num_keys):
for layer in range(client_context.num_layers):
original_block = i * 16
retrieved_block = retrieve_offset + i * 16
original_tensor = client_context.gpu_kv_caches[layer][
:, original_block : original_block + 16
]
retrieved_tensor = client_context.gpu_kv_caches[layer][
:, retrieved_block : retrieved_block + 16
]
assert torch.allclose(original_tensor, retrieved_tensor, atol=1e-4), (
f"Mismatch for key {i}, layer {layer}"
)
@pytest.mark.skipif(
not torch.cuda.is_available(),
reason="Partial miss retrieval requires CUDA",
)
def test_retrieve_partial_miss(
client: MessageQueueClient,
client_context: ClientContext,
registered_instance: int,
):
"""
Test retrieving when some keys exist and some don't.
The retrieve should return ALL FALSE if any key is missing.
"""
# Store first 30 keys (480 pages)
num_stored = 30
stored_keys = [create_cache_key(i) for i in range(num_stored)]
store_block_ids = list(range(0, 16 * num_stored))
event = torch.cuda.Event(interprocess=True)
event.record()
store_keys(client, stored_keys, registered_instance, store_block_ids, event)
# Lookup to ensure keys are stored
lookup_result = lookup_all(client, stored_keys)
assert lookup_result == num_stored
# Try to retrieve 60 keys (only first 30 exist)
# Total pages needed: 60 * 16 = 960 (< 1024)
num_requested = 60
all_keys = [create_cache_key(i) for i in range(num_requested)]
# Start retrieve at offset 2 keys (32 pages)
retrieve_offset_keys = 2
retrieve_block_ids = list(
range(retrieve_offset_keys * 16, (retrieve_offset_keys + num_requested) * 16)
)
event = torch.cuda.Event(interprocess=True)
event.record()
retrieve_result = retrieve_keys(
client, all_keys, registered_instance, retrieve_block_ids, event
)
assert len(retrieve_result) == num_requested
# First 30 keys exist, remaining 30 don't
assert all(retrieve_result[:num_stored]), "Stored keys should be retrieved"
assert not any(retrieve_result[num_stored:]), (
"Non-existent keys should fail to retrieve"
)
# Doing look up again to ensure data is ready
lookup_result_2 = lookup_all(client, stored_keys)
assert lookup_result_2 == num_stored
# Try to retrieve the first 30 keys only (all exist)
retrieve_block_ids_2 = list(range(0, 16 * num_stored))
event = torch.cuda.Event(interprocess=True)
event.record()
retrieve_result_2 = retrieve_keys(
client, stored_keys, registered_instance, retrieve_block_ids_2, event
)
assert len(retrieve_result_2) == num_stored
assert all(retrieve_result_2), "All stored keys should be retrieved successfully"
@pytest.mark.skipif(
not torch.cuda.is_available(),
reason="Multiple retrieve operations require CUDA",
)
def test_multiple_retrieve_operations(
client: MessageQueueClient,
client_context: ClientContext,
registered_instance: int,
):
"""
Test multiple retrieve operations:
Store 8,8,8,8 keys and then retrieve 8,8,8,8 keys in sequence.
"""
num_batches = 4
keys_per_batch = 8
pages_per_key = 16
# Initialize the values in GPU KV cache
for layer in range(client_context.num_layers):
layer_cache = client_context.gpu_kv_caches[layer]
for i in range(num_batches):
start_page = (i * keys_per_batch) * pages_per_key
end_page = start_page + (keys_per_batch * pages_per_key)
layer_cache[:, start_page:end_page] = (i + 1) / num_batches
# Store in batches
for batch_idx in range(num_batches):
keys = [
create_cache_key(batch_idx * keys_per_batch + i)
for i in range(keys_per_batch)
]
blocks = list(
range(
(batch_idx * keys_per_batch) * 16,
(batch_idx * keys_per_batch + keys_per_batch) * 16,
)
)
event = torch.cuda.Event(interprocess=True)
event.record()
store_keys(client, keys, registered_instance, blocks, event)
# Doing look up to ensure data is ready to be retrieved
all_keys = [
create_cache_key(batch_idx * keys_per_batch + i)
for batch_idx in range(num_batches)
for i in range(keys_per_batch)
]
lookup_result = lookup_all(client, all_keys)
assert lookup_result == num_batches * keys_per_batch, "All stored keys should exist"
# Retrieve in batches
retrieve_offset = 32 # Start retrieving at offset of 32 chunks
event = torch.cuda.Event(interprocess=True)
event.record()
for batch_idx in range(num_batches):
keys = [
create_cache_key(batch_idx * keys_per_batch + i)
for i in range(keys_per_batch)
]
blocks = list(
range(
(batch_idx * keys_per_batch + retrieve_offset) * pages_per_key,
(batch_idx * keys_per_batch + retrieve_offset + keys_per_batch)
* pages_per_key,
)
)
retrieve_result = retrieve_keys(
client, keys, registered_instance, blocks, event
)
assert len(retrieve_result) == keys_per_batch
assert all(retrieve_result), "All keys should be retrieved successfully"
# Verify correctness
for layer in range(client_context.num_layers):
layer_cache = client_context.gpu_kv_caches[layer]
for batch_idx in range(num_batches):
start_page = (retrieve_offset + batch_idx * keys_per_batch) * pages_per_key
end_page = start_page + (keys_per_batch * pages_per_key)
retrieved_tensor = layer_cache[:, start_page:end_page]
expected_value = (batch_idx + 1) / num_batches
assert torch.allclose(
retrieved_tensor,
torch.full_like(retrieved_tensor, expected_value),
), f"Mismatch in batch {batch_idx}, layer {layer}"
@pytest.mark.skipif(
not torch.cuda.is_available(),
reason="Multiple store operations require CUDA",
)
def test_multiple_store_operations(
client: MessageQueueClient,
client_context: ClientContext,
registered_instance: int,
):
"""
Test multiple store operations in sequence.
"""
# Store batch 1
keys1 = [create_cache_key(i) for i in range(30)]
blocks1 = list(range(0, 16 * 30))
event = torch.cuda.Event(interprocess=True)
event.record()
store_keys(client, keys1, registered_instance, blocks1, event)
# Store batch 2
keys2 = [create_cache_key(i + 30) for i in range(20)]
blocks2 = list(range(30 * 16, 50 * 16))
# Test with the same event for 2 store requests
store_keys(client, keys2, registered_instance, blocks2, event)
# Verify all keys exist
all_keys = keys1 + keys2
lookup_result = lookup_all(client, all_keys)
assert lookup_result == 50, "All stored keys from both batches should exist"
@pytest.mark.skipif(
not torch.cuda.is_available(), reason="Get chunk size requires CUDA"
)
def test_get_chunk_size(
client: MessageQueueClient,
):
"""
Test retrieving the chunk size from the server.
"""
chunk_size = client.submit_request(
RequestType.GET_CHUNK_SIZE,
[],
get_response_class(RequestType.GET_CHUNK_SIZE),
).result(timeout=DEFAULT_TIMEOUT)
assert chunk_size == CHUNK_SIZE, f"Chunk size should be {CHUNK_SIZE}"