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