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

567 lines
19 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Shared utilities for check modes"""
# Standard
from typing import Optional
import asyncio
import hashlib
import threading
import time
# Third Party
import torch
# First Party
from lmcache.utils import CacheEngineKey
from lmcache.v1.kv_layer_groups import DTYPE_MAP
# Import from lmcache with absolute paths
from lmcache.v1.memory_management import MemoryFormat, MemoryObj
from lmcache.v1.metadata import LMCacheMetadata
from lmcache.v1.storage_backend.remote_backend import RemoteBackend
from lmcache.v1.storage_backend.storage_manager import StorageManager
DEFAULT_KV_DTYPE_STR = "bfloat16"
DEFAULT_OBJ_SIZE = 1024
def _compute_kv_shape(
obj_size: int,
) -> tuple:
"""Compute a kv_shape that yields the given obj_size.
The returned shape is in vllm format:
``(num_layers, 2, num_tokens, num_heads, head_size)``.
The final KV_2LTD tensor has
``2 * num_layers * num_tokens * (num_heads * head_size)``
elements, which equals *obj_size*.
We fix ``num_layers=1, num_heads=1`` and split the
remaining factor between ``num_tokens`` and ``head_size``
so that ``num_tokens * head_size = obj_size // 2``.
"""
if obj_size % 2 != 0:
raise ValueError("obj_size must be even (got %d)" % obj_size)
half = obj_size // 2
# (num_layers, kv_dim, num_tokens, num_heads, head_size)
return (1, 2, half, 1, 1)
def parse_kv_dtype(kv_dtype_str: str) -> Optional[torch.dtype]:
"""Parse a kv_dtype string to a torch.dtype.
Returns None if the string is not recognized.
"""
return DTYPE_MAP.get(kv_dtype_str)
def _get_default_metadata(
model: str,
kv_dtype: torch.dtype = torch.bfloat16,
obj_size: Optional[int] = None,
) -> LMCacheMetadata:
"""Get default metadata for testing.
When *obj_size* is given the ``kv_shape`` is computed so
that the resulting KV_2LTD tensor has exactly *obj_size*
elements. Otherwise a small default shape is used.
"""
if obj_size is not None:
kv_shape = _compute_kv_shape(obj_size)
else:
kv_shape = _compute_kv_shape(DEFAULT_OBJ_SIZE)
return LMCacheMetadata(
model_name=model,
world_size=8,
local_world_size=8,
worker_id=0,
local_worker_id=0,
kv_dtype=kv_dtype,
kv_shape=kv_shape,
)
def create_test_key(
model: str,
key_id: str = "test_key",
kv_dtype: torch.dtype = torch.bfloat16,
) -> CacheEngineKey:
"""Create a test CacheEngineKey."""
return CacheEngineKey(
model_name=model,
world_size=8,
worker_id=0,
chunk_hash=int(hashlib.sha256(key_id.encode()).hexdigest(), 16),
dtype=kv_dtype,
)
def create_test_memory_obj_for_storage_manager(
storage_manager: StorageManager, metadata: LMCacheMetadata
) -> Optional[MemoryObj]:
"""Create a test MemoryObj for testing with StorageManager."""
# The metadata.kv_shape is in vllm format:
# [num_layers, 2, num_tokens, num_heads, head_size]
# For KV_2LTD format, we need shape: [2, num_layers, num_tokens, hidden_dim]
# where hidden_dim = num_heads * head_size
vllm_shape = metadata.kv_shape # [num_layers, 2, num_tokens, num_heads, head_size]
num_layers = vllm_shape[0] # 8
kv_dim = vllm_shape[1] # 2 (K and V)
num_tokens = vllm_shape[2] # 16
num_heads = vllm_shape[3] # 8
head_size = vllm_shape[4] # 16
# Convert to KV_2LTD format shape: [2, num_layers, num_tokens, hidden_dim]
hidden_dim = num_heads * head_size
kv_2ltd_shape = torch.Size([kv_dim, num_layers, num_tokens, hidden_dim])
memory_obj = storage_manager.allocate(
kv_2ltd_shape,
metadata.kv_dtype,
fmt=MemoryFormat.KV_2LTD,
eviction=True,
busy_loop=False,
)
return memory_obj
def create_storage_manager_with_config(
model: str,
kv_dtype: torch.dtype = torch.bfloat16,
obj_size: Optional[int] = None,
):
"""Create storage manager with default configuration"""
# First Party
from lmcache.integration.vllm.utils import lmcache_get_or_create_config
from lmcache.v1.event_manager import EventManager
config = lmcache_get_or_create_config()
metadata = _get_default_metadata(model, kv_dtype=kv_dtype, obj_size=obj_size)
# Create event manager
event_manager = EventManager()
# Create storage manager
storage_manager = StorageManager(
config=config,
metadata=metadata,
event_manager=event_manager,
)
return storage_manager
def find_remote_backend(storage_manager: StorageManager) -> Optional[RemoteBackend]:
"""Find remote backend from storage manager"""
for backend_name, backend in storage_manager.storage_backends.items():
if isinstance(backend, RemoteBackend):
return backend
return None
def wait_put_tasks_complete(
remote_backend: Optional[RemoteBackend], max_wait_time: float = 5.0
):
"""Wait for remote backend put tasks to complete"""
if remote_backend is None:
return
check_interval = 0.001
elapsed_time = 0.0
while elapsed_time < max_wait_time:
if not remote_backend.put_tasks:
break
time.sleep(check_interval)
elapsed_time += check_interval
# Log warning if timeout
remaining_tasks = len(remote_backend.put_tasks)
if remaining_tasks > 0:
print(
f"Warning: {remaining_tasks} remote put tasks still "
f"pending after {max_wait_time}s timeout"
)
def create_memory_objects_batch(
storage_manager: StorageManager, metadata: LMCacheMetadata, batch_size: int
) -> list[MemoryObj]:
"""Create a batch of memory objects for reuse"""
memory_objs = []
for i in range(batch_size):
memory_obj = create_test_memory_obj_for_storage_manager(
storage_manager, metadata
)
if memory_obj is not None:
memory_obj.ref_count_up()
memory_objs.append(memory_obj)
return memory_objs
async def flow_control_check(
remote_backend: Optional[RemoteBackend], concurrency: int, sleep_count: float = 1.0
) -> float:
"""Check flow control and wait if necessary"""
if remote_backend is None:
return sleep_count
high_watermark = 100 * concurrency
low_watermark = 10 * concurrency
current_tasks = len(remote_backend.put_tasks)
while current_tasks > high_watermark:
current_tasks = len(remote_backend.put_tasks)
if current_tasks > high_watermark:
# Too many pending tasks, wait before proceeding
sleep_sec = 0.1 * sleep_count
current_tasks = len(remote_backend.put_tasks)
await asyncio.sleep(sleep_sec)
current_tasks_after_sleep = len(remote_backend.put_tasks)
if current_tasks_after_sleep > low_watermark:
sleep_count *= 2.0
elif current_tasks_after_sleep == 0:
sleep_count /= 2.0
continue
if current_tasks <= low_watermark:
break
return sleep_count
async def run_perf_test_with_timeout(func, args_list, timeout=30.0):
"""Common performance test framework with timeout handling"""
times = []
results = [] # Collect results for each operation
for i, args in enumerate(args_list):
try:
start = time.perf_counter()
result = await asyncio.wait_for(func(*args), timeout=timeout)
end = time.perf_counter()
times.append((end - start) * 1000)
results.append(result)
print(
f" Test {i + 1}/{len(args_list)} completed in "
f"{(end - start) * 1000:.2f}ms"
)
except asyncio.TimeoutError:
print(f" Test {i + 1}/{len(args_list)} timed out after {timeout}s")
times.append(timeout * 1000)
results.append(None)
except Exception as e:
print(f" Test {i + 1}/{len(args_list)} failed: {e}")
times.append(0)
results.append(None)
if times:
return {
"time_stats": {
"avg": sum(times) / len(times),
"max": max(times),
"min": min(times),
},
"results": results,
}
else:
return {"time_stats": {"avg": 0, "max": 0, "min": 0}, "results": []}
def _format_throughput(avg_ms: float, obj_bytes: int) -> str:
"""Format throughput as a human-readable string."""
if avg_ms <= 0 or obj_bytes <= 0:
return "N/A"
bps = obj_bytes / (avg_ms / 1000.0)
if bps >= 1 << 30:
return "%.2f GB/s" % (bps / (1 << 30))
if bps >= 1 << 20:
return "%.2f MB/s" % (bps / (1 << 20))
return "%.2f KB/s" % (bps / (1 << 10))
def print_performance_results(
stats_data,
obj_bytes: int = 0,
throughput_ops: Optional[set] = None,
):
"""Print performance results in a formatted table.
Args:
stats_data: list of (op, stats, results, pass_count).
obj_bytes: size of one object in bytes. When > 0 a
throughput column is shown for operations listed
in *throughput_ops*.
throughput_ops: set of operation name prefixes that
should show throughput (e.g. {"STORE", "LOAD"}).
Defaults to common data-transfer operations.
"""
if throughput_ops is None:
throughput_ops = {
"STORE",
"LOAD",
"PUT",
"GET",
}
show_tp = obj_bytes > 0
sep_len = 118 if show_tp else 100
tp_hdr = " | %s" % "Throughput".center(14) if show_tp else ""
print("\nPerformance Results:")
print("-" * sep_len)
print(
f"| {'Operation':<20} | {'Avg (ms)':>12} "
f"| {'Max (ms)':>12} "
f"| {'Min (ms)':>12} "
f"| {'Pass/All':>10} "
f"| {'Pass Rate':>10} |" + tp_hdr
)
print("-" * sep_len)
for op, stats, results, pass_count in stats_data:
total = len(results)
pass_all = f"{pass_count}/{total}"
pass_rate = pass_count / total * 100 if total > 0 else 0
tp_col = ""
if show_tp:
is_data_op = any(op.startswith(p) for p in throughput_ops)
if is_data_op:
tp_col = " | %s" % _format_throughput(
stats["avg"],
obj_bytes,
).center(14)
else:
tp_col = " | %s" % "-".center(14)
print(
f"| {op:<20} "
f"| {stats['avg']:>12.6f} "
f"| {stats['max']:>12.6f} "
f"| {stats['min']:>12.6f} "
f"| {pass_all:>10} "
f"| {pass_rate:>9.1f}% |" + tp_col
)
print("-" * sep_len)
def validate_get_results(get_results, exist_keys, exist_memories, num_tests):
"""Validate GET operation results and return statistics"""
content_valid_count = 0
for i, result in enumerate(get_results["results"]):
if result is None:
print(f" GET for key {exist_keys[i]} returned None result")
continue
try:
if result.tensor is None:
print(f" GET for key {exist_keys[i]} returned None tensor")
continue
if exist_memories[i].tensor is None:
print(f" Original memory object {i} has None tensor")
continue
# Compare data content
data_match = torch.equal(result.tensor, exist_memories[i].tensor)
if data_match:
content_valid_count += 1
else:
print(f" GET for key {exist_keys[i]} returned incorrect memory object")
print(" Data content mismatch detected")
except Exception as e:
print(f" Data comparison failed for key {exist_keys[i]}: {e}")
# Standard
import traceback
traceback.print_exc()
# Calculate pass rates
not_none_count = sum(1 for r in get_results["results"] if r is not None)
content_pass_rate = content_valid_count / num_tests * 100
print(f" Validation (not None): {not_none_count}/{num_tests} passed")
print(
f" Validation (content correct): {content_valid_count}/{num_tests}"
f" passed ({content_pass_rate:.1f}%)"
)
return content_valid_count, not_none_count
async def run_common_test_framework(
test_context,
model: str,
num_tests: int = 5,
settle_time: float = 0.0,
):
"""
Common test framework for both storage manager and remote backend tests.
Args:
test_context: A dictionary containing test-specific functions and objects:
- 'create_test_data_func': Function to create test data
- 'async_contains_func': Async function for contains operations
- 'async_put_func': Async function for put operations
- 'async_get_func': Async function for get operations
- 'validate_get_func': Function to validate get results
- 'test_object': The main test object (storage_manager or backend)
- 'extra_args': Extra arguments for test data creation (optional)
model: Model name for testing
num_tests: Number of tests to run
"""
print("Testing basic operations...")
# Create test data using the provided function
extra_args = test_context.get("extra_args", [])
extra_kwargs = {}
if "kv_dtype" in test_context:
extra_kwargs["kv_dtype"] = test_context["kv_dtype"]
if extra_args:
non_exist_keys, exist_keys, exist_memories, num_tests = test_context[
"create_test_data_func"
](test_context["test_object"], *extra_args, model, num_tests, **extra_kwargs)
else:
kv_dtype = test_context.get("kv_dtype")
obj_size = test_context.get("obj_size")
meta_kw = {}
if kv_dtype:
meta_kw["kv_dtype"] = kv_dtype
if obj_size is not None:
meta_kw["obj_size"] = obj_size
metadata = _get_default_metadata(model, **meta_kw)
non_exist_keys, exist_keys, exist_memories, num_tests = test_context[
"create_test_data_func"
](test_context["test_object"], metadata, model, num_tests, **extra_kwargs)
# Phase 1: exists test (key does not exist)
print("Phase 1: Testing exists for non-existing keys...")
exists_non_exist_res = await run_perf_test_with_timeout(
test_context["async_contains_func"],
[(test_context["test_object"], key) for key in non_exist_keys],
)
exists_non_exist_stats = exists_non_exist_res["time_stats"]
# Validation: All non-existing keys should return False
exists_non_exist_pass_count = sum(
1 for r in exists_non_exist_res["results"] if r is False
)
pass_rate = exists_non_exist_pass_count / len(non_exist_keys) * 100
print(
f" Validation: {exists_non_exist_pass_count}/{len(non_exist_keys)} "
f"passed ({pass_rate:.1f}%)"
)
# Phase 2: put test (create new key)
print("Phase 2: Testing put operations...")
put_res = await run_perf_test_with_timeout(
test_context["async_put_func"],
[
(test_context["test_object"], exist_keys[i], exist_memories[i])
for i in range(num_tests)
],
)
put_stats = put_res["time_stats"]
# Validation: All PUT operations should return True
put_pass_count = sum(1 for r in put_res["results"] if r is True)
pass_rate = put_pass_count / num_tests * 100
print(f" Validation: {put_pass_count}/{num_tests} passed ({pass_rate:.1f}%)")
if settle_time > 0:
print(" Waiting %.1fs for data to settle..." % settle_time)
await asyncio.sleep(settle_time)
# Phase 3: exists test (key exists)
print("Phase 3: Testing exists for existing keys...")
exists_exist_res = await run_perf_test_with_timeout(
test_context["async_contains_func"],
[(test_context["test_object"], key) for key in exist_keys],
)
exists_exist_stats = exists_exist_res["time_stats"]
# Validation: All existing keys should return True
exists_exist_pass_count = sum(1 for r in exists_exist_res["results"] if r is True)
pass_rate = exists_exist_pass_count / num_tests * 100
print(
f" Validation: {exists_exist_pass_count}/{num_tests} passed ({pass_rate:.1f}%)"
)
# Phase 4: get test (key exists)
print("Phase 4: Testing get operations...")
get_res = await run_perf_test_with_timeout(
test_context["async_get_func"],
[(test_context["test_object"], key) for key in exist_keys],
)
get_stats = get_res["time_stats"]
# Validation: Check for non-None results and content correctness
content_valid_count, not_none_count = test_context["validate_get_func"](
get_res, exist_keys, exist_memories, num_tests
)
# Use content_valid_count as the pass_count for GET operations
get_pass_count = content_valid_count
stats_data = [
(
"EXISTS (non-exist)",
exists_non_exist_stats,
exists_non_exist_res["results"],
exists_non_exist_pass_count,
),
("PUT", put_stats, put_res["results"], put_pass_count),
(
"EXISTS (exist)",
exists_exist_stats,
exists_exist_res["results"],
exists_exist_pass_count,
),
("GET", get_stats, get_res["results"], get_pass_count),
]
# Compute per-object byte size for throughput display
tp_obj_size = test_context.get("obj_size") or DEFAULT_OBJ_SIZE
tp_kv_dtype = test_context.get("kv_dtype") or torch.bfloat16
obj_bytes = tp_obj_size * torch.tensor([], dtype=tp_kv_dtype).element_size()
# Use common performance results printing
print_performance_results(stats_data, obj_bytes=obj_bytes)
class EventLoopManager:
"""Manages a dedicated event loop in a separate thread"""
def __init__(self):
self.loop = None
self.thread = None
self._loop_started = threading.Event()
def start(self):
"""Start the event loop in a separate thread"""
if self.thread is not None and self.thread.is_alive():
return
self.loop = asyncio.new_event_loop()
self.thread = threading.Thread(target=self._run_loop, daemon=True)
self.thread.start()
self._loop_started.wait()
def _run_loop(self):
"""Run the event loop"""
asyncio.set_event_loop(self.loop)
self._loop_started.set()
try:
self.loop.run_forever()
except Exception as e:
print(f"Event loop error: {e}")
finally:
self.loop.close()
def stop(self):
"""Stop the event loop and thread"""
if self.loop and not self.loop.is_closed():
self.loop.call_soon_threadsafe(self.loop.stop)
if self.thread and self.thread.is_alive():
self.thread.join(timeout=5.0)
def get_loop(self):
"""Get the event loop"""
return self.loop