#!/usr/bin/env python3 """Per-GPU utilization breakdown: KVC 1P3D v2 vs 4-way DP CA. Generates docs/figures/gpu_utilization.png — two-panel: left: per-GPU request count right: per-GPU compute work (uncached prefill tokens + decode tokens, stacked) The point of the figure is to push back on the naïve reading "KVC's prefill GPU is idle 90% of the time, so KVC is using fewer GPUs." By request count, the prefill GPU is indeed touched by only ~8% of requests. By compute work, the prefill GPU bears comparable per-GPU load to each decode GPU — it is a low-frequency, high-cost safety net for cache misses, not idle capacity. Work attribution: KVC direct-to-D path: prefill happens locally on the assigned D worker (append-prefill of `uncached_tokens` tokens). KVC seed/reseed/fallback path: prefill happens on prefill-0 (full uncached_tokens), decode on assigned D. DP: all work on assigned direct-N worker. Aborted / errored requests are excluded. """ from __future__ import annotations import json from collections import defaultdict from pathlib import Path import matplotlib.pyplot as plt import numpy as np ROOT = Path(__file__).resolve().parents[2] KVC = ROOT / "outputs/qwen3-30b-tp1-ts1-migration-v2/kvc_1p3d_migration_v2_run1_metrics.jsonl" DP = ROOT / "outputs/qwen3-30b-tp1-ts1-validation/dp4_metrics.jsonl" OUT = ROOT / "docs/figures/gpu_utilization.png" def load(p: Path) -> list[dict]: return [json.loads(line) for line in p.open()] def is_failed(r: dict) -> bool: if r.get("error"): return True fr = r.get("finish_reason") if fr and ("abort" in str(fr).lower() or "badrequest" in str(fr).lower()): return True return False def uncached(r: dict) -> int: return max(0, r["input_length"] - r.get("cached_tokens", 0)) def out_tokens(r: dict) -> int: return r.get("actual_output_tokens") or r.get("output_length") or 0 def main() -> None: kvc = [r for r in load(KVC) if not is_failed(r)] dp = [r for r in load(DP) if not is_failed(r)] # ------------------------------------------------------------------ # KVC per-GPU attribution # ------------------------------------------------------------------ kvc_req_count = defaultdict(int) kvc_prefill_tokens = defaultdict(int) # uncached prefill compute kvc_decode_tokens = defaultdict(int) for r in kvc: d = r["assigned_decode_node"] # decode-0/1/2 p = r["assigned_prefill_node"] # prefill-0 mode = r.get("execution_mode", "") if mode == "kvcache-direct-to-d-session": # P is bypassed entirely; D does the append-prefill + decode kvc_req_count[d] += 1 kvc_prefill_tokens[d] += uncached(r) kvc_decode_tokens[d] += out_tokens(r) else: # P does the full prefill; D handles decode kvc_req_count[p] += 1 kvc_req_count[d] += 1 # decode side still counts kvc_prefill_tokens[p] += uncached(r) kvc_decode_tokens[d] += out_tokens(r) # ------------------------------------------------------------------ # DP per-GPU attribution (fused P+D on every worker) # ------------------------------------------------------------------ dp_req_count = defaultdict(int) dp_prefill_tokens = defaultdict(int) dp_decode_tokens = defaultdict(int) for r in dp: w = r["assigned_decode_node"] # direct-0..3 dp_req_count[w] += 1 dp_prefill_tokens[w] += uncached(r) dp_decode_tokens[w] += out_tokens(r) # ------------------------------------------------------------------ # Build ordered GPU list, KVC then DP # ------------------------------------------------------------------ kvc_gpus = ["prefill-0", "decode-0", "decode-1", "decode-2"] dp_gpus = ["direct-0", "direct-1", "direct-2", "direct-3"] all_gpus = kvc_gpus + dp_gpus def get(d, k): return d.get(k, 0) counts = [get(kvc_req_count, g) for g in kvc_gpus] + \ [get(dp_req_count, g) for g in dp_gpus] prefill_tk = [get(kvc_prefill_tokens, g) for g in kvc_gpus] + \ [get(dp_prefill_tokens, g) for g in dp_gpus] decode_tk = [get(kvc_decode_tokens, g) for g in kvc_gpus] + \ [get(dp_decode_tokens, g) for g in dp_gpus] # Display labels: P/D role + worker id labels = [ "KVC P\nprefill-0", "KVC D\ndecode-0", "KVC D\ndecode-1", "KVC D\ndecode-2", "DP P+D\ndirect-0", "DP P+D\ndirect-1", "DP P+D\ndirect-2", "DP P+D\ndirect-3", ] kvc_mask = [True, True, True, True, False, False, False, False] KVC_P_COLOR = "#E89D44" # orange — P GPU stands out KVC_D_COLOR = "#1F77B4" # blue DP_COLOR = "#D62728" # red bar_colors = [KVC_P_COLOR, KVC_D_COLOR, KVC_D_COLOR, KVC_D_COLOR, DP_COLOR, DP_COLOR, DP_COLOR, DP_COLOR] fig, axes = plt.subplots(1, 2, figsize=(15, 7.0)) x = np.arange(len(all_gpus)) # -- Left: per-GPU request count ---------------------------------- ax = axes[0] bars = ax.bar(x, counts, color=bar_colors, edgecolor="black", linewidth=0.6) for xi, c in zip(x, counts): ax.text(xi, c + max(counts) * 0.015, f"{c:,}", ha="center", va="bottom", fontsize=9.5) ax.set_xticks(x) ax.set_xticklabels(labels, fontsize=9.5) ax.set_ylabel("Number of requests touching this GPU", fontsize=11) # Headroom for the annotation: extend ylim 35% above tallest bar ax.set_ylim(0, max(counts) * 1.40) ax.set_title("Per-GPU request count\n(naïve view: P seems idle)", fontsize=12, pad=24) ax.grid(axis="y", linestyle=":", alpha=0.4) ax.set_axisbelow(True) # Annotate: KVC P GPU is "low frequency" # Place in upper-right area (over DP group) so it doesn't sit on KVC D bars p_idx = 0 ax.annotate( f"P GPU only sees\n" f"{counts[p_idx]:,} requests\n" f"({counts[p_idx]/len(kvc)*100:.1f}% of all KVC requests)", xy=(p_idx, counts[p_idx]), xytext=(2.4, max(counts) * 1.20), fontsize=10, color=KVC_P_COLOR, fontweight="bold", ha="center", bbox=dict(facecolor="white", edgecolor=KVC_P_COLOR, alpha=0.92, pad=4), arrowprops=dict(arrowstyle="->", color=KVC_P_COLOR, lw=1.0), ) # -- Right: per-GPU compute work (stacked prefill + decode) ------- ax = axes[1] prefill_M = [t / 1e6 for t in prefill_tk] decode_M = [t / 1e6 for t in decode_tk] total_M = [p + d for p, d in zip(prefill_M, decode_M)] bars_p = ax.bar(x, prefill_M, color=[c for c in bar_colors], edgecolor="black", linewidth=0.6, label="Uncached prefill tokens", alpha=0.95) bars_d = ax.bar(x, decode_M, bottom=prefill_M, color=[c for c in bar_colors], edgecolor="black", linewidth=0.6, hatch="///", label="Decode tokens", alpha=0.55) for xi, t in zip(x, total_M): ax.text(xi, t + max(total_M) * 0.015, f"{t:.2f}M", ha="center", va="bottom", fontsize=9.5) ax.set_xticks(x) ax.set_xticklabels(labels, fontsize=9.5) ax.set_ylabel("Compute tokens (millions)", fontsize=11) # Headroom for the annotation ax.set_ylim(0, max(total_M) * 1.45) ax.set_title("Per-GPU compute work\n(work view: P is comparable to each D)", fontsize=12, pad=24) ax.grid(axis="y", linestyle=":", alpha=0.4) ax.set_axisbelow(True) # Legend placed at upper-left where bars are tallest is fine after raising ylim ax.legend(loc="upper left", fontsize=10, framealpha=0.95) # Annotate: KVC P GPU does similar work to each D. # Place over DP region (right side) so it doesn't sit on KVC D bars. ax.annotate( f"P GPU does {total_M[p_idx]:.2f}M tokens of prefill\n" f"— comparable per-GPU load to each KVC D worker\n" f"(KVC D avg = {np.mean(total_M[1:4]):.2f}M)", xy=(p_idx, total_M[p_idx]), xytext=(5.5, max(total_M) * 1.30), fontsize=10, color=KVC_P_COLOR, fontweight="bold", ha="center", bbox=dict(facecolor="white", edgecolor=KVC_P_COLOR, alpha=0.92, pad=4), arrowprops=dict(arrowstyle="->", color=KVC_P_COLOR, lw=1.0), ) # Separator + group labels (placed in axes-fraction coords, below subplot # title at pad=24 we now have safe room for these at y_axes_frac ≈ 1.02) for ax in axes: ax.axvline(3.5, color="gray", linestyle="--", linewidth=1.0, alpha=0.5) ax.text(0.25, 1.02, "KVC 1P3D", transform=ax.transAxes, ha="center", va="bottom", fontsize=11.5, fontweight="bold", color="#444", bbox=dict(facecolor="#F2F2F2", edgecolor="#888", alpha=0.85, pad=3)) ax.text(0.75, 1.02, "DP 4-way CA", transform=ax.transAxes, ha="center", va="bottom", fontsize=11.5, fontweight="bold", color="#444", bbox=dict(facecolor="#F2F2F2", edgecolor="#888", alpha=0.85, pad=3)) fig.suptitle( "Per-GPU utilization: \"is KVC's prefill GPU wasted?\"\n" "Left view says yes (only 8% of requests); right view says no (comparable work to each D).", fontsize=13, y=1.02, ) plt.tight_layout() plt.savefig(OUT, dpi=150, bbox_inches="tight") print(f"wrote {OUT}") plt.close(fig) # ------------------------------------------------------------------ # Print numbers for doc reference # ------------------------------------------------------------------ print("\n=== Per-GPU numbers ===") print(f"{'GPU':<22} {'requests':>10} {'prefill(M)':>12} {'decode(M)':>12} {'total(M)':>10}") for lbl, n, pM, dM in zip(labels, counts, prefill_M, decode_M): print(f" {lbl.replace(chr(10), ' '):<20} {n:>10} {pM:>12.3f} {dM:>12.3f} {pM+dM:>10.3f}") if __name__ == "__main__": main()