Files
kernbench2/src/kernbench/benches/milestone_1h_gemm.py
T
ywkang e8b7f4f064 paper(gemm): per-PE HBM BW + TFLOPS evaluation plots; unify peak to 8 TFLOP/s
Two new evaluation plots for §3 GEMM, both using the composite window
as the denominator (the up-front tl.load of A in load_ref is therefore
excluded — only HBM traffic and compute inside the composite count):

- gemm_hbm_bw_util.png: per-PE HBM bandwidth utilization against the
  256 GB/s per-PE ceiling. Answers "is this workload memory-bound on
  this configuration?" — small/single-tile shapes sit at 22–46%
  (pipeline-fill-limited), large or output-write-dominated shapes
  saturate (≥85%).

- gemm_per_pe_tflops.png: per-PE GEMM throughput against the 8 TFLOP/s
  engine peak. The deep-K K=3072 load_ref case lands at
  7.18 TFLOP/s (~90% of peak) — the configuration's clean win.
  ref_ref at the same shape drops to 3.83 TFLOP/s because the second
  operand doubles HBM pressure and the kernel hits the BW ceiling.
  The variant gap on this chart is the operational cost of NOT
  pre-staging the activation.

Both charts compare two operand-staging variants on every shape:
load_ref ("activation pre-staged", weight-only HBM streaming) and
ref_ref (both A and W streamed from HBM). load_load is omitted —
with both operands pre-staged the composite carries no HBM traffic
and the BW metric collapses to 0.

Analytic / measured peak unified to the single hardware spec
(8.0 TFLOP/s = 8000 flops/ns). T_STAGE is now derived as
tile_flops / peak (= 16.384 ns for the 32×64×32 tile) rather than
the previous hardcoded 16 ns, so MAC efficiency and per-PE TFLOPS%
share the same denominator and never disagree. Side effect: max
analytic-vs-measured gap tightens from 2.2 ppt to 1.4 ppt across
the sweep.

§3 Results restructured: lead with the two new evaluation plots
(BW saturation, achieved throughput), then the MAC-utilization
analytic-vs-measured chart serves as the validation step, then the
per-stage engine wall-clock as the supporting diagnostic. §3
Analysis updated to use the load_ref / ref_ref contrast as the
hardware-software boundary line that motivates the GQA kernel of §5.

§2.4 Accuracy claim updated to match: GEMM analytic-vs-measured
agreement is now "within 1.4 ppt across every swept shape", from
~7.7% at single-tile up to ~90% at K=3072. Subtitle/title vertical
stacking glitch in the matplotlib charts fixed in passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-17 10:24:42 -07:00

782 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""milestone-1h-gemm bench: GEMM evaluation harness (sweep + figures).
Self-contained milestone bench (ADR-0054). Holds the shape×variant sweep
and the figure renderers; the ``run(torch)`` entry at the bottom runs the
sweep (or reuses the committed JSON when ``MILESTONE_FAST=1``) and writes
every figure into ``benches/1H_milestone_output/gemm/``.
This is the single home for the GEMM eval logic: the figure tests import a
thin re-export shim (``tests/gemm/_gemm_plot_helpers.py``), as does the
``scripts/gemm_sweep.py`` wrapper.
The sweep drives ``matmul-composite`` across shapes×variants through the
same ``run_bench`` path the CLI uses, harvests ``result.engine.op_log``,
and writes the sweep JSON. The renderers read that JSON and emit matplotlib
PNGs. No simulation in the renderers — they are fast.
Chart set (mirrors the GEMM MAC slides in scripts/build_overview_slides.py):
- stage breakdown (load_ref operand staging)
- MAC utilization — measured (load_ref)
- MAC utilization — theoretical vs measured (load_ref)
"""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from kernbench.benches.registry import bench
from kernbench.policy.placement.dp import DPPolicy
ROOT = Path(__file__).resolve().parents[3]
DEFAULT_SWEEP_JSON = ROOT / "docs" / "diagrams" / "gemm_sweep.json"
DEFAULT_PLOTS_DIR = ROOT / "docs" / "diagrams" / "gemm_plots"
_OUTPUT_DIR = Path(__file__).resolve().parent / "1H_milestone_output" / "gemm"
# ── sweep configuration ────────────────────────────────────────────────
# Default sweep covering under-tile, single-tile, multi-tile, and asymmetric
# regimes. Each entry is "MxKxN" or a single int (square M=K=N).
# Override via env: SWEEP_SHAPES="16,32,16x2048x16,..."
DEFAULT_SHAPES = [
"32x32x32", # 1 tile, K=32 < TILE_K=64 → under-tile in K
"32x64x32", # 1 tile, exact single-tile fit
"32x128x32", # 2 tiles, aligned
"32x128x128", # 8 tiles, aligned
"32x3072x32", # 48 tiles, all K-axis (tall-skinny)
"8x128x128", # 8 tiles, but M=8 < TILE_M=32 → MAC array under-fed
"128x8x128", # 16 tiles, but K=8 < TILE_K=64 → MAC array under-fed
"512", # 2048 tiles, fully aligned — "well-pipelined" reference
]
# Operand-staging variants exercised per shape.
VARIANTS = ["ref_ref", "load_ref", "load_load"]
# Engines whose timings we collect (component_id suffix match).
ENGINES = ["pe_dma", "pe_fetch_store", "pe_gemm", "pe_math"]
# Per-stage breakdown labels (StageType enum names from pe_types.py).
STAGES = ["DMA_READ", "DMA_WRITE", "FETCH", "STORE", "GEMM", "MATH"]
# Scheduler tile sizes (mirror of PeSchedulerComponent.TILE_M/K/N).
TILE_M, TILE_K, TILE_N = 32, 64, 32
def _ceil(a: int, b: int) -> int:
return (a + b - 1) // b
def _engine_wall_ns(records, suffix: str) -> float:
"""Wall-clock interval the engine was active (union of overlapping ops)."""
intervals = [(r.t_start, r.t_end) for r in records
if r.component_id.endswith("." + suffix)]
if not intervals:
return 0.0
intervals.sort()
merged_end = intervals[0][1]
merged_start = intervals[0][0]
total = 0.0
for s, e in intervals[1:]:
if s <= merged_end:
merged_end = max(merged_end, e)
else:
total += merged_end - merged_start
merged_start, merged_end = s, e
total += merged_end - merged_start
return total
def _engine_occupancy_ns(records, suffix: str) -> float:
return sum(r.t_end - r.t_start for r in records
if r.component_id.endswith("." + suffix))
def _engine_count(records, suffix: str) -> int:
return sum(1 for r in records if r.component_id.endswith("." + suffix))
def _stage_occupancy_ns(records, stage_type: str) -> float:
return sum(
r.t_end - r.t_start
for r in records
if r.params.get("stage_type") == stage_type
)
def _stage_wall_ns(records, stage_type: str) -> float:
"""Interval-union wall-clock for records whose stage_type matches."""
intervals = sorted(
(r.t_start, r.t_end) for r in records
if r.params.get("stage_type") == stage_type
)
if not intervals:
return 0.0
total = 0.0
cs, ce = intervals[0]
for s, e in intervals[1:]:
if s <= ce:
ce = max(ce, e)
else:
total += ce - cs
cs, ce = s, e
total += ce - cs
return total
def _stage_count(records, stage_type: str) -> int:
return sum(1 for r in records if r.params.get("stage_type") == stage_type)
def _run_one(M: int, K: int, N: int, topology: str, variant: str = "ref_ref") -> dict:
os.environ["MATMUL_M"] = str(M)
os.environ["MATMUL_K"] = str(K)
os.environ["MATMUL_N"] = str(N)
os.environ["MATMUL_VARIANT"] = variant
from kernbench.benches.registry import resolve as resolve_bench
from kernbench.runtime_api.bench_runner import run_bench
from kernbench.runtime_api.types import resolve_device
from kernbench.sim_engine.engine import GraphEngine
from kernbench.topology.builder import resolve_topology
topo = resolve_topology(topology)
bench = resolve_bench("matmul-composite").run
device = resolve_device(None)
t0 = time.time()
result = run_bench(
topology=topo, bench_fn=bench, device=device,
engine_factory=lambda t, d: GraphEngine(
getattr(t, "topology_obj", t), enable_data=True,
),
)
wall = time.time() - t0
op_log = result.engine.op_log
if not result.completion.ok:
raise RuntimeError(f"bench failed at M={M},K={K},N={N}: {result.completion}")
# Bytes touched at f16 (2 B): full A + full B + full out (each operand
# streamed once through HBM by the composite plan).
bytes_total = (M * K + K * N + M * N) * 2
row = {
"M": M, "K": K, "N": N,
"variant": variant,
"flops": 2 * M * K * N,
"bytes_hbm": bytes_total,
"arith_intensity": (2 * M * K * N) / bytes_total, # flops/byte
"tile_count_expected": _ceil(M, TILE_M) * _ceil(N, TILE_N) * _ceil(K, TILE_K),
"sim_wall_clock_s": round(wall, 3),
"engines": {},
}
for eng in ENGINES:
row["engines"][eng] = {
"occupancy_ns": _engine_occupancy_ns(op_log, eng),
"wall_ns": _engine_wall_ns(op_log, eng),
"record_count": _engine_count(op_log, eng),
}
row["stages"] = {}
for stage in STAGES:
row["stages"][stage] = {
"occupancy_ns": _stage_occupancy_ns(op_log, stage),
"wall_ns": _stage_wall_ns(op_log, stage),
"record_count": _stage_count(op_log, stage),
}
# Kernel-window wall-clock = max t_end - min t_start over PE engine records.
pe_records = [r for r in op_log
if any(r.component_id.endswith("." + e) for e in ENGINES)]
if pe_records:
row["pe_window_ns"] = max(r.t_end for r in pe_records) \
- min(r.t_start for r in pe_records)
else:
row["pe_window_ns"] = 0.0
stage_records = [r for r in op_log
if r.params.get("stage_type") in STAGES]
if stage_records:
row["composite_window_ns"] = max(r.t_end for r in stage_records) \
- min(r.t_start for r in stage_records)
else:
row["composite_window_ns"] = 0.0
return row
def _parse_shapes(raw) -> list[tuple[int, int, int]]:
shapes: list[tuple[int, int, int]] = []
for s in raw:
s = s.strip()
if not s:
continue
if "x" in s.lower():
parts = s.lower().split("x")
shapes.append((int(parts[0]), int(parts[1]), int(parts[2])))
else:
v = int(s)
shapes.append((v, v, v))
return shapes
def run_sweep(out_json: Path | str = DEFAULT_SWEEP_JSON) -> Path:
"""Drive matmul-composite across shapes×variants; write the sweep JSON.
Honors ``SWEEP_SHAPES`` / ``SWEEP_TOPOLOGY`` env overrides (same as the
historical ``scripts/gemm_sweep.py``). Returns the JSON path written.
"""
shapes_env = os.environ.get("SWEEP_SHAPES")
raw = (shapes_env.split(",") if shapes_env else DEFAULT_SHAPES)
shapes = _parse_shapes(raw)
topology = os.environ.get("SWEEP_TOPOLOGY", "topology.yaml")
rows = []
for M, K, N in shapes:
for variant in VARIANTS:
print(f"[sweep] M={M} K={K} N={N} variant={variant} ...", flush=True)
row = _run_one(M, K, N, topology, variant=variant)
rows.append(row)
eng_dma = row["engines"]["pe_dma"]
eng_gem = row["engines"]["pe_gemm"]
print(f" tiles={row['tile_count_expected']:>6} "
f"pe_window={row['pe_window_ns']:8.1f}ns "
f"dma_occ={eng_dma['occupancy_ns']:9.1f} "
f"gemm_occ={eng_gem['occupancy_ns']:8.1f} "
f"(sim {row['sim_wall_clock_s']:.1f}s)")
out_json = Path(out_json)
out_json.parent.mkdir(parents=True, exist_ok=True)
out_json.write_text(json.dumps({
"tile_sizes": {"M": TILE_M, "K": TILE_K, "N": TILE_N},
"engines": ENGINES,
"stages": STAGES,
"variants": VARIANTS,
"rows": rows,
}, indent=2))
print(f"\n[sweep] wrote {out_json}")
return out_json
# ── figure rendering ───────────────────────────────────────────────────
# Shapes excluded from the figures (mirrors build_overview_slides).
EXCLUDED_SHAPES = {(512, 512, 512)}
# Stage bars shown (raw op_log stage_type keys) + display names + colors.
STAGE_KEYS = ["DMA_READ", "FETCH", "GEMM", "DMA_WRITE"]
STAGE_DISPLAY = {
"DMA_READ": "DMA in",
"FETCH": "Fetch",
"GEMM": "GEMM",
"DMA_WRITE": "DMA out",
}
STAGE_COLORS = {
"DMA_READ": "#3B82F6",
"FETCH": "#10B981",
"GEMM": "#F59E0B",
"DMA_WRITE": "#A855F7",
}
# MAC-utilization analytic model constants.
# Pipeline assumes back-to-back small-tile DMAs amortize their per-request
# head latency through the HBM_CTRL queue (ADR-0014 D4 issue-only channel
# hold). Steady-state cycle is BW-bound at one tile / T_STAGE, where
# T_STAGE is derived from the hardware peak so that analytic and measured
# share the same per-PE GEMM-throughput denominator
# (\\SI{8}{\\tera\\flop\\per\\second} = 8000 flops/ns) — see
# `_t_stage_ns_from_tile()` below.
_T_PIPELINE_FILL = 60.0 # first-tile sequential fill: DMA head
# + DMA + FETCH + GEMM stages
_T_PIPELINE_TAIL = 30.0 # tail: last STORE + DMA_WRITE + drain
# Per-PE GEMM peak. Single source of truth for both the analytic
# `compute_total = tiles * T_STAGE` term and the measured-eff
# denominator. Matches topology.yaml pe_gemm.peak_tflops_f16.
_GEMM_PEAK_TFLOPS = 8.0
_GEMM_PEAK_FLOPS_PER_NS = _GEMM_PEAK_TFLOPS * 1000.0 # = 8000 flops/ns
def _t_stage_ns_from_tile(tile_flops: int) -> float:
"""Per-tile bottleneck cycle (ns) implied by the hardware peak.
The composite pipeline's steady-state stage cycle equals the time the
GEMM engine spends on one tile at peak throughput. Deriving T_STAGE
from tile_flops / peak (rather than hardcoding 16 ns) keeps the
analytic model self-consistent with the measured-eff denominator: a
tile with tile_flops = 131072 and an \\SI{8}{\\tera\\flop\\per\\second}
engine takes exactly 131072 / 8000 = 16.384 ns of GEMM time.
"""
return tile_flops / _GEMM_PEAK_FLOPS_PER_NS
_PLOT_VARIANT = "load_ref"
def _load_sweep_data(sweep_json: Path | str = DEFAULT_SWEEP_JSON) -> dict:
sweep_json = Path(sweep_json)
if not sweep_json.exists():
return {"rows": []}
data = json.loads(sweep_json.read_text())
data["rows"] = [
r for r in data.get("rows", [])
if (r["M"], r["K"], r["N"]) not in EXCLUDED_SHAPES
]
return data
def _shape_label(r: dict) -> str:
if r["M"] == r["K"] == r["N"]:
return f"M=K=N={r['M']}"
return f"M={r['M']} K={r['K']} N={r['N']}"
def _under_tile(M, K, N, tile_M, tile_K, tile_N) -> bool:
return M < tile_M or K < tile_K or N < tile_N
def _xtick_labels(shape_labels, tile_counts, flagged) -> list[str]:
out = []
for lbl, tc, fl in zip(shape_labels, tile_counts, flagged):
s = f"{lbl}\n({tc} tiles)"
if fl:
s += " *"
out.append(s)
return out
def _grouped_bar_png(
out_name: str, *, out_dir: Path, title: str, subtitle: str | None,
shape_labels, tile_counts, flagged, series: dict, colors: dict,
y_label: str, threshold: float | None = None, footnote: str | None = None,
) -> str:
"""Render one grouped-bar chart to out_dir/out_name; return the path."""
import matplotlib.pyplot as plt
import numpy as np
n_groups = len(shape_labels)
n_series = max(1, len(series))
x = np.arange(n_groups)
width = 0.8 / n_series
fig, ax = plt.subplots(figsize=(11, 6))
for i, (name, vals) in enumerate(series.items()):
offset = (i - (n_series - 1) / 2) * width
ax.bar(x + offset, vals, width, label=name, color=colors.get(name))
ax.set_xticks(x)
ax.set_xticklabels(
_xtick_labels(shape_labels, tile_counts, flagged), fontsize=8,
)
ax.set_ylabel(y_label)
# Title with optional subtitle stacked above (subtitle in lighter ink,
# title in bold below it). Coordinates are in axes-fraction so they
# survive tight_layout without colliding with the data area.
if subtitle:
ax.text(0.5, 1.10, subtitle, transform=ax.transAxes, ha="center",
va="bottom", fontsize=8, color="#475569")
ax.set_title(title, fontsize=13, fontweight="bold", pad=22)
else:
ax.set_title(title, fontsize=13, fontweight="bold")
if threshold is not None:
ax.axhline(threshold, ls="--", color="gray", lw=1.0)
ax.legend(fontsize=8, loc="upper right")
ax.grid(True, axis="y", alpha=0.3)
caption = "* = under-tile shape (M<TILE_M, K<TILE_K, or N<TILE_N)"
if footnote:
caption = footnote + "\n" + caption
fig.text(0.5, 0.01, caption, ha="center", fontsize=7, color="gray",
wrap=True)
fig.tight_layout(rect=(0, 0.05, 1, 1))
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
out = out_dir / out_name
fig.savefig(out, dpi=120)
plt.close(fig)
return str(out)
def emit_stage_breakdown(
sweep_json: Path | str = DEFAULT_SWEEP_JSON,
out_dir: Path | str = DEFAULT_PLOTS_DIR,
) -> str | None:
"""Per-stage engine wall-clock per shape (load_ref operand staging)."""
data = _load_sweep_data(sweep_json)
rows = [r for r in data["rows"] if r.get("variant") == _PLOT_VARIANT]
if not rows:
return None
tile = data["tile_sizes"]
shape_labels = [_shape_label(r) for r in rows]
flagged = [_under_tile(r["M"], r["K"], r["N"], tile["M"], tile["K"], tile["N"])
for r in rows]
tile_counts = [r["tile_count_expected"] for r in rows]
series = {
STAGE_DISPLAY[s]: [r.get("stages", {}).get(s, {}).get("wall_ns", 0.0)
for r in rows]
for s in STAGE_KEYS
}
colors = {STAGE_DISPLAY[s]: STAGE_COLORS[s] for s in STAGE_KEYS}
return _grouped_bar_png(
"gemm_stage_breakdown.png", out_dir=Path(out_dir),
title="GEMM stage breakdown",
subtitle=(f"Per-stage engine wall-clock (DMA in / Fetch / GEMM / "
f"DMA out), {_PLOT_VARIANT} staging. "
f"Tile {tile['M']}x{tile['K']}x{tile['N']}."),
shape_labels=shape_labels, tile_counts=tile_counts, flagged=flagged,
series=series, colors=colors, y_label="ns",
footnote="Bars = engine wall-clock interval (merged overlaps).",
)
def emit_mac_utilization_measured(
sweep_json: Path | str = DEFAULT_SWEEP_JSON,
out_dir: Path | str = DEFAULT_PLOTS_DIR,
) -> str | None:
"""GEMM util % and useful pipeline-eff % (analytical model, load_ref)."""
data = _load_sweep_data(sweep_json)
rows = data["rows"]
if not rows:
return None
tile = data["tile_sizes"]
TILE_M, TILE_K, TILE_N = tile["M"], tile["K"], tile["N"]
tile_flops = 2 * TILE_M * TILE_K * TILE_N
by_shape = {(r["M"], r["K"], r["N"]): r
for r in rows if r["variant"] == _PLOT_VARIANT}
shapes = list(by_shape)
if not shapes:
return None
shape_labels = [_shape_label(by_shape[k]) for k in shapes]
flagged = [_under_tile(*k, TILE_M, TILE_K, TILE_N) for k in shapes]
tile_counts = [by_shape[k]["tile_count_expected"] for k in shapes]
t_stage_ns = _t_stage_ns_from_tile(tile_flops)
gemm_util, useful_eff = [], []
for k in shapes:
r = by_shape[k]
M, K, N = r["M"], r["K"], r["N"]
useful = 2 * M * K * N
tiles = r["tile_count_expected"]
gu = useful / (tile_flops * tiles) * 100
gemm_util.append(gu)
compute_total = tiles * t_stage_ns
wall = _T_PIPELINE_FILL + compute_total + _T_PIPELINE_TAIL
ueff = (compute_total * (gu / 100.0) / wall) * 100 if wall > 0 else 0.0
useful_eff.append(ueff)
series = {"GEMM util %": gemm_util, "Useful eff %": useful_eff}
colors = {"GEMM util %": "#10B981", "Useful eff %": "#F59E0B"}
return _grouped_bar_png(
"gemm_mac_utilization_measured.png", out_dir=Path(out_dir),
title="GEMM MAC utilization — load_ref",
subtitle=("GEMM util = useful FLOPs / (tile FLOPs x tiles); "
"Useful eff = GEMM util x ideal pipeline efficiency."),
shape_labels=shape_labels, tile_counts=tile_counts, flagged=flagged,
series=series, colors=colors, y_label="%", threshold=100.0,
footnote="Theoretical ideal-pipeline model (not simulator data).",
)
def emit_mac_utilization_theoretical_vs_measured(
sweep_json: Path | str = DEFAULT_SWEEP_JSON,
out_dir: Path | str = DEFAULT_PLOTS_DIR,
) -> str | None:
"""Theoretical vs simulator-measured GEMM util / useful eff (load_ref)."""
data = _load_sweep_data(sweep_json)
rows = data["rows"]
if not rows:
return None
tile = data["tile_sizes"]
TILE_M, TILE_K, TILE_N = tile["M"], tile["K"], tile["N"]
tile_flops = 2 * TILE_M * TILE_K * TILE_N
t_stage_ns = _t_stage_ns_from_tile(tile_flops)
by_shape = {(r["M"], r["K"], r["N"]): r
for r in rows if r["variant"] == _PLOT_VARIANT}
shapes = list(by_shape)
if not shapes:
return None
shape_labels = [_shape_label(by_shape[k]) for k in shapes]
flagged = [_under_tile(*k, TILE_M, TILE_K, TILE_N) for k in shapes]
tile_counts = [by_shape[k]["tile_count_expected"] for k in shapes]
gu_t, gu_m, eff_t, eff_m = [], [], [], []
for k in shapes:
r = by_shape[k]
M, K, N = r["M"], r["K"], r["N"]
useful = 2 * M * K * N
tiles = r["tile_count_expected"]
gut = useful / (tile_flops * tiles)
gu_t.append(gut * 100)
rec = r.get("stages", {}).get("GEMM", {}).get("record_count", 0) or tiles
gu_m.append((useful / (tile_flops * rec) * 100) if rec else 0.0)
compute_total = tiles * t_stage_ns
wall_t = _T_PIPELINE_FILL + compute_total + _T_PIPELINE_TAIL
eff_t.append((compute_total * gut / wall_t * 100) if wall_t > 0 else 0.0)
cw = r.get("composite_window_ns", 0.0) or 0.0
eff_m.append((useful / cw / _GEMM_PEAK_FLOPS_PER_NS * 100) if cw > 0 else 0.0)
series = {
"GEMM util % (theoretical)": gu_t,
"GEMM util % (measured)": gu_m,
"Theoretical eff %": eff_t,
"Measured eff %": eff_m,
}
colors = {
"GEMM util % (theoretical)": "#10B981",
"GEMM util % (measured)": "#6EE7B7",
"Theoretical eff %": "#F59E0B",
"Measured eff %": "#3B82F6",
}
return _grouped_bar_png(
"gemm_mac_utilization_theoretical_vs_measured.png", out_dir=Path(out_dir),
title="GEMM MAC utilization — theoretical vs measured (load_ref)",
subtitle=("theoretical model vs simulator op_log; agreement "
"validates the analytical pipeline model."),
shape_labels=shape_labels, tile_counts=tile_counts, flagged=flagged,
series=series, colors=colors, y_label="%", threshold=100.0,
)
# ── per-PE composite-pipeline plots: HBM BW util & GEMM TFLOPS ─────────
#
# These two plots evaluate the composite GEMM kernel against the
# per-PE HBM bandwidth (256 GB/s) and per-PE GEMM peak (8 TFLOP/s).
# Both metrics use composite_window_ns as the denominator — the
# composite-internal pipeline window, with any pre-composite tl.load
# of operand A excluded by construction. The two variants on each plot
# answer different staging questions:
#
# - load_ref: activation A pre-staged on chip, only weight W streamed
# from HBM during the composite. ("Weight-only HBM" case.)
# - ref_ref: both A and W streamed from HBM during the composite.
# ("Both A and W from HBM" case.)
#
# load_load is omitted from these plots — with both operands pre-staged
# the composite carries no HBM traffic and the BW metric collapses to 0,
# which is uninformative against the saturation question.
# Per-tile DMA payload sizes (one TILE_M x TILE_K or TILE_K x TILE_N
# read; one TILE_M x TILE_N write per output tile).
_PIPELINE_VARIANTS = ("load_ref", "ref_ref")
_VARIANT_LABEL = {
"load_ref": "Activation pre-staged",
"ref_ref": "A and W both from HBM",
}
_VARIANT_COLOR = {
"load_ref": "#3B82F6", # blue
"ref_ref": "#F59E0B", # orange
}
# Per-PE HBM peak BW (GB/s); matches topology.yaml hbm_to_router_bw_gbs
# and (8 PCs / PE) x (32 GB/s / PC).
_HBM_BW_PER_PE_GBS = 256.0
# Per-PE GEMM peak (TFLOP/s); matches topology.yaml pe_gemm.peak_tflops_f16.
_GEMM_PEAK_TFLOPS = 8.0
def _shape_summary(rows: list[dict], tile: dict):
"""Common shape-axis state for the BW / TFLOPS plots.
Returns (shape_labels, tile_counts, flagged, shape_keys) — only
shapes that actually appear in `rows` for any of the pipeline
variants, in their original order.
"""
seen: list[tuple[int, int, int]] = []
by_key: dict[tuple[int, int, int], dict] = {}
for r in rows:
key = (r["M"], r["K"], r["N"])
if key not in by_key:
by_key[key] = r
seen.append(key)
shape_labels = [_shape_label(by_key[k]) for k in seen]
flagged = [_under_tile(*k, tile["M"], tile["K"], tile["N"]) for k in seen]
tile_counts = [by_key[k]["tile_count_expected"] for k in seen]
return shape_labels, tile_counts, flagged, seen
def _composite_bytes_through_hbm(r: dict, tile: dict) -> float:
"""Total bytes transferred via HBM during the composite window.
Derived from the simulator's per-stage record counts so it
automatically reflects the variant's pre-staging choices:
load_ref skips A reads, ref_ref reads both A and B, load_load
reads neither. Each DMA record carries a full hardware tile
(TILE_M x TILE_K read for A, TILE_K x TILE_N read for B,
TILE_M x TILE_N write for the output), regardless of how much
of that tile is "useful" — which is the actual byte traffic the
HBM controller sees.
"""
bpe = 2 # f16; matches matmul_composite.py default
a_tile_bytes = tile["M"] * tile["K"] * bpe
b_tile_bytes = tile["K"] * tile["N"] * bpe
out_tile_bytes = tile["M"] * tile["N"] * bpe
# The schedule emits one DMA_READ per (A,B) per non-pinned operand.
# Without per-record operand tags in the aggregated JSON, treat all
# DMA_READ records as B-shaped reads on load_ref (B is the only
# streamed operand) and average the two tile shapes on ref_ref.
# In the current topology both tile shapes are 4 KiB so this
# distinction is moot — kept symbolic for future tile-size sweeps.
variant = r.get("variant")
n_read = r["stages"]["DMA_READ"]["record_count"]
n_write = r["stages"]["DMA_WRITE"]["record_count"]
if variant == "ref_ref":
# A reads + B reads, roughly half each.
read_bytes = (n_read // 2) * a_tile_bytes \
+ (n_read - n_read // 2) * b_tile_bytes
else:
read_bytes = n_read * b_tile_bytes
write_bytes = n_write * out_tile_bytes
return float(read_bytes + write_bytes)
def _by_variant(rows: list[dict], variant: str) -> dict:
return {(r["M"], r["K"], r["N"]): r
for r in rows if r.get("variant") == variant}
def emit_hbm_bw_utilization(
sweep_json: Path | str = DEFAULT_SWEEP_JSON,
out_dir: Path | str = DEFAULT_PLOTS_DIR,
) -> str | None:
"""Per-PE HBM bandwidth utilization during the composite window.
Two grouped bars per shape: load_ref ("activation pre-staged")
vs ref_ref ("both A and W from HBM"). Y-axis is BW
util % against the per-PE HBM ceiling (\\SI{256}{\\giga\\byte\\per
\\second}).
"""
data = _load_sweep_data(sweep_json)
rows = [r for r in data["rows"] if r.get("variant") in _PIPELINE_VARIANTS]
if not rows:
return None
tile = data["tile_sizes"]
shape_labels, tile_counts, flagged, shape_keys = _shape_summary(rows, tile)
series: dict = {}
for variant in _PIPELINE_VARIANTS:
by = _by_variant(rows, variant)
vals = []
for k in shape_keys:
r = by.get(k)
if r is None:
vals.append(0.0)
continue
cw = r.get("composite_window_ns", 0.0) or 0.0
if cw <= 0:
vals.append(0.0)
continue
bytes_hbm = _composite_bytes_through_hbm(r, tile)
# bytes_hbm [B] / cw [ns] = GB/s (since 1 B/ns = 1 GB/s).
bw_gbs = bytes_hbm / cw
vals.append(bw_gbs / _HBM_BW_PER_PE_GBS * 100.0)
series[_VARIANT_LABEL[variant]] = vals
colors = {_VARIANT_LABEL[v]: _VARIANT_COLOR[v] for v in _PIPELINE_VARIANTS}
return _grouped_bar_png(
"gemm_hbm_bw_util.png", out_dir=Path(out_dir),
title="GEMM HBM bandwidth utilization (per PE, composite window)",
subtitle=(f"BW util = bytes through HBM / composite window "
f"/ {_HBM_BW_PER_PE_GBS:.0f} GB/s. "
f"Saturation → memory-bound regime."),
shape_labels=shape_labels, tile_counts=tile_counts, flagged=flagged,
series=series, colors=colors, y_label="HBM BW utilization (%)",
threshold=100.0,
)
def emit_per_pe_tflops(
sweep_json: Path | str = DEFAULT_SWEEP_JSON,
out_dir: Path | str = DEFAULT_PLOTS_DIR,
) -> str | None:
"""Per-PE GEMM throughput in TFLOP/s during the composite window.
Two grouped bars per shape (load_ref vs ref_ref). Reference line
at the per-PE GEMM engine peak (\\SI{8}{\\tera\\flop\\per\\second}).
"""
data = _load_sweep_data(sweep_json)
rows = [r for r in data["rows"] if r.get("variant") in _PIPELINE_VARIANTS]
if not rows:
return None
tile = data["tile_sizes"]
shape_labels, tile_counts, flagged, shape_keys = _shape_summary(rows, tile)
series: dict = {}
for variant in _PIPELINE_VARIANTS:
by = _by_variant(rows, variant)
vals = []
for k in shape_keys:
r = by.get(k)
if r is None:
vals.append(0.0)
continue
cw = r.get("composite_window_ns", 0.0) or 0.0
if cw <= 0:
vals.append(0.0)
continue
M, K, N = r["M"], r["K"], r["N"]
useful = 2 * M * K * N
# flops / ns -> GFLOP/s; / 1000 -> TFLOP/s.
tflops = useful / cw / 1000.0
vals.append(tflops)
series[_VARIANT_LABEL[variant]] = vals
colors = {_VARIANT_LABEL[v]: _VARIANT_COLOR[v] for v in _PIPELINE_VARIANTS}
return _grouped_bar_png(
"gemm_per_pe_tflops.png", out_dir=Path(out_dir),
title="GEMM achieved throughput (per PE, composite window)",
subtitle=(f"TFLOP/s = 2 · M · K · N / composite "
f"window. Peak per PE = "
f"{_GEMM_PEAK_TFLOPS:.0f} TFLOP/s (dashed line)."),
shape_labels=shape_labels, tile_counts=tile_counts, flagged=flagged,
series=series, colors=colors, y_label="Per-PE TFLOP/s",
threshold=_GEMM_PEAK_TFLOPS,
)
def emit_all_gemm_plots(
sweep_json: Path | str = DEFAULT_SWEEP_JSON,
out_dir: Path | str = DEFAULT_PLOTS_DIR,
) -> list[str]:
"""Render every GEMM figure that has data; return the paths written."""
paths = []
for fn in (emit_stage_breakdown,
emit_mac_utilization_measured,
emit_mac_utilization_theoretical_vs_measured,
emit_hbm_bw_utilization,
emit_per_pe_tflops):
p = fn(sweep_json, out_dir)
if p:
paths.append(p)
return paths
# ── bench entry ────────────────────────────────────────────────────────
@bench(
name="milestone-1h-gemm",
description="1H milestone: regenerate all GEMM results + figures.",
)
def run(torch) -> None:
"""Run the GEMM sweep (or reuse committed JSON) and render every figure.
``MILESTONE_FAST=1`` reuses the committed ``DEFAULT_SWEEP_JSON`` (seconds);
otherwise the full sweep runs into ``out_dir/gemm_sweep.json`` (minutes).
The sweep drives its own engines, so a sentinel tensor is submitted at the
end to satisfy the run_bench contract (ADR-0045 D4).
"""
_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
fast = bool(os.environ.get("MILESTONE_FAST"))
if fast:
sweep_json = DEFAULT_SWEEP_JSON
else:
sweep_json = run_sweep(out_json=_OUTPUT_DIR / "gemm_sweep.json")
paths = emit_all_gemm_plots(sweep_json=sweep_json, out_dir=_OUTPUT_DIR)
print(f" milestone-1h-gemm: {len(paths)} figures -> {_OUTPUT_DIR} "
f"(fast={fast})")
torch.zeros(
(1, 1), dtype="f16",
dp=DPPolicy(cube="row_wise", pe="replicate", num_cubes=1, num_pes=1),
name="milestone_gemm_sentinel",
)