| """Shared utilities for evaluation and visualization scripts.""" |
|
|
| from __future__ import annotations |
|
|
| import csv |
| import json |
| import logging |
| import math |
| import re |
| import shutil |
| import statistics |
| import subprocess |
| import sys |
| import time |
| from collections import defaultdict |
| from dataclasses import dataclass |
| from itertools import zip_longest |
| from pathlib import Path |
| from urllib.error import URLError |
| from urllib.request import urlopen |
|
|
| import numpy as np |
|
|
| try: |
| from scipy.interpolate import UnivariateSpline |
| except ImportError: |
| UnivariateSpline = None |
|
|
| logger = logging.getLogger("evaluate") |
|
|
|
|
| |
| |
| |
|
|
| METRICS: dict[str, dict[str, str | bool]] = { |
| "latency": { |
| "csv_col": "latency_median_s", |
| "json_key": "request_latency", |
| "stat": "median", |
| "label": "Median Latency (s)", |
| "increasing": True, |
| }, |
| "itl": { |
| "csv_col": "itl_median_ms", |
| "json_key": "inter_token_latency_ms", |
| "stat": "median", |
| "label": "Median ITL (ms)", |
| "increasing": True, |
| }, |
| "ttft": { |
| "csv_col": "ttft_median_ms", |
| "json_key": "time_to_first_token_ms", |
| "stat": "median", |
| "label": "Median TTFT (ms)", |
| "increasing": True, |
| }, |
| "output_tps": { |
| "csv_col": "output_tps_median", |
| "json_key": "output_tokens_per_second", |
| "stat": "median", |
| "label": "Output Tokens/s", |
| "increasing": False, |
| }, |
| } |
|
|
| PRETTY_SUBSET_NAMES: dict[str, str] = { |
| "HumanEval": "HumanEval", |
| "math_reasoning": "Math Reasoning", |
| "qa": "QA", |
| "question": "Question", |
| "rag": "RAG", |
| "summarization": "Summarization", |
| "tool_call": "Tool Call", |
| "translation": "Translation", |
| "writing": "Writing", |
| } |
|
|
| METRICS_TO_EXTRACT = [ |
| ("requests_per_second", "rps_median"), |
| ("request_latency", "latency_median_s"), |
| ("inter_token_latency_ms", "itl_median_ms"), |
| ("time_to_first_token_ms", "ttft_median_ms"), |
| ("output_tokens_per_second", "output_tps_median"), |
| ] |
|
|
| SKIP_STRATEGIES = {"throughput"} |
|
|
| BASE_CSV_COLUMNS = [ |
| "subset", |
| "strategy", |
| "target_rate", |
| "rps_median", |
| "latency_median_s", |
| "itl_median_ms", |
| "ttft_median_ms", |
| "output_tps_median", |
| "total_output_tokens", |
| ] |
|
|
| |
| |
| |
|
|
|
|
| @dataclass |
| class Metric: |
| name: str |
|
|
|
|
| @dataclass |
| class Counter(Metric): |
| value: float |
|
|
|
|
| @dataclass |
| class Vector(Metric): |
| values: list[float] |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _load_csv( |
| filepath: Path, |
| metric_name: str, |
| ) -> dict[str, list[tuple[float, float]]]: |
| cfg = METRICS[metric_name] |
| result: dict[str, list[tuple[float, float]]] = defaultdict(list) |
| with filepath.open(newline="") as f: |
| for row in csv.DictReader(f): |
| if row.get("strategy") != "constant": |
| continue |
| try: |
| subset = re.sub(r"^run_", "", row.get("subset", "unknown")) |
| result[subset].append( |
| (float(row["rps_median"]), float(row[cfg["csv_col"]])) |
| ) |
| except (ValueError, KeyError): |
| continue |
| return dict(result) |
|
|
|
|
| def _load_json( |
| filepath: Path, |
| metric_name: str, |
| ) -> dict[str, list[tuple[float, float]]]: |
| cfg = METRICS[metric_name] |
| with filepath.open() as f: |
| data = json.load(f) |
|
|
| subset = Path(data["config"]["spec"]["data"][0]["load_kwargs"]["data_files"]).stem |
| points: list[tuple[float, float]] = [] |
| for bench in data.get("benchmarks", []): |
| if bench.get("config", {}).get("strategy", {}).get("type_") != "constant": |
| continue |
| try: |
| rps = bench["metrics"]["requests_per_second"]["successful"]["mean"] |
| y = bench["metrics"][cfg["json_key"]]["successful"][cfg["stat"]] |
| points.append((rps, y)) |
| except (KeyError, TypeError): |
| continue |
|
|
| points.sort(key=lambda p: p[0]) |
| return {subset: points} if points else {} |
|
|
|
|
| def load_data( |
| filepath: Path, |
| metric_name: str, |
| ) -> dict[str, list[tuple[float, float]]]: |
| if filepath.suffix == ".csv": |
| return _load_csv(filepath, metric_name) |
| if filepath.suffix == ".json": |
| return _load_json(filepath, metric_name) |
| raise ValueError( |
| f"Unsupported file type: {filepath.suffix} (expected .csv or .json)" |
| ) |
|
|
|
|
| def parse_source_args(source_args: list[str]) -> dict[str, list[Path]]: |
| """Parse ``LABEL=PATH`` strings into ``{label: [path, ...]}``.""" |
| result: dict[str, list[Path]] = defaultdict(list) |
| for arg in source_args: |
| if "=" not in arg: |
| raise ValueError(f"Invalid source format: '{arg}'. Expected 'LABEL=PATH'.") |
| label, path_str = arg.rsplit("=", 1) |
| path = Path(path_str.strip()) |
| if not path.exists(): |
| raise FileNotFoundError(f"File not found: {path}") |
| result[label.strip()].append(path) |
| return dict(result) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def smooth_curve( |
| x: list[float] | np.ndarray, |
| y: list[float] | np.ndarray, |
| n_dense: int = 300, |
| ) -> tuple[np.ndarray, np.ndarray]: |
| """Fit a smooth spline through noisy data.""" |
| x, y = np.asarray(x, dtype=float), np.asarray(y, dtype=float) |
| order = np.argsort(x) |
| x, y = x[order], y[order] |
|
|
| if len(x) < 2: |
| return x, y |
|
|
| spline = UnivariateSpline(x, y, s=len(x) * np.var(y)) |
| x_dense = np.linspace(x.min(), x.max(), n_dense) |
| y_dense = spline(x_dense) |
|
|
| return x_dense, y_dense |
|
|
|
|
| def pretty_subset(name: str) -> str: |
| return PRETTY_SUBSET_NAMES.get(name, name) |
|
|
|
|
| |
| |
| |
|
|
|
|
| def _extract_subset(filepath: Path) -> str: |
| """Extract the subset from evaluator artifact filenames.""" |
| match = re.search(r"(?:gen_len|sweeps?|run)_(.+)\.json$", filepath.name) |
| return match.group(1) if match else filepath.stem |
|
|
|
|
| def parse_gen_len_file(filepath: Path) -> dict: |
| """Parse a single gen-len JSON and return statistics.""" |
| with filepath.open() as f: |
| data = json.load(f) |
|
|
| benchmarks = data.get("benchmarks", []) |
| if not benchmarks: |
| raise ValueError(f"No benchmarks found in {filepath}") |
|
|
| successful = benchmarks[0].get("requests", {}).get("successful", []) |
| if not successful: |
| raise ValueError(f"No successful requests found in {filepath}") |
|
|
| output_tokens = [r["output_metrics"]["text_tokens"] for r in successful] |
| median = statistics.median(output_tokens) |
|
|
| return { |
| "count": len(output_tokens), |
| "median": median, |
| "min": min(output_tokens), |
| "max": max(output_tokens), |
| "max_tokens": 2 ** math.ceil(math.log2(max(median, 1))), |
| } |
|
|
|
|
| |
| |
| |
|
|
| _RE_SIMPLE = re.compile(r"^([a-zA-Z_:][a-zA-Z0-9_:]*)\s+([0-9.eE+-]+)$") |
| _RE_LABELED = re.compile(r"^([a-zA-Z_:][a-zA-Z0-9_:]*)\{([^}]+)\}\s+([0-9.eE+-]+)$") |
| _SPEC = "vllm:spec_decode" |
|
|
|
|
| def parse_prometheus_metrics(raw_text: str) -> list[Metric]: |
| """Parse Prometheus-formatted metrics into Metric objects.""" |
| if not raw_text: |
| return [] |
|
|
| metrics: list[Metric] = [] |
| vector_data: dict[str, list[tuple[int, float]]] = {} |
|
|
| for raw_line in raw_text.strip().split("\n"): |
| line = raw_line.strip() |
| if not line or line.startswith("#"): |
| continue |
|
|
| m = _RE_SIMPLE.match(line) or _RE_LABELED.match(line) |
| if not m or _SPEC not in m.group(1): |
| continue |
|
|
| groups = m.groups() |
| name = groups[0].replace("_total", "") |
| value = float(groups[-1]) |
|
|
| if "per_pos" not in name: |
| metrics.append(Counter(name=name, value=value)) |
| elif len(groups) == 3: |
| pos_match = re.search(r'position="(\d+)"', groups[1]) |
| if pos_match: |
| pos = int(pos_match.group(1)) |
| vector_data.setdefault(name, []).append((pos, value)) |
|
|
| for name, pos_values in vector_data.items(): |
| pos_values.sort() |
| values = [0.0] * (pos_values[-1][0] + 1) |
| for pos, val in pos_values: |
| values[pos] += val |
| metrics.append(Vector(name=name, values=values)) |
|
|
| return metrics |
|
|
|
|
| def _accumulate(metrics: list[Metric]) -> tuple[float, float, float, list[float]]: |
| sums: dict[str, float] = defaultdict(float) |
| counts: list[float] = [] |
| for m in metrics: |
| if isinstance(m, Counter): |
| sums[m.name] += m.value |
| elif isinstance(m, Vector) and "per_pos" in m.name: |
| counts = [a + b for a, b in zip_longest(counts, m.values, fillvalue=0.0)] |
| return ( |
| sums.get("vllm:spec_decode_num_drafts", 0), |
| sums.get("vllm:spec_decode_num_draft_tokens", 0), |
| sums.get("vllm:spec_decode_num_accepted_tokens", 0), |
| counts, |
| ) |
|
|
|
|
| def extract_spec_decode_metrics( |
| raw_metrics: list[Metric], |
| baseline_metrics: list[Metric] | None = None, |
| ) -> dict[str, float]: |
| """Extract speculative decoding metrics and calculate acceptance rates.""" |
| drafts, draft_tok, accepted, counts = _accumulate(raw_metrics) |
|
|
| if baseline_metrics: |
| bd, bdt, ba, bc = _accumulate(baseline_metrics) |
| drafts -= bd |
| draft_tok -= bdt |
| accepted -= ba |
| counts = [a - b for a, b in zip_longest(counts, bc, fillvalue=0.0)] |
|
|
| result: dict[str, float] = { |
| "num_drafts": drafts, |
| "num_draft_tokens": draft_tok, |
| "num_accepted_tokens": accepted, |
| "acceptance_length": 1 + accepted / drafts if drafts > 0 else 0, |
| } |
| for i, count in enumerate(counts): |
| result[f"acceptance_at_pos_{i}"] = count / drafts if drafts > 0 else 0 |
| return result |
|
|
|
|
| def load_prometheus_file(path: Path | None) -> list[Metric]: |
| if path and path.exists(): |
| with path.open() as f: |
| return parse_prometheus_metrics(f.read()) |
| return [] |
|
|
|
|
| |
| |
| |
|
|
|
|
| def parse_sweep_file( |
| filepath: Path, |
| *, |
| include_throughput: bool = False, |
| ) -> list[dict]: |
| """Parse GuideLLM benchmark JSON and return performance CSV rows.""" |
| with filepath.open() as f: |
| data = json.load(f) |
|
|
| benchmarks = data.get("benchmarks", []) |
| if not benchmarks: |
| raise ValueError(f"No benchmarks found in {filepath}") |
|
|
| subset = _extract_subset(filepath) |
| rows = [] |
| for bm in benchmarks: |
| strategy = bm.get("config", {}).get("strategy", {}) |
| strategy_type = strategy.get("type_", "unknown") |
| if strategy_type in SKIP_STRATEGIES and not ( |
| include_throughput and strategy_type == "throughput" |
| ): |
| continue |
|
|
| metrics = bm.get("metrics", {}) |
| row = { |
| "subset": subset, |
| "strategy": strategy_type, |
| "target_rate": strategy.get("rate", ""), |
| } |
| for metric_key, csv_key in METRICS_TO_EXTRACT: |
| val = metrics.get(metric_key, {}) |
| row[csv_key] = val.get("successful", {}).get("median", "") |
| out_tok = metrics.get("output_tokens", {}) |
| row["total_output_tokens"] = out_tok.get("successful", {}).get("sum", "") |
| rows.append(row) |
| return rows |
|
|
|
|
| |
| |
| |
|
|
|
|
| def acceptance_csv_columns(spec: dict[str, float]) -> list[str]: |
| cols = [ |
| "num_drafts", |
| "num_draft_tokens", |
| "num_accepted_tokens", |
| "acceptance_length", |
| ] |
| pos = 0 |
| while f"acceptance_at_pos_{pos}" in spec: |
| cols.append(f"acceptance_at_pos_{pos}") |
| pos += 1 |
| return cols |
|
|
|
|
| def parse_gen_len_results(files: list[Path], output: Path) -> dict[str, int]: |
| """Parse gen-len JSONs into a {subset: max_tokens} mapping and write to *output*.""" |
| header = ( |
| f"{'Subset':<20} {'Count':>6} {'Median':>8}" |
| f" {'Min':>8} {'Max':>8} {'max_tokens':>12}" |
| ) |
| print(header) |
| print("-" * 70) |
|
|
| max_tokens_map: dict[str, int] = {} |
| for filepath in files: |
| subset = _extract_subset(filepath) |
| try: |
| s = parse_gen_len_file(filepath) |
| except (FileNotFoundError, ValueError, KeyError, json.JSONDecodeError) as e: |
| logger.warning("Skipping %s: %s", filepath, e) |
| continue |
| max_tokens_map[subset] = s["max_tokens"] |
| print( |
| f"{subset:<20} {s['count']:>6} {s['median']:>8.0f}" |
| f" {s['min']:>8} {s['max']:>8} {s['max_tokens']:>12}" |
| ) |
|
|
| if not max_tokens_map: |
| logger.error("No files were successfully parsed") |
| sys.exit(1) |
|
|
| output.parent.mkdir(parents=True, exist_ok=True) |
| with output.open("w") as f: |
| json.dump(max_tokens_map, f, indent=2) |
| print(f"\nmax_tokens mapping written to: {output}") |
| return max_tokens_map |
|
|
|
|
| def parse_sweep_results( |
| filepath: Path, |
| spec_decode_metrics: dict[str, float] | None = None, |
| *, |
| include_throughput: bool = False, |
| ) -> list[dict]: |
| """Parse benchmark JSON and enrich rows with acceptance metrics.""" |
| rows = parse_sweep_file(filepath, include_throughput=include_throughput) |
| if spec_decode_metrics: |
| for row in rows: |
| row.update(spec_decode_metrics) |
| return rows |
|
|
|
|
| class CsvWriter: |
| """Append rows incrementally to a CSV file, writing the header on first row.""" |
|
|
| def __init__(self, path: Path, columns: list[str]) -> None: |
| self.path = path |
| self.columns = columns |
| self._started = self.path.exists() |
|
|
| def append(self, row: dict) -> None: |
| self.append_rows([row]) |
|
|
| def append_rows(self, rows: list[dict]) -> None: |
| if not rows: |
| return |
| self.path.parent.mkdir(parents=True, exist_ok=True) |
| mode = "a" if self._started else "w" |
| with self.path.open(mode, newline="") as f: |
| writer = csv.DictWriter(f, fieldnames=self.columns, extrasaction="ignore") |
| if not self._started: |
| writer.writeheader() |
| self._started = True |
| writer.writerows(rows) |
|
|
|
|
| def print_acceptance_report(spec: dict[str, float]) -> None: |
| summary = [ |
| ("Num drafts", f"{spec.get('num_drafts', 0):.0f}"), |
| ("Num draft tokens", f"{spec.get('num_draft_tokens', 0):.0f}"), |
| ("Num accepted tokens", f"{spec.get('num_accepted_tokens', 0):.0f}"), |
| ("Acceptance length", f"{spec.get('acceptance_length', 0):.4f}"), |
| ] |
| print("\n=== Speculative Decoding Acceptance Report ===\n") |
| for label, val in summary: |
| print(f" {label:<25} {val:>12}") |
|
|
| positions = [ |
| (i, spec[f"acceptance_at_pos_{i}"]) |
| for i in range(100) |
| if f"acceptance_at_pos_{i}" in spec |
| ] |
| if positions: |
| print(f"\n {'Position':<25} {'Acceptance Rate':>12}") |
| print(f" {'-' * 25} {'-' * 12}") |
| for pos, rate in positions: |
| print(f" {f'Position {pos}':<25} {rate:>12.4f}") |
| print() |
|
|
|
|
| |
| |
| |
|
|
|
|
| def check_dependencies() -> None: |
| missing = [cmd for cmd in ("guidellm", "python") if not shutil.which(cmd)] |
| if missing: |
| logger.error("Missing required dependencies: %s", ", ".join(missing)) |
| sys.exit(1) |
|
|
|
|
| def parse_gen_kwargs(gen_kwargs: str) -> dict: |
| if not gen_kwargs: |
| return {} |
| try: |
| return json.loads(gen_kwargs) |
| except json.JSONDecodeError as e: |
| msg = f"Invalid JSON in --gen-kwargs: {gen_kwargs!r}: {e}" |
| raise ValueError(msg) from e |
|
|
|
|
| def fetch_metrics(metrics_url: str, retries: int = 3, delay: float = 2.0) -> str | None: |
| for attempt in range(1, retries + 1): |
| try: |
| with urlopen(metrics_url, timeout=30) as resp: |
| return resp.read().decode() |
| except (URLError, OSError) as e: |
| logger.warning( |
| "Failed to fetch metrics from %s (attempt %d/%d): %s", |
| metrics_url, |
| attempt, |
| retries, |
| e, |
| ) |
| if attempt < retries: |
| time.sleep(delay) |
| return None |
|
|
|
|
| def run_guidellm( |
| target: str, |
| dataset: str, |
| subset: str | None, |
| data_column_mapper: str, |
| profile: str, |
| rate: int | str, |
| max_requests: int | None, |
| max_concurrency: int, |
| output_path: Path, |
| max_tokens: int, |
| gen_kwargs: dict | None = None, |
| ) -> None: |
| backend = ( |
| f"kind=openai_http,target={target}," |
| f"request_format=/v1/chat/completions,max_tokens={max_tokens}" |
| ) |
| for k, v in (gen_kwargs or {}).items(): |
| backend += f",extras.body.{k}={v}" |
| cmd = ["guidellm", "run", "--backend", backend] |
|
|
| if subset is not None: |
| data = f"kind=huggingface,source={dataset}" |
| data += f",load_kwargs.data_files={subset}.jsonl,load_kwargs.split=train" |
| else: |
| data = f"kind=json_file,path={dataset}" |
| cmd.extend(["--data", data]) |
|
|
| cmd.extend(["--data-column-mapper", data_column_mapper]) |
|
|
| profile_str = f"kind={profile},max_concurrency={max_concurrency}" |
| if profile == "sweep": |
| profile_str += f",sweep_size={rate}" |
| cmd.extend(["--profile", profile_str]) |
|
|
| if max_requests is not None: |
| cmd.extend(["--constraint", f"kind=max_requests,count={max_requests}"]) |
|
|
| cmd.extend(["--output", f"kind=json,path={output_path}"]) |
|
|
| subprocess.run(cmd, check=True) |
|
|