from __future__ import annotations
import atexit
import contextlib
import errno
import logging
import os
import threading
import time
from . import envs
logger = logging.getLogger("modelexpress.metrics")
ENV_ENABLED = "MX_METRICS_ENABLED"
_CANDIDATE_BUCKETS = (0, 1, 2, 4, 8, 16, 32, 64, 128)
LIST_SOURCES_RESULTS = ("ok", "empty", "error")
NIXL_ERROR_KINDS = ("timeout", "status_error")
NIXL_RECEIVE_RESULTS = ("complete", "partial", "empty", "rejected")
_XSLOW_BUCKETS = (0.5, 1, 2.5, 5, 15, 30, 60, 120, 300, 600, 900, 1200, 1800, 2700, 3600)
LOAD_ENGINES = ("vllm", "sglang", "trtllm", "other")
LOAD_MODEL_ROLES = ("main", "draft", "other")
LOAD_PHASES = ("artifact_install", "model_init", "chain", "publish")
LOAD_OUTCOMES = ("success", "error")
ARTIFACT_KINDS = (
"torch_compile_cache",
"triton_cache",
"tvm_ffi_cache",
"deep_gemm_cache",
"tilelang_cache",
"cute_dsl_cache",
"flashinfer_cache",
"other",
)
ARTIFACT_INSTALL_STEPS = ("validate", "extract")
_ARTIFACT_STEP_BUCKETS = (0.1, 0.25, 0.5, 1, 2.5, 5, 15, 30, 60, 120, 300)
SOURCE_ATTEMPT_PHASES = (
"metadata",
"prepare",
"register",
"handshake",
"receive",
"finalize",
"release",
)
SOURCE_ATTEMPT_PHASE_OUTCOMES = ("ok", "error")
_ATTEMPT_PHASE_BUCKETS = (0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120, 300, 600)
_MODEL_LABEL_MAX = 96
def _model_label(name: object) -> str:
if not name:
return "unknown"
text = str(name).strip()
if not text:
return "unknown"
return text[:_MODEL_LABEL_MAX]
def _enabled() -> bool:
return envs.MX_METRICS_ENABLED
def _multiproc_dir() -> str | None:
value = envs.PROMETHEUS_MULTIPROC_DIR
return value.strip() or None if value else None
def _package_version() -> str:
try:
from importlib.metadata import version
return version("modelexpress")
except Exception:
return "unknown"
def reset_multiproc_dir(path: str | None = None) -> None:
directory = path or _multiproc_dir()
if not directory:
return
try:
os.makedirs(directory, exist_ok=True)
removed = 0
for name in os.listdir(directory):
if not name.endswith(".db"):
continue
try:
os.unlink(os.path.join(directory, name))
removed += 1
except OSError as e:
logger.warning("Failed to remove stale metrics file %s: %s", name, e)
logger.info("Reset metrics directory %s (%d stale file(s))", directory, removed)
except OSError as e:
logger.warning("Failed to reset metrics directory %s: %s", directory, e)
class _PhaseSpan:
__slots__ = ("seconds",)
def __init__(self) -> None:
self.seconds = 0.0
class MetricsCollector:
def __init__(self, registry=None) -> None:
self._registry = registry
self._ready = False
self._init_attempted = False
self._server_started = False
self._push_registered = False
self._bind_owner = False
self._source_id_label = False
self._retry_thread: threading.Thread | None = None
self._lock = threading.Lock()
self.scheme = envs.MX_METRICS_SCHEME
def _ensure(self) -> bool:
with self._lock:
if self._ready:
return True
if self._init_attempted:
return False
self._init_attempted = True
if not _enabled():
return False
try:
self._build_families()
multiproc_dir = _multiproc_dir()
if multiproc_dir:
_warn_if_multiprocess_inactive(multiproc_dir)
self._ready = True
logger.info(
"ModelExpress metrics enabled (scheme=%r, multiprocess=%s)",
self.scheme,
bool(_multiproc_dir()),
)
except Exception as e:
logger.warning("Failed to initialize metrics, disabling: %s", e)
self._ready = False
return False
try:
self._start_exposition()
except Exception as e:
logger.warning("Failed to start metrics exposition: %s", e)
return True
def _build_families(self) -> None:
from prometheus_client import REGISTRY, Counter, Histogram
registry = REGISTRY if self._registry is None else self._registry
self.build_info = _make_gauge(
registry,
"mx_build_info",
"Build and deployment constants for this process; always 1.",
["component", "version", "scheme"],
)
self.build_info.labels("client", _package_version(), self.scheme).set(1)
self._source_id_label = envs.MX_METRICS_SOURCE_ID_LABEL
selection_labels = ["policy", "scheme"]
if self._source_id_label:
selection_labels.append("source_worker_id")
logger.warning(
"MX_METRICS_SOURCE_ID_LABEL=1: mx_p2p_source_selections_total "
"and mx_p2p_source_load carry source_worker_id, whose domain "
"grows with process count over time rather than with cluster "
"size. Benchmark runs only -- never a long-lived production "
"Prometheus."
)
self.selections = Counter(
"mx_p2p_source_selections_total",
"Source workers chosen, by selection policy.",
selection_labels,
registry=registry,
)
self.attempts = Counter(
"mx_p2p_source_attempts_total",
"Source attempts by result.",
["policy", "scheme", "result"],
registry=registry,
)
self.metadata_failures = Counter(
"mx_p2p_metadata_lookup_failures_total",
"Metadata lookup failures during source selection.",
["policy", "scheme"],
registry=registry,
)
self.list_sources = Counter(
"mx_p2p_list_sources_total",
"ListSources outcomes: ok|empty|error. Separates a backend outage "
"from a healthy cluster that has published no peers yet.",
["policy", "scheme", "result"],
registry=registry,
)
self.nixl_errors = Counter(
"mx_nixl_data_plane_errors_total",
"NIXL data-plane failures by classified kind: timeout|status_error. "
"A non-zero rate is what demotes an agent from READY.",
["scheme", "kind"],
registry=registry,
)
self.nixl_receives = Counter(
"mx_nixl_receive_total",
"Receive outcomes: complete|partial|empty|rejected. `partial` and "
"`empty` both return success today, so they are otherwise "
"indistinguishable from a healthy transfer; `rejected` is the "
"strict-mode refusal, which raises instead of returning.",
["scheme", "result"],
registry=registry,
)
self.source_load = _make_gauge(
registry,
"mx_p2p_source_load",
"Source-published load in [0,1] observed on candidates at selection.",
["scheme", "source_worker_id"] if self._source_id_label else ["scheme"],
)
self.candidates = Histogram(
"mx_p2p_candidates",
"Candidate count at a selection stage.",
["policy", "scheme", "stage"], buckets=_CANDIDATE_BUCKETS,
registry=registry,
)
self.selection_seconds = Histogram(
"mx_p2p_source_selection_seconds",
"Selection (ordering) overhead in seconds.",
["policy", "scheme"],
buckets=(1e-5, 1e-4, 1e-3, 1e-2, 1e-1, 1.0),
registry=registry,
)
self.transfer_seconds = Histogram(
"mx_p2p_transfer_seconds",
"End-to-end transfer time in seconds.",
["policy", "scheme", "outcome"], buckets=(0.5, 1, 2, 5, 10, 30, 60, 120, 300),
registry=registry,
)
self.source_attempt_phase_seconds = Histogram(
"mx_p2p_source_attempt_phase_seconds",
"Duration of one phase of a P2P source attempt: metadata, prepare, "
"register, handshake, receive, finalize or release. The phases "
"partition the attempt; all but metadata fall inside "
"mx_p2p_transfer_seconds.",
["policy", "phase", "outcome", "scheme"],
buckets=_ATTEMPT_PHASE_BUCKETS,
registry=registry,
)
self.load_seconds = Histogram(
"mx_load_seconds",
"Model load duration in seconds: the load_model() window. Excludes "
"CUDA graph capture, JIT and KV cache setup, which run after the "
"loader returns and are measured by the engine.",
["engine", "model", "model_role", "scheme", "outcome"],
buckets=_XSLOW_BUCKETS,
registry=registry,
)
self.load_phase_seconds = Histogram(
"mx_load_phase_seconds",
"Duration of one phase of a model load: artifact_install, "
"model_init, chain or publish. The phases partition the load, so "
"their sum is bounded by mx_load_seconds.",
["engine", "model", "phase", "scheme"],
buckets=_XSLOW_BUCKETS,
registry=registry,
)
self.artifact_install_step_seconds = Histogram(
"mx_artifact_install_step_seconds",
"On-target time for one step of an artifact install, per archive: "
"validate (walk the tar members) or extract (tar -xf). Both run "
"inside the artifact_install load phase, which also covers "
"discovery and the RDMA copy, so they sum to less than it.",
["artifact", "archive", "step", "scheme"],
buckets=_ARTIFACT_STEP_BUCKETS,
registry=registry,
)
self.artifact_install_bytes = Counter(
"mx_artifact_install_bytes_total",
"Archive bytes extracted on the target, per artifact and archive. "
"Divided by the extract step's _sum this is extraction throughput, "
"which is what separates a slow disk from a large cache.",
["artifact", "archive", "scheme"],
registry=registry,
)
def _exposition_registry(self):
multiproc_dir = _multiproc_dir()
if multiproc_dir:
from prometheus_client import CollectorRegistry, multiprocess
registry = CollectorRegistry()
multiprocess.MultiProcessCollector(registry, path=multiproc_dir)
return registry
from prometheus_client import REGISTRY
return REGISTRY if self._registry is None else self._registry
def _start_exposition(self) -> None:
with self._lock:
if self._server_started:
return
self._server_started = True
port = _configured_port()
gateway = envs.MX_METRICS_PUSHGATEWAY
if port and gateway:
logger.error(
"MX_METRICS_PORT and MX_METRICS_PUSHGATEWAY are both set; "
"running both double-counts every series. Serving the scrape "
"endpoint and disabling the push."
)
gateway = None
if port:
self._try_bind(port)
if gateway and not self._push_registered:
self._push_registered = True
atexit.register(push_metrics_if_enabled)
def _try_bind(self, port: int | None) -> None:
if port is None:
return
try:
from prometheus_client import start_http_server
start_http_server(port, registry=self._exposition_registry())
except OSError as e:
if e.errno == errno.EADDRINUSE and _multiproc_dir():
logger.info(
"Metrics port %s is held by another rank in this pod (%s); "
"its endpoint serves this rank's metrics too.",
port,
e,
)
self._start_bind_retry(port)
elif e.errno == errno.EADDRINUSE:
logger.warning(
"Failed to bind metrics port %s (%s). Without "
"PROMETHEUS_MULTIPROC_DIR this process's metrics are not "
"exposed anywhere.",
port,
e,
)
else:
logger.error(
"Metrics port %s cannot be bound (%s). This is not a lost "
"race with another rank -- it will fail identically on every "
"rank and no retry can fix it, so this pod will never be "
"scrapeable. Check the port number and the container's "
"capabilities.",
port,
e,
)
return
except Exception as e:
logger.warning("Failed to start metrics server on port %s: %s", port, e)
return
self._bind_owner = True
logger.info("Metrics /metrics endpoint listening on :%s", port)
def _start_bind_retry(self, port: int) -> None:
if self._retry_thread is not None:
return
interval = max(1.0, float(envs.MX_METRICS_BIND_RETRY_SECS))
def _retry() -> None:
while not self._bind_owner:
try:
threading.Event().wait(interval)
from prometheus_client import start_http_server
start_http_server(port, registry=self._exposition_registry())
except OSError:
continue except Exception as e:
logger.debug("Metrics bind retry gave up: %s", e)
return
else:
self._bind_owner = True
logger.info(
"Took ownership of the metrics endpoint on :%s "
"(previous owner exited)",
port,
)
return
thread = threading.Thread(
target=_retry, name="mx-metrics-bind-retry", daemon=True
)
self._retry_thread = thread
thread.start()
def record_selection(self, policy: str, source_worker_id: str | None = None) -> None:
if self._ensure():
try:
if self._source_id_label:
self.selections.labels(
policy, self.scheme, source_worker_id or "unknown"
).inc()
else:
self.selections.labels(policy, self.scheme).inc()
except Exception:
pass
def record_attempt(self, policy: str, result: str) -> None:
if self._ensure():
try:
self.attempts.labels(policy, self.scheme, result).inc()
except Exception:
pass
def record_metadata_failure(self, policy: str) -> None:
if self._ensure():
try:
self.metadata_failures.labels(policy, self.scheme).inc()
except Exception:
pass
def record_list_sources(self, policy: str, result: str) -> None:
if self._ensure():
try:
if result not in LIST_SOURCES_RESULTS:
result = "error"
self.list_sources.labels(policy, self.scheme, result).inc()
except Exception:
pass
def record_nixl_error(self, kind: str) -> None:
if self._ensure():
try:
if kind not in NIXL_ERROR_KINDS:
kind = "status_error"
self.nixl_errors.labels(self.scheme, kind).inc()
except Exception:
pass
def record_nixl_receive(self, result: str) -> None:
if self._ensure():
try:
if result not in NIXL_RECEIVE_RESULTS:
result = "empty"
self.nixl_receives.labels(self.scheme, result).inc()
except Exception:
pass
def observe_candidates(self, policy: str, stage: str, count: int) -> None:
if self._ensure():
try:
self.candidates.labels(policy, self.scheme, stage).observe(count)
except Exception:
pass
def observe_selection_seconds(self, policy: str, seconds: float) -> None:
if self._ensure():
try:
self.selection_seconds.labels(policy, self.scheme).observe(seconds)
except Exception:
pass
def observe_candidate_loads(self, ordered) -> None:
if not ordered or not self._ensure():
return
try:
targets = ordered if self._source_id_label else ordered[:1]
for inst in targets:
has_field = getattr(inst, "HasField", None)
try:
known = bool(has_field("source_load")) if has_field else False
except (ValueError, TypeError):
known = False
if not known:
continue
value = float(inst.source_load)
if self._source_id_label:
self.source_load.labels(self.scheme, inst.worker_id).set(value)
else:
self.source_load.labels(self.scheme).set(value)
except Exception:
pass
def observe_transfer_seconds(self, policy: str, outcome: str, seconds: float) -> None:
if self._ensure():
try:
self.transfer_seconds.labels(policy, self.scheme, outcome).observe(seconds)
except Exception:
pass
def observe_source_attempt_phase_seconds(
self, policy: str, phase: str, outcome: str, seconds: float
) -> None:
if self._ensure():
try:
if phase not in SOURCE_ATTEMPT_PHASES:
return
if outcome not in SOURCE_ATTEMPT_PHASE_OUTCOMES:
outcome = "error"
self.source_attempt_phase_seconds.labels(
policy, phase, outcome, self.scheme
).observe(seconds)
except Exception:
pass
@contextlib.contextmanager
def time_source_attempt_phase(self, policy: str, phase: str):
span = _PhaseSpan()
start = time.perf_counter()
outcome = "ok"
try:
yield span
except BaseException:
outcome = "error"
raise
finally:
span.seconds = time.perf_counter() - start
self.observe_source_attempt_phase_seconds(policy, phase, outcome, span.seconds)
def observe_load_seconds(
self, engine: str, model: object, model_role: str, outcome: str, seconds: float
) -> None:
if self._ensure():
try:
if engine not in LOAD_ENGINES:
engine = "other"
if model_role not in LOAD_MODEL_ROLES:
model_role = "other"
if outcome not in LOAD_OUTCOMES:
outcome = "error"
self.load_seconds.labels(
engine, _model_label(model), model_role, self.scheme, outcome
).observe(seconds)
except Exception:
pass
def observe_load_phase_seconds(
self, engine: str, model: object, phase: str, seconds: float
) -> None:
if self._ensure():
try:
if phase not in LOAD_PHASES:
return
if engine not in LOAD_ENGINES:
engine = "other"
self.load_phase_seconds.labels(
engine, _model_label(model), phase, self.scheme
).observe(seconds)
except Exception:
pass
def observe_artifact_install_step_seconds(
self, artifact: str, archive: str, step: str, seconds: float
) -> None:
if self._ensure():
try:
if step not in ARTIFACT_INSTALL_STEPS:
return
if artifact not in ARTIFACT_KINDS:
artifact = "other"
self.artifact_install_step_seconds.labels(
artifact, archive, step, self.scheme
).observe(seconds)
except Exception:
pass
def record_artifact_install_bytes(self, artifact: str, archive: str, nbytes: int) -> None:
if self._ensure():
try:
if artifact not in ARTIFACT_KINDS:
artifact = "other"
self.artifact_install_bytes.labels(artifact, archive, self.scheme).inc(
nbytes
)
except Exception:
pass
@contextlib.contextmanager
def time_artifact_install_step(self, artifact: str, archive: str, step: str):
start = time.perf_counter()
try:
yield
finally:
self.observe_artifact_install_step_seconds(
artifact, archive, step, time.perf_counter() - start
)
@contextlib.contextmanager
def time_load(self, engine: str, model: object, model_role: str):
start = time.perf_counter()
try:
yield
except BaseException:
self.observe_load_seconds(
engine, model, model_role, "error", time.perf_counter() - start
)
raise
self.observe_load_seconds(
engine, model, model_role, "success", time.perf_counter() - start
)
@contextlib.contextmanager
def time_load_phase(self, engine: str, model: object, phase: str):
start = time.perf_counter()
try:
yield
finally:
self.observe_load_phase_seconds(
engine, model, phase, time.perf_counter() - start
)
metrics = MetricsCollector()
def _configured_port() -> int | None:
value = envs.MX_METRICS_PORT
if value is None:
return None
try:
port = int(value)
except (TypeError, ValueError):
logger.warning("Invalid MX_METRICS_PORT=%r; not serving /metrics", value)
return None
if port <= 0:
logger.info("MX_METRICS_PORT=%s disables the /metrics endpoint", port)
return None
return port
def _make_gauge(registry, name: str, documentation: str, labels: list[str]):
from prometheus_client import Gauge
for mode in ("mostrecent", "max"):
try:
return Gauge(
name, documentation, labels, registry=registry, multiprocess_mode=mode
)
except (ValueError, TypeError):
continue
return Gauge(name, documentation, labels, registry=registry)
def enable_metrics() -> bool:
if not _enabled():
return False
try:
multiproc_dir = _multiproc_dir()
if multiproc_dir:
_check_multiproc_dir(multiproc_dir)
return metrics._ensure()
except Exception as e: logger.warning("Failed to enable metrics: %s", e)
return False
def _check_multiproc_dir(multiproc_dir: str) -> None:
try:
os.makedirs(multiproc_dir, exist_ok=True)
except OSError as e:
logger.error(
"PROMETHEUS_MULTIPROC_DIR=%s is not usable (%s); metrics will be "
"empty. Mount it as an emptyDir in the pod manifest.",
multiproc_dir,
e,
)
def _warn_if_multiprocess_inactive(multiproc_dir: str) -> None:
try:
if any(os.scandir(multiproc_dir)):
return
except OSError:
return
logger.error(
"PROMETHEUS_MULTIPROC_DIR=%s is set but no .db files were written, so "
"prometheus_client is not in multiprocess mode and the merged endpoint "
"will be empty. get_value_class() latches at import, so the variable "
"must be set in the pod manifest before the process starts -- assigning "
"it from Python lands after the engine has already imported "
"prometheus_client.",
multiproc_dir,
)
def push_metrics_if_enabled(job: str = "modelexpress") -> None:
if not _enabled():
return
gateway = envs.MX_METRICS_PUSHGATEWAY
if not gateway:
return
if _configured_port():
logger.debug("Scrape endpoint is configured; skipping the push.")
return
try:
import socket
from prometheus_client import push_to_gateway
pod = envs.POD_UID or envs.POD_NAME or socket.gethostname()
grouping_key = {"pod": pod}
push_to_gateway(
gateway,
job=job,
grouping_key=grouping_key,
registry=metrics._exposition_registry(),
)
logger.info("Pushed metrics to %s (job=%s, %s)", gateway, job, grouping_key)
except Exception as e:
logger.warning("Failed to push metrics to %s: %s", gateway, e)
def _main(argv: list[str] | None = None) -> int:
import argparse
parser = argparse.ArgumentParser(prog="modelexpress.metrics")
parser.add_argument(
"--reset",
action="store_true",
help=(
"Delete stale .db files from PROMETHEUS_MULTIPROC_DIR. Run this from "
"the container entrypoint, never from worker code: an emptyDir "
"survives container restarts within a pod, but wiping it after "
"ranks have started unlinks their mmapped files."
),
)
parser.add_argument("--dir", default=None, help="Override PROMETHEUS_MULTIPROC_DIR.")
args = parser.parse_args(argv)
if args.reset:
logging.basicConfig(level=logging.INFO)
reset_multiproc_dir(args.dir)
return 0
if __name__ == "__main__":
raise SystemExit(_main())