lmcache--lmcache
524 行
17 KiB
Python
524 行
17 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# Standard
|
|
from typing import List, Tuple
|
|
import argparse
|
|
import asyncio
|
|
import os
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
|
|
# Third Party
|
|
import pytest
|
|
import torch
|
|
|
|
pytest.importorskip("nixl", reason="nixl package is required for nixl tests")
|
|
|
|
# First Party
|
|
from lmcache.logging import init_logger
|
|
from lmcache.utils import CacheEngineKey
|
|
from lmcache.v1.config import LMCacheEngineConfig
|
|
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
|
|
from lmcache.v1.storage_backend.nixl_storage_backend import (
|
|
NixlStorageBackend,
|
|
NixlStorageConfig,
|
|
)
|
|
from lmcache.v1.transfer_channel.transfer_utils import get_correct_device
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
|
|
def generate_test_data(
|
|
num_objs: int, shape: torch.Size, dtype: torch.dtype = torch.bfloat16
|
|
) -> Tuple[List[CacheEngineKey], List[MemoryObj]]:
|
|
keys = []
|
|
objs = []
|
|
allocator = AdHocMemoryAllocator(
|
|
device="cuda" if torch.cuda.is_available() else "cpu",
|
|
)
|
|
for i in range(num_objs):
|
|
keys.append(
|
|
CacheEngineKey(
|
|
model_name="test_model",
|
|
world_size=1,
|
|
worker_id=0,
|
|
chunk_hash=i,
|
|
dtype=dtype,
|
|
)
|
|
)
|
|
obj = allocator.allocate(shape, dtype, fmt=MemoryFormat.KV_2LTD)
|
|
obj.tensor.fill_((i + 1) / num_objs) # Fill with some test data
|
|
objs.append(obj)
|
|
return keys, objs
|
|
|
|
|
|
def calculate_throughput(total_bytes: int, elapsed_time: float) -> float:
|
|
"""Calculate throughput in GB/s"""
|
|
if elapsed_time == 0:
|
|
return float("inf")
|
|
gb = total_bytes / (1024 * 1024 * 1024)
|
|
return gb / elapsed_time
|
|
|
|
|
|
def create_test_config(
|
|
buffer_device: str = "cuda" if torch.cuda.is_available() else "cpu",
|
|
backend: str = "GDS_MT" if torch.cuda.is_available() else "POSIX",
|
|
) -> LMCacheEngineConfig:
|
|
"""Create a test configuration for NixlStorageBackend"""
|
|
config = LMCacheEngineConfig()
|
|
config.nixl_buffer_size = 2**32 # 4GB
|
|
config.nixl_buffer_device = buffer_device
|
|
config.extra_config = {
|
|
"enable_nixl_storage": True,
|
|
"nixl_backend": backend,
|
|
"nixl_pool_size": 10,
|
|
"nixl_path": tempfile.mkdtemp(), # Create a temporary directory for testing
|
|
}
|
|
return config
|
|
|
|
|
|
def create_test_metadata() -> LMCacheMetadata:
|
|
"""Create test metadata for NixlStorageBackend"""
|
|
return LMCacheMetadata(
|
|
model_name="test_model",
|
|
worker_id=0,
|
|
local_world_size=1,
|
|
local_worker_id=0,
|
|
world_size=1,
|
|
kv_dtype=torch.bfloat16,
|
|
kv_shape=(
|
|
32,
|
|
2,
|
|
256,
|
|
1024,
|
|
128,
|
|
), # (num_layer, 2, chunk_size, num_kv_head, head_size)
|
|
)
|
|
|
|
|
|
@pytest.mark.no_shared_allocator
|
|
def test_nixl_storage_config():
|
|
"""Test NixlStorageConfig creation and validation"""
|
|
config = create_test_config()
|
|
metadata = create_test_metadata()
|
|
|
|
nixl_config = NixlStorageConfig.from_cache_engine_config(config, metadata)
|
|
if config.nixl_buffer_device == "cpu":
|
|
# CPU mode shares LocalCPUBackend's pool (sized by max_local_cpu_size),
|
|
# so the NIXL buffer_size is left at 0.
|
|
assert nixl_config.buffer_size == 0
|
|
else:
|
|
assert nixl_config.buffer_size == config.nixl_buffer_size
|
|
# buffer_device is normalized to include the worker's device id
|
|
# (e.g. "cuda" -> "cuda:0"); "cpu" stays "cpu".
|
|
assert nixl_config.buffer_device == get_correct_device(
|
|
config.nixl_buffer_device, metadata.worker_id
|
|
)
|
|
assert nixl_config.backend == config.extra_config["nixl_backend"]
|
|
assert nixl_config.pool_size == config.extra_config["nixl_pool_size"]
|
|
assert nixl_config.path == config.extra_config["nixl_path"]
|
|
|
|
# Test validation
|
|
assert NixlStorageConfig.validate_nixl_backend("GDS", "cuda")
|
|
assert NixlStorageConfig.validate_nixl_backend("GDS", "cpu")
|
|
assert NixlStorageConfig.validate_nixl_backend("GDS_MT", "cuda")
|
|
assert NixlStorageConfig.validate_nixl_backend("GDS_MT", "cpu")
|
|
assert NixlStorageConfig.validate_nixl_backend("POSIX", "cpu")
|
|
assert not NixlStorageConfig.validate_nixl_backend("POSIX", "cuda")
|
|
assert not NixlStorageConfig.validate_nixl_backend("INVALID", "cpu")
|
|
|
|
|
|
def _make_obj_config(
|
|
extra_overrides: dict | None = None,
|
|
) -> LMCacheEngineConfig:
|
|
"""Create a minimal OBJ-backend config for endpoint-list tests."""
|
|
config = LMCacheEngineConfig()
|
|
# CPU mode: nixl_buffer_size is rejected; the pool comes from LocalCPUBackend.
|
|
config.nixl_buffer_device = "cpu"
|
|
config.extra_config = {
|
|
"enable_nixl_storage": True,
|
|
"nixl_backend": "OBJ",
|
|
"nixl_pool_size": 0,
|
|
"nixl_path": tempfile.mkdtemp(),
|
|
}
|
|
if extra_overrides:
|
|
config.extra_config.update(extra_overrides)
|
|
return config
|
|
|
|
|
|
def _make_metadata(local_worker_id: int = 0) -> LMCacheMetadata:
|
|
"""Create test metadata with a configurable local_worker_id."""
|
|
return LMCacheMetadata(
|
|
model_name="test_model",
|
|
worker_id=local_worker_id,
|
|
local_world_size=1,
|
|
local_worker_id=local_worker_id,
|
|
world_size=1,
|
|
kv_dtype=torch.bfloat16,
|
|
kv_shape=(32, 2, 256, 1024, 128),
|
|
)
|
|
|
|
|
|
@pytest.mark.no_shared_allocator
|
|
def test_endpoint_list_round_robin():
|
|
"""nixl_endpoint_list should assign endpoints to workers round-robin."""
|
|
endpoints = [
|
|
"https://node-0:9021",
|
|
"https://node-1:9021",
|
|
"https://node-2:9021",
|
|
]
|
|
config = _make_obj_config({"nixl_endpoint_list": endpoints})
|
|
|
|
for local_worker_id in range(6):
|
|
metadata = _make_metadata(local_worker_id=local_worker_id)
|
|
nixl_config = NixlStorageConfig.from_cache_engine_config(config, metadata)
|
|
expected = endpoints[local_worker_id % len(endpoints)]
|
|
assert nixl_config.backend_params["endpoint_override"] == expected
|
|
|
|
|
|
@pytest.mark.no_shared_allocator
|
|
def test_endpoint_list_overrides_endpoint_override():
|
|
"""nixl_endpoint_list takes precedence over backend_params endpoint_override."""
|
|
endpoints = ["https://node-0:9021"]
|
|
config = _make_obj_config(
|
|
{
|
|
"nixl_endpoint_list": endpoints,
|
|
"nixl_backend_params": {
|
|
"endpoint_override": "https://should-be-ignored:9021",
|
|
"access_key": "key",
|
|
},
|
|
}
|
|
)
|
|
metadata = _make_metadata(local_worker_id=0)
|
|
|
|
nixl_config = NixlStorageConfig.from_cache_engine_config(config, metadata)
|
|
|
|
assert nixl_config.backend_params["endpoint_override"] == endpoints[0]
|
|
# other params must be preserved
|
|
assert nixl_config.backend_params["access_key"] == "key"
|
|
|
|
|
|
@pytest.mark.no_shared_allocator
|
|
def test_endpoint_list_does_not_mutate_original_config():
|
|
"""Setting nixl_endpoint_list must not mutate the original backend_params dict."""
|
|
original_params = {"access_key": "key", "secret_key": "secret"}
|
|
config = _make_obj_config(
|
|
{
|
|
"nixl_endpoint_list": ["https://node-0:9021"],
|
|
"nixl_backend_params": original_params,
|
|
}
|
|
)
|
|
metadata = _make_metadata(local_worker_id=0)
|
|
|
|
NixlStorageConfig.from_cache_engine_config(config, metadata)
|
|
|
|
assert "endpoint_override" not in original_params
|
|
|
|
|
|
@pytest.mark.no_shared_allocator
|
|
def test_endpoint_list_empty_raises():
|
|
"""nixl_endpoint_list=[] should raise ValueError before any nixl ops."""
|
|
config = _make_obj_config({"nixl_endpoint_list": []})
|
|
metadata = _make_metadata(local_worker_id=0)
|
|
|
|
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_no_endpoint_list_leaves_backend_params_unchanged():
|
|
"""When nixl_endpoint_list is absent, endpoint_override must not be injected."""
|
|
config = _make_obj_config() # no nixl_endpoint_list key
|
|
metadata = _make_metadata(local_worker_id=0)
|
|
|
|
nixl_config = NixlStorageConfig.from_cache_engine_config(config, metadata)
|
|
|
|
assert "endpoint_override" not in nixl_config.backend_params
|
|
|
|
|
|
@pytest.mark.no_shared_allocator
|
|
def test_endpoint_list_malformed_url_raises():
|
|
"""A non-http(s) entry in nixl_endpoint_list should raise ValueError."""
|
|
config = _make_obj_config({"nixl_endpoint_list": ["htps://typo.example.com"]})
|
|
metadata = _make_metadata(local_worker_id=0)
|
|
|
|
with pytest.raises(ValueError, match="is not a valid URL"):
|
|
NixlStorageConfig.from_cache_engine_config(config, metadata)
|
|
|
|
|
|
@pytest.mark.no_shared_allocator
|
|
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires CUDA")
|
|
def test_nixl_storage_backend_basic():
|
|
"""Test basic NixlStorageBackend operations"""
|
|
config = create_test_config()
|
|
metadata = create_test_metadata()
|
|
|
|
thread_loop = None
|
|
thread = None
|
|
backend = None
|
|
try:
|
|
thread_loop = asyncio.new_event_loop()
|
|
thread = threading.Thread(target=thread_loop.run_forever)
|
|
thread.start()
|
|
|
|
backend = NixlStorageBackend.CreateNixlStorageBackend(
|
|
config=config,
|
|
loop=thread_loop,
|
|
metadata=metadata,
|
|
)
|
|
|
|
# Test allocation
|
|
shape = torch.Size([32, 2, 256, 1024])
|
|
dtype = torch.bfloat16
|
|
obj = backend.allocate(shape, dtype)
|
|
assert obj is not None
|
|
assert obj.tensor is not None
|
|
assert obj.tensor.shape == shape
|
|
assert obj.tensor.dtype == dtype
|
|
|
|
# Test batched allocation
|
|
batch_size = 5
|
|
objs = backend.batched_allocate(shape, dtype, batch_size)
|
|
assert objs is not None
|
|
assert len(objs) == batch_size
|
|
for obj in objs:
|
|
assert obj.tensor is not None
|
|
assert obj.tensor.shape == shape
|
|
assert obj.tensor.dtype == dtype
|
|
|
|
except Exception:
|
|
raise
|
|
finally:
|
|
if backend:
|
|
backend.close()
|
|
if thread_loop and thread_loop.is_running():
|
|
thread_loop.call_soon_threadsafe(thread_loop.stop)
|
|
if thread and thread.is_alive():
|
|
thread.join()
|
|
# Cleanup temporary directory
|
|
if os.path.exists(config.extra_config["nixl_path"]):
|
|
os.rmdir(config.extra_config["nixl_path"])
|
|
|
|
|
|
@pytest.mark.no_shared_allocator
|
|
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires CUDA")
|
|
def test_nixl_storage_backend_put_get():
|
|
"""Test put and get operations in NixlStorageBackend"""
|
|
config = create_test_config()
|
|
metadata = create_test_metadata()
|
|
|
|
thread_loop = None
|
|
thread = None
|
|
backend = None
|
|
try:
|
|
thread_loop = asyncio.new_event_loop()
|
|
thread = threading.Thread(target=thread_loop.run_forever)
|
|
thread.start()
|
|
|
|
backend = NixlStorageBackend.CreateNixlStorageBackend(
|
|
config=config,
|
|
loop=thread_loop,
|
|
metadata=metadata,
|
|
)
|
|
|
|
# Generate test data
|
|
keys, objs = generate_test_data(10, torch.Size([32, 2, 256, 1024]))
|
|
|
|
# Test contains before put
|
|
for key in keys:
|
|
assert not backend.contains(key)
|
|
assert not backend.exists_in_put_tasks(key)
|
|
|
|
# Test put
|
|
backend.batched_submit_put_task(keys, objs)
|
|
|
|
# Test get
|
|
for key, original_obj in zip(keys, objs, strict=False):
|
|
assert backend.contains(key)
|
|
retrieved_obj = backend.get_blocking(key)
|
|
assert retrieved_obj is not None
|
|
assert retrieved_obj.tensor is not None
|
|
assert torch.equal(retrieved_obj.tensor, original_obj.tensor)
|
|
|
|
# Test batched get
|
|
retrieved_objs = asyncio.run(
|
|
backend.batched_get_non_blocking(lookup_id="test", keys=keys)
|
|
)
|
|
assert len(retrieved_objs) == len(objs)
|
|
for retrieved_obj, original_obj in zip(retrieved_objs, objs, strict=False):
|
|
assert retrieved_obj is not None
|
|
assert retrieved_obj.tensor is not None
|
|
assert torch.equal(retrieved_obj.tensor, original_obj.tensor)
|
|
|
|
# Test remove
|
|
for key in keys:
|
|
backend.remove(key)
|
|
assert not backend.contains(key)
|
|
|
|
except Exception:
|
|
raise
|
|
finally:
|
|
if backend:
|
|
backend.close()
|
|
if thread_loop and thread_loop.is_running():
|
|
thread_loop.call_soon_threadsafe(thread_loop.stop)
|
|
if thread and thread.is_alive():
|
|
thread.join()
|
|
# Cleanup temporary directory
|
|
if os.path.exists(config.extra_config["nixl_path"]):
|
|
os.rmdir(config.extra_config["nixl_path"])
|
|
|
|
|
|
@pytest.mark.no_shared_allocator
|
|
@pytest.mark.skipif(not torch.cuda.is_available(), reason="Requires CUDA")
|
|
def test_nixl_storage_backend_different_backends():
|
|
"""Test NixlStorageBackend with different backend types"""
|
|
backends = (
|
|
[
|
|
("GDS_MT", "cuda"),
|
|
("GDS", "cuda"),
|
|
("GDS_MT", "cpu"),
|
|
("GDS", "cpu"),
|
|
("POSIX", "cpu"),
|
|
]
|
|
if torch.cuda.is_available()
|
|
else [
|
|
("GDS_MT", "cpu"),
|
|
("GDS", "cpu"),
|
|
("POSIX", "cpu"),
|
|
]
|
|
)
|
|
|
|
for backend_type, device in backends:
|
|
config = create_test_config(buffer_device=device, backend=backend_type)
|
|
metadata = create_test_metadata()
|
|
|
|
thread_loop = None
|
|
thread = None
|
|
backend = None
|
|
try:
|
|
thread_loop = asyncio.new_event_loop()
|
|
thread = threading.Thread(target=thread_loop.run_forever)
|
|
thread.start()
|
|
|
|
backend = NixlStorageBackend.CreateNixlStorageBackend(
|
|
config=config,
|
|
loop=thread_loop,
|
|
metadata=metadata,
|
|
)
|
|
|
|
# Basic allocation test
|
|
obj = backend.allocate(torch.Size([32, 2, 256, 1024]), torch.bfloat16)
|
|
assert obj is not None
|
|
assert obj.tensor is not None
|
|
|
|
except Exception:
|
|
raise
|
|
finally:
|
|
if backend:
|
|
backend.close()
|
|
if thread_loop and thread_loop.is_running():
|
|
thread_loop.call_soon_threadsafe(thread_loop.stop)
|
|
if thread and thread.is_alive():
|
|
thread.join()
|
|
# Cleanup temporary directory
|
|
if os.path.exists(config.extra_config["nixl_path"]):
|
|
os.rmdir(config.extra_config["nixl_path"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(
|
|
description="Test NixlStorageBackend with different configurations"
|
|
)
|
|
parser.add_argument(
|
|
"--backend",
|
|
type=str,
|
|
default="GDS_MT",
|
|
choices=["GDS_MT", "GDS", "POSIX"],
|
|
help="NIXL backend type to use",
|
|
)
|
|
parser.add_argument(
|
|
"--device",
|
|
type=str,
|
|
default="cuda",
|
|
choices=["cuda", "cpu"],
|
|
help="Device to use for buffer",
|
|
)
|
|
parser.add_argument(
|
|
"--num-objs",
|
|
type=int,
|
|
default=100,
|
|
help="Number of objects to test with",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
# Create config and metadata
|
|
config = create_test_config(buffer_device=args.device, backend=args.backend)
|
|
metadata = create_test_metadata()
|
|
|
|
thread_loop = None
|
|
thread = None
|
|
backend = None
|
|
try:
|
|
thread_loop = asyncio.new_event_loop()
|
|
thread = threading.Thread(target=thread_loop.run_forever)
|
|
thread.start()
|
|
|
|
# Create backend
|
|
backend = NixlStorageBackend.CreateNixlStorageBackend(
|
|
config=config,
|
|
loop=thread_loop,
|
|
metadata=metadata,
|
|
)
|
|
|
|
# Generate and test with data
|
|
keys, objs = generate_test_data(args.num_objs, torch.Size([32, 2, 256, 1024]))
|
|
total_size = sum(obj.get_size() for obj in objs)
|
|
logger.info(
|
|
"Generated %d objects with total size %.2f MB",
|
|
len(objs),
|
|
total_size / (1024 * 1024),
|
|
)
|
|
|
|
# Test put performance
|
|
start_time = time.time()
|
|
backend.batched_submit_put_task(keys, objs)
|
|
end_time = time.time()
|
|
elapsed_time = end_time - start_time
|
|
throughput = calculate_throughput(total_size, elapsed_time)
|
|
logger.info("Put throughput: %.2f GB/s", throughput)
|
|
|
|
# Test get performance
|
|
start_time = time.time()
|
|
retrieved_objs = asyncio.run(
|
|
backend.batched_get_non_blocking(lookup_id="test", keys=keys)
|
|
)
|
|
end_time = time.time()
|
|
elapsed_time = end_time - start_time
|
|
throughput = calculate_throughput(total_size, elapsed_time)
|
|
logger.info("Get throughput: %.2f GB/s", throughput)
|
|
|
|
# Verify data
|
|
for retrieved_obj, original_obj in zip(retrieved_objs, objs, strict=False):
|
|
assert torch.equal(retrieved_obj.tensor, original_obj.tensor)
|
|
|
|
logger.info("All tests passed successfully!")
|
|
|
|
except Exception:
|
|
raise
|
|
finally:
|
|
if backend:
|
|
backend.close()
|
|
if thread_loop and thread_loop.is_running():
|
|
thread_loop.call_soon_threadsafe(thread_loop.stop)
|
|
if thread and thread.is_alive():
|
|
thread.join()
|
|
# Cleanup temporary directory
|
|
if os.path.exists(config.extra_config["nixl_path"]):
|
|
os.rmdir(config.extra_config["nixl_path"])
|