#!/usr/bin/env python3 """ Plot router benchmark results from router_microbenchmark.py. Produces three side-by-side plots (all scales on one figure per metric): 1. Bar plot: p50 throughput by replica count, grouped by router 2. Bar plot: p50 latency by replica count, grouped by router 3. Box plot: per-replica utilization distribution by replica count and router Example: python plot_router_benchmark.py results.json -o /tmp/plots """ import argparse import json import os import re from pathlib import Path import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np import pandas as pd ALGO_NAMES = {"pow2": "Power of Two", "capacity_queue": "CapacityQueue"} ALGO_COLORS = {"Power of Two": "lightsteelblue", "CapacityQueue": "plum"} # Ordered: pow2 first (left), capacity_queue second (right) ROUTER_ORDER = ["Power of Two", "CapacityQueue"] def load_results(path: str) -> dict: with open(path) as f: data = json.load(f) if isinstance(data, list): return {"perf_metrics": data, "utilization_raw": {}} return { "perf_metrics": data.get("perf_metrics", []), "utilization_raw": data.get("utilization_raw", {}), } def _parse_metric(name: str): """Parse e.g. router_pow2_128_p50_throughput_rps -> (pow2, 128, p50_throughput_rps).""" m = re.match(r"router_(\w+?)_(\d+)_(.+)", name) if not m: return None return m.group(1), int(m.group(2)), m.group(3) def _parse_util_key(key: str): """Parse e.g. router_pow2_128 -> (pow2, 128).""" m = re.match(r"router_(\w+?)_(\d+)$", key) if not m: return None return m.group(1), int(m.group(2)) def build_metric_df(json_paths: list) -> pd.DataFrame: rows = [] for path in json_paths: label = Path(path).stem data = load_results(path) for m in data["perf_metrics"]: parsed = _parse_metric(m["perf_metric_name"]) if not parsed: continue router, replicas, kind = parsed rows.append( { "file": label, "router": ALGO_NAMES.get(router, router), "replicas": replicas, "kind": kind, "value": float(m["perf_metric_value"]), } ) return pd.DataFrame(rows) def build_util_df(json_paths: list) -> pd.DataFrame: rows = [] for path in json_paths: label = Path(path).stem data = load_results(path) for key, values in data.get("utilization_raw", {}).items(): parsed = _parse_util_key(key) if not parsed: continue router, replicas = parsed for v in values: rows.append( { "file": label, "router": ALGO_NAMES.get(router, router), "replicas": replicas, "utilization": float(v), } ) return pd.DataFrame(rows) def _get_improvement(df, kind, replicas_list): """Get CQ improvement over pow2 for each scale. Returns list of (pct_str, higher_is_better).""" improvements = [] for r in replicas_list: pow2_row = df[ (df["replicas"] == r) & (df["router"] == "Power of Two") & (df["kind"] == kind) ] cq_row = df[ (df["replicas"] == r) & (df["router"] == "CapacityQueue") & (df["kind"] == kind) ] if pow2_row.empty or cq_row.empty: improvements.append(None) continue v_pow2 = pow2_row["value"].values[0] v_cq = cq_row["value"].values[0] if v_pow2 == 0: improvements.append(None) continue pct = (v_cq - v_pow2) / v_pow2 * 100 improvements.append(pct) return improvements def _bar_plot(ax, df, kind, ylabel, title, legend_loc="best"): """Grouped bar: x=replicas, hue=router (pow2 left, CQ right).""" subset = df[df["kind"] == kind].copy() if subset.empty: return replicas_list = sorted(subset["replicas"].unique()) routers = [r for r in ROUTER_ORDER if r in subset["router"].values] x = np.arange(len(replicas_list)) width = 0.6 / len(routers) bar_positions = {} for i, router in enumerate(routers): vals = [] for r in replicas_list: row = subset[(subset["replicas"] == r) & (subset["router"] == router)] vals.append(row["value"].values[0] if not row.empty else 0) offset = (i - (len(routers) - 1) / 2) * width ax.bar( x + offset, vals, width, label=router, color=ALGO_COLORS.get(router), edgecolor="gray", linewidth=0.5, ) for j, v in enumerate(vals): ax.text( x[j] + offset, v, f"{v:.1f}", ha="center", va="bottom", fontsize=7, ) bar_positions[router] = (x + offset, vals) ax.set_xticks(x) ax.set_xticklabels(replicas_list) ax.set_xlabel("Replicas") ax.set_ylabel(ylabel) ax.set_title(title) legend = ax.legend( title="Router", loc=legend_loc, frameon=True, fancybox=False, edgecolor="gray" ) legend.get_frame().set_facecolor("white") legend.get_frame().set_alpha(1.0) legend.set_zorder(10) # Annotate CQ improvement over pow2, stacked above the CQ value label # (done after setting axes so we can compute offset in data coords) improvements = _get_improvement(df, kind, replicas_list) cq_positions, cq_vals = bar_positions.get( "CapacityQueue", (x, [0] * len(replicas_list)) ) ymax = max(max(v for v in vals) for _, vals in bar_positions.values()) label_offset = ymax * 0.05 # gap between value label and improvement % for j, pct in enumerate(improvements): if pct is None: continue sign = "+" if pct >= 0 else "" is_good = (kind == "p50_throughput_rps" and pct > 0) or ( kind == "p50_latency_ms" and pct < 0 ) # Nudge latency annotations slightly right to avoid overlap with value x_nudge = 0.03 if kind == "p50_latency_ms" else 0 ax.text( cq_positions[j] + x_nudge, cq_vals[j] + label_offset, f"{sign}{pct:.1f}%", ha="center", va="bottom", fontsize=7, fontweight="bold", color="green" if is_good else "red", ) ax.set_ylim(top=ymax * 1.2) def _util_box_plot(ax, util_df): """Real box plot from per-replica utilization data (pow2 left, CQ right).""" if util_df.empty: return replicas_list = sorted(util_df["replicas"].unique()) routers = [r for r in ROUTER_ORDER if r in util_df["router"].values] n_routers = len(routers) n_scales = len(replicas_list) width = 0.6 / n_routers positions_map = {} for i, router in enumerate(routers): offset = (i - (n_routers - 1) / 2) * width for j, r in enumerate(replicas_list): data = util_df[(util_df["replicas"] == r) & (util_df["router"] == router)][ "utilization" ].values if len(data) == 0: continue pos = j + offset bp = ax.boxplot( data, positions=[pos], widths=width * 0.8, patch_artist=True, showfliers=False, medianprops={"color": "black", "linewidth": 1.5}, ) color = ALGO_COLORS.get(router, "#999999") for patch in bp["boxes"]: patch.set_facecolor(color) patch.set_alpha(0.8) patch.set_edgecolor("gray") positions_map[router] = color ax.axhline(y=1.0, color="gray", linestyle="--", alpha=0.5) ax.set_xticks(range(n_scales)) ax.set_xticklabels(replicas_list) ax.set_xlabel("Replicas") ax.set_ylabel("Utilization") ax.set_title("Per-Replica Utilization Distribution") ax.set_ylim(0.7, 1.05) handles = [ plt.Rectangle((0, 0), 1, 1, facecolor=positions_map[r], alpha=0.8) for r in routers if r in positions_map ] ax.legend(handles, [r for r in routers if r in positions_map], title="Router") def _util_fallback_plot(ax, metric_df): """Fallback: bar + error bars from p25/p50/p75 when raw data is missing.""" util_kinds = ["p25_utilization", "p50_utilization", "p75_utilization"] subset = metric_df[metric_df["kind"].isin(util_kinds)].copy() if subset.empty: return replicas_list = sorted(subset["replicas"].unique()) routers = [r for r in ROUTER_ORDER if r in subset["router"].values] x = np.arange(len(replicas_list)) width = 0.6 / len(routers) for i, router in enumerate(routers): medians, lows, highs = [], [], [] for r in replicas_list: rs = subset[(subset["replicas"] == r) & (subset["router"] == router)] p25 = rs[rs["kind"] == "p25_utilization"]["value"] p50 = rs[rs["kind"] == "p50_utilization"]["value"] p75 = rs[rs["kind"] == "p75_utilization"]["value"] medians.append(p50.values[0] if not p50.empty else 0) lows.append(p25.values[0] if not p25.empty else 0) highs.append(p75.values[0] if not p75.empty else 0) medians = np.array(medians) lows = np.array(lows) highs = np.array(highs) offset = (i - (len(routers) - 1) / 2) * width ax.bar( x + offset, medians, width, label=router, color=ALGO_COLORS.get(router), alpha=0.8, ) ax.errorbar( x + offset, medians, yerr=[medians - lows, highs - medians], fmt="none", color="black", capsize=4, capthick=1, ) ax.axhline(y=1.0, color="gray", linestyle="--", alpha=0.5) ax.set_xticks(x) ax.set_xticklabels(replicas_list) ax.set_xlabel("Replicas") ax.set_ylabel("Utilization") ax.set_title("Utilization (p25 / p50 / p75)") ax.legend(title="Router") ax.set_ylim(0.7, 1.05) def plot_results(metric_df, util_df, output_dir, suffix=""): """Produce one 3-panel figure with all scales side by side.""" plt.style.use("seaborn-v0_8-whitegrid") fig, axes = plt.subplots(1, 3, figsize=(18, 5)) _bar_plot( axes[0], metric_df, "p50_throughput_rps", "Throughput (req/s)", "P50 Throughput" ) _bar_plot( axes[1], metric_df, "p50_latency_ms", "Latency (ms)", "P50 Latency", legend_loc="lower left", ) if not util_df.empty: _util_box_plot(axes[2], util_df) else: _util_fallback_plot(axes[2], metric_df) plt.tight_layout() name = f"router_benchmark{suffix}.png" path = output_dir / name fig.savefig(path, dpi=150, bbox_inches="tight") plt.close(fig) print(f"Saved {path}") def _print_delta_table(df, baseline, compare): """Print comparison table between two runs.""" b = df[df["file"] == baseline] c = df[df["file"] == compare] print(f"\n--- Delta: {compare} vs {baseline} ---") header = f"{'Router':<18} {'Replicas':>8} {'Metric':<22} {'Base':>10} {'New':>10} {'Delta':>8}" print(header) print("-" * len(header)) for kind in ["p50_throughput_rps", "p50_latency_ms", "p50_utilization"]: for _, rb in b[b["kind"] == kind].iterrows(): rc = c[ (c["kind"] == kind) & (c["router"] == rb["router"]) & (c["replicas"] == rb["replicas"]) ] if rc.empty: continue vb, vc = rb["value"], rc.iloc[0]["value"] delta = (vc - vb) / vb * 100 if vb else 0 print( f"{rb['router']:<18} {rb['replicas']:>8} " f"{kind:<22} {vb:>10.2f} {vc:>10.2f} {delta:>+7.1f}%" ) def main(): parser = argparse.ArgumentParser(description="Plot router benchmark results.") parser.add_argument( "json_files", nargs="+", help="One or more JSON files from router_microbenchmark.py", ) parser.add_argument( "-o", "--output-dir", default=".", help="Output directory for plots (default: current directory)", ) args = parser.parse_args() for p in args.json_files: if not os.path.isfile(p): raise FileNotFoundError(f"File not found: {p}") output_dir = Path(args.output_dir) output_dir.mkdir(parents=True, exist_ok=True) metric_df = build_metric_df(args.json_files) util_df = build_util_df(args.json_files) if metric_df.empty: raise ValueError("No valid router metrics found.") if len(args.json_files) == 1: plot_results(metric_df, util_df, output_dir) else: files = sorted(metric_df["file"].unique()) for f in files: plot_results( metric_df[metric_df["file"] == f], util_df[util_df["file"] == f] if not util_df.empty else util_df, output_dir, suffix=f"_{f}", ) if len(files) == 2: _print_delta_table(metric_df, files[0], files[1]) if __name__ == "__main__": main()