490 lines
17 KiB
Python
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()
|