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

267 lines
10 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Standard
from typing import Any, Dict, List, Optional
from unittest.mock import MagicMock
import hashlib
import json
# Third Party
from fastapi.testclient import TestClient
import pytest
import torch
# First Party
from lmcache.utils import compress_slot_mapping
from lmcache.v1.internal_api_server.api_server import app
class TestKVCacheCheckAPI:
"""Test suite for the /kvcache/check API endpoint."""
@pytest.fixture(autouse=True)
def reset_adapter(self):
"""Reset adapter before each test."""
yield
app.state.lmcache_adapter = None
@pytest.fixture
def mock_kv_caches(self) -> Dict[str, torch.Tensor]:
"""Create mock kv_caches tensors for testing."""
return {
"layer_0": torch.randn(4, 4, 8, 64),
"layer_1": torch.randn(4, 4, 8, 64),
}
@pytest.fixture
def mock_lmcache_adapter(self, mock_kv_caches):
"""Create a mock LMCacheConnectorV1Impl adapter."""
adapter = MagicMock()
adapter.kv_caches = mock_kv_caches
adapter.kvcaches = mock_kv_caches # API uses kvcaches (no underscore)
# Mock lmcache_engine for record_slot tests
mock_engine = MagicMock()
mock_engine.kvcache_check_log_enabled = False
adapter.lmcache_engine = mock_engine
def compute_checksums(
slot_indices: List[int], chunk_size: Optional[int] = None
) -> Optional[Dict[str, Any]]:
if not adapter.kv_caches:
return None
layer_checksums: Dict[str, str] = {}
chunk_checksums: Dict[str, List[str]] = {}
for layer_name, kv_tensor in adapter.kv_caches.items():
slot_tensor = torch.tensor(
slot_indices, dtype=torch.long, device=kv_tensor.device
)
kv_at_slots = kv_tensor.view(-1, *kv_tensor.shape[2:])[slot_tensor]
tensor_bytes = kv_at_slots.detach().cpu().contiguous().numpy().tobytes()
layer_checksums[layer_name] = hashlib.md5(tensor_bytes).hexdigest()
if chunk_size and chunk_size > 0:
num_slots = len(slot_indices)
chunk_checksum_list: List[str] = []
for i in range((num_slots + chunk_size - 1) // chunk_size):
chunk_data = kv_at_slots[i * chunk_size : (i + 1) * chunk_size]
chunk_bytes = (
chunk_data.detach().cpu().contiguous().numpy().tobytes()
)
chunk_checksum_list.append(hashlib.md5(chunk_bytes).hexdigest())
chunk_checksums[layer_name] = chunk_checksum_list
result: Dict[str, Any] = {"layer_checksums": layer_checksums}
if chunk_size:
result["chunk_checksums"] = chunk_checksums
result["chunk_size"] = chunk_size
result["num_chunks"] = (
(len(slot_indices) + chunk_size - 1) // chunk_size
if slot_indices
else 0
)
return result
adapter.compute_kvcache_checksums = compute_checksums
return adapter
@pytest.fixture
def client_with_adapter(self, mock_lmcache_adapter):
"""Create a test client with mocked adapter."""
app.state.lmcache_adapter = mock_lmcache_adapter
client = TestClient(app)
yield client
client.close()
# ==========================================================================
# Tests for /kvcache/check endpoint
# ==========================================================================
def test_kvcache_check_success_layer_only(self, client_with_adapter):
"""Test successful kvcache check with layer-level checksums."""
response = client_with_adapter.get(
"/cache/kvcache/check?slot_mapping=0,1,2,3&chunk_size=2"
)
assert response.status_code == 200
data = json.loads(response.text)
assert data["status"] == "success"
assert "chunk_checksums" in data
@pytest.mark.parametrize(
"url,expected_error",
[
("/cache/kvcache/check?slot_mapping=a,b,c", "Invalid slot_mapping format"),
(
"/cache/kvcache/check?slot_mapping=0,1&chunk_size=0",
"Invalid chunk_size",
),
(
"/cache/kvcache/check?slot_mapping=0,1&chunk_size=-1",
"Invalid chunk_size",
),
],
)
def test_kvcache_check_invalid_params(
self, client_with_adapter, url, expected_error
):
"""Test kvcache check with various invalid parameters."""
response = client_with_adapter.get(url)
assert response.status_code == 400
assert json.loads(response.text)["error"] == expected_error
@pytest.mark.parametrize(
"slots,chunk_size,expected_chunks",
[
("0,1,2,3,4,5,6,7", 4, 2),
("0,1,2,3,4,5,6", 3, 3),
("0,1,2,3", 4, 1),
("0,1,2,3", 1, 4),
],
)
def test_kvcache_check_with_chunk_size(
self, client_with_adapter, slots, chunk_size, expected_chunks
):
"""Test kvcache check with various chunk sizes."""
response = client_with_adapter.get(
f"/cache/kvcache/check?slot_mapping={slots}&chunk_size={chunk_size}"
)
assert response.status_code == 200
data = json.loads(response.text)
assert data["num_chunks"] == expected_chunks
def test_kvcache_check_consistency(self, client_with_adapter):
"""Test that checksums are consistent across calls."""
url = "/cache/kvcache/check?slot_mapping=0,1,2,3&chunk_size=2"
data1 = json.loads(client_with_adapter.get(url).text)
data2 = json.loads(client_with_adapter.get(url).text)
assert data1["chunk_checksums"] == data2["chunk_checksums"]
# ==========================================================================
# Tests for adapter unavailable scenarios
# ==========================================================================
@pytest.mark.parametrize(
"method,url",
[
("get", "/cache/kvcache/check?slot_mapping=0,1,2,3&chunk_size=2"),
("get", "/cache/kvcache/info"),
("post", "/cache/kvcache/record_slot?enabled=true"),
],
)
def test_no_adapter(self, method, url):
"""Test endpoints when adapter is not available."""
app.state.lmcache_adapter = None
with TestClient(app) as client:
response = getattr(client, method)(url)
assert response.status_code == 503
assert json.loads(response.text)["error"] == "LMCache adapter unavailable"
def test_kvcache_check_empty_kv_caches(self):
"""Test kvcache check when kv_caches is empty."""
adapter = MagicMock()
adapter.kv_caches = {}
adapter.kvcaches = {}
adapter.compute_kvcache_checksums = MagicMock(return_value=None)
app.state.lmcache_adapter = adapter
with TestClient(app) as client:
response = client.get(
"/cache/kvcache/check?slot_mapping=0,1,2,3&chunk_size=2"
)
assert response.status_code == 404
# ==========================================================================
# Tests for /cache/kvcache/info endpoint
# ==========================================================================
def test_kvcache_info_success(self, client_with_adapter):
"""Test successful kvcache info retrieval."""
response = client_with_adapter.get("/cache/kvcache/info")
assert response.status_code == 200
data = json.loads(response.text)
assert data["num_layers"] == 2
assert "layer_0" in data["layers"]
def test_kvcache_info_empty(self):
"""Test kvcache info when kvcaches is empty."""
adapter = MagicMock()
adapter.kvcaches = {}
app.state.lmcache_adapter = adapter
with TestClient(app) as client:
response = client.get("/cache/kvcache/info")
assert response.status_code == 404
# ==========================================================================
# Tests for /cache/kvcache/record_slot endpoint
# ==========================================================================
@pytest.mark.parametrize(
"enabled_param,expected_value",
[("true", True), ("false", False), ("TRUE", True), ("False", False)],
)
def test_kvcache_record_slot_toggle(
self, client_with_adapter, mock_lmcache_adapter, enabled_param, expected_value
):
"""Test enabling/disabling KVCache Check logging."""
response = client_with_adapter.post(
f"/cache/kvcache/record_slot?enabled={enabled_param}"
)
assert response.status_code == 200
assert (
mock_lmcache_adapter.lmcache_engine.kvcache_check_log_enabled
== expected_value
)
def test_kvcache_record_slot_invalid(self, client_with_adapter):
"""Test with invalid enabled parameter."""
response = client_with_adapter.post(
"/cache/kvcache/record_slot?enabled=invalid"
)
assert response.status_code == 400
class TestCompressSlotMapping:
"""Test suite for the compress_slot_mapping function."""
@pytest.mark.parametrize(
"input_slots,expected",
[
([1, 2, 3, 4, 5], [[1, 5]]),
([1, 2, 3, 4, 5, 9, 10, 11, 12], [[1, 5], [9, 12]]),
# No consecutive elements, no compression
([1, 3, 5, 7], [1, 3, 5, 7]),
# 5 is single, not compressed
([1, 2, 3, 5, 7, 8, 9], [[1, 3], 5, [7, 9]]),
([], []),
([42], [42]), # Single element, not compressed
# Mixed order
([5, 3, 1, 2, 4, 9, 10, 11, 13], [5, 3, 1, 2, 4, [9, 11], 13]),
([0, 1, 2, 100, 101, 102], [[0, 2], [100, 102]]),
([1, 2], [1, 2]), # Two elements, not compressed
([1, 2, 3], [[1, 3]]), # Three elements, compressed
],
)
def test_compress_slot_mapping(self, input_slots, expected):
"""Test compression of slot mappings."""
assert compress_slot_mapping(input_slots) == expected