421 lines
13 KiB
Python
421 lines
13 KiB
Python
#!/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()
|