#!/usr/bin/env python3 """Plot per-instance KV-pool composition over time from MB5 snapshots. Input: a directory of mb5_kv_snapshot_pid.jsonl files (one per EngineCore PID) — typically MB5_LOG_DIR set during a run. For each snapshot file, builds a stacked-area chart: x axis = wall-clock time since first snapshot y axis = KV block count (total pool size = ceiling line) stacked bands = per-request block usage; each request gets one band colored by a hash-based palette so the eye can track individual requests across the run. Also overlays: - free_blocks (white area at the top) - 90% capacity line (red dashed) - waiting-queue depth (separate small subplot beneath) """ from __future__ import annotations import argparse import hashlib import json from collections import defaultdict from pathlib import Path import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np def load_snapshots(path: Path) -> list[dict]: out = [] with path.open() as f: for line in f: line = line.strip() if not line: continue try: out.append(json.loads(line)) except json.JSONDecodeError: continue return out def req_color(req_id: str) -> str: """Deterministic color per request_id.""" h = hashlib.md5(req_id.encode()).hexdigest() return f"#{h[:6]}" def plot_one_instance(snaps: list[dict], out: Path, title: str) -> None: if not snaps: return snaps = sorted(snaps, key=lambda s: s["t_unix"]) t0 = snaps[0]["t_unix"] times = [s["t_unix"] - t0 for s in snaps] total_blocks = snaps[0]["total_blocks"] # Build the request × time block-count matrix all_req_ids: list[str] = [] req_first_seen: dict[str, float] = {} for s in snaps: for r in s.get("running", []): rid = r["req_id"] if rid not in req_first_seen: req_first_seen[rid] = s["t_unix"] - t0 all_req_ids.append(rid) # Sort by first-seen time so the band order follows arrival all_req_ids.sort(key=lambda r: req_first_seen[r]) if not all_req_ids: # No requests ever ran on this instance; plot a flat used_blocks line # instead of the stackplot (which can't handle empty input). fig, ax1 = plt.subplots(figsize=(13, 4)) used = [s["used_blocks"] for s in snaps] ax1.plot(times, used, color="#888", lw=1.5, label="used_blocks (no running reqs sampled)") ax1.axhline(total_blocks, color="#444", lw=1.5, ls="-", label=f"pool total = {total_blocks} blocks") ax1.axhline(total_blocks * 0.9, color="#c44e52", lw=1.2, ls="--", alpha=0.7, label="90% capacity") ax1.set_ylabel("KV blocks") ax1.set_ylim(0, total_blocks * 1.05) ax1.set_xlabel("wall-clock since first snapshot (s)") ax1.set_title(title + " [no per-request data; instance idle?]") ax1.legend(loc="upper right", fontsize=9) ax1.grid(True, alpha=0.3) out.parent.mkdir(parents=True, exist_ok=True) fig.tight_layout() fig.savefig(out, dpi=120) plt.close(fig) print(f"wrote {out} (n_snapshots={len(snaps)}, 0 running reqs ever)") return matrix = np.zeros((len(all_req_ids), len(times)), dtype=np.int64) req_to_row = {r: i for i, r in enumerate(all_req_ids)} for j, s in enumerate(snaps): for r in s.get("running", []): i = req_to_row[r["req_id"]] matrix[i, j] = r.get("n_blocks", 0) fig, (ax1, ax2) = plt.subplots( 2, 1, figsize=(13, 6), sharex=True, gridspec_kw={"height_ratios": [4, 1]}, ) colors = [req_color(r) for r in all_req_ids] ax1.stackplot(times, matrix, colors=colors, linewidth=0) ax1.axhline(total_blocks, color="#444", lw=1.5, ls="-", label=f"pool total = {total_blocks} blocks") ax1.axhline(total_blocks * 0.9, color="#c44e52", lw=1.2, ls="--", alpha=0.7, label="90% capacity") ax1.set_ylabel("KV blocks (per-request stacked)") ax1.set_ylim(0, total_blocks * 1.05) ax1.set_title(title) ax1.legend(loc="upper right", fontsize=9, framealpha=0.95) ax1.grid(True, alpha=0.3) # Waiting queue depth subplot wait_lens = [len(s.get("waiting", [])) for s in snaps] ax2.fill_between(times, 0, wait_lens, color="#c44e52", alpha=0.55, label="waiting requests") ax2.set_ylabel("queue\ndepth") ax2.set_xlabel("wall-clock since first snapshot (s)") ax2.set_ylim(0, max(max(wait_lens, default=0), 1) * 1.2 + 1) ax2.grid(True, alpha=0.3) ax2.legend(loc="upper right", fontsize=8) out.parent.mkdir(parents=True, exist_ok=True) fig.tight_layout() fig.savefig(out, dpi=120) plt.close(fig) print(f"wrote {out} (n_snapshots={len(snaps)}, n_requests={len(all_req_ids)})") def main() -> None: p = argparse.ArgumentParser() p.add_argument("--snapshot-dir", type=Path, required=True, help="dir containing mb5_kv_snapshot_pid*.jsonl files") p.add_argument("--out-dir", type=Path, required=True) p.add_argument("--label", default="", help="prefix for output filenames + figure title") args = p.parse_args() files = sorted(args.snapshot_dir.glob("mb5_kv_snapshot_pid*.jsonl")) if not files: print(f"[plot] no snapshot files in {args.snapshot_dir}") return for f in files: pid = f.stem.replace("mb5_kv_snapshot_pid", "") snaps = load_snapshots(f) if not snaps: print(f"[plot] {f.name}: empty") continue out = args.out_dir / f"{args.label}_pid{pid}.png" title = f"{args.label} pid={pid} (n_snap={len(snaps)})" plot_one_instance(snaps, out, title) if __name__ == "__main__": main()