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

490 lines
17 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""``lmcache describe`` — show detailed status of a running LMCache service.
Usage::
lmcache describe kvcache --url http://localhost:8000
"""
# Standard
from typing import TYPE_CHECKING
import argparse
import json
import sys
import urllib.error
import urllib.request
# Third Party
from prometheus_client.parser import text_string_to_metric_families
# First Party
from lmcache.cli.commands.base import BaseCommand
if TYPE_CHECKING:
# First Party
from lmcache.cli.metrics import Metrics
# Default server URLs per describe target (ZMQ/HTTP semantics differ).
DEFAULT_URLS: dict[str, str] = {
"kvcache": "http://localhost:8080",
"engine": "http://localhost:8000",
}
# -------------------------------------------------------------------
# Shared helpers
# -------------------------------------------------------------------
class DescribeError(Exception):
"""Raised when the describe command cannot fetch or parse status data."""
def normalize_url(url: str) -> str:
"""Ensure *url* has an ``http://`` or ``https://`` scheme."""
if not url.startswith(("http://", "https://")):
url = f"http://{url}"
return url.rstrip("/")
def fetch_json(url: str, timeout: int = 10) -> dict:
"""GET *url* and return the parsed JSON body.
Raises:
DescribeError: On network/HTTP errors.
"""
req = urllib.request.Request(url)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
if exc.code == 503:
body = exc.read().decode()
try:
detail = json.loads(body).get("error", body)
except (json.JSONDecodeError, AttributeError):
detail = body
raise DescribeError(f"Server unhealthy: {detail}") from exc
raise DescribeError(f"HTTP {exc.code} from {url}: {exc.reason}") from exc
except urllib.error.URLError as exc:
raise DescribeError(f"Cannot connect to {url}: {exc.reason}") from exc
except OSError as exc:
raise DescribeError(f"Cannot connect to {url}: {exc}") from exc
def fetch_health(url: str, timeout: int = 10) -> bool:
"""Return whether *url* responds with HTTP 200.
A lightweight liveness probe for endpoints (e.g. the vLLM ``/health``
route) that return an empty body rather than JSON, so :func:`fetch_json`
cannot be used.
Args:
url: Full health-check URL to GET.
timeout: Socket timeout in seconds.
Returns:
``True`` if the server responds with HTTP 200, ``False`` on any
non-200 status or connection error.
"""
try:
with urllib.request.urlopen(
urllib.request.Request(url), timeout=timeout
) as resp:
return resp.status == 200
except (urllib.error.URLError, OSError):
return False
def fetch_running_requests(url: str, timeout: int = 10) -> int | None:
"""Return the number of in-flight requests from a vLLM ``/metrics`` page.
Parses the Prometheus ``vllm:num_requests_running`` gauge, summing the
value across all reported series (e.g. one per model). This is best
effort: the metric is informational, so any failure to fetch or parse
degrades to ``None`` (rendered as ``N/A``) rather than raising.
Args:
url: Full ``/metrics`` URL to GET.
timeout: Socket timeout in seconds.
Returns:
The total running-request count, or ``None`` if the endpoint is
unreachable or the metric is absent (e.g. metrics disabled or an
unsupported engine version).
"""
try:
with urllib.request.urlopen(
urllib.request.Request(url), timeout=timeout
) as resp:
text = resp.read().decode()
except (urllib.error.URLError, OSError):
return None
total = 0.0
found = False
try:
for family in text_string_to_metric_families(text):
if family.name != "vllm:num_requests_running":
continue
for sample in family.samples:
total += sample.value
found = True
except ValueError:
# Malformed exposition text; treat as unavailable.
return None
return int(total) if found else None
def fmt_bytes(n: int) -> str:
"""Format a byte count as a human-readable string."""
if n >= 1024**3:
return f"{n / 1024**3:.2f} GB"
if n >= 1024**2:
return f"{n / 1024**2:.2f} MB"
if n >= 1024:
return f"{n / 1024:.2f} KB"
return f"{n} B"
def fmt_health(is_healthy: object) -> str | None:
"""Format a boolean health flag as ``'OK'`` / ``'UNHEALTHY'``."""
if is_healthy is None:
return None
return "OK" if is_healthy else "UNHEALTHY"
def safe_get(data: dict, *keys, default=None): # type: ignore[type-arg]
"""Walk nested dicts by *keys*, returning *default* on any miss."""
cur: object = data
for key in keys:
if not isinstance(cur, dict):
return default
cur = cur.get(key)
if cur is None:
return default
return cur
# -------------------------------------------------------------------
# KVCache describer
# -------------------------------------------------------------------
class KVCacheDescriber:
"""Builds the ``describe kvcache`` output from a ``/status`` response.
Each ``add_*`` method populates one logical section. The orchestrating
:meth:`describe` calls them in order and emits the result. Adding a
new section is a one-method change — no other code needs to know
about it.
"""
def __init__(self, metrics: "Metrics", data: dict, base_url: str) -> None:
self.metrics = metrics
self.data = data
self.base_url = base_url
def describe(self) -> None:
"""Run all section builders and emit."""
self.add_overview()
self.add_l1_storage()
self.add_models()
self.add_l2_adapters()
self.metrics.emit()
# -- sections --------------------------------------------------------
def add_overview(self) -> None:
"""Top-level engine overview."""
self.metrics.add("health", "Health", fmt_health(self.data.get("is_healthy")))
self.metrics.add("url", "URL", self.base_url)
self.metrics.add("engine_type", "Engine type", self.data.get("engine_type"))
self.metrics.add("chunk_size", "Chunk size", self.data.get("chunk_size"))
def add_l1_storage(self) -> None:
"""L1 cache capacity, usage, eviction, and object count."""
total_bytes = safe_get(
self.data, "storage_manager", "l1_manager", "memory_total_bytes"
)
if total_bytes is not None:
self.metrics.add(
"l1_capacity_gb",
"L1 capacity (GB)",
round(total_bytes / (1024**3), 2),
)
else:
self.metrics.add("l1_capacity_gb", "L1 capacity (GB)", None)
used_bytes = safe_get(
self.data, "storage_manager", "l1_manager", "memory_used_bytes"
)
usage_ratio = safe_get(
self.data, "storage_manager", "l1_manager", "memory_usage_ratio"
)
if used_bytes is not None and usage_ratio is not None:
gb = used_bytes / (1024**3)
pct = usage_ratio * 100
self.metrics.add("l1_used_gb", "L1 used (GB)", f"{gb:.2f} ({pct:.1f}%)")
else:
self.metrics.add("l1_used_gb", "L1 used (GB)", None)
self.metrics.add(
"eviction_policy",
"Eviction policy",
safe_get(
self.data,
"storage_manager",
"eviction_controller",
"eviction_policy",
),
)
self.metrics.add(
"cached_objects",
"Cached objects",
safe_get(self.data, "storage_manager", "l1_manager", "total_object_count"),
)
self.metrics.add(
"active_sessions", "Active sessions", self.data.get("active_sessions")
)
def add_models(self) -> None:
"""Per-model KV cache layout sections.
Each model gets one section with context-wide fields, followed by
one ``kernel_groups`` list entry per kernel group carrying that
group's identity and geometry.
"""
gpu_meta = self.data.get("cache_context_meta", {})
if not gpu_meta:
return
# Deduplicate by (model_name, world_size) — multiple GPU IDs
# may share the same model.
seen: dict[tuple[str, int], dict] = {}
for gpu_id, meta in gpu_meta.items():
key = (meta["model_name"], meta["world_size"])
if key not in seen:
seen[key] = {
"gpu_ids": [],
"layout": meta.get("kv_cache_layout"),
}
seen[key]["gpu_ids"].append(gpu_id)
for idx, ((model_name, world_size), info) in enumerate(seen.items()):
section_key = f"model_{idx}"
self.metrics.add_list_section("models", section_key, f"Model: {model_name}")
sec = self.metrics[section_key]
sec.add("model", "Model", model_name)
sec.add("world_size", "World size", world_size)
sec.add("gpu_ids", "GPU IDs", ", ".join(info["gpu_ids"]))
layout = info.get("layout")
if not layout:
continue
for _key, _label in (
("num_layers", "Num layers"),
("num_blocks", "Num blocks"),
("cache_size_per_token", "Cache size per token (bytes)"),
):
if _key in layout:
sec.add(_key, _label, layout[_key])
self._add_kernel_groups(idx, model_name, layout.get("kernel_groups", []))
def _add_kernel_groups(
self, model_idx: int, model_name: str, kernel_groups: list
) -> None:
"""Emit one ``kernel_groups`` list section per kernel group.
Args:
model_idx: Index of the owning model section (keeps section keys
unique across models).
model_name: Human-readable model name, shown in each group header.
kernel_groups: The model layout's ``kernel_groups`` list (each a
dict produced by ``GPUCacheContext.report_status``).
"""
for group in kernel_groups:
kg_idx = group.get("kernel_group_idx")
section_key = f"model_{model_idx}_kg_{kg_idx}"
self.metrics.add_list_section(
"kernel_groups",
section_key,
f"Kernel group {kg_idx} ({model_name})",
)
sec = self.metrics[section_key]
sec.add("model", "Model", model_name)
for _key, _label in (
("kernel_group_idx", "Kernel group index"),
("engine_group_idx", "Engine group index"),
("object_group_idx", "Object group index"),
("num_layers", "Num layers"),
("tokens_per_block", "Tokens per block"),
("slots_per_block", "Slots per block"),
("dtype", "Dtype"),
("is_mla", "MLA"),
("attention_backend", "Attention backend"),
("engine_kv_shape", "Engine KV shape"),
("engine_kv_concrete_shape", "Engine KV tensor shape"),
):
if _key in group:
sec.add(_key, _label, group[_key])
def add_l2_adapters(self) -> None:
"""L2 adapter sections."""
l2_adapters = safe_get(self.data, "storage_manager", "l2_adapters") or []
for idx, adapter in enumerate(l2_adapters):
adapter_type = adapter.get("type", "Unknown")
section_key = f"l2_{idx}"
self.metrics.add_list_section(
"l2_adapters", section_key, f"L2: {adapter_type}"
)
sec = self.metrics[section_key]
sec.add("type", "Type", adapter_type)
sec.add("health", "Health", fmt_health(adapter.get("is_healthy")))
if "backend" in adapter:
sec.add("backend", "Backend", adapter["backend"])
if "base_path" in adapter:
sec.add("base_path", "Base path", adapter["base_path"])
if "stored_object_count" in adapter:
sec.add(
"stored_object_count",
"Stored objects",
adapter["stored_object_count"],
)
cap = adapter.get("max_capacity_bytes")
used = adapter.get("current_size_bytes")
if cap is not None and used is not None:
pct = used / cap * 100 if cap > 0 else 0.0
sec.add(
"used",
"Used",
f"{fmt_bytes(used)} / {fmt_bytes(cap)} ({pct:.1f}%)",
)
pool_size = adapter.get("pool_size")
pool_free = adapter.get("pool_free_slots")
if pool_size is not None and pool_free is not None:
pool_used = pool_size - pool_free
pct = pool_used / pool_size * 100 if pool_size > 0 else 0.0
sec.add(
"pool_used",
"Pool used",
f"{pool_used} / {pool_size} ({pct:.1f}%)",
)
# -------------------------------------------------------------------
# Engine describer
# -------------------------------------------------------------------
class EngineDescriber:
"""Builds the ``describe engine`` output from vLLM server responses.
Reads model identity and context window from the engine's
``/v1/models`` response and combines them with a ``/health`` liveness
result to render a concise status view.
"""
def __init__(
self,
metrics: "Metrics",
models_data: dict,
is_healthy: bool,
running_requests: int | None,
base_url: str,
) -> None:
self.metrics = metrics
self.models_data = models_data
self.is_healthy = is_healthy
self.running_requests = running_requests
self.base_url = base_url
def describe(self) -> None:
"""Run all section builders and emit."""
self.add_overview()
self.metrics.emit()
def add_overview(self) -> None:
"""Model identity, context window, health status, and load."""
model = self._first_model()
self.metrics.add("model", "Model", model.get("id") if model else None)
self.metrics.add(
"max_context",
"Max context (tokens)",
model.get("max_model_len") if model else None,
)
self.metrics.add("status", "Status", fmt_health(self.is_healthy))
self.metrics.add("running_requests", "Running requests", self.running_requests)
def _first_model(self) -> dict | None:
"""Return the first model entry from a ``/v1/models`` response."""
data = self.models_data.get("data") or []
return data[0] if data else None
# -------------------------------------------------------------------
# Command
# -------------------------------------------------------------------
class DescribeCommand(BaseCommand):
"""Show detailed status of a running LMCache service."""
def name(self) -> str:
return "describe"
def help(self) -> str:
return "Show detailed status of a running LMCache service."
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
parser.add_argument(
"target",
choices=["kvcache", "engine"],
help="What to describe.",
)
parser.add_argument(
"--url",
default=None,
help=(
"Server URL (default: http://localhost:8080 for kvcache, "
"http://localhost:8000 for engine)."
),
)
def execute(self, args: argparse.Namespace) -> None:
if getattr(args, "url", None) is None:
args.url = DEFAULT_URLS[args.target]
if args.target == "kvcache":
self._describe_kvcache(args)
elif args.target == "engine":
self._describe_engine(args)
def _describe_kvcache(self, args: argparse.Namespace) -> None:
base_url = normalize_url(args.url)
try:
data = fetch_json(f"{base_url}/status")
except DescribeError as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
metrics = self.create_metrics("LMCache KV Cache Service", args, width=50)
KVCacheDescriber(metrics, data, base_url).describe()
def _describe_engine(self, args: argparse.Namespace) -> None:
base_url = normalize_url(args.url)
try:
models = fetch_json(f"{base_url}/v1/models")
except DescribeError as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)
is_healthy = fetch_health(f"{base_url}/health")
running_requests = fetch_running_requests(f"{base_url}/metrics")
metrics = self.create_metrics("Inference Engine", args, width=50)
EngineDescriber(
metrics, models, is_healthy, running_requests, base_url
).describe()