# SPDX-License-Identifier: Apache-2.0 """Tests for Device-DAX-backed L1 allocation. The tests use a regular mmap-able file rather than requiring real ``/dev/dax`` hardware. That exercises the allocator contract and storage manager wiring while keeping CI portable. """ # Standard from typing import Any, cast import argparse import gc import json import os # Third Party import pytest import torch # First Party from lmcache.v1.distributed.api import MemoryLayoutDesc, ObjectKey from lmcache.v1.distributed.config import ( EvictionConfig, L1ManagerConfig, L1MemoryManagerConfig, StorageManagerConfig, add_storage_manager_args, parse_args_to_config, ) from lmcache.v1.distributed.error import L1Error from lmcache.v1.distributed.l1_manager import L1Manager from lmcache.v1.distributed.l2_adapters.config import ( L2AdapterConfigBase, L2AdaptersConfig, get_type_name_for_config, ) from lmcache.v1.distributed.memory_manager.devdax_l1_memory_manager import ( DevDaxL1MemoryManager, ) from lmcache.v1.memory_allocators.devdax_memory_allocator import DevDaxMemoryAllocator from lmcache.v1.multiprocess.config import add_mp_server_args from lmcache.v1.multiprocess.engine_context import MPCacheServerContext import lmcache.v1.memory_management as memory_management def _make_mmap_file( tmp_path, size: int = 4 * 1024 * 1024, name: str = "l1-devdax-test.bin" ) -> str: path = tmp_path / name with open(path, "wb") as f: f.truncate(size) return str(path) def _key(seed: int = 0) -> ObjectKey: return ObjectKey( chunk_hash=seed.to_bytes(4, "big") + b"\0" * 28, model_name="devdax-l1-test", kv_rank=0, ) def _layout(num_bytes: int = 4096) -> MemoryLayoutDesc: return MemoryLayoutDesc(shapes=[torch.Size([num_bytes])], dtypes=[torch.uint8]) def _parse_mp_storage_args(args: list[str]) -> StorageManagerConfig: parser = argparse.ArgumentParser() add_mp_server_args(parser) add_storage_manager_args(parser) return parse_args_to_config(parser.parse_args(args)) class _FakeMooncakeL2Config: def __init__(self, setup_config: dict[str, str]) -> None: self.setup_config = setup_config class _FakeExt: is_pin_supported = True def __init__(self, fake_runtime: "_FakeCudaRuntime") -> None: self._runtime = fake_runtime def pin_memory(self, ptr: int, size: int, flags: int = 0) -> bool: self._runtime.register_calls.append((ptr, size, flags)) return self._runtime.register_error == 0 def unpin_memory(self, ptr: int) -> bool: self._runtime.unregister_calls.append(ptr) return True class _FakeCudaRuntime: def __init__(self, register_error: int = 0) -> None: self.register_error = register_error self.register_calls: list[tuple[int, int, int]] = [] self.unregister_calls: list[int] = [] self.synchronize_calls = 0 self.ext = _FakeExt(self) def is_available(self) -> bool: return True def synchronize(self) -> None: self.synchronize_calls += 1 def cudart(self) -> "_FakeCudaRuntime": return self def cudaHostRegister(self, ptr: int, size: int, flags: int) -> int: self.register_calls.append((ptr, size, flags)) return self.register_error def cudaHostUnregister(self, ptr: int) -> int: self.unregister_calls.append(ptr) return 0 def _hybrid_storage_config(path: str, adapter_config: object) -> StorageManagerConfig: config = StorageManagerConfig( l1_manager_config=L1ManagerConfig( memory_config=L1MemoryManagerConfig( size_in_bytes=1024 * 1024, use_lazy=False, shm_name="", devdax_path=path, devdax_size_in_bytes=1024 * 1024, ) ), eviction_config=EvictionConfig(eviction_policy="LRU"), l2_adapter_config=L2AdaptersConfig( adapters=[cast(L2AdapterConfigBase, adapter_config)] ), ) return config def test_devdax_config_rejects_lazy_allocation(tmp_path): path = _make_mmap_file(tmp_path) with pytest.raises(ValueError, match="--no-l1-use-lazy"): L1MemoryManagerConfig( size_in_bytes=1024 * 1024, use_lazy=True, shm_name="", devdax_path=path, ) def test_devdax_config_rejects_shm(tmp_path): path = _make_mmap_file(tmp_path) with pytest.raises(ValueError, match="--shm-name"): L1MemoryManagerConfig( size_in_bytes=1024 * 1024, use_lazy=False, shm_name="lmcache_l1_pool_test", devdax_path=path, devdax_size_in_bytes=2 * 1024 * 1024, ) def test_devdax_config_accepts_explicit_lazy_and_shm_disable(tmp_path): path = _make_mmap_file(tmp_path) cfg = L1MemoryManagerConfig( size_in_bytes=1024 * 1024, use_lazy=False, shm_name="", devdax_path=path, ) assert cfg.devdax_path == path assert cfg.use_lazy is False assert cfg.shm_name == "" @pytest.mark.parametrize( ("adapter_name", "adapter_config"), [ ("nixl_store", object()), ("nixl_store_dynamic", object()), ("mooncake_store", _FakeMooncakeL2Config({"protocol": "rdma"})), ], ) def test_devdax_overflow_rejects_single_region_l2_adapters( tmp_path, monkeypatch, adapter_name, adapter_config ): path = _make_mmap_file(tmp_path) monkeypatch.setattr( "lmcache.v1.distributed.config.get_type_name_for_config", lambda _: adapter_name, ) with pytest.raises(ValueError, match=adapter_name): _hybrid_storage_config(path, adapter_config) def test_devdax_overflow_allows_mooncake_without_rdma(tmp_path, monkeypatch): path = _make_mmap_file(tmp_path) monkeypatch.setattr( "lmcache.v1.distributed.config.get_type_name_for_config", lambda _: "mooncake_store", ) config = _hybrid_storage_config(path, _FakeMooncakeL2Config({"protocol": "tcp"})) assert config.l1_manager_config.memory_config.devdax_size_in_bytes == 1024 * 1024 def test_devdax_allocator_uses_mmap_backing_file(tmp_path): path = _make_mmap_file(tmp_path) allocator = DevDaxMemoryAllocator( size=1024 * 1024, device_path=path, align_bytes=4096, ) objs = allocator.batched_allocate(torch.Size([4096]), torch.uint8, 2) assert objs is not None first = objs[0] assert first.data_ptr == allocator.buffer.data_ptr() assert first.shm_offset == 0 first.raw_tensor.fill_(0x5A) allocator.batched_free(objs) del first del objs gc.collect() allocator.close() with open(path, "rb") as f: assert f.read(4096) == bytes([0x5A]) * 4096 def test_devdax_allocator_registers_cuda_host_mapping(tmp_path, monkeypatch): path = _make_mmap_file(tmp_path) cuda_runtime = _FakeCudaRuntime() monkeypatch.setattr(memory_management, "torch_device_type", "cuda") monkeypatch.setattr(memory_management, "torch_dev", cuda_runtime) monkeypatch.setattr(memory_management, "current_device_spec", cuda_runtime.ext) allocator = DevDaxMemoryAllocator( size=1024 * 1024, device_path=path, align_bytes=4096, ) ptr = allocator.buffer.data_ptr() assert cuda_runtime.register_calls == [(ptr, 1024 * 1024, 0)] allocator.close() assert cuda_runtime.unregister_calls == [ptr] def test_devdax_allocator_falls_back_when_cuda_host_register_fails( tmp_path, monkeypatch ): path = _make_mmap_file(tmp_path) cuda_runtime = _FakeCudaRuntime(register_error=1) monkeypatch.setattr(memory_management, "torch_device_type", "cuda") monkeypatch.setattr(memory_management, "torch_dev", cuda_runtime) monkeypatch.setattr(memory_management, "current_device_spec", cuda_runtime.ext) allocator = DevDaxMemoryAllocator( size=1024 * 1024, device_path=path, align_bytes=4096, ) obj = allocator.allocate(torch.Size([4096]), torch.uint8) assert cuda_runtime.register_calls == [ (allocator.buffer.data_ptr(), 1024 * 1024, 0) ] assert cuda_runtime.unregister_calls == [] assert obj is not None allocator.free(obj) del obj gc.collect() allocator.close() assert cuda_runtime.unregister_calls == [] def test_devdax_close_failure_preserves_allocator_state(tmp_path): path = _make_mmap_file(tmp_path) allocator = DevDaxMemoryAllocator( size=1024 * 1024, device_path=path, align_bytes=4096, ) obj = allocator.allocate(torch.Size([4096]), torch.uint8) assert obj is not None with pytest.raises(BufferError): allocator.close() assert allocator.devdax_allocator is not None assert allocator.devdax_buffer.numel() == 1024 * 1024 allocator.free(obj) del obj gc.collect() allocator.close() def test_l1_manager_round_trip_on_devdax_mapping(tmp_path): path = _make_mmap_file(tmp_path) cfg = L1ManagerConfig( memory_config=L1MemoryManagerConfig( size_in_bytes=1024 * 1024, use_lazy=False, shm_name="", devdax_path=path, ) ) manager = L1Manager(cfg) key = _key(1) write = manager.reserve_write([key], [False], _layout()) assert write[key][0] == L1Error.SUCCESS obj = write[key][1] assert obj is not None obj.tensor.fill_(0x23) assert manager.finish_write([key])[key] == L1Error.SUCCESS read = manager.reserve_read([key]) assert read[key][0] == L1Error.SUCCESS read_obj = read[key][1] assert read_obj is not None assert int(read_obj.tensor[0]) == 0x23 assert manager.finish_read([key])[key] == L1Error.SUCCESS del write del read del obj del read_obj gc.collect() manager.close() with open(path, "rb") as f: assert f.read(1) == bytes([0x23]) def test_devdax_l1_memory_manager_spills_from_dram_to_devdax(tmp_path): path = _make_mmap_file(tmp_path, size=8192) manager = DevDaxL1MemoryManager( L1MemoryManagerConfig( size_in_bytes=8192, use_lazy=False, shm_name="", align_bytes=4096, devdax_path=path, devdax_size_in_bytes=8192, ) ) error, objs = manager.allocate(_layout(4096), count=3) assert error == L1Error.SUCCESS assert len(objs) == 3 assert isinstance(manager._allocator, DevDaxMemoryAllocator) assert manager._allocator.local_allocator is not None assert objs[0].parent() is manager._allocator.local_allocator assert objs[1].parent() is manager._allocator.local_allocator assert objs[2].parent() is manager._allocator assert objs[0].data_ptr == manager._allocator.local_allocator.buffer.data_ptr() assert ( objs[1].data_ptr == manager._allocator.local_allocator.buffer.data_ptr() + 4096 ) assert objs[2].data_ptr == manager._allocator.devdax_buffer.data_ptr() used, total = manager.get_memory_usage() assert used == 3 * 4096 assert total == 4 * 4096 objs[2].raw_tensor.fill_(0x6D) manager.free(objs) used, total = manager.get_memory_usage() assert used == 0 assert total == 4 * 4096 manager.close() with open(path, "rb") as f: assert f.read(4096) == bytes([0x6D]) * 4096 def test_devdax_l1_memory_manager_reports_devdax_desc(tmp_path): path = _make_mmap_file(tmp_path) manager = DevDaxL1MemoryManager( L1MemoryManagerConfig( size_in_bytes=1024 * 1024, use_lazy=False, shm_name="", devdax_path=path, ) ) desc = manager.get_l1_memory_desc() used, total = manager.get_memory_usage() assert desc.ptr != 0 assert desc.size == 1024 * 1024 assert desc.align_bytes == 4096 assert used == 0 assert total == 1024 * 1024 manager.close() def test_cli_parses_l1_devdax_path(tmp_path): path = _make_mmap_file(tmp_path) config = _parse_mp_storage_args( [ "--l1-size-gb", "1", "--eviction-policy", "LRU", "--no-l1-use-lazy", "--shm-name", "", "--l1-devdax-path", path, ] ) mem_cfg = config.l1_manager_config.memory_config assert mem_cfg.devdax_path == path assert mem_cfg.use_lazy is False assert mem_cfg.shm_name == "" def test_cli_rejects_devdax_l1_with_gds_l1(tmp_path): path = _make_mmap_file(tmp_path) with pytest.raises(ValueError, match="gds-l1-path"): _parse_mp_storage_args( [ "--l1-size-gb", "1", "--eviction-policy", "LRU", "--no-l1-use-lazy", "--shm-name", "", "--l1-devdax-path", path, "--gds-l1-path", str(tmp_path), ] ) def test_cli_infers_l1_devdax_overflow_from_registered_dax_adapter(tmp_path): path = _make_mmap_file(tmp_path) config = _parse_mp_storage_args( [ "--l1-size-gb", "1", "--eviction-policy", "LRU", "--no-l1-use-lazy", "--shm-name", "", "--l1-devdax-path", path, "--l2-adapter", ('{"type":"dax","device_path":"%s","max_dax_size_gb":2,"slot_bytes":4096}') % path, ] ) mem_cfg = config.l1_manager_config.memory_config assert mem_cfg.size_in_bytes == 1 << 30 assert mem_cfg.devdax_path == path assert mem_cfg.devdax_size_in_bytes == 2 << 30 assert mem_cfg.use_lazy is False assert mem_cfg.shm_name == "" assert config.l2_adapter_config.adapters == [] @pytest.mark.parametrize( ("adapter_spec", "expected_adapter_type"), [ ( { "type": "raw_block", "device_path": "rawblock-l2.bin", "slot_bytes": 8192, "capacity_bytes": 16384, "meta_total_bytes": 4096, "use_odirect": False, "meta_enable_periodic": False, "load_checkpoint_on_init": False, "meta_verify_on_load": False, }, "raw_block", ), ], ) def test_cli_hybrid_l1_keeps_ordinary_l2_adapters( tmp_path, adapter_spec, expected_adapter_type ): path = _make_mmap_file(tmp_path) adapter_spec = { key: str(tmp_path / value) if key in ("base_path", "device_path") else value for key, value in adapter_spec.items() } config = _parse_mp_storage_args( [ "--l1-size-gb", "1", "--eviction-policy", "LRU", "--no-l1-use-lazy", "--shm-name", "", "--l1-devdax-path", path, "--l2-adapter", json.dumps( { "type": "dax", "device_path": path, "max_dax_size_gb": 2, "slot_bytes": 4096, } ), "--l2-adapter", json.dumps(adapter_spec), ] ) mem_cfg = config.l1_manager_config.memory_config assert mem_cfg.devdax_size_in_bytes == 2 << 30 assert len(config.l2_adapter_config.adapters) == 1 assert ( get_type_name_for_config(config.l2_adapter_config.adapters[0]) == expected_adapter_type ) def test_cli_hybrid_l1_splits_matching_dax_device_and_keeps_other_l2(tmp_path): l1_dax_path = _make_mmap_file(tmp_path, name="l1-devdax.bin") l2_dax_path = _make_mmap_file(tmp_path, name="l2-devdax.bin") config = _parse_mp_storage_args( [ "--l1-size-gb", "1", "--eviction-policy", "LRU", "--no-l1-use-lazy", "--shm-name", "", "--l1-devdax-path", l1_dax_path, "--l2-adapter", json.dumps( { "type": "dax", "devices": [ {"device_path": l1_dax_path, "max_dax_size_gb": 2}, {"device_path": l2_dax_path, "max_dax_size_gb": 3}, ], "slot_bytes": 4096, "hotplug_enabled": True, "num_store_workers": 2, "num_lookup_workers": 3, "num_load_workers": 4, } ), ] ) mem_cfg = config.l1_manager_config.memory_config assert mem_cfg.devdax_size_in_bytes == 2 << 30 assert len(config.l2_adapter_config.adapters) == 1 dax_adapter = cast(Any, config.l2_adapter_config.adapters[0]) assert get_type_name_for_config(dax_adapter) == "dax" assert [device.device_path for device in dax_adapter.devices] == [l2_dax_path] assert dax_adapter.max_dax_size_gb == 3 assert dax_adapter.hotplug_enabled is True assert dax_adapter.num_store_workers == 2 assert dax_adapter.num_lookup_workers == 3 assert dax_adapter.num_load_workers == 4 def test_devdax_l1_does_not_advertise_shm_pool(tmp_path): path = _make_mmap_file(tmp_path) config = StorageManagerConfig( l1_manager_config=L1ManagerConfig( memory_config=L1MemoryManagerConfig( size_in_bytes=1024 * 1024, use_lazy=False, shm_name="", devdax_path=path, ) ), eviction_config=EvictionConfig(eviction_policy="LRU"), ) context = MPCacheServerContext(config) try: assert context.shm_pool_info == {"shm_name": "", "pool_size": 0} assert os.path.exists(path) finally: context.storage_manager.close()