# SPDX-License-Identifier: Apache-2.0 # Standard from typing import Optional from unittest.mock import MagicMock import asyncio import ctypes import functools import inspect import os import random import socket import string import tempfile import threading import uuid # Third Party import torch # First Party from lmcache.utils import CacheEngineKey from lmcache.v1.config import LMCacheEngineConfig from lmcache.v1.gpu_connector.gpu_connectors import VLLMPagedMemGPUConnectorV2 from lmcache.v1.memory_allocators.ad_hoc_memory_allocator import AdHocMemoryAllocator from lmcache.v1.memory_management import ( MemoryFormat, MemoryObj, ) from lmcache.v1.metadata import LMCacheMetadata # Conditional import for CUDA-only operations if torch.cuda.is_available() or torch.xpu.is_available(): try: # First Party import lmcache.c_ops as lmc_ops except ImportError: # If c_ops is not built, create a mock lmc_ops = None else: # Mock c_ops when CUDA is not available # First Party lmc_ops = None # Define mock EngineKVFormat enum if c_ops is not available if lmc_ops is None: class MockEngineKVFormat: NL_X_TWO_NB_BS_NH_HS = 0 NL_X_NB_TWO_BS_NH_HS = 1 NL_X_NB_BS_HS = 2 NL_X_TWO_NB_NH_BS_HS = 3 NL_X_NB_TWO_NH_BS_HS = 4 NL_X_NB_NH_BS_TWO_HS = 5 class MockCOps: EngineKVFormat = MockEngineKVFormat GPUKVFormat = MockEngineKVFormat lmc_ops = MockCOps() def _probe_cufile_register() -> bool: """ Try to actually register a cuFile handle on a real file in the test scratch dir. Returns True iff cuFileHandleRegister succeeds. Importability of cufile / libcufile.so is necessary but not sufficient: on hosts without nvidia-fs (or on a non-GDS-capable filesystem), cuFileHandleRegister fails at runtime with CU_FILE_IO_NOT_SUPPORTED (err=5027). This probe matches the exact path tests will exercise. """ try: # Third Party import cufile except Exception: return False probe_dir = os.environ.get("LMCACHE_TEST_TMPDIR") or tempfile.gettempdir() if not os.path.isdir(probe_dir): return False try: fd, probe_path = tempfile.mkstemp(dir=probe_dir, prefix="cufile-probe-") except OSError: return False try: try: os.write(fd, b"\0" * 4096) finally: os.close(fd) # Mirror production: GdsBackend opens with mode "r+" and # use_direct_io=True (see gds_backend.py:950). If the FS doesn't # support GDS+O_DIRECT, register fails here exactly as in tests. cu = cufile.CuFile(probe_path, "r+", use_direct_io=True) try: cu.open() except Exception: # Register failed. cu._handle may hold the raw fd from os.open # without a registered cuFile handle; close it ourselves and # null the state so __del__ doesn't try to deregister None. raw_fd = getattr(cu, "_handle", None) if raw_fd is not None: try: os.close(raw_fd) except OSError: pass cu._handle = None return False cu.close() return True finally: try: os.unlink(probe_path) except OSError: pass @functools.lru_cache(maxsize=1) def has_cufile() -> bool: """ True only when NVIDIA cuFile is usable on this host's test scratch dir: - python package `cufile` importable - dynamic library `libcufile.so` loadable - cuFileHandleRegister succeeds on a real file in LMCACHE_TEST_TMPDIR (or the system tmpdir as a fallback) """ try: # Third Party import cufile # noqa: F401 except Exception: return False try: ctypes.CDLL("libcufile.so") except OSError: return False return _probe_cufile_register() def has_hipfile() -> bool: """ True only when AMD hipFile is available: - python package `hipfile` importable - dynamic library `libhipfile.so` loadable """ try: # Third Party import hipfile # noqa: F401 except Exception: return False try: ctypes.CDLL("libhipfile.so") except OSError: return False return True def recover_engine_states(engine): engine.gpu_connector.kv_cache_pointers_on_gpu = {} def recover_gpu_connector_states(gpu_connector): gpu_connector.kv_cache_pointers_on_gpu = {} def dumb_metadata(kv_shape=(32, 2, 256, 8, 128)): return LMCacheMetadata( model_name="test_model", world_size=3, local_world_size=3, worker_id=1, local_worker_id=1, kv_dtype=torch.bfloat16, kv_shape=kv_shape, ) def dumb_metadata_with_model_name(model_name: str, kv_shape=(32, 2, 256, 8, 128)): return LMCacheMetadata( model_name=model_name, world_size=3, local_world_size=3, worker_id=1, local_worker_id=1, kv_dtype=torch.bfloat16, kv_shape=kv_shape, ) def dumb_cache_engine_key(id: int = 0) -> CacheEngineKey: return CacheEngineKey( model_name="test_model", world_size=3, worker_id=1, chunk_hash=id, dtype=torch.bfloat16, ) def random_string(N): return "".join(random.choices(string.ascii_uppercase + string.digits, k=N)) def init_asyncio_loop(): async_loop = asyncio.new_event_loop() async_thread = threading.Thread(target=async_loop.run_forever) async_thread.start() return async_loop, async_thread def close_asyncio_loop(async_loop, async_thread): if async_loop.is_running(): # First, cancel all pending tasks try: # Get all tasks and cancel them pending = asyncio.all_tasks(async_loop) for task in pending: if not task.done(): task.cancel() except Exception as e: print(f"Error during close pending tasks: - {e}") # Then stop the loop async_loop.call_soon_threadsafe(async_loop.stop) if async_thread.is_alive(): async_thread.join(timeout=2.0) # Close the loop to release resources if not async_loop.is_closed(): async_loop.close() # Set event loop to None asyncio.set_event_loop(None) def get_available_port(host: str = "127.0.0.1") -> int: """ Get an available port dynamically by binding to port 0. Args: host: The host address to bind to. Default is "127.0.0.1". Returns: An available port number. """ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind((host, 0)) s.listen(1) port = s.getsockname()[1] return port def get_available_ports(count: int, host: str = "127.0.0.1") -> list[int]: """ Get multiple available ports dynamically. Args: count: Number of ports to get. host: The host address to bind to. Default is "127.0.0.1". Returns: A list of available port numbers. """ ports = [] for _ in range(count): ports.append(get_available_port(host)) return ports def generate_kv_cache(num_tokens, device): ret = [] num_layers = 32 num_heads = 8 head_size = 128 shape = [num_tokens, num_heads, head_size] dtype = torch.bfloat16 for i in range(num_layers): k = torch.rand(shape, dtype=dtype, device=device) v = torch.rand(shape, dtype=dtype, device=device) ret.append((k, v)) return tuple(ret) def generate_kv_cache_paged_list_tensors( num_blocks, device, block_size=16, dtype=torch.bfloat16, num_layers=32, head_size=128, # default vllm non-MLA flash attention engine_kv_format=lmc_ops.EngineKVFormat.NL_X_TWO_NB_BS_NH_HS, ): """ Instead of Tuple[Tuple[Tensor, Tensor]], return List[Tensor] where KV are in the same tensor """ ret = [] # only support vllm MLA format for now use_mla = engine_kv_format == lmc_ops.EngineKVFormat.NL_X_NB_BS_HS num_heads = 1 if use_mla else 8 if use_mla: shape = [num_blocks, block_size, head_size] else: if engine_kv_format == lmc_ops.EngineKVFormat.NL_X_TWO_NB_BS_NH_HS: shape = [2, num_blocks, block_size, num_heads, head_size] elif engine_kv_format == lmc_ops.EngineKVFormat.NL_X_NB_TWO_BS_NH_HS: shape = [num_blocks, 2, block_size, num_heads, head_size] elif engine_kv_format == lmc_ops.EngineKVFormat.NL_X_TWO_NB_NH_BS_HS: shape = [2, num_blocks, num_heads, block_size, head_size] elif engine_kv_format == lmc_ops.EngineKVFormat.NL_X_NB_TWO_NH_BS_HS: shape = [num_blocks, 2, num_heads, block_size, head_size] elif engine_kv_format == lmc_ops.EngineKVFormat.NL_X_NB_NH_BS_TWO_HS: # blocks-first, K/V fused into the trailing dim shape = [num_blocks, num_heads, block_size, 2, head_size] else: raise ValueError(f"Unsupported engine_kv_format: {engine_kv_format}") for i in range(num_layers): # TODO(chunxiaozheng): support more dtypes if dtype == torch.uint8: kv = torch.randint(0, 256, shape, dtype=dtype, device=device) else: kv = torch.rand(shape, dtype=dtype, device=device) ret.append(kv) return ret def generate_sglang_kv_cache_paged_list_tensors( num_layers, num_blocks, block_size, num_heads, head_size, use_mla=False, device="cuda", dtype=torch.bfloat16, ): """ Instead of Tuple[Tuple[Tensor, Tensor]], return List[Tensor] where KV are in the same tensor For MLA: List[num_layers] of [page_buffer_size, 1, head_size] For MHA: List[2] -> List[num_layers] of [page_buffer_size, num_heads, head_size] """ shape = ( [num_blocks * block_size, 1, head_size] if use_mla else [num_blocks * block_size, num_heads, head_size] ) if use_mla: kv_cache = [ torch.rand(shape, dtype=dtype, device=device) for i in range(num_layers) ] else: # MHA: List[2] -> List[num_layers] k_cache = [ torch.rand(shape, dtype=dtype, device=device) for i in range(num_layers) ] v_cache = [ torch.rand(shape, dtype=dtype, device=device) for i in range(num_layers) ] kv_cache = [k_cache, v_cache] return kv_cache def generate_kv_cache_paged(num_blocks, device, block_size=16, dtype=torch.bfloat16): ret = [] num_layers = 32 num_heads = 8 head_size = 128 shape = [num_blocks, block_size, num_heads, head_size] for i in range(num_layers): k = torch.rand(shape, dtype=dtype, device=device) v = torch.rand(shape, dtype=dtype, device=device) ret.append((k, v)) return tuple(ret) def generate_tokens(num_tokens, device, fixed=False): if fixed: return torch.tensor([-1] * num_tokens).to(device) else: # random tokens return torch.randint(0, 10000, size=[num_tokens]).to(device) def concatenate_kv_caches(kv_chunks): dim = 0 ret = [] for kv_layer in zip(*kv_chunks, strict=False): klist, vlist = zip(*kv_layer, strict=False) klayer = torch.cat(klist, dim=dim) vlayer = torch.cat(vlist, dim=dim) ret.append((klayer, vlayer)) return tuple(ret) def check_mem_obj_equal(left, right, use_mla: bool = False): """ check whether two memory objects are the same """ for left_mem_obj, right_mem_obj in zip(left, right, strict=False): left_tensor_size = left_mem_obj.tensor.size() right_tensor_size = right_mem_obj.tensor.size() if use_mla: assert left_tensor_size[0] == 1 assert right_tensor_size[0] == 1 left_kv, right_kv = left_mem_obj.tensor[0], right_mem_obj.tensor[0] right_kv = right_kv.to(left_kv.device) assert len(left_kv.shape) == 3 assert len(right_kv.shape) == 3 assert (left_kv[:, :, :] == right_kv[:, :, :]).all() else: assert left_tensor_size[0] == 2 assert right_tensor_size[0] == 2 left_kv, right_kv = left_mem_obj.tensor, right_mem_obj.tensor left_k, left_v = left_kv[0], left_kv[1] right_k, right_v = right_kv[0], right_kv[1] right_k = right_k.to(left_k.device) right_v = right_v.to(left_v.device) assert len(left_k.shape) == 3 assert len(left_v.shape) == 3 assert len(right_k.shape) == 3 assert len(right_v.shape) == 3 assert (left_k[:, :, :] == right_k[:, :, :]).all() assert (left_v[:, :, :] == right_v[:, :, :]).all() # default checks for vllm non-MLA flash attention def check_paged_kv_cache_equal( left, right, slot_mapping, num_heads=8, head_size=128, engine_kv_format=lmc_ops.EngineKVFormat.NL_X_TWO_NB_BS_NH_HS, ): """ check whether two paged kv caches are the same at slot_mapping """ if engine_kv_format == lmc_ops.EngineKVFormat.NL_X_TWO_NB_BS_NH_HS: token_dim = 0 num_tokens = slot_mapping.shape[0] for left_kv_layer, right_kv_layer in zip(left, right, strict=False): left_k = left_kv_layer[0].reshape(-1, num_heads, head_size) left_v = left_kv_layer[1].reshape(-1, num_heads, head_size) right_k = right_kv_layer[0].reshape(-1, num_heads, head_size) right_v = right_kv_layer[1].reshape(-1, num_heads, head_size) assert len(left_k.shape) == 3 assert len(left_v.shape) == 3 assert len(right_k.shape) == 3 assert len(right_v.shape) == 3 assert left_k.shape[token_dim] >= num_tokens assert left_v.shape[token_dim] >= num_tokens assert right_k.shape[token_dim] >= num_tokens assert right_v.shape[token_dim] >= num_tokens assert (left_k[slot_mapping, :, :] == right_k[slot_mapping, :, :]).all() assert (left_v[slot_mapping, :, :] == right_v[slot_mapping, :, :]).all() elif engine_kv_format == lmc_ops.EngineKVFormat.NL_X_NB_TWO_BS_NH_HS: token_dim = 0 num_tokens = slot_mapping.shape[0] for left_kv_layer, right_kv_layer in zip(left, right, strict=False): left_k = left_kv_layer[:, 0].contiguous().reshape(-1, num_heads, head_size) left_v = left_kv_layer[:, 1].contiguous().reshape(-1, num_heads, head_size) right_k = ( right_kv_layer[:, 0].contiguous().reshape(-1, num_heads, head_size) ) right_v = ( right_kv_layer[:, 1].contiguous().reshape(-1, num_heads, head_size) ) assert len(left_k.shape) == 3 assert len(left_v.shape) == 3 assert len(right_k.shape) == 3 assert len(right_v.shape) == 3 assert left_k.shape[token_dim] >= num_tokens assert left_v.shape[token_dim] >= num_tokens assert right_k.shape[token_dim] >= num_tokens assert right_v.shape[token_dim] >= num_tokens assert (left_k[slot_mapping, :, :] == right_k[slot_mapping, :, :]).all() assert (left_v[slot_mapping, :, :] == right_v[slot_mapping, :, :]).all() elif engine_kv_format == lmc_ops.EngineKVFormat.NL_X_TWO_NB_NH_BS_HS: # HND flash attention: [2, num_blocks, num_heads, block_size, head_size] # Flatten [num_blocks, num_heads, block_size, head_size] -> # swap to [num_blocks, block_size, num_heads, head_size] -> # reshape to [num_blocks*block_size, num_heads, head_size] num_tokens = slot_mapping.shape[0] for left_kv_layer, right_kv_layer in zip(left, right, strict=False): left_k = ( left_kv_layer[0] .permute(0, 2, 1, 3) .contiguous() .reshape(-1, num_heads, head_size) ) left_v = ( left_kv_layer[1] .permute(0, 2, 1, 3) .contiguous() .reshape(-1, num_heads, head_size) ) right_k = ( right_kv_layer[0] .permute(0, 2, 1, 3) .contiguous() .reshape(-1, num_heads, head_size) ) right_v = ( right_kv_layer[1] .permute(0, 2, 1, 3) .contiguous() .reshape(-1, num_heads, head_size) ) assert left_k.shape[0] >= num_tokens assert (left_k[slot_mapping, :, :] == right_k[slot_mapping, :, :]).all() assert (left_v[slot_mapping, :, :] == right_v[slot_mapping, :, :]).all() elif engine_kv_format == lmc_ops.EngineKVFormat.NL_X_NB_TWO_NH_BS_HS: # HND flash infer: [num_blocks, 2, num_heads, block_size, head_size] # left_kv_layer[:, 0] -> [num_blocks, num_heads, block_size, head_size] num_tokens = slot_mapping.shape[0] for left_kv_layer, right_kv_layer in zip(left, right, strict=False): left_k = ( left_kv_layer[:, 0] .permute(0, 2, 1, 3) .contiguous() .reshape(-1, num_heads, head_size) ) left_v = ( left_kv_layer[:, 1] .permute(0, 2, 1, 3) .contiguous() .reshape(-1, num_heads, head_size) ) right_k = ( right_kv_layer[:, 0] .permute(0, 2, 1, 3) .contiguous() .reshape(-1, num_heads, head_size) ) right_v = ( right_kv_layer[:, 1] .permute(0, 2, 1, 3) .contiguous() .reshape(-1, num_heads, head_size) ) assert left_k.shape[0] >= num_tokens assert (left_k[slot_mapping, :, :] == right_k[slot_mapping, :, :]).all() assert (left_v[slot_mapping, :, :] == right_v[slot_mapping, :, :]).all() def check_sglang_paged_kv_cache_equal( left, right, slot_mapping, num_heads=8, head_size=128 ): """ check whether two paged kv caches are the same at slot_mapping Format: List[2] -> List[num_layers] of [page_buffer_size, num_heads, head_size] """ token_dim = 0 num_tokens = slot_mapping.shape[0] # left and right are [k_list, v_list] assert len(left) == 2, "Expected [k_list, v_list]" assert len(right) == 2, "Expected [k_list, v_list]" # Check K and V separately for kv_idx in range(2): # 0 for K, 1 for V left_kv_list = left[kv_idx] right_kv_list = right[kv_idx] for left_kv, right_kv in zip(left_kv_list, right_kv_list, strict=False): _left_kv = left_kv.reshape(-1, num_heads, head_size) _right_kv = right_kv.reshape(-1, num_heads, head_size) assert len(_left_kv.shape) == 3 assert len(_right_kv.shape) == 3 assert _left_kv.shape[token_dim] >= num_tokens assert _right_kv.shape[token_dim] >= num_tokens assert (_left_kv[slot_mapping, :, :] == _right_kv[slot_mapping, :, :]).all() def check_paged_kv_cache_equal_with_mla(left, right, slot_mapping, head_size=128): """ check whether two paged kv caches are the same at slot_mapping when use mla """ token_dim = 0 num_tokens = slot_mapping.shape[0] for left_kv, right_kv in zip(left, right, strict=False): new_left_kv = left_kv.reshape(-1, head_size) new_right_kv = right_kv.reshape(-1, head_size) assert len(new_left_kv.shape) == 2 assert len(new_right_kv.shape) == 2 assert new_left_kv.shape[token_dim] >= num_tokens assert new_right_kv.shape[token_dim] >= num_tokens assert (new_left_kv[slot_mapping, :] == new_right_kv[slot_mapping, :]).all() def check_kv_cache_device(kvs, device): for kv in kvs: k, v = kv assert k.device == torch.device(device) assert v.device == torch.device(device) def create_gpu_connector(hidden_dim, num_layers): return VLLMPagedMemGPUConnectorV2(hidden_dim, num_layers) def get_all_methods_from_base(base_class): """ Get all public methods defined in the base class (excluding inherited from object). """ methods = set() for name in dir(base_class): # Skip private and special methods if name.startswith("_"): continue attr = getattr(base_class, name) if callable(attr): methods.add(name) return methods def get_methods_implemented_in_class(cls, base_class=None): """ Get methods that are actually implemented in the class itself. Args: cls: The class to inspect base_class: Optional base class to stop at. If None, stops at abstract base classes. """ implemented = set() # Check the class's own __dict__ for methods for name in cls.__dict__: if name.startswith("_"): continue attr = cls.__dict__[name] # Check if it's callable (function, method, etc.) if callable(attr): implemented.add(name) # Also check using getattr to catch any dynamically added methods for name in dir(cls): if name.startswith("_"): continue if name in implemented: continue # Already found try: attr = getattr(cls, name) if callable(attr): # Verify it's not inherited from base class # by checking if it exists in the class's MRO for base in cls.__mro__: # Stop when we hit the specified base class if base_class is not None and base is base_class: break # Or stop when we hit an abstract base class if base_class is None and inspect.isabstract(base): break if name in base.__dict__: implemented.add(name) break except AttributeError: pass return implemented def get_abstract_methods(cls): """ Get all abstract methods from a class. """ abstract_methods = set() for name, method in inspect.getmembers(cls, predicate=inspect.isfunction): if getattr(method, "__isabstractmethod__", False): abstract_methods.add(name) return abstract_methods def check_method_signatures(base_class, impl_class): """ Check if method signatures in implementation class match the base class. Returns a list of mismatches. """ base_methods = get_all_methods_from_base(base_class) signature_mismatches = [] for method_name in base_methods: base_method = getattr(base_class, method_name) impl_method = getattr(impl_class, method_name, None) if impl_method is None: continue try: base_sig = inspect.signature(base_method) impl_sig = inspect.signature(impl_method) # Compare parameter names (excluding 'self') base_params = [p for p in base_sig.parameters.keys() if p != "self"] impl_params = [p for p in impl_sig.parameters.keys() if p != "self"] if base_params != impl_params: signature_mismatches.append( { "method": method_name, "base_params": base_params, "impl_params": impl_params, } ) except (ValueError, TypeError): # Some methods might not have inspectable signatures pass return signature_mismatches class DummyLMCacheAsyncLookupServer: def __init__(self): pass def send_response_to_scheduler( self, lookup_id: str, retrieved_length: int, ) -> None: pass class MockAdapter: """ Mock adapter to provide config and lmcache_engine to InternalAPIServer. """ def __init__(self, engine, config): self.lmcache_engine = engine self.config = config def create_test_metadata( worker_id: int = 0, world_size: int = 1, kv_shape: tuple = (4, 2, 256, 8, 128), engine_id: Optional[str] = "test_engine", kv_connector_extra_config: Optional[dict] = None, ) -> LMCacheMetadata: """Create test metadata for LMCacheEngine.""" return LMCacheMetadata( model_name="test_model", world_size=world_size, local_world_size=world_size, worker_id=worker_id, local_worker_id=worker_id, kv_dtype=torch.bfloat16, kv_shape=kv_shape, engine_id=engine_id, kv_connector_extra_config=kv_connector_extra_config, ) def create_test_config( chunk_size: int = 256, local_cpu: bool = True, max_local_cpu_size: float = 1.0, rpc_port: int = 0, extra_config: Optional[dict] = None, instance_id: Optional[str] = None, ) -> LMCacheEngineConfig: """Create test configuration for LMCacheEngine.""" if instance_id is None: instance_id = f"test_instance_{uuid.uuid4().hex[:8]}" config = LMCacheEngineConfig.from_defaults( chunk_size=chunk_size, local_cpu=local_cpu, max_local_cpu_size=max_local_cpu_size, lmcache_instance_id=instance_id, ) config.extra_config = extra_config.copy() if extra_config else {} config.extra_config["lmcache_rpc_port"] = rpc_port return config def create_mock_vllm_config( rank: int = 0, world_size: int = 1, rpc_port: int = 0 ) -> MagicMock: """Create a mock VllmConfig for testing.""" vllm_config = MagicMock() # Mock model_config vllm_config.model_config = MagicMock() vllm_config.model_config.model = "test_model" vllm_config.model_config.dtype = torch.bfloat16 vllm_config.model_config.get_num_layers = MagicMock(return_value=4) vllm_config.model_config.get_num_kv_heads = MagicMock(return_value=8) vllm_config.model_config.get_head_size = MagicMock(return_value=128) vllm_config.model_config.hf_config = MagicMock() vllm_config.model_config.hf_config.model_type = "llama" # Mock parallel_config vllm_config.parallel_config = MagicMock() vllm_config.parallel_config.rank = rank vllm_config.parallel_config.world_size = world_size vllm_config.parallel_config.tensor_parallel_size = world_size vllm_config.parallel_config.pipeline_parallel_size = 1 # Mock cache_config vllm_config.cache_config = MagicMock() vllm_config.cache_config.cache_dtype = torch.bfloat16 # Mock kv_transfer_config with engine_id vllm_config.kv_transfer_config = MagicMock() vllm_config.kv_transfer_config.engine_id = "test_engine" vllm_config.kv_transfer_config.get_from_extra_config = MagicMock( side_effect=lambda key, default: ( rpc_port if key == "lmcache_rpc_port" else default ) ) return vllm_config def create_test_memory_obj(shape=None, dtype=torch.bfloat16, device="cpu") -> MemoryObj: """Create a test MemoryObj using AdHocMemoryAllocator for testing.""" if shape is None: shape = torch.Size([2, 16, 8, 128]) allocator = AdHocMemoryAllocator(device=device) memory_obj = allocator.allocate([shape], [dtype], fmt=MemoryFormat.KV_T2D) return memory_obj