453 lines
16 KiB
Python
453 lines
16 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# Standard
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
import asyncio
|
|
import tempfile
|
|
|
|
# Third Party
|
|
import pytest
|
|
import torch
|
|
|
|
# First Party
|
|
from lmcache.v1.config import LMCacheEngineConfig
|
|
from lmcache.v1.memory_allocators.pin_memory_allocator import PinMemoryAllocator
|
|
from lmcache.v1.metadata import LMCacheMetadata
|
|
from lmcache.v1.protocol import RemoteMetadata
|
|
from lmcache.v1.storage_backend import LocalCPUBackend
|
|
from lmcache.v1.storage_backend.connector import CreateConnector
|
|
|
|
# Local
|
|
from .utils import (
|
|
check_mem_obj_equal,
|
|
close_asyncio_loop,
|
|
dumb_cache_engine_key,
|
|
init_asyncio_loop,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("lmserver_v1_process", ["cpu"], indirect=True)
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"lm://localhost:65000",
|
|
],
|
|
)
|
|
def test_lm_connector(url, autorelease_v1, lmserver_v1_process):
|
|
if url.startswith("lm"):
|
|
url = lmserver_v1_process.server_url
|
|
|
|
async_loop, async_thread = init_asyncio_loop()
|
|
memory_allocator = PinMemoryAllocator(1024 * 1024 * 1024)
|
|
local_cpu_backend = _create_local_cpu_backend(memory_allocator, False)
|
|
connector = autorelease_v1(CreateConnector(url, async_loop, local_cpu_backend))
|
|
|
|
random_key = dumb_cache_engine_key()
|
|
future = asyncio.run_coroutine_threadsafe(connector.exists(random_key), async_loop)
|
|
assert not future.result()
|
|
|
|
num_tokens = 1000
|
|
mem_obj_shape = torch.Size([2, 32, num_tokens, 1024])
|
|
dtype = torch.bfloat16
|
|
memory_obj = local_cpu_backend.allocate(mem_obj_shape, dtype)
|
|
memory_obj.ref_count_up()
|
|
|
|
torch.manual_seed(42)
|
|
test_tensor = torch.randint(0, 100, memory_obj.raw_data.shape, dtype=torch.int64)
|
|
memory_obj.raw_data.copy_(test_tensor.to(torch.float32).to(dtype))
|
|
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
connector.put(random_key, memory_obj), async_loop
|
|
)
|
|
future.result()
|
|
|
|
future = asyncio.run_coroutine_threadsafe(connector.exists(random_key), async_loop)
|
|
assert future.result()
|
|
assert memory_obj.get_ref_count() == 1
|
|
|
|
future = asyncio.run_coroutine_threadsafe(connector.get(random_key), async_loop)
|
|
retrieved_memory_obj = future.result()
|
|
|
|
check_mem_obj_equal(
|
|
[retrieved_memory_obj],
|
|
[memory_obj],
|
|
)
|
|
|
|
close_asyncio_loop(async_loop, async_thread)
|
|
local_cpu_backend.close()
|
|
|
|
|
|
@pytest.mark.parametrize("full_chunk", [True, False])
|
|
@pytest.mark.parametrize("save_chunk_meta", [True, False])
|
|
@pytest.mark.parametrize("use_mla", [True, False])
|
|
def test_fs_connector(autorelease_v1, full_chunk, save_chunk_meta, use_mla):
|
|
"""
|
|
Test FSConnector: exists, put, get, list, and file store
|
|
with the following conditions:
|
|
full_chunk: is the block full
|
|
save_chunk_meta: save the metadata of the chunk or not
|
|
use_mla: is mla enabled
|
|
"""
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
# Setup
|
|
url = f"fs://host:0/{temp_dir}/"
|
|
async_loop, async_thread = init_asyncio_loop()
|
|
memory_allocator = PinMemoryAllocator(1024 * 1024 * 1024)
|
|
# full chunk's kv_shape (num_layer, 2, chunk_size, num_kv_head, head_size)
|
|
kv_shape = (32, 1 if use_mla else 2, 256, 1 if use_mla else 8, 128)
|
|
dtype = torch.bfloat16
|
|
config = LMCacheEngineConfig.from_defaults(
|
|
extra_config={"save_chunk_meta": save_chunk_meta}
|
|
)
|
|
local_cpu_backend = _create_local_cpu_backend(memory_allocator, use_mla, config)
|
|
connector = autorelease_v1(
|
|
CreateConnector(url, async_loop, local_cpu_backend, config)
|
|
)
|
|
random_key = dumb_cache_engine_key()
|
|
|
|
# Test 1: Verify key doesn't exist initially
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
connector.exists(random_key), async_loop
|
|
)
|
|
assert not future.result()
|
|
|
|
# Test 2: Create and store test data
|
|
# The size of the full chunk is 256.
|
|
# If test unfull chunk, use 100 (<256) to allocate memory_obj.
|
|
memory_obj_shape = torch.Size(
|
|
[
|
|
kv_shape[1],
|
|
kv_shape[0],
|
|
kv_shape[2] if full_chunk else 100,
|
|
kv_shape[3] * kv_shape[4],
|
|
]
|
|
)
|
|
memory_obj = local_cpu_backend.allocate(memory_obj_shape, dtype)
|
|
memory_obj.ref_count_up()
|
|
# Fill with deterministic test data
|
|
torch.manual_seed(42)
|
|
test_tensor = torch.randint(
|
|
0, 100, memory_obj.raw_data.shape, dtype=torch.int64
|
|
)
|
|
memory_obj.raw_data.copy_(test_tensor.to(torch.float32).to(dtype))
|
|
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
connector.put(random_key, memory_obj), async_loop
|
|
)
|
|
future.result()
|
|
|
|
# Test 3: Verify key exists after putting data
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
connector.exists(random_key), async_loop
|
|
)
|
|
assert future.result()
|
|
assert memory_obj.get_ref_count() == 1
|
|
|
|
# Test 4: Retrieve and verify data
|
|
future = asyncio.run_coroutine_threadsafe(connector.get(random_key), async_loop)
|
|
check_mem_obj_equal([future.result()], [memory_obj], use_mla)
|
|
|
|
# Test 5: List the keys
|
|
future = asyncio.run_coroutine_threadsafe(connector.list(), async_loop)
|
|
assert future.result() == [random_key.to_string()]
|
|
|
|
# Test 6: Verify file existence and other attributes
|
|
# file name
|
|
files = list(Path(temp_dir).glob("*.data"))
|
|
assert len(files) == 1
|
|
assert files[0].name == f"{random_key.to_string()}.data"
|
|
|
|
# file size
|
|
dtype_size = torch.tensor([], dtype=dtype).element_size()
|
|
num_elements = 1
|
|
for dim in memory_obj_shape:
|
|
num_elements *= dim
|
|
expected_file_size = dtype_size * num_elements + (28 if save_chunk_meta else 0)
|
|
assert files[0].stat().st_size == expected_file_size
|
|
|
|
close_asyncio_loop(async_loop, async_thread)
|
|
local_cpu_backend.close()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"redis://localhost:6379",
|
|
"redis://user:password@localhost:6379/0",
|
|
"redis://:password@localhost:6379/1",
|
|
"rediss://user:password@localhost:6380?ssl_cert_reqs=CERT_REQUIRED",
|
|
"unix:///tmp/redis.sock",
|
|
"plugin://redis",
|
|
],
|
|
)
|
|
def test_redis_connector(url, autorelease_v1):
|
|
"""Test Redis connector: exists, put, get operations.
|
|
|
|
This test uses the MockRedis from conftest.py to simulate
|
|
Redis behavior without requiring an actual Redis server.
|
|
"""
|
|
|
|
async_loop, async_thread = init_asyncio_loop()
|
|
memory_allocator = PinMemoryAllocator(1024 * 1024 * 1024)
|
|
local_cpu_backend = _create_local_cpu_backend(memory_allocator, False)
|
|
connector = autorelease_v1(CreateConnector(url, async_loop, local_cpu_backend))
|
|
|
|
random_key = dumb_cache_engine_key()
|
|
|
|
# Test 1: Verify key doesn't exist initially
|
|
future = asyncio.run_coroutine_threadsafe(connector.exists(random_key), async_loop)
|
|
assert not future.result()
|
|
|
|
# Test 2: Create and store test data
|
|
num_tokens = 1000
|
|
mem_obj_shape = torch.Size([2, 32, num_tokens, 1024])
|
|
dtype = torch.bfloat16
|
|
memory_obj = local_cpu_backend.allocate(mem_obj_shape, dtype)
|
|
memory_obj.ref_count_up()
|
|
|
|
torch.manual_seed(42)
|
|
test_tensor = torch.randint(0, 100, memory_obj.raw_data.shape, dtype=torch.int64)
|
|
memory_obj.raw_data.copy_(test_tensor.to(torch.float32).to(dtype))
|
|
|
|
# Test 3: Put data
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
connector.put(random_key, memory_obj), async_loop
|
|
)
|
|
future.result()
|
|
|
|
# Test 4: Verify key exists after putting data
|
|
future = asyncio.run_coroutine_threadsafe(connector.exists(random_key), async_loop)
|
|
assert future.result()
|
|
assert memory_obj.get_ref_count() == 1
|
|
|
|
# Test 5: Retrieve and verify data
|
|
future = asyncio.run_coroutine_threadsafe(connector.get(random_key), async_loop)
|
|
retrieved_memory_obj = future.result()
|
|
|
|
check_mem_obj_equal(
|
|
[retrieved_memory_obj],
|
|
[memory_obj],
|
|
)
|
|
|
|
close_asyncio_loop(async_loop, async_thread)
|
|
local_cpu_backend.close()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"redis-sentinel://localhost:26379,localhost:26380,localhost:26381",
|
|
"redis-sentinel://user:password@localhost:26379,localhost:26380",
|
|
"redis-sentinel://localhost:26379",
|
|
],
|
|
)
|
|
def test_redis_sentinel_connector(url, autorelease_v1):
|
|
"""Test Redis Sentinel connector: exists, put, get operations.
|
|
|
|
This test uses the MockRedisSentinel from conftest.py to simulate
|
|
Redis Sentinel behavior without requiring an actual Redis Sentinel setup.
|
|
"""
|
|
# Standard
|
|
import os
|
|
|
|
# Set required environment variables for Redis Sentinel
|
|
os.environ["REDIS_SERVICE_NAME"] = "mymaster"
|
|
os.environ["REDIS_TIMEOUT"] = "5"
|
|
|
|
async_loop, async_thread = init_asyncio_loop()
|
|
memory_allocator = PinMemoryAllocator(1024 * 1024 * 1024)
|
|
local_cpu_backend = _create_local_cpu_backend(memory_allocator, False)
|
|
connector = autorelease_v1(CreateConnector(url, async_loop, local_cpu_backend))
|
|
|
|
random_key = dumb_cache_engine_key()
|
|
|
|
# Test 1: Verify key doesn't exist initially
|
|
future = asyncio.run_coroutine_threadsafe(connector.exists(random_key), async_loop)
|
|
assert not future.result()
|
|
|
|
# Test 2: Create and store test data
|
|
num_tokens = 1000
|
|
mem_obj_shape = torch.Size([2, 32, num_tokens, 1024])
|
|
dtype = torch.bfloat16
|
|
memory_obj = local_cpu_backend.allocate(mem_obj_shape, dtype)
|
|
memory_obj.ref_count_up()
|
|
|
|
# Fill with deterministic test data for Redis Sentinel test
|
|
torch.manual_seed(123)
|
|
test_tensor = torch.randint(0, 100, memory_obj.raw_data.shape, dtype=torch.int64)
|
|
memory_obj.raw_data.copy_(test_tensor.to(torch.float32).to(dtype))
|
|
|
|
# Test 3: Put data
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
connector.put(random_key, memory_obj), async_loop
|
|
)
|
|
future.result()
|
|
|
|
# Test 4: Verify key exists after putting data
|
|
future = asyncio.run_coroutine_threadsafe(connector.exists(random_key), async_loop)
|
|
assert future.result()
|
|
|
|
# Test 5: Retrieve and verify data
|
|
future = asyncio.run_coroutine_threadsafe(connector.get(random_key), async_loop)
|
|
future.result()
|
|
|
|
close_asyncio_loop(async_loop, async_thread)
|
|
local_cpu_backend.close()
|
|
|
|
|
|
REDIS_CLUSTER_URLS = [
|
|
"redis-cluster://host1:7000,host2:7000,host3:7000",
|
|
"redis-cluster://clustercfg.cluster-name.id.region.cache.amazonaws.com:6379",
|
|
"redis-cluster://user:password@host1:7000,host2:7000,host3:7000",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("url", REDIS_CLUSTER_URLS)
|
|
def test_redis_cluster_connector(url, autorelease_v1):
|
|
"""Test Redis Cluster connector: exists, put, get operations.
|
|
|
|
This test uses the MockRedisCluster from conftest.py to simulate
|
|
Redis Cluster behavior without requiring an actual Redis Cluster setup.
|
|
"""
|
|
|
|
# Standard
|
|
import os
|
|
|
|
os.environ["REDIS_TIMEOUT"] = "3.5"
|
|
|
|
async_loop, async_thread = init_asyncio_loop()
|
|
memory_allocator = PinMemoryAllocator(1024 * 1024 * 1024)
|
|
local_cpu_backend = _create_local_cpu_backend(memory_allocator, False)
|
|
connector = autorelease_v1(CreateConnector(url, async_loop, local_cpu_backend))
|
|
|
|
random_key = dumb_cache_engine_key()
|
|
|
|
# Test 1: Verify key doesn't exist initially, test contains key not exist
|
|
future = asyncio.run_coroutine_threadsafe(connector.exists(random_key), async_loop)
|
|
assert not future.result()
|
|
|
|
# Test 2: Create and store test data
|
|
num_tokens = 1000
|
|
mem_obj_shape = torch.Size([2, 32, num_tokens, 1024])
|
|
dtype = torch.bfloat16
|
|
memory_obj = local_cpu_backend.allocate(mem_obj_shape, dtype)
|
|
memory_obj.ref_count_up()
|
|
|
|
# Fill with deterministic test data
|
|
torch.manual_seed(42)
|
|
test_tensor = torch.randint(0, 100, memory_obj.raw_data.shape, dtype=torch.int64)
|
|
memory_obj.raw_data.copy_(test_tensor.to(torch.float32).to(dtype))
|
|
|
|
# Test 3: Put data
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
connector.put(random_key, memory_obj), async_loop
|
|
)
|
|
future.result()
|
|
|
|
# Test 4: Verify key exists after putting data, test contains key exists
|
|
future = asyncio.run_coroutine_threadsafe(connector.exists(random_key), async_loop)
|
|
assert future.result()
|
|
assert memory_obj.get_ref_count() == 1
|
|
|
|
# Test 5: Retrieve and verify data
|
|
future = asyncio.run_coroutine_threadsafe(connector.get(random_key), async_loop)
|
|
retrieved_memory_obj = future.result()
|
|
|
|
check_mem_obj_equal([retrieved_memory_obj], [memory_obj])
|
|
|
|
close_asyncio_loop(async_loop, async_thread)
|
|
local_cpu_backend.close()
|
|
|
|
|
|
@pytest.mark.parametrize("url", REDIS_CLUSTER_URLS)
|
|
def test_cluster_metadata_without_kv_bytes(url, autorelease_v1):
|
|
async_loop, async_thread = init_asyncio_loop()
|
|
memory_allocator = PinMemoryAllocator(1024 * 1024 * 1024)
|
|
local_cpu_backend = _create_local_cpu_backend(memory_allocator, False)
|
|
connector = autorelease_v1(CreateConnector(url, async_loop, local_cpu_backend))
|
|
|
|
random_key = dumb_cache_engine_key()
|
|
# build a small mem obj to get correct metadata bytes
|
|
memory_obj = local_cpu_backend.allocate(torch.Size([2, 32, 8, 64]), torch.bfloat16)
|
|
kv_bytes = memory_obj.byte_array
|
|
meta = RemoteMetadata(
|
|
len(kv_bytes),
|
|
memory_obj.get_shapes(),
|
|
memory_obj.get_dtypes(),
|
|
memory_obj.get_memory_format(),
|
|
)
|
|
metadata_bytes = meta.serialize()
|
|
|
|
# clean up memory object after getting metadata
|
|
memory_obj.ref_count_down()
|
|
|
|
# inject only metadata, no kv_bytes
|
|
meta_key = random_key.to_string() + "metadata"
|
|
connector._connector.cluster.set(meta_key, metadata_bytes)
|
|
|
|
# get() should return None and remove the metadata without kv_bytes pair
|
|
future = asyncio.run_coroutine_threadsafe(connector.get(random_key), async_loop)
|
|
assert future.result() is None
|
|
|
|
future = asyncio.run_coroutine_threadsafe(connector.exists(random_key), async_loop)
|
|
assert not future.result()
|
|
|
|
close_asyncio_loop(async_loop, async_thread)
|
|
local_cpu_backend.close()
|
|
|
|
|
|
def _get_metadata(use_mla: bool):
|
|
kv_shape = (32, 1 if use_mla else 2, 256, 1 if use_mla else 8, 128)
|
|
dtype = torch.bfloat16
|
|
metadata = LMCacheMetadata(
|
|
model_name="deepseek/DeepSeek-R1",
|
|
world_size=1,
|
|
local_world_size=1,
|
|
worker_id=0,
|
|
local_worker_id=0,
|
|
kv_dtype=dtype,
|
|
kv_shape=kv_shape,
|
|
use_mla=use_mla,
|
|
)
|
|
return metadata
|
|
|
|
|
|
def _create_local_cpu_backend(memory_allocator, use_mla, config=None):
|
|
if config is None:
|
|
config = LMCacheEngineConfig.from_defaults()
|
|
metadata = _get_metadata(use_mla)
|
|
return LocalCPUBackend(
|
|
config=config, metadata=metadata, memory_allocator=memory_allocator
|
|
)
|
|
|
|
|
|
@patch("lmcache.v1.storage_backend.connector.redis_connector.RedisConnector")
|
|
def test_redis_plugin_custom_url(mock_redis_connector, autorelease_v1) -> None:
|
|
"""Verify that RedisConnectorAdapter extracts custom Redis URL
|
|
from extra_config when loaded dynamically as a plugin.
|
|
"""
|
|
async_loop, async_thread = init_asyncio_loop()
|
|
memory_allocator = PinMemoryAllocator(1024 * 1024 * 1024)
|
|
local_cpu_backend = _create_local_cpu_backend(memory_allocator, False)
|
|
|
|
# Define custom Redis URL inside extra_config
|
|
custom_url = "redis://my-custom-redis-host:6379"
|
|
config = LMCacheEngineConfig.from_defaults(
|
|
extra_config={"remote_storage_plugin.redis.redis_url": custom_url}
|
|
)
|
|
|
|
# Create connector using dynamic plugin schema URL
|
|
autorelease_v1(
|
|
CreateConnector("plugin://redis", async_loop, local_cpu_backend, config)
|
|
)
|
|
|
|
mock_redis_connector.assert_called_once_with(
|
|
url=custom_url,
|
|
loop=async_loop,
|
|
local_cpu_backend=local_cpu_backend,
|
|
)
|
|
|
|
close_asyncio_loop(async_loop, async_thread)
|
|
local_cpu_backend.close()
|