# SPDX-License-Identifier: Apache-2.0 # Standard from pathlib import Path import asyncio import contextlib import functools import os import shutil import tempfile import threading import uuid # Third Party import pytest import torch pytest.importorskip("nixl", reason="nixl package is required for nixl tests") # Third Party from nixl._api import nixl_agent as NixlAgent from nixl._api import nixl_agent_config as NixlAgentConfig # First Party from lmcache.utils import CacheEngineKey from lmcache.v1.config import LMCacheEngineConfig from lmcache.v1.memory_allocators.paged_tensor_memory_allocator import ( PagedTensorMemoryAllocator, ) from lmcache.v1.metadata import LMCacheMetadata from lmcache.v1.storage_backend import CreateStorageBackends from lmcache.v1.storage_backend.nixl_storage_backend import ( NixlStorageBackend, NixlStorageConfig, ) from lmcache.v1.transfer_channel.transfer_utils import get_correct_device # cuFile-based backends (GDS, GDS_MT) need a GDS-capable filesystem _TEST_TMPDIR = os.environ.get("LMCACHE_TEST_TMPDIR") or None @functools.lru_cache(maxsize=None) def _can_register_file_with_nixl_backend(backend: str) -> bool: """Probe ``cuFileHandleRegister`` via NIXL on the test scratch dir.""" probe_dir = tempfile.mkdtemp(prefix="nixl_gds_probe_", dir=_TEST_TMPDIR) probe_path = os.path.join(probe_dir, "probe.bin") fd = -1 try: agent = NixlAgent( f"NixlGdsProbe_{uuid.uuid4().hex}", NixlAgentConfig(backends=[]), ) agent.create_backend(backend, {}) fd = os.open(probe_path, os.O_CREAT | os.O_RDWR, 0o600) os.write(fd, b"\x00" * 4096) agent.register_memory([(0, 4096, fd, "")], mem_type="FILE") return True except Exception: return False finally: if fd >= 0: with contextlib.suppress(OSError): os.close(fd) shutil.rmtree(probe_dir, ignore_errors=True) _GDS_SKIP_REASON = ( "NIXL {backend} cannot register file handles in this environment; " "set LMCACHE_TEST_TMPDIR to a GDS-capable mount (ext4/xfs) to enable." ) @pytest.fixture def nixl_tmp_path(): """Per-test scratch dir, honoring ``LMCACHE_TEST_TMPDIR``.""" path = tempfile.mkdtemp(prefix="nixl_test_", dir=_TEST_TMPDIR) try: yield path finally: shutil.rmtree(path, ignore_errors=True) def create_key(chunk_hash: str): return CacheEngineKey( model_name="meta-llama/Llama-3.1-70B-Instruct", world_size=8, worker_id=0, chunk_hash=int(chunk_hash, base=16), dtype=torch.bfloat16, ) def run(config: LMCacheEngineConfig, shape, dtype): BACKEND_NAME = "NixlStorageBackend" keys = [] objs = [] keys.append( create_key("e3229141e680fb413d2c5d3ebb416c4ad300d381e309fc9e417757b91406c157") ) keys.append( create_key("e3229141e680fb413d2c5d3ebb416c4ad300d381e309fc9e417757b91406d268") ) keys.append( create_key("e3229141e680fb413d2c5d3ebb416c4ad300d381e309fc9e417757b91406e379") ) bad_key = create_key("deadbeefdeadbeef") thread_loop = None thread = None try: thread_loop = asyncio.new_event_loop() thread = threading.Thread(target=thread_loop.run_forever) thread.start() metadata = LMCacheMetadata( model_name="Llama-3.1-70B-Instruct", world_size=1, local_world_size=1, worker_id=0, local_worker_id=0, kv_dtype=dtype, kv_shape=shape, ) backends = CreateStorageBackends( config, metadata, thread_loop, dst_device=get_correct_device( config.nixl_buffer_device, metadata.worker_id ), ) assert len(backends) == 2 # NixlStorageBackend + LocalCPUBackend assert BACKEND_NAME in backends nixl_backend = backends[BACKEND_NAME] assert isinstance(nixl_backend, NixlStorageBackend) assert isinstance(nixl_backend.memory_allocator, PagedTensorMemoryAllocator) assert nixl_backend is not None assert nixl_backend.memory_allocator is not None # Allocate via the chunk shape that LMCacheMetadata derives from # kv_shape (LocalCPUBackend / NIXL share the same paged pool sized # by metadata.get_shapes()); passing the raw 5D kv_shape would # produce a same-byte-count but differently-indexed tensor. alloc_shape = metadata.get_shapes()[0] for key in keys: assert not nixl_backend.contains(key, False) assert not nixl_backend.exists_in_put_tasks(key) obj = nixl_backend.memory_allocator.allocate(alloc_shape, dtype) assert obj is not None assert obj.tensor is not None objs.append(obj) # small tensor changes for data validation (chunk shape is 4D: # [kv_size, num_layers, num_tokens, num_heads * head_size] = # [2, 4, 256, 1024] for kv_shape (4, 2, 256, 8, 128)) objs[0].tensor[0, 0, 100, 200] = 1e-3 objs[0].tensor[1, 0, 200, 100] = 1e-4 objs[1].tensor[0, 1, 150, 400] = 1e-2 objs[1].tensor[1, 1, 100, 300] = 1e-5 objs[2].tensor[0, 2, 50, 200] = 3e-2 objs[2].tensor[1, 3, 200, 100] = 4e-5 # Insert first 2 keys first_keys = keys[0:2] first_objs = objs[0:2] nixl_backend.batched_submit_put_task(first_keys, first_objs) for key, obj in zip(first_keys, first_objs, strict=False): returned_memory_obj = nixl_backend.get_blocking(key) assert returned_memory_obj is not None assert returned_memory_obj.get_size() == obj.get_size() assert returned_memory_obj.get_shape() == obj.get_shape() assert returned_memory_obj.get_dtype() == obj.get_dtype() assert returned_memory_obj.metadata.address != obj.metadata.address returned_tensor = returned_memory_obj.tensor obj_tensor = obj.tensor assert returned_tensor is not None assert obj_tensor is not None assert torch.equal(returned_tensor, obj_tensor) obj_list = asyncio.run( nixl_backend.batched_get_non_blocking(lookup_id="test", keys=first_keys) ) for i, obj in enumerate(first_objs): returned_memory_obj = obj_list[i] assert returned_memory_obj is not None assert returned_memory_obj.get_size() == obj.get_size() assert returned_memory_obj.get_shape() == obj.get_shape() assert returned_memory_obj.get_dtype() == obj.get_dtype() assert returned_memory_obj.metadata.address != obj.metadata.address returned_tensor = returned_memory_obj.tensor obj_tensor = obj.tensor assert returned_tensor is not None assert obj_tensor is not None assert torch.equal(returned_tensor, obj_tensor) def test_eviction(new_idx, old_idx): nixl_backend.batched_submit_put_task([keys[new_idx]], [objs[new_idx]]) obj = nixl_backend.get_blocking(keys[new_idx]) assert obj is not None assert obj.tensor is not None assert torch.equal(obj.tensor, objs[new_idx].tensor) obj = nixl_backend.get_blocking(keys[old_idx]) assert obj is None ######## Test bad key lookup ######### obj = nixl_backend.get_blocking(bad_key) assert obj is None ######## Test eviction ######### obj = nixl_backend.get_blocking(keys[0]) assert obj is not None # At this point, key 0 & key 1 are cached. Key 1 is LRU key. # Submitting key 2 should evict key 1. test_eviction(new_idx=2, old_idx=1) ######## Test pin ######### val = nixl_backend.pin(keys[2]) assert val is True obj = nixl_backend.get_blocking(keys[0]) assert obj is not None # At this point, key 0 & key 2 are cached. # Key 2 is LRU key, but is pinned. # Submitting key 1 should evict key 0. test_eviction(new_idx=1, old_idx=0) ######## Test unpin ######### val = nixl_backend.unpin(keys[2]) assert val is True obj = nixl_backend.get_blocking(keys[1]) assert obj is not None # At this point, key 1 & key 2 are cached. # Key 2 is LRU key, and is now unpinned. # Submitting key 0 should evict key 2. test_eviction(new_idx=0, old_idx=2) for backend in backends.values(): backend.close() except Exception: raise finally: if thread_loop and thread_loop.is_running(): thread_loop.call_soon_threadsafe(thread_loop.stop) if thread and thread.is_alive(): thread.join() @pytest.mark.no_shared_allocator @pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires CUDA") @pytest.mark.skipif( not _can_register_file_with_nixl_backend("GDS_MT"), reason=_GDS_SKIP_REASON.format(backend="GDS_MT"), ) def test_nixl_gds_mt_cuda_backend(nixl_tmp_path): BASE_DIR = Path(__file__).parent config = LMCacheEngineConfig.from_file(BASE_DIR / "data/nixl.yaml") dtype = torch.bfloat16 shape = torch.Size([4, 2, 256, 8, 128]) config.nixl_buffer_device = "cuda" # data/nixl.yaml is CPU-mode (no nixl_buffer_size); CUDA mode sizes the # NIXL buffer via nixl_buffer_size, so restore it when flipping the device. config.nixl_buffer_size = 1024**3 config.extra_config["nixl_backend"] = "GDS_MT" config.extra_config["enable_cuda"] = True config.extra_config["nixl_path"] = nixl_tmp_path run(config, shape, dtype) @pytest.mark.no_shared_allocator @pytest.mark.skipif( not _can_register_file_with_nixl_backend("GDS_MT"), reason=_GDS_SKIP_REASON.format(backend="GDS_MT"), ) def test_nixl_gds_mt_cpu_backend(nixl_tmp_path): BASE_DIR = Path(__file__).parent config = LMCacheEngineConfig.from_file(BASE_DIR / "data/nixl.yaml") dtype = torch.bfloat16 shape = torch.Size([4, 2, 256, 8, 128]) config.nixl_buffer_device = "cpu" config.extra_config["nixl_backend"] = "GDS_MT" config.extra_config["enable_cuda"] = False config.extra_config["nixl_path"] = nixl_tmp_path run(config, shape, dtype) @pytest.mark.no_shared_allocator @pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires CUDA") @pytest.mark.skipif( not _can_register_file_with_nixl_backend("GDS"), reason=_GDS_SKIP_REASON.format(backend="GDS"), ) def test_nixl_gds_cuda_backend(nixl_tmp_path): BASE_DIR = Path(__file__).parent config = LMCacheEngineConfig.from_file(BASE_DIR / "data/nixl.yaml") dtype = torch.bfloat16 shape = torch.Size([4, 2, 256, 8, 128]) config.nixl_buffer_device = "cuda" # data/nixl.yaml is CPU-mode (no nixl_buffer_size); CUDA mode sizes the # NIXL buffer via nixl_buffer_size, so restore it when flipping the device. config.nixl_buffer_size = 1024**3 config.extra_config["nixl_backend"] = "GDS" config.extra_config["enable_cuda"] = True config.extra_config["nixl_path"] = nixl_tmp_path run(config, shape, dtype) @pytest.mark.no_shared_allocator @pytest.mark.skipif( not _can_register_file_with_nixl_backend("GDS"), reason=_GDS_SKIP_REASON.format(backend="GDS"), ) def test_nixl_gds_cpu_backend(nixl_tmp_path): BASE_DIR = Path(__file__).parent config = LMCacheEngineConfig.from_file(BASE_DIR / "data/nixl.yaml") dtype = torch.bfloat16 shape = torch.Size([4, 2, 256, 8, 128]) config.nixl_buffer_device = "cpu" config.extra_config["nixl_backend"] = "GDS" config.extra_config["enable_cuda"] = False config.extra_config["nixl_path"] = nixl_tmp_path run(config, shape, dtype) @pytest.mark.no_shared_allocator def test_nixl_endpoint_list_empty_raises(): """nixl_endpoint_list=[] should raise ValueError before any nixl ops.""" BASE_DIR = Path(__file__).parent config = LMCacheEngineConfig.from_file(BASE_DIR / "data/nixl.yaml") config.extra_config["nixl_endpoint_list"] = [] metadata = LMCacheMetadata( model_name="Llama-3.1-70B-Instruct", world_size=1, local_world_size=1, worker_id=0, local_worker_id=0, kv_dtype=torch.bfloat16, kv_shape=(4, 2, 256, 8, 128), ) with pytest.raises(ValueError, match="nixl_endpoint_list is set but empty"): NixlStorageConfig.from_cache_engine_config(config, metadata) @pytest.mark.no_shared_allocator def test_nixl_endpoint_list_malformed_url_raises(): """A non-http(s) entry in nixl_endpoint_list should raise ValueError.""" BASE_DIR = Path(__file__).parent config = LMCacheEngineConfig.from_file(BASE_DIR / "data/nixl.yaml") config.extra_config["nixl_endpoint_list"] = ["htps://typo.example.com"] config.extra_config["nixl_backend"] = "OBJ" metadata = LMCacheMetadata( model_name="Llama-3.1-70B-Instruct", world_size=1, local_world_size=1, worker_id=0, local_worker_id=0, kv_dtype=torch.bfloat16, kv_shape=(4, 2, 256, 8, 128), ) with pytest.raises(ValueError, match="is not a valid URL"): NixlStorageConfig.from_cache_engine_config(config, metadata) def _presence_cache_metadata() -> LMCacheMetadata: return LMCacheMetadata( model_name="Llama-3.1-70B-Instruct", world_size=1, local_world_size=1, worker_id=0, local_worker_id=0, kv_dtype=torch.bfloat16, kv_shape=(4, 2, 256, 8, 128), ) @pytest.mark.no_shared_allocator def test_nixl_presence_cache_only_defaults_false(): """presence_cache_only defaults to False when the config key is absent.""" BASE_DIR = Path(__file__).parent config = LMCacheEngineConfig.from_file(BASE_DIR / "data/nixl.yaml") nixl_config = NixlStorageConfig.from_cache_engine_config( config, _presence_cache_metadata() ) assert nixl_config.presence_cache_only is False @pytest.mark.no_shared_allocator def test_nixl_presence_cache_only_parsed(): """nixl_presence_cache_only=True is parsed onto NixlStorageConfig.""" BASE_DIR = Path(__file__).parent config = LMCacheEngineConfig.from_file(BASE_DIR / "data/nixl.yaml") config.extra_config["nixl_presence_cache"] = True config.extra_config["nixl_presence_cache_only"] = True nixl_config = NixlStorageConfig.from_cache_engine_config( config, _presence_cache_metadata() ) assert nixl_config.presence_cache_only is True @pytest.mark.no_shared_allocator def test_nixl_presence_cache_only_requires_presence_cache(): """nixl_presence_cache_only=True without nixl_presence_cache raises ValueError.""" BASE_DIR = Path(__file__).parent config = LMCacheEngineConfig.from_file(BASE_DIR / "data/nixl.yaml") config.extra_config["nixl_presence_cache_only"] = True with pytest.raises(ValueError, match="nixl_presence_cache must be true"): config.validate() @pytest.mark.no_shared_allocator def test_nixl_posix_backend(nixl_tmp_path): BASE_DIR = Path(__file__).parent config = LMCacheEngineConfig.from_file(BASE_DIR / "data/nixl.yaml") dtype = torch.bfloat16 shape = torch.Size([4, 2, 256, 8, 128]) config.nixl_buffer_device = "cpu" config.extra_config["nixl_backend"] = "POSIX" config.extra_config["enable_cuda"] = False config.extra_config["nixl_path"] = nixl_tmp_path run(config, shape, dtype) @pytest.mark.no_shared_allocator def test_nixl_posix_backend_multipath(): """Test NIXL backend with multipath support and path sharding.""" BASE_DIR = Path(__file__).parent config = LMCacheEngineConfig.from_file(BASE_DIR / "data/nixl_multipath.yaml") dtype = torch.bfloat16 shape = torch.Size([4, 2, 256, 8, 128]) config.nixl_buffer_device = "cpu" config.extra_config["nixl_backend"] = "POSIX" config.extra_config["enable_cuda"] = False # Test that multipath configuration is properly handled assert isinstance(config.extra_config["nixl_path"], list) assert len(config.extra_config["nixl_path"]) == 3 assert config.extra_config["nixl_path_sharding"] == "by_gpu" run(config, shape, dtype)