#!/usr/bin/env python3 """Parse raw sglang.bench_serving JSONL outputs for one backend. Reads JSONL files like {backend}_{label}_MMDD_concurrency_inputlen_outputlen.jsonl and updates results.json + report.md in the given result root. Usage: python3 parse_backend.py [--backend sglang|vllm] """ import argparse import json from pathlib import Path def parse_jsonl(path: Path) -> dict | None: with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: return json.loads(line) except json.JSONDecodeError: continue return None def compute_metrics(data: dict) -> dict: completed = data.get("completed", 0) total = len(data.get("input_lens", [])) failed = total - completed if total > 0 else 0 duration_s = data.get("duration", 0.0) return { "success": completed, "failed": failed, "duration_s": duration_s, "request_throughput": data.get("request_throughput", 0.0), "input_token_throughput": data.get("input_throughput", 0.0), "output_token_throughput": data.get("output_throughput", 0.0), "total_token_throughput": data.get("total_throughput", 0.0), "total_input_tokens": data.get("total_input_tokens", 0), "total_output_tokens": data.get("total_output_tokens", 0), "e2e_ms": { "mean": data.get("mean_e2e_latency_ms", 0.0), "p50": data.get("median_e2e_latency_ms", 0.0), "p90": data.get("p90_e2e_latency_ms", 0.0), "p95": data.get("p95_e2e_latency_ms", 0.0), "p99": data.get("p99_e2e_latency_ms", 0.0), }, "ttft_ms": { "mean": data.get("mean_ttft_ms", 0.0), "p50": data.get("median_ttft_ms", 0.0), "p90": data.get("p90_ttft_ms", 0.0), "p95": data.get("p95_ttft_ms", 0.0), "p99": data.get("p99_ttft_ms", 0.0), }, "tpot_ms": { "mean": data.get("mean_tpot_ms", 0.0), "p50": data.get("median_tpot_ms", 0.0), "p90": data.get("p90_tpot_ms", 0.0), "p95": data.get("p95_tpot_ms", 0.0), "p99": data.get("p99_tpot_ms", 0.0), }, "itl_ms": { "mean": data.get("mean_itl_ms", 0.0), "p50": data.get("median_itl_ms", 0.0), "p90": data.get("p90_itl_ms", 0.0), "p95": data.get("p95_itl_ms", 0.0), "p99": data.get("p99_itl_ms", 0.0), }, } def scenario_name(concurrency: int, input_len: int, output_len: int) -> str: return f"c{concurrency}_i{input_len}_o{output_len}" def slo_status(metrics: dict, ttft_limit_ms: float = 3000.0, tpot_limit_ms: float = 50.0) -> dict: """Check SLO: TTFT P95 < limit, TPOT mean < limit. Defaults to S2 tier.""" ttft_ok = metrics["ttft_ms"]["p95"] < ttft_limit_ms tpot_ok = metrics["tpot_ms"]["mean"] < tpot_limit_ms if ttft_ok and tpot_ok: mark = "✅" elif ttft_ok or tpot_ok: mark = "⚠️" else: mark = "❌" return { "ttft_p95_ok": ttft_ok, "tpot_mean_ok": tpot_ok, "overall": mark, } def parse_gpu_memory_csv(jsonl_path: Path) -> dict | None: """Parse a paired nvidia-smi CSV for GPU memory/utilization statistics. The CSV is expected to live in a sibling `gpu_logs/` directory or in the same `raw_outputs/` directory, named `gpu_mem_c_i_o.csv`. """ result_root = jsonl_path.parent.parent scenario_id = jsonl_path.stem.split("_", 2)[2] # e.g. c1_i1024_o128 csv_name = f"gpu_mem_{scenario_id}.csv" csv_path = None for candidate in ( result_root / "gpu_logs" / csv_name, result_root / "raw_outputs" / csv_name, ): if candidate.exists() and candidate.stat().st_size > 0: csv_path = candidate break if csv_path is None: return None per_gpu = {} total_mb = None try: with open(csv_path, "r", encoding="utf-8") as f: header = f.readline() if not header.strip(): return None for line in f: line = line.strip() if not line: continue parts = [p.strip() for p in line.split(",")] if len(parts) < 5: continue idx = parts[1] try: used = float(parts[2].split()[0]) total = float(parts[3].split()[0]) util = float(parts[4].split()[0]) except (ValueError, IndexError): continue per_gpu.setdefault(idx, {"used": [], "util": []}) per_gpu[idx]["used"].append(used) per_gpu[idx]["util"].append(util) if total_mb is None: total_mb = total except Exception: return None if not per_gpu or total_mb is None: return None peak_used = max(max(g["used"]) for g in per_gpu.values()) avg_used = sum(sum(g["used"]) / len(g["used"]) for g in per_gpu.values()) / len(per_gpu) peak_util = max(max(g["util"]) for g in per_gpu.values()) return { "peak_used_mb": peak_used, "avg_used_mb": avg_used, "peak_utilization_pct": peak_util, "memory_total_mb": total_mb, } def generate_report(result_root: Path, backend: str, scenarios: list[dict], metadata: dict | None) -> None: report_path = result_root / "report.md" model = metadata.get("model", "unknown") if metadata else "unknown" hardware = metadata.get("hardware", "unknown") if metadata else "unknown" with open(report_path, "w", encoding="utf-8") as f: f.write(f"# {hardware} {backend.upper()} Benchmark Report\n\n") f.write(f"- Result root: `{result_root}`\n") f.write(f"- Model: `{model}`\n") f.write(f"- Backend: {backend.upper()}\n") f.write(f"- Benchmark client: `sglang.bench_serving --backend {backend}`\n\n") f.write("## Results\n\n") f.write("| Scenario | Phase | Concurrency | Input | Output | Duration(s) | Success | Req/s | In tok/s | Out tok/s | Total tok/s | Mean TTFT(ms) | P95 TTFT(ms) | P99 TTFT(ms) | Mean TPOT(ms) | P95 TPOT(ms) | P99 TPOT(ms) | Mean E2E(ms) | P95 E2E(ms) | P99 E2E(ms) | Peak GPU mem | SLO |\n") f.write("|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|\n") for s in scenarios: if "metrics" not in s: # Manually-recorded skipped/failed scenario without metrics; # listed separately below instead of crashing the report. continue cfg = s["config"] m = s["metrics"] slo = s.get("slo_status", {}).get("overall", "") gpu = m.get("gpu_memory") if gpu: gpu_str = f"{gpu['peak_used_mb']:.0f}/{gpu['memory_total_mb']:.0f} MiB ({100*gpu['peak_used_mb']/gpu['memory_total_mb']:.1f}%)" else: gpu_str = "-" f.write( f"| {s['name']} | {cfg['phase']} | {cfg['concurrency']} | {cfg['input_len']} | {cfg['output_len']} | " f"{m['duration_s']:.2f} | {m['success']} | {m['request_throughput']:.2f} | " f"{m['input_token_throughput']:.2f} | {m['output_token_throughput']:.2f} | " f"{m['total_token_throughput']:.2f} | " f"{m['ttft_ms']['mean']:.2f} | {m['ttft_ms']['p95']:.2f} | {m['ttft_ms']['p99']:.2f} | " f"{m['tpot_ms']['mean']:.2f} | {m['tpot_ms']['p95']:.2f} | {m['tpot_ms']['p99']:.2f} | " f"{m['e2e_ms']['mean']:.2f} | {m['e2e_ms']['p95']:.2f} | {m['e2e_ms']['p99']:.2f} | {gpu_str} | {slo} |\n" ) f.write("\n") f.write("SLO: S2 tier — TTFT P95 < 3000ms, TPOT mean < 50ms. ✅ pass, ⚠️ partial, ❌ fail.\n\n") skipped = [s for s in scenarios if "metrics" not in s] if skipped: f.write("## Skipped or failed scenarios\n\n") f.write("| Scenario | Status | Note |\n") f.write("|---|---|---|\n") for s in skipped: f.write(f"| {s['name']} | {s.get('status', '')} | {s.get('note', '')} |\n") f.write("\n") def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("result_root", type=Path) parser.add_argument("--backend", default=None, choices=["sglang", "vllm"]) args = parser.parse_args() result_root = args.result_root raw_dir = result_root / "raw_outputs" results_json = result_root / "results.json" if not raw_dir.exists(): raise SystemExit(f"raw_outputs directory not found: {raw_dir}") backend = args.backend if backend is None: for p in raw_dir.iterdir(): if p.name.startswith("sglang_"): backend = "sglang" break if p.name.startswith("vllm_"): backend = "vllm" break if backend is None: raise SystemExit("Could not infer backend from raw outputs") metadata = None old_scenarios = [] if results_json.exists(): with open(results_json, "r", encoding="utf-8") as f: try: existing = json.load(f) metadata = existing.get("metadata") old_scenarios = existing.get("scenarios", []) except json.JSONDecodeError: pass scenarios = [] for jsonl_path in sorted(raw_dir.glob(f"{backend}_*.jsonl")): parts = jsonl_path.stem.split("_") if len(parts) < 5: continue label = parts[1] if label == "sharegpt": phase = "sharegpt" dataset = "sharegpt" else: phase = label dataset = "random" try: concurrency, input_len, output_len = int(parts[-3]), int(parts[-2]), int(parts[-1]) except ValueError: continue data = parse_jsonl(jsonl_path) if data is None: continue metrics = compute_metrics(data) metrics["gpu_memory"] = parse_gpu_memory_csv(jsonl_path) scenario = { "name": scenario_name(concurrency, input_len, output_len), "config": { "phase": phase, "concurrency": concurrency, "input_len": input_len, "output_len": output_len, "dataset": dataset, "num_prompts": metrics["success"] + metrics["failed"], }, "metrics": metrics, "slo_status": slo_status(metrics), "raw_file": str(jsonl_path), } scenarios.append(scenario) if not scenarios and not old_scenarios: print("No benchmark outputs found to parse") return # Preserve any manually-recorded skipped/failed scenarios (e.g. optional # combinations that OOMed) that do not have a fresh raw output. parsed_names = {s["name"] for s in scenarios} for old in old_scenarios: if old.get("status") and old["name"] not in parsed_names: scenarios.append(old) scenarios.sort(key=lambda s: ( s["config"]["input_len"], s["config"]["output_len"], s["config"]["concurrency"], )) if results_json.exists(): with open(results_json, "r", encoding="utf-8") as f: data = json.load(f) data["scenarios"] = scenarios with open(results_json, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) generate_report(result_root, backend, scenarios, metadata) print(f"Parsed {len(scenarios)} scenarios into {result_root}/report.md") if __name__ == "__main__": main()