# SPDX-License-Identifier: Apache-2.0 # Standard from functools import partial import os import random import tempfile import time # Third Party import pytest import torch # First Party from lmcache.utils import mock_up_broadcast_fn, mock_up_broadcast_object_fn from lmcache.v1.cache_engine import LMCacheEngineBuilder from lmcache.v1.config import LMCacheEngineConfig from lmcache.v1.gpu_connector.xpu_connectors import VLLMPagedMemLayerwiseXPUConnector from tests.v1.utils import ( dumb_metadata, generate_kv_cache_paged_list_tensors, generate_tokens, ) DEVICE_PARAMS = ["xpu"] BACKENDS = ["cpu", "disk"] # Optional override for tempfile root; see tests/v1/test_cache_engine.py # for rationale. _TEST_TMPDIR = os.environ.get("LMCACHE_TEST_TMPDIR") or None def _skip_if_no_xpu(): if not hasattr(torch, "xpu") or not torch.xpu.is_available(): pytest.skip("torch.xpu is not available") def generate_random_slot_mapping(num_blocks, block_size, num_tokens, device): slot_mapping = random.sample(range(0, num_blocks * block_size), num_tokens) return torch.tensor(slot_mapping, device=device) def get_expected_count(token_len, save_unfull_chunk, chunk_size): # expected tokens stored (not chunks) if save_unfull_chunk: return token_len return (token_len // chunk_size) * chunk_size def _device_from_type(device_type): if device_type == "xpu": return torch.device("xpu") raise ValueError(device_type) def _wait_for_store(engine, tokens, expected, timeout=60): start = time.time() last_hits = [] while time.time() - start < timeout: last_hits = [engine.lookup(t) for t in tokens] ready = all(hit == expected for hit in last_hits) if ready: return time.sleep(0.1) raise TimeoutError( "Store operation hasn't finished in " f"{timeout} seconds. expected={expected}, hits={last_hits}" ) def _create_connector( device: torch.device, hidden_dim: int, num_layers: int, *, use_gpu: bool, chunk_size: int, dtype: torch.dtype, use_mla: bool = False, ): return VLLMPagedMemLayerwiseXPUConnector( hidden_dim, num_layers, use_gpu=use_gpu, chunk_size=chunk_size, dtype=dtype, device=device, use_mla=use_mla, ) def _layerwise_store_vllm_contract( engine, list_token_ids, # list[Tensor] or list[list[int]] list_slot_mappings, # list[Tensor] kvcaches, *, num_layers: int, chunk_size: int, save_unfull_chunk: bool, ): """ Mimic vLLM layerwise store contract: - create one store_layer generator per request (first one sync=True, rest sync=False) - tick generators once per layer (num_layers times) - tick generators one final time (like wait_for_save) IMPORTANT: - store_layer expects token_ids as list[int] (like vLLM adapter) - for save_unfull_chunk=False, truncate to aligned_len instead of trailing False mask - mask False indicates skipped LEADING tokens only (we use skip=0 here) """ storers = [] is_first = True for token_ids, slot_mapping in zip( list_token_ids, list_slot_mappings, strict=False ): # token_ids: Tensor -> list[int] token_ids_list = ( token_ids.tolist() if isinstance(token_ids, torch.Tensor) else token_ids ) # keep slot_mapping on same device as kvcaches (xpu) slot_mapping = slot_mapping.to(kvcaches[0].device) if save_unfull_chunk: aligned_len = len(token_ids_list) else: aligned_len = (len(token_ids_list) // chunk_size) * chunk_size token_ids_list = token_ids_list[:aligned_len] slot_mapping = slot_mapping[:aligned_len] if aligned_len == 0: continue skip_leading_tokens = 0 # put mask on same device (avoid implicit device moves) mask = torch.ones( len(token_ids_list), dtype=torch.bool, device=slot_mapping.device ) mask[:skip_leading_tokens] = False st = engine.store_layer( token_ids_list, mask=mask, kvcaches=kvcaches, slot_mapping=slot_mapping, offset=skip_leading_tokens, sync=is_first, ) storers.append(st) is_first = False # Tick once per layer for _ in range(num_layers): for st in storers: next(st) # Finalize tick (equivalent of wait_for_save) for st in storers: next(st) def _layerwise_retrieve_vllm_contract( engine, list_token_ids, list_slot_mappings, kvcaches, *, num_layers: int, chunk_size: int, save_unfull_chunk: bool, ): """Mimic vLLM layerwise retrieve contract. For layerwise mode, benchmark should call retrieve_layer with the same slot mapping + KV cache context used by GPU connector. """ retrievers = [] is_first = True for token_ids, slot_mapping in zip( list_token_ids, list_slot_mappings, strict=False ): if isinstance(token_ids, torch.Tensor): tokens = token_ids else: tokens = torch.tensor( token_ids, dtype=torch.long, device=slot_mapping.device ) slot_mapping = slot_mapping.to(kvcaches[0].device) if save_unfull_chunk: aligned_len = len(tokens) else: aligned_len = (len(tokens) // chunk_size) * chunk_size tokens = tokens[:aligned_len] slot_mapping = slot_mapping[:aligned_len] if aligned_len == 0: continue mask = torch.ones(aligned_len, dtype=torch.bool, device=slot_mapping.device) retriever = engine.retrieve_layer( tokens, mask=mask, kvcaches=kvcaches, slot_mapping=slot_mapping, sync=is_first, ) retrievers.append(retriever) is_first = False # Tick once per layer to overlap multi-request loading/copying. for _ in range(num_layers): for retriever in retrievers: next(retriever) # Finalize connector sync stage. for retriever in retrievers: next(retriever) # Consume final ret_mask yield. for retriever in retrievers: next(retriever) @pytest.fixture def create_config(): def make_config(backend, size, save_unfull_chunk=True, **kwargs): # NOTE: use_layerwise=True because we use store_layer contract common = dict( save_unfull_chunk=save_unfull_chunk, extra_config={"force_store_wait": True}, # deterministic for benchmarks use_layerwise=True, ) match backend: case "cpu": return LMCacheEngineConfig.from_defaults( local_cpu=True, max_local_cpu_size=size, **common, ) case "disk": assert "path" in kwargs, "'path' is missing for disk backend" return LMCacheEngineConfig.from_defaults( local_cpu=False, max_local_cpu_size=size, local_disk=kwargs["path"], max_local_disk_size=size, **common, ) case _: raise ValueError(f"Unknown backend: {backend}") with tempfile.TemporaryDirectory( dir=_TEST_TMPDIR, ignore_cleanup_errors=True ) as temp_dir: yield partial(make_config, path=temp_dir) def _build_engine( *, name: str, cfg, connector, num_layers: int, chunk_size: int, num_heads: int, head_dim: int, autorelease_v1, ): # metadata shape used by tests kv_shape = (num_layers, 2, chunk_size, num_heads, head_dim) return autorelease_v1( LMCacheEngineBuilder.get_or_create( name, cfg, dumb_metadata(kv_shape), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) ) # -------------------------- # Store benchmarks # -------------------------- @pytest.mark.no_shared_allocator @pytest.mark.benchmark(group="store") @pytest.mark.parametrize("device_type", DEVICE_PARAMS) @pytest.mark.parametrize("backend", ["cpu", "disk"]) @pytest.mark.parametrize("use_gpu", [True]) @pytest.mark.parametrize("save_unfull_chunk", [False, True]) def test_store_1GB( benchmark, device_type, backend, use_gpu, save_unfull_chunk, create_config, autorelease_v1, ): _skip_if_no_xpu() """ Store benchmark for XPU layerwise connector. Reduces volatility by: - warming up once outside timing - reusing the same engine/backend state - shuffling request order per round """ # model-related metadata num_heads = 8 head_dim = 128 num_layers = 32 dtype = torch.bfloat16 # lmcache / vllm configs device = _device_from_type(device_type) num_tokens = 2000 num_blocks = 1000 block_size = 16 chunk_size = 256 kv_shape = (num_layers, 2, chunk_size, num_heads, head_dim) # benchmark configs num_requests = 4 num_repeats = 10 connector = _create_connector( device, hidden_dim=num_heads * head_dim, num_layers=num_layers, use_gpu=use_gpu, chunk_size=chunk_size, dtype=dtype, use_mla=False, ) kv_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype, num_layers=num_layers, head_size=head_dim, ) cache_size = 1.5 cfg = create_config(backend, cache_size, save_unfull_chunk=save_unfull_chunk) engine = autorelease_v1( LMCacheEngineBuilder.get_or_create( "test", cfg, dumb_metadata(kv_shape), connector, mock_up_broadcast_fn, mock_up_broadcast_object_fn, ) ) expected = get_expected_count(num_tokens, save_unfull_chunk, chunk_size) def run_func(tokens, slot_mappings): _layerwise_store_vllm_contract( engine, tokens, slot_mappings, kv_cache, num_layers=num_layers, chunk_size=chunk_size, save_unfull_chunk=save_unfull_chunk, ) _wait_for_store(engine, tokens, expected) def setup(): list_tokens = [generate_tokens(num_tokens, device) for _ in range(num_requests)] list_slot_mappings = [ generate_random_slot_mapping(num_blocks, block_size, num_tokens, device) for _ in range(num_requests) ] return ( list_tokens, list_slot_mappings, ), {} # Warm up once outside timing to absorb first-touch XPU/backend overhead. warm_tokens, warm_slot_mappings = setup()[0] run_func(warm_tokens, warm_slot_mappings) benchmark.pedantic( run_func, setup=setup, rounds=num_repeats, iterations=1, ) # -------------------------- # Retrieve benchmarks (100% hit) # -------------------------- @pytest.mark.no_shared_allocator @pytest.mark.benchmark(group="retrieve") @pytest.mark.parametrize("device_type", DEVICE_PARAMS) @pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("use_gpu", [True]) @pytest.mark.parametrize("save_unfull_chunk", [False, True]) def test_retrieve_1GB_allhit( benchmark, device_type, backend, use_gpu, save_unfull_chunk, create_config, autorelease_v1, ): _skip_if_no_xpu() num_heads = 8 head_dim = 128 num_layers = 32 dtype = torch.bfloat16 device = _device_from_type(device_type) num_tokens = 2000 num_blocks = 1000 block_size = 16 chunk_size = 256 num_requests = 4 num_repeats = 10 connector = _create_connector( device, hidden_dim=num_heads * head_dim, num_layers=num_layers, use_gpu=use_gpu, chunk_size=chunk_size, dtype=dtype, use_mla=False, ) kv_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype, num_layers=num_layers, head_size=head_dim, ) kvcaches = kv_cache list_tokens = [generate_tokens(num_tokens, device) for _ in range(num_requests)] list_slot_mappings = [ generate_random_slot_mapping(num_blocks, block_size, num_tokens, device) for _ in range(num_requests) ] cfg = create_config(backend, 1.5, save_unfull_chunk=save_unfull_chunk) engine = _build_engine( name="test", cfg=cfg, connector=connector, num_layers=num_layers, chunk_size=chunk_size, num_heads=num_heads, head_dim=head_dim, autorelease_v1=autorelease_v1, ) # Pre-populate cache once (not timed) _layerwise_store_vllm_contract( engine, list_tokens, list_slot_mappings, kvcaches, num_layers=num_layers, chunk_size=chunk_size, save_unfull_chunk=save_unfull_chunk, ) expected = get_expected_count(num_tokens, save_unfull_chunk, chunk_size) _wait_for_store(engine, list_tokens, expected) def run_func(): _layerwise_retrieve_vllm_contract( engine, list_tokens, list_slot_mappings, kvcaches, num_layers=num_layers, chunk_size=chunk_size, save_unfull_chunk=save_unfull_chunk, ) # Warm up once outside timing to absorb first-touch retrieve overhead. run_func() benchmark.pedantic(run_func, rounds=num_repeats, iterations=1) # -------------------------- # Lookup benchmarks (100% hit) # 10 rounds, 1 iteration (requested) # -------------------------- @pytest.mark.no_shared_allocator @pytest.mark.benchmark(group="lookup") @pytest.mark.parametrize("device_type", DEVICE_PARAMS) @pytest.mark.parametrize("backend", BACKENDS) @pytest.mark.parametrize("use_gpu", [True]) @pytest.mark.parametrize("save_unfull_chunk", [False, True]) def test_lookup_20K_tokens( benchmark, device_type, backend, use_gpu, save_unfull_chunk, create_config, autorelease_v1, ): _skip_if_no_xpu() num_heads = 8 head_dim = 128 num_layers = 32 dtype = torch.bfloat16 device = _device_from_type(device_type) num_tokens = 2000 num_blocks = 1000 block_size = 16 chunk_size = 256 # use_gpu=True stages per-request chunks on XPU during layerwise store. # Keeping 10 requests can exceed the default staging pool in pre-population. num_requests = 8 if use_gpu else 10 num_repeats = 10 connector = _create_connector( device, hidden_dim=num_heads * head_dim, num_layers=num_layers, use_gpu=use_gpu, chunk_size=chunk_size, dtype=dtype, use_mla=False, ) kv_cache = generate_kv_cache_paged_list_tensors( num_blocks, device, block_size, dtype, num_layers=num_layers, head_size=head_dim, ) kvcaches = kv_cache list_tokens = [generate_tokens(num_tokens, device) for _ in range(num_requests)] list_slot_mappings = [ generate_random_slot_mapping(num_blocks, block_size, num_tokens, device) for _ in range(num_requests) ] cfg = create_config(backend, 3.0, save_unfull_chunk=save_unfull_chunk) engine = _build_engine( name="test", cfg=cfg, connector=connector, num_layers=num_layers, chunk_size=chunk_size, num_heads=num_heads, head_dim=head_dim, autorelease_v1=autorelease_v1, ) # Pre-populate once (not timed) _layerwise_store_vllm_contract( engine, list_tokens, list_slot_mappings, kvcaches, num_layers=num_layers, chunk_size=chunk_size, save_unfull_chunk=save_unfull_chunk, ) expected = get_expected_count(num_tokens, save_unfull_chunk, chunk_size) _wait_for_store(engine, list_tokens, expected) def run_func(): # 1 iteration per round, 10 rounds (requested) for t in list_tokens: engine.lookup(t) benchmark.pedantic(run_func, iterations=1, rounds=num_repeats)