Skip to content

Commit 66262cf

Browse files
[TRTLLM-12648][test] implement disagg cancel stress metrics_thread (#14807)
Signed-off-by: Chien-Chun Hung <2679986+chienchunhung@users.noreply.github.com>
1 parent 6ef7b38 commit 66262cf

4 files changed

Lines changed: 606 additions & 76 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
"""Shared helpers for ``disagg_cancel`` thread-body unit tests.
16+
17+
Each thread (``log_scanner``, ``metrics``, ``injector``, ``canary``,
18+
``load``) is tested in isolation against a harness wired with
19+
placeholder ``WorkerLaunchSpec`` entries — no live disagg cluster.
20+
Per-thread tests only need to override the spec fields relevant to
21+
their thread, so this module exposes a single ``make_spec`` factory
22+
that fills in the rest, plus a minimal valid marathon YAML for the
23+
``DisaggCancellationStressHarness`` constructor.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import textwrap
29+
from pathlib import Path
30+
from typing import Optional
31+
32+
from .harness import WorkerLaunchSpec
33+
34+
# Minimal valid marathon YAML for tests that only need the harness to
35+
# construct. Includes ``log_scan`` so the log-scanner thread has
36+
# patterns to compile; threads that don't read ``log_scan`` ignore it.
37+
DUMMY_YAML = textwrap.dedent(
38+
"""\
39+
hostname: localhost
40+
model: dummy
41+
backend: pytorch
42+
context_servers: {}
43+
generation_servers: {}
44+
stress_config:
45+
duration_min: 1
46+
kv_cache_manager: v1
47+
transceiver: cpp
48+
log_scan:
49+
hard_zero_patterns:
50+
- "Broken promise"
51+
- "Segfault"
52+
- "Poisoned .* cache transfer buffer"
53+
"""
54+
)
55+
56+
57+
def make_spec(
58+
role: str,
59+
index: int,
60+
*,
61+
port: int = 18000,
62+
host: str = "localhost",
63+
log_path: Optional[Path] = None,
64+
) -> WorkerLaunchSpec:
65+
"""``WorkerLaunchSpec`` factory for unit tests.
66+
67+
Callers override only the fields their thread reads (``log_path``
68+
for the log scanner, ``host`` / ``port`` for the metrics scraper,
69+
etc.); the rest are placeholders that ``setup_disagg_cluster``
70+
would normally populate.
71+
72+
Args:
73+
role: ``"ctx"`` or ``"gen"``. Surfaces in failure reasons.
74+
index: Per-role index (``ctx_0``, ``gen_0``, ...).
75+
port: TCP port for HTTP scraping. Default ``18000``; tests
76+
that bind a real socket pass an OS-allocated port.
77+
host: HTTP hostname; default ``"localhost"``.
78+
log_path: File path the log scanner should tail, or ``None``
79+
to simulate a worker launched with ``save_log=False``.
80+
"""
81+
return WorkerLaunchSpec(
82+
role=role,
83+
index=index,
84+
model_name="dummy",
85+
worker_config={},
86+
work_dir="/tmp",
87+
port=port,
88+
device="0",
89+
env={},
90+
log_path=str(log_path) if log_path is not None else None,
91+
host=host,
92+
)

tests/integration/defs/stress_test/disagg_cancel/harness.py

Lines changed: 142 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
import re
3333
import threading
3434
import time
35+
import urllib.error
36+
import urllib.request
3537
from dataclasses import dataclass, field
3638
from pathlib import Path
3739
from typing import IO, Any, Callable, Optional
@@ -167,6 +169,9 @@ class WorkerLaunchSpec:
167169
# ``None`` when launched with ``save_log=False`` (output inherits
168170
# pytest stdout); log_scanner skips ``None`` paths with a warning.
169171
log_path: Optional[str] = None
172+
# Host for HTTP scraping (Prometheus / health). Multi-host setups
173+
# set this from the YAML ``hostname:`` field at cluster-setup time.
174+
host: str = "localhost"
170175

171176

172177
# ---------------------------------------------------------------------------
@@ -305,6 +310,79 @@ def _compile_patterns(raw_patterns: list[Any]) -> list[tuple[str, re.Pattern[str
305310
return compiled
306311

307312

313+
# ---------------------------------------------------------------------------
314+
# Metrics-thread helpers
315+
# ---------------------------------------------------------------------------
316+
317+
318+
# Tolerates optional ``{labels}`` and trailing timestamp; captures value in group 1.
319+
_KV_CACHE_UTIL_RE = re.compile(
320+
r"^trtllm_kv_cache_utilization(?:\{[^}]*\})?\s+([+-]?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)"
321+
)
322+
323+
324+
def _parse_kv_cache_utilization(metrics_text: str) -> Optional[float]:
325+
"""Extract ``trtllm_kv_cache_utilization`` from a Prometheus exposition.
326+
327+
Skips ``# HELP`` / ``# TYPE`` lines. Returns the first matching
328+
sample's value as a float, or ``None`` if no sample is present.
329+
330+
Args:
331+
metrics_text: Body of an HTTP response from
332+
``/prometheus/metrics``.
333+
334+
Returns:
335+
Float utilization in the range the worker exposes (typically
336+
``[0.0, 1.0]``), or ``None`` if the metric is absent.
337+
"""
338+
for line in metrics_text.splitlines():
339+
if not line or line.startswith("#"):
340+
continue
341+
m = _KV_CACHE_UTIL_RE.match(line)
342+
if m is not None:
343+
try:
344+
return float(m.group(1))
345+
except ValueError:
346+
return None
347+
return None
348+
349+
350+
def _fetch_kv_cache_utilization(
351+
host: str, port: int, timeout_s: float
352+
) -> tuple[Optional[float], Optional[str]]:
353+
"""HTTP GET ``/prometheus/metrics`` and parse the KV-cache utilization.
354+
355+
All failures (connection refused, timeout, HTTP error, parse miss)
356+
are folded into ``(None, error_string)`` rather than raising. The
357+
metrics thread must not fail-fast on transient scrape misses — the
358+
worker may be mid-restart, the metrics endpoint may not be wired
359+
up on every backend, etc.
360+
361+
Args:
362+
host: Worker hostname or IP.
363+
port: Worker HTTP port.
364+
timeout_s: Per-request timeout in seconds.
365+
366+
Returns:
367+
Tuple ``(utilization, error)``. Exactly one is ``None``: on
368+
success ``utilization`` is the gauge value and ``error`` is
369+
``None``; on failure ``utilization`` is ``None`` and ``error``
370+
is a short string explaining why (for the timeseries record).
371+
"""
372+
url = f"http://{host}:{port}/prometheus/metrics"
373+
try:
374+
with urllib.request.urlopen(url, timeout=timeout_s) as response:
375+
body = response.read().decode("utf-8", errors="replace")
376+
except urllib.error.URLError as exc:
377+
return None, f"url_error: {exc.reason}"
378+
except (TimeoutError, OSError) as exc:
379+
return None, f"io_error: {exc}"
380+
util = _parse_kv_cache_utilization(body)
381+
if util is None:
382+
return None, "metric_absent"
383+
return util, None
384+
385+
308386
# ---------------------------------------------------------------------------
309387
# Harness
310388
# ---------------------------------------------------------------------------
@@ -337,6 +415,8 @@ def __init__(
337415
yaml_path: Path,
338416
*,
339417
log_scanner_poll_interval_s: float = 0.5,
418+
metrics_scrape_interval_s: float = 30.0,
419+
metrics_scrape_timeout_s: float = 5.0,
340420
) -> None:
341421
"""Construct a marathon harness.
342422
@@ -349,6 +429,14 @@ def __init__(
349429
measurable load source on its own; tests pass a
350430
smaller value (e.g. 0.02 s) to keep real-clock
351431
latency bounded.
432+
metrics_scrape_interval_s: Cadence (seconds) between
433+
consecutive Prometheus scrapes of every worker.
434+
Default 30 s matches the spec's KV-cache utilization
435+
sampling rate; tests pass a smaller value.
436+
metrics_scrape_timeout_s: Per-request HTTP timeout for
437+
the metrics scrape. Short by design — a slow scrape
438+
is recorded as a miss rather than blocking the
439+
metrics thread past its next scheduled scrape.
352440
353441
Raises:
354442
ValueError: If the YAML is malformed or its
@@ -364,12 +452,9 @@ def __init__(
364452
self._failure_reason: Optional[str] = None
365453
self._failure_lock = threading.Lock()
366454

367-
# Per-thread tunables. The default cadence is reactive enough
368-
# for human-scale debugging (~0.5 s lag from log line to
369-
# fail-fast) without becoming a measurable load source on its
370-
# own; tests pass a smaller value via the constructor to keep
371-
# real-clock latency bounded.
372455
self._log_scanner_poll_interval_s: float = log_scanner_poll_interval_s
456+
self._metrics_scrape_interval_s: float = metrics_scrape_interval_s
457+
self._metrics_scrape_timeout_s: float = metrics_scrape_timeout_s
373458

374459
# Cluster + worker tracking (populated by setup()).
375460
self._cluster: Any = None # tuple returned by setup_disagg_cluster
@@ -658,13 +743,59 @@ def _log_scanner_thread_body(self) -> None:
658743
logger.debug("[log_scanner] exiting; closed %d source(s)", len(sources))
659744

660745
def _metrics_thread_body(self) -> None:
661-
"""Scrape ``/prometheus/metrics`` for KV-cache utilization at ~30 s cadence.
662-
663-
Stub: no-op. Real implementation parses
664-
``trtllm_kv_cache_utilization`` and appends timestamped
665-
samples to ``self._kv_utilization_samples``.
746+
"""Scrape Prometheus KV-cache utilization from every worker.
747+
748+
Polls each ``WorkerLaunchSpec`` at
749+
``_metrics_scrape_interval_s`` cadence, parses
750+
``trtllm_kv_cache_utilization`` out of
751+
``/prometheus/metrics``, and appends a timestamped sample to
752+
``self._kv_utilization_samples``. Each sample records
753+
``timestamp``, ``role``, ``index``, ``host``, ``port``,
754+
``utilization`` (``float`` on success, ``None`` on scrape
755+
miss), and ``error`` (``None`` on success, short string on
756+
miss). Scrape misses are recorded — not fail-fast — because
757+
the worker may be mid-restart from a SIGKILL injection, mid-
758+
SIGSTOP pause, or the Prometheus endpoint may not be wired
759+
for the active backend.
760+
761+
Exits when ``stop_event`` or ``failed_event`` is set. Honors
762+
either signal between scrapes via ``stop_event.wait`` rather
763+
than ``time.sleep`` to keep teardown latency bounded.
666764
"""
667-
logger.debug("[metrics_thread] stub — exiting immediately")
765+
if not self._worker_specs:
766+
logger.warning("[metrics_thread] no worker specs; exiting")
767+
return
768+
769+
logger.info(
770+
"[metrics_thread] scraping %d worker(s) every %.1fs",
771+
len(self._worker_specs),
772+
self._metrics_scrape_interval_s,
773+
)
774+
775+
while not self.stop_event.is_set() and not self.failed_event.is_set():
776+
scrape_start = time.monotonic()
777+
for spec in self._worker_specs:
778+
if self.stop_event.is_set() or self.failed_event.is_set():
779+
break
780+
util, err = _fetch_kv_cache_utilization(
781+
spec.host, spec.port, self._metrics_scrape_timeout_s
782+
)
783+
self._kv_utilization_samples.append(
784+
{
785+
"timestamp": time.time(),
786+
"role": spec.role,
787+
"index": spec.index,
788+
"host": spec.host,
789+
"port": spec.port,
790+
"utilization": util,
791+
"error": err,
792+
}
793+
)
794+
remaining = self._metrics_scrape_interval_s - (time.monotonic() - scrape_start)
795+
if remaining > 0.0:
796+
self.stop_event.wait(timeout=remaining)
797+
798+
logger.debug("[metrics_thread] exiting; %d sample(s)", len(self._kv_utilization_samples))
668799

669800
# ------------------------------------------------------------------
670801
# Internal helpers

0 commit comments

Comments
 (0)