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

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