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

626 lines
20 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for the ``lmcache bench server`` CLI command.
Covers:
- Sub-command registration under ``lmcache bench``
- Argument registration and defaults
- Pure helper functions (_build_token_ids, _make_key, _query_checksum)
"""
# Standard
from http.server import BaseHTTPRequestHandler, HTTPServer
import argparse
import json
import threading
# Third Party
import msgspec
import pytest
import torch
import zmq
# First Party
from lmcache.cli.commands.bench import BenchCommand
from lmcache.cli.commands.bench.server_bench.helpers import (
_allocate_kv_cache,
_build_token_ids,
_make_key,
_poll_prefetch_status,
_query_checksum,
_send_lookup,
_send_unregister_kv_cache,
)
from lmcache.v1.multiprocess.mq import MessageQueueClient
from lmcache.v1.multiprocess.protocols.base import RequestType
# ------------------------------------------------------------------ #
# Fixtures
# ------------------------------------------------------------------ #
@pytest.fixture
def cmd() -> BenchCommand:
return BenchCommand()
@pytest.fixture
def parser(cmd: BenchCommand) -> argparse.ArgumentParser:
"""Parser with ``bench server`` subcommand registered."""
p = argparse.ArgumentParser()
sub = p.add_subparsers(dest="command")
cmd.register(sub)
return p
# ------------------------------------------------------------------ #
# Command metadata
# ------------------------------------------------------------------ #
class TestCommandMetadata:
def test_name(self, cmd: BenchCommand) -> None:
assert cmd.name() == "bench"
def test_help(self, cmd: BenchCommand) -> None:
assert "benchmark" in cmd.help().lower()
def test_server_helpers_live_under_server_bench_package(self) -> None:
"""Helpers backing ``bench server`` must live inside the
``server_bench`` sub-package, mirroring the engine / l2 layout.
"""
# First Party
from lmcache.cli.commands.bench.server_bench import command as sv_cmd
from lmcache.cli.commands.bench.server_bench import helpers as sv_helpers
assert sv_cmd.__name__ == ("lmcache.cli.commands.bench.server_bench.command")
assert sv_helpers.__name__ == (
"lmcache.cli.commands.bench.server_bench.helpers"
)
# Public command surface mirrors the sibling subpackages.
assert callable(sv_cmd.add_server_arguments)
assert callable(sv_cmd.run_server_bench)
# ------------------------------------------------------------------ #
# Argument registration
# ------------------------------------------------------------------ #
class TestCommandArguments:
def test_registers_subcommand(
self,
parser: argparse.ArgumentParser,
) -> None:
args = parser.parse_args(["bench", "server"])
assert hasattr(args, "func")
assert args.bench_target == "server"
def test_default_values(
self,
parser: argparse.ArgumentParser,
) -> None:
args = parser.parse_args(["bench", "server"])
assert args.rpc_url == "tcp://localhost:5555"
assert args.mode == "gpu"
assert args.num_tokens == 512
assert args.num_blocks == 1024
assert args.block_size == 16
assert args.start == 0
assert args.end is None
assert args.interval == 0.5
assert args.url == "http://localhost:8080"
def test_custom_values(
self,
parser: argparse.ArgumentParser,
) -> None:
args = parser.parse_args(
[
"bench",
"server",
"--rpc-url",
"tcp://host:9999",
"--num-tokens",
"256",
"--num-blocks",
"512",
"--block-size",
"8",
"--start",
"5",
"--end",
"10",
"--interval",
"1.0",
"--url",
"http://other:9090",
],
)
assert args.rpc_url == "tcp://host:9999"
assert args.num_tokens == 256
assert args.num_blocks == 512
assert args.block_size == 8
assert args.start == 5
assert args.end == 10
assert args.interval == 1.0
assert args.url == "http://other:9090"
def test_kvcache_shape_spec_default(
self,
parser: argparse.ArgumentParser,
) -> None:
args = parser.parse_args(["bench", "server"])
assert "float16" in args.kvcache_shape_spec
def test_kvcache_shape_spec_custom(
self,
parser: argparse.ArgumentParser,
) -> None:
args = parser.parse_args(
[
"bench",
"server",
"--kvcache-shape-spec",
"(2,512,8,4,64):bfloat16:16",
],
)
assert args.kvcache_shape_spec == ("(2,512,8,4,64):bfloat16:16")
# ------------------------------------------------------------------ #
# _build_token_ids
# ------------------------------------------------------------------ #
class TestBuildTokenIds:
def test_basic(self):
ids = _build_token_ids(seq_no=7, num_tokens=3)
assert ids[0] == 7
assert len(ids) == 4 # seq_no + 3 hello tokens
# All remaining tokens should be the hello token
assert all(t == 9906 for t in ids[1:])
def test_zero_tokens(self):
ids = _build_token_ids(seq_no=0, num_tokens=0)
assert ids == (0,)
def test_different_seq_no(self):
ids1 = _build_token_ids(seq_no=1, num_tokens=2)
ids2 = _build_token_ids(seq_no=2, num_tokens=2)
assert ids1[0] != ids2[0]
assert ids1[1:] == ids2[1:]
# ------------------------------------------------------------------ #
# _make_key
# ------------------------------------------------------------------ #
class TestMakeKey:
def test_basic_key(self):
token_ids = (0, 9906, 9906)
key = _make_key(
token_ids,
request_id="req-0-cold",
)
assert key.model_name == "test-model"
assert key.world_size == 1
assert key.worker_id is None
assert key.token_ids == token_ids
assert key.start == 0
assert key.end == len(token_ids)
assert key.request_id == "req-0-cold"
def test_custom_start_end(self):
token_ids = (0, 9906, 9906, 9906, 9906)
key = _make_key(
token_ids,
request_id="req-1-warm",
start=2,
end=4,
)
assert key.start == 2
assert key.end == 4
def test_worker_id(self):
token_ids = (0, 9906)
key = _make_key(
token_ids,
request_id="req-0-cold",
worker_id=0,
)
assert key.worker_id == 0
# ------------------------------------------------------------------ #
# _query_checksum
# ------------------------------------------------------------------ #
class _ChecksumHandler(BaseHTTPRequestHandler):
"""Tiny HTTP handler that records the POST body and returns fake checksums.
Mirrors the MP server's ``POST /cache/checksums`` (the old ``GET
/kvcache/check`` was removed). The received JSON is stored on the server so
the test can assert the request shape ``_query_checksum`` sends.
"""
def do_POST(self):
if "/cache/checksums" not in self.path:
self.send_response(404)
self.end_headers()
return
length = int(self.headers.get("Content-Length", 0))
payload = json.loads(self.rfile.read(length).decode())
self.server.received_payloads.append(payload)
body = json.dumps(
{
"status": "success",
"chunk_checksums": ["a" * 32, "b" * 32],
}
).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass # suppress logs
class TestQueryChecksum:
@pytest.fixture(autouse=True)
def _start_server(self):
"""Start a tiny HTTP server for the test."""
self.server = HTTPServer(
("127.0.0.1", 0),
_ChecksumHandler,
)
self.server.received_payloads = []
self.port = self.server.server_address[1]
self.thread = threading.Thread(
target=self.server.serve_forever,
)
self.thread.daemon = True
self.thread.start()
yield
self.server.shutdown()
def test_success(self):
base = "http://127.0.0.1:%d" % self.port
result = _query_checksum(
base,
block_offset=0,
num_blocks=2,
block_size=2,
chunk_size=2,
)
assert result is not None
assert len(result) == 2
assert result[0] == "a" * 32
# The POST body matches the MP /cache/checksums contract: block-native
# ids and a block-level chunk_size (token chunk_size 2 / block_size 2).
assert len(self.server.received_payloads) == 1
sent = self.server.received_payloads[0]
assert sent["block_ids"] == [0, 1]
assert sent["chunk_size"] == 1
assert sent["layerwise"] is False
def test_unreachable_returns_none(self):
result = _query_checksum(
"http://127.0.0.1:1",
block_offset=0,
num_blocks=2,
block_size=2,
chunk_size=2,
)
assert result is None
# ------------------------------------------------------------------ #
# ROUTER endpoint fixture #
# ------------------------------------------------------------------ #
@pytest.fixture
def router_endpoint() -> str:
"""Allocate an ephemeral inproc/tcp endpoint for the ROUTER."""
# Use tcp with port=0 so the OS assigns a free port.
ctx = zmq.Context.instance()
probe = ctx.socket(zmq.ROUTER)
probe.bind("tcp://127.0.0.1:0")
endpoint = probe.getsockopt_string(zmq.LAST_ENDPOINT)
probe.close(linger=0)
return endpoint
# ------------------------------------------------------------------ #
# _allocate_kv_cache (dtype branching)
# ------------------------------------------------------------------ #
class TestAllocateKVCache:
"""Regression tests for ``_allocate_kv_cache`` dtype handling.
``torch.randn`` only supports floating-point dtypes, so integer
dtypes in ``DTYPE_MAP`` (e.g. ``uint8`` used by FP8 quantized
layouts) must fall back to ``torch.randint`` -- see Bugbot
#3147565172.
"""
@staticmethod
def _alloc(dtype: torch.dtype) -> list[torch.Tensor]:
return _allocate_kv_cache(
num_layers=1,
num_heads=2,
head_size=4,
num_blocks=2,
block_size=2,
dtype=dtype,
device="cpu",
kv_size=2,
)
@pytest.mark.parametrize(
"dtype",
[torch.float16, torch.float32, torch.bfloat16],
)
def test_floating_point_dtype(self, dtype: torch.dtype) -> None:
tensors = self._alloc(dtype)
assert len(tensors) == 1
assert tensors[0].dtype == dtype
assert tensors[0].shape == (2, 2, 2, 2, 4)
def test_uint8_dtype_uses_randint(self) -> None:
"""Regression: ``torch.randn`` crashes with integer dtypes."""
tensors = self._alloc(torch.uint8)
assert len(tensors) == 1
assert tensors[0].dtype == torch.uint8
assert tensors[0].shape == (2, 2, 2, 2, 4)
def test_groups_honour_per_group_shape_and_dtype(self) -> None:
"""Multi-group spec must allocate per-layer shape / dtype.
Regression for Bugbot #3150738055: previously every layer was
allocated with the *first* group's ``nh`` / ``hs`` / ``dtype``
(and the total ``num_layers`` from the sum), silently producing
wrong tensors for layers in later groups.
"""
# Standard
from types import SimpleNamespace
# First Party
from lmcache.v1.kv_layer_groups import KVLayerGroupInfo
# Group A: 3 layers of (2, 2, 2, 8, 16), float16
# Group B: 2 layers of (1, 2, 2, 4, 32), bfloat16
# (NB / BS are intentionally identical — that's a hard
# requirement of paged KV, enforced in CLI execute().)
group_a = KVLayerGroupInfo(
layer_indices=[0, 1, 2],
shape_desc=SimpleNamespace(kv_size=2, nb=2, bs=2, nh=8, hs=16, nl=3),
dtype=torch.float16,
)
group_b = KVLayerGroupInfo(
layer_indices=[3, 4],
shape_desc=SimpleNamespace(kv_size=1, nb=2, bs=2, nh=4, hs=32, nl=2),
dtype=torch.bfloat16,
)
tensors = _allocate_kv_cache(
device="cpu",
groups=[group_a, group_b],
)
assert len(tensors) == 5
for t in tensors[:3]:
assert t.shape == (2, 2, 2, 8, 16)
assert t.dtype == torch.float16
for t in tensors[3:]:
assert t.shape == (1, 2, 2, 4, 32)
assert t.dtype == torch.bfloat16
# ------------------------------------------------------------------ #
# _send_lookup / _poll_prefetch_status (protocol regression) #
# ------------------------------------------------------------------ #
class _LookupRouter:
"""Fake ROUTER implementing the LOOKUP / QUERY_PREFETCH_STATUS
subset of the MP server protocol.
* ``LOOKUP`` replies with **no payload** (void response) — the
real server-side handler returns ``None``. Regression for a
bug where the client treated the empty frame list as a
timeout and printed ``LOOKUP timeout``.
* ``QUERY_PREFETCH_STATUS`` accepts a ``request_id`` (str) and
returns ``None`` on the first N polls, then a fixed chunk
count — exercising both the in-progress and done branches.
"""
def __init__(
self,
endpoint: str,
in_progress_polls: int = 1,
hit_chunks: int = 3,
) -> None:
self._endpoint = endpoint
self._in_progress_left = in_progress_polls
self._hit_chunks = hit_chunks
self.last_query_request_id: str | None = None
self._ctx = zmq.Context.instance()
self._router = self._ctx.socket(zmq.ROUTER)
self._router.bind(endpoint)
self._stop = threading.Event()
self._thread = threading.Thread(target=self._run, daemon=True)
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self._stop.set()
self._thread.join(timeout=2)
self._router.close(linger=0)
def _run(self) -> None:
while not self._stop.is_set():
if not self._router.poll(100, zmq.POLLIN):
continue
frames = self._router.recv_multipart()
identity, uid_f, type_f, *payload = frames
req_type = msgspec.msgpack.decode(type_f, type=RequestType)
if req_type == RequestType.LOOKUP:
# Void reply: no payload frame.
self._router.send_multipart([identity, uid_f, type_f])
elif req_type == RequestType.QUERY_PREFETCH_STATUS:
req_id = msgspec.msgpack.decode(payload[0], type=str)
self.last_query_request_id = req_id
if self._in_progress_left > 0:
self._in_progress_left -= 1
body = msgspec.msgpack.encode(None)
else:
body = msgspec.msgpack.encode(self._hit_chunks)
self._router.send_multipart([identity, uid_f, type_f, body])
class TestLookupProtocol:
def _make_client(self, endpoint: str) -> MessageQueueClient:
ctx = zmq.Context.instance()
return MessageQueueClient(endpoint, ctx)
def test_send_lookup_void_reply_is_success(
self,
router_endpoint: str,
) -> None:
"""LOOKUP handler returns None (void) — must not be timeout."""
router = _LookupRouter(router_endpoint)
router.start()
try:
client = self._make_client(router_endpoint)
key = _make_key((1, 9906, 9906), request_id="req-void")
assert _send_lookup(client, key) is True
client.close()
finally:
router.stop()
def test_poll_prefetch_status_uses_request_id(
self,
router_endpoint: str,
) -> None:
"""QUERY_PREFETCH_STATUS payload is keyed by request_id str."""
router = _LookupRouter(
router_endpoint,
in_progress_polls=2,
hit_chunks=5,
)
router.start()
try:
client = self._make_client(router_endpoint)
hit = _poll_prefetch_status(
client,
"req-42",
max_polls=10,
poll_interval=0.0,
)
assert hit == 5
assert router.last_query_request_id == "req-42"
client.close()
finally:
router.stop()
# ------------------------------------------------------------------ #
# _send_unregister_kv_cache (deregister on shutdown) #
# ------------------------------------------------------------------ #
class _UnregisterRouter:
"""Fake ROUTER that records UNREGISTER requests and replies void.
Both ``UNREGISTER_KV_CACHE`` and
``UNREGISTER_KV_CACHE_ENGINE_DRIVEN_CONTEXT`` carry a single
``instance_id`` payload and return ``None`` (void). This fake
records the request type and decoded ``instance_id`` of the last
UNREGISTER it saw so the test can assert the bench sends the
correct protocol for each transfer mode.
"""
def __init__(self, endpoint: str) -> None:
self.last_request_type: RequestType | None = None
self.last_instance_id: int | None = None
self._ctx = zmq.Context.instance()
self._router = self._ctx.socket(zmq.ROUTER)
self._router.bind(endpoint)
self._stop = threading.Event()
self._thread = threading.Thread(target=self._run, daemon=True)
def start(self) -> None:
self._thread.start()
def stop(self) -> None:
self._stop.set()
self._thread.join(timeout=2)
self._router.close(linger=0)
def _run(self) -> None:
while not self._stop.is_set():
if not self._router.poll(100, zmq.POLLIN):
continue
frames = self._router.recv_multipart()
identity, uid_f, type_f, *payload = frames
req_type = msgspec.msgpack.decode(type_f, type=RequestType)
if req_type in (
RequestType.UNREGISTER_KV_CACHE,
RequestType.UNREGISTER_KV_CACHE_ENGINE_DRIVEN_CONTEXT,
):
self.last_request_type = req_type
self.last_instance_id = msgspec.msgpack.decode(payload[0], type=int)
# Void reply: no payload frame.
self._router.send_multipart([identity, uid_f, type_f])
class TestUnregisterKVCache:
def _make_client(self, endpoint: str) -> MessageQueueClient:
ctx = zmq.Context.instance()
return MessageQueueClient(endpoint, ctx)
def test_handle_mode_sends_unregister_kv_cache(
self,
router_endpoint: str,
) -> None:
"""Handle mode uses the GPU/SHM ``UNREGISTER_KV_CACHE`` protocol."""
router = _UnregisterRouter(router_endpoint)
router.start()
try:
client = self._make_client(router_endpoint)
assert (
_send_unregister_kv_cache(client, instance_id=7, use_handle=True)
is True
)
assert router.last_request_type == RequestType.UNREGISTER_KV_CACHE
assert router.last_instance_id == 7
client.close()
finally:
router.stop()
def test_data_mode_sends_engine_driven_unregister(
self,
router_endpoint: str,
) -> None:
"""Data mode uses the engine-driven context unregister protocol."""
router = _UnregisterRouter(router_endpoint)
router.start()
try:
client = self._make_client(router_endpoint)
assert (
_send_unregister_kv_cache(client, instance_id=0, use_handle=False)
is True
)
assert (
router.last_request_type
== RequestType.UNREGISTER_KV_CACHE_ENGINE_DRIVEN_CONTEXT
)
assert router.last_instance_id == 0
client.close()
finally:
router.stop()