Files
2026-07-13 12:24:33 +08:00

484 lines
16 KiB
Python

# 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)