Files
TheRock/rocm-systems/projects/rocshmem/scripts/functional_tests/perf_compare.py
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

651 lines
23 KiB
Python

#!/usr/bin/env python3
###############################################################################
# Copyright (c) Advanced Micro Devices, Inc. All rights reserved.
#
# SPDX-License-Identifier: MIT
###############################################################################
"""Compare rocshmem functional test performance across build variants.
Parses log files produced by scripts/functional_tests/driver.sh, computes
three metrics per test (small-message latency, peak bandwidth, area under
the bandwidth curve), generates per-test latency-vs-size plots, and produces
a summary heatmap of performance differences relative to a baseline.
Usage:
python3 scripts/perf_compare.py \\
--baseline build-develop/logs-develop \\
--variants sdma-off:build-sdma-off/logs-sdma-off \\
sdma-disabled:build-sdma-on/logs-sdma-disabled \\
sdma-enabled:build-sdma-on/logs-sdma-enabled \\
--outdir plots/
"""
import argparse
import re
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
# ---------------------------------------------------------------------------
# Log parsing
# ---------------------------------------------------------------------------
# Matches ANSI escape sequences
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
# Extracts test name and PE/WG/thread counts from log filename
_FNAME_RE = re.compile(r"^(.+?)_n(\d+)_w(\d+)_z(\d+)")
def parse_log(filepath: Path) -> pd.DataFrame | None:
"""Parse a single driver log file into a DataFrame.
Returns None if the file contains no usable data rows.
"""
rows = []
with open(filepath, "r", errors="replace") as fh:
for line in fh:
line = line.strip()
if not line:
continue
# Skip comment/header lines and ANSI warning lines
if line.startswith("#") or line.startswith("###"):
continue
if _ANSI_RE.search(line):
continue
parts = line.split()
if len(parts) < 6:
continue
try:
row = {
"volume": int(parts[0]),
"msg_size": int(parts[1]),
"num_msgs": int(parts[2]),
"latency_us": float(parts[3]),
"bandwidth_gbs": float(parts[4]),
"msg_rate": float(parts[5]),
}
rows.append(row)
except (ValueError, IndexError):
continue
if not rows:
return None
df = pd.DataFrame(rows)
df.sort_values("msg_size", inplace=True)
df.reset_index(drop=True, inplace=True)
return df
def collect_variant(log_dir: Path) -> dict[str, pd.DataFrame]:
"""Collect all test results from a single log directory.
Returns a dict mapping test_key -> DataFrame. The key encodes the test
name and its PE/WG/thread configuration (e.g. "put_pe2w1z1"). When
multiple log files map to the same key (different max message sizes),
the one with the most data rows is kept.
"""
results: dict[str, pd.DataFrame] = {}
for logfile in sorted(log_dir.glob("*.log")):
m = _FNAME_RE.match(logfile.stem)
if not m:
continue
test_name, n, w, z = m.group(1), m.group(2), m.group(3), m.group(4)
test_key = f"{test_name}_pe{n}w{w}z{z}"
df = parse_log(logfile)
if df is None or df.empty:
continue
if test_key not in results or len(df) > len(results[test_key]):
results[test_key] = df
return results
def _remove_outliers_iqr(values: np.ndarray) -> np.ndarray:
"""Remove outliers using the IQR method (1.5x IQR fence)."""
if len(values) < 4:
return values
q1, q3 = np.percentile(values, [25, 75])
iqr = q3 - q1
lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr
return values[(values >= lo) & (values <= hi)]
def collect_variant_multi(
log_dirs: list[Path],
) -> tuple[dict[str, pd.DataFrame], dict[str, dict]]:
"""Collect test results from multiple iteration directories.
For each test_key and message size, gathers latency/bandwidth values
across all iterations, removes outliers via IQR, and returns the
median. This produces a single clean DataFrame per test_key.
Also returns IQR filtering statistics:
iqr_stats[test_key][msg_size] = {
n_total, n_kept, lat_lo, lat_hi, lat_med, bw_med
}
"""
# Step 1: gather all DataFrames per test_key across iterations
raw: dict[str, list[pd.DataFrame]] = {}
for log_dir in log_dirs:
single = collect_variant(log_dir)
for key, df in single.items():
raw.setdefault(key, []).append(df)
# Step 2: for each test_key, aggregate across iterations
results: dict[str, pd.DataFrame] = {}
iqr_stats: dict[str, dict] = {}
for key, dfs in raw.items():
# Stack all iterations, group by msg_size
combined = pd.concat(dfs, ignore_index=True)
grouped = combined.groupby("msg_size")
rows = []
size_stats: dict[int, dict] = {}
for msg_size, grp in grouped:
lat_vals = grp["latency_us"].values
bw_vals = grp["bandwidth_gbs"].values
lat_clean = _remove_outliers_iqr(lat_vals)
bw_clean = _remove_outliers_iqr(bw_vals)
q1, q3 = np.percentile(lat_vals, [25, 75])
iqr = q3 - q1
lat_lo, lat_hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr
lat_med = float(np.median(lat_clean)) if len(lat_clean) else float(grp["latency_us"].median())
bw_med = float(np.median(bw_clean)) if len(bw_clean) else float(grp["bandwidth_gbs"].median())
size_stats[int(msg_size)] = {
"n_total": len(lat_vals),
"n_kept": len(lat_clean),
"lat_lo": lat_lo,
"lat_hi": lat_hi,
"lat_med": lat_med,
"bw_med": bw_med,
}
rows.append({
"msg_size": msg_size,
"volume": grp["volume"].iloc[0],
"num_msgs": grp["num_msgs"].iloc[0],
"latency_us": lat_med,
"bandwidth_gbs": bw_med,
"msg_rate": grp["msg_rate"].median(),
})
df_agg = pd.DataFrame(rows).sort_values("msg_size").reset_index(drop=True)
results[key] = df_agg
iqr_stats[key] = size_stats
return results, iqr_stats
# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------
def small_msg_latency(df: pd.DataFrame) -> float:
"""Latency (us) at the smallest message size."""
return df.iloc[0]["latency_us"]
def peak_bandwidth(df: pd.DataFrame) -> float:
"""Maximum bandwidth (GB/s) across all message sizes."""
return df["bandwidth_gbs"].max()
def bandwidth_auc(df: pd.DataFrame) -> float:
"""Area under the bandwidth curve (trapezoidal, log2 msg_size axis)."""
x = np.log2(df["msg_size"].values.astype(float))
y = df["bandwidth_gbs"].values
if len(x) < 2:
return y[0] if len(y) else 0.0
_trapz = getattr(np, "trapezoid", getattr(np, "trapz", None))
return float(_trapz(y, x))
METRICS = {
"latency_us": small_msg_latency,
"peak_bw_gbs": peak_bandwidth,
"bw_auc": bandwidth_auc,
}
# For the heatmap: lower is better for latency, higher is better for BW/AUC
_LOWER_IS_BETTER = {"latency_us"}
# ---------------------------------------------------------------------------
# Plotting
# ---------------------------------------------------------------------------
_VARIANT_STYLES = {
"baseline": {"color": "black", "linestyle": "-", "marker": "o"},
"sdma-off": {"color": "tab:blue", "linestyle": "--", "marker": "s"},
"sdma-disabled": {"color": "tab:orange", "linestyle": "-.", "marker": "^"},
"sdma-enabled": {"color": "tab:red", "linestyle": "-", "marker": "D"},
}
def _fmt_size(x, _pos=None):
"""Format byte count as B / KiB / MiB."""
if x < 1:
return ""
if x < 1024:
return f"{int(x)} B"
if x < 1024 * 1024:
v = x / 1024
return f"{int(v)} KiB" if v == int(v) else f"{v:.1f} KiB"
v = x / (1024 * 1024)
return f"{int(v)} MiB" if v == int(v) else f"{v:.1f} MiB"
def _fmt_latency(x, _pos=None):
"""Format latency in plain decimal (no scientific notation)."""
if x < 1:
return f"{x:.2f}"
if x < 10:
return f"{x:.1f}"
return f"{int(x)}" if x == int(x) else f"{x:.1f}"
def plot_latency(
test_key: str,
baseline_df: pd.DataFrame,
variant_dfs: dict[str, pd.DataFrame],
outdir: Path,
) -> None:
"""Generate a latency-vs-message-size plot for one test."""
fig, ax = plt.subplots(figsize=(10, 6))
for label, df in [("baseline", baseline_df), *variant_dfs.items()]:
style = _VARIANT_STYLES.get(label, {})
ax.plot(
df["msg_size"],
df["latency_us"],
label=label,
markersize=4,
linewidth=1.5,
**style,
)
ax.set_xscale("log", base=2)
ax.xaxis.set_major_formatter(plt.FuncFormatter(_fmt_size))
# Only use log scale if all latency values are positive
all_positive = all(
(df["latency_us"] > 0).all()
for df in [baseline_df, *variant_dfs.values()]
)
if all_positive:
ax.set_yscale("log")
ax.yaxis.set_major_locator(plt.LogLocator(base=10, subs=[1, 2, 5]))
ax.yaxis.set_minor_locator(plt.NullLocator())
ax.yaxis.set_major_formatter(plt.FuncFormatter(_fmt_latency))
ax.set_xlabel("Message Size")
ax.set_ylabel("Latency (us)")
ax.set_title(test_key)
ax.legend(fontsize=8)
ax.grid(True, which="both", alpha=0.3)
fig.autofmt_xdate(rotation=45, ha="right")
fig.tight_layout()
fig.savefig(outdir / f"{test_key}_latency.png", dpi=150)
plt.close(fig)
def make_heatmap(
baseline_data: dict[str, pd.DataFrame],
variant_datasets: dict[str, dict[str, pd.DataFrame]],
outdir: Path,
) -> None:
"""Generate a summary heatmap of % performance change vs baseline."""
# Compute metrics for baseline
baseline_metrics: dict[str, dict[str, float]] = {}
for test_name, df in baseline_data.items():
baseline_metrics[test_name] = {
m: fn(df) for m, fn in METRICS.items()
}
# Build a table: rows = tests, columns = (variant, metric)
records = []
for vname, vdata in variant_datasets.items():
for test_name in sorted(baseline_metrics.keys()):
if test_name not in vdata:
continue
bm = baseline_metrics[test_name]
vm = {m: fn(vdata[test_name]) for m, fn in METRICS.items()}
for metric in METRICS:
if bm[metric] == 0:
pct = 0.0
else:
pct = (vm[metric] - bm[metric]) / abs(bm[metric]) * 100.0
records.append({
"test": test_name,
"variant": vname,
"metric": metric,
"pct_change": pct,
})
if not records:
print("No common tests found for heatmap.", file=sys.stderr)
return
df_all = pd.DataFrame(records)
# Pivot: rows=test, columns=(variant, metric)
pivot = df_all.pivot_table(
index="test",
columns=["variant", "metric"],
values="pct_change",
)
pivot.sort_index(inplace=True)
# For display: flip sign on latency so green = improvement everywhere
display = pivot.copy()
for col in display.columns:
variant, metric = col
if metric in _LOWER_IS_BETTER:
display[col] = -display[col]
# Rename columns for readability
col_labels = []
for variant, metric in display.columns:
short_metric = {"latency_us": "lat", "peak_bw_gbs": "bw", "bw_auc": "auc"}[metric]
col_labels.append(f"{variant}\n{short_metric}")
display.columns = col_labels
# Clamp display values for color mapping (annotations stay original)
annot_df = display.copy()
# Custom non-linear colormap:
# -100% deep red → -50% red → -20% orange → 0 white
# 0 white → +5% light green → +20% green → +100% dark green
# +100% → +1000%+ deep teal
boundaries = [-100, -50, -20, -10, -5, 0, 5, 10, 20, 50, 100, 500, 5000]
from matplotlib.colors import BoundaryNorm, LinearSegmentedColormap
colors = [
"#b2182b", # -100 to -50: deep red
"#d6604d", # -50 to -20: red
"#f4a582", # -20 to -10: orange
"#fddbc7", # -10 to -5: light orange
"#f7f7f7", # -5 to 0: near white
"#e5f5e0", # 0 to +5: very light green
"#a1d99b", # +5 to +10: light green
"#74c476", # +10 to +20: medium green
"#31a354", # +20 to +50: green
"#006d2c", # +50 to +100: dark green
"#00441b", # +100 to +500: very dark green
"#002a11", # +500 to +5000: deepest green
]
cmap = LinearSegmentedColormap.from_list("perf", colors, N=len(colors))
norm = BoundaryNorm(boundaries, cmap.N, clip=True)
# Clamp values into boundary range for coloring
clamped = display.clip(lower=boundaries[0], upper=boundaries[-1])
# Format annotations: use "k" suffix for values >= 1000
def _fmt_annot(v):
if not np.isfinite(v):
return ""
if abs(v) >= 1000:
return f"{v/1000:.0f}k"
return f"{v:.1f}"
annot_text = annot_df.map(_fmt_annot)
# Plot
n_rows = len(display)
fig_height = max(8, n_rows * 0.25 + 2)
fig, ax = plt.subplots(figsize=(14, fig_height))
sns.heatmap(
clamped,
cmap=cmap,
norm=norm,
annot=annot_text,
fmt="",
linewidths=0.5,
ax=ax,
cbar_kws={"label": "% change (green = better)", "ticks": boundaries},
)
ax.set_title("Performance vs develop baseline (% change)")
ax.set_ylabel("")
fig.tight_layout()
fig.savefig(outdir / "heatmap_summary.png", dpi=150)
plt.close(fig)
# Also save raw numbers as CSV
pivot.to_csv(outdir / "heatmap_data.csv")
print(f"Heatmap saved to {outdir / 'heatmap_summary.png'}")
print(f"Raw data saved to {outdir / 'heatmap_data.csv'}")
def write_per_test_txt(
test_key: str,
baseline_df: pd.DataFrame,
variant_dfs: dict[str, pd.DataFrame],
baseline_iqr: dict, # msg_size -> stats (may be empty for single-iter)
variant_iqr: dict[str, dict], # variant -> msg_size -> stats
outdir: Path,
) -> None:
"""Write a per-test summary text file with salient metrics and IQR stats."""
lines = []
lines.append(f"Test: {test_key}")
lines.append("=" * 72)
# --- Salient metrics ---
lines.append("")
lines.append("Salient Metrics")
lines.append("-" * 40)
VAL_W, PCT_W = 12, 8
hdr = f"{'Metric':<14} {'baseline':>{VAL_W}}"
for vname in variant_dfs:
col_w = VAL_W + 1 + PCT_W
hdr += f" {vname:>{col_w}}"
lines.append(hdr)
for metric_name, metric_fn in METRICS.items():
bval = metric_fn(baseline_df)
row = f"{metric_name:<14} {bval:>{VAL_W}.3f}"
for vname, vdf in variant_dfs.items():
vval = metric_fn(vdf)
if bval != 0:
pct = (vval - bval) / abs(bval) * 100
row += f" {vval:>{VAL_W}.3f} {pct:>+{PCT_W}.1f}%"
else:
row += f" {'N/A':>{VAL_W + 1 + PCT_W}}"
lines.append(row)
# --- IQR filtering tables ---
def _iqr_table(label: str, iqr: dict) -> None:
if not iqr:
return
n_iter = next(iter(iqr.values()))["n_total"]
lines.append("")
lines.append(f"IQR Filtering — {label} ({n_iter} sample{'s' if n_iter != 1 else ''} per size)")
lines.append("-" * 72)
lines.append(
f" {'size':>8} {'n_total':>7} {'n_kept':>6} "
f"{'lat_lo_us':>10} {'lat_hi_us':>10} {'lat_med_us':>10} {'bw_med_gbs':>10}"
)
for msg_size in sorted(iqr):
s = iqr[msg_size]
lo = f"{s['lat_lo']:10.3f}"
hi = f"{s['lat_hi']:10.3f}"
lines.append(
f" {_fmt_size(msg_size):>8} {s['n_total']:>7} {s['n_kept']:>6} "
f"{lo} {hi} {s['lat_med']:>10.3f} {s['bw_med']:>10.3f}"
)
_iqr_table("baseline", baseline_iqr)
for vname in variant_dfs:
_iqr_table(vname, variant_iqr.get(vname, {}))
(outdir / f"{test_key}.txt").write_text("\n".join(lines) + "\n")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def _resolve_dirs(pattern: str) -> list[Path]:
"""Resolve a path or glob pattern to a list of directories."""
p = Path(pattern).expanduser()
if p.is_dir():
return [p]
# Try glob from parent
parent = p.parent if p.parent != p else Path(".")
matches = sorted(parent.glob(p.name))
dirs = [m for m in matches if m.is_dir()]
if not dirs:
print(f"Warning: no directories matched '{pattern}'", file=sys.stderr)
return dirs
def main():
parser = argparse.ArgumentParser(
description="Compare rocshmem functional test performance across variants.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--baseline", required=True,
help="Baseline log dir(s) — single path or glob (e.g. build-develop/logs-heatmap-*)",
)
parser.add_argument(
"--variants", nargs="+", required=True,
help="Variant specs as name:path-or-glob (e.g. sdma-off:build-sdma-off/logs-heatmap-*)",
)
parser.add_argument(
"--outdir", type=lambda p: Path(p).expanduser(), default=Path("plots"),
help="Output directory for plots (default: plots/)",
)
args = parser.parse_args()
args.outdir.mkdir(parents=True, exist_ok=True)
# Parse variant specs
variant_dir_lists: dict[str, list[Path]] = {}
for spec in args.variants:
if ":" not in spec:
parser.error(f"Invalid variant spec '{spec}', expected name:path")
name, path = spec.split(":", 1)
variant_dir_lists[name] = _resolve_dirs(path)
# Collect data — use multi-iteration aggregation when multiple dirs
baseline_iqr: dict[str, dict] = {}
baseline_dirs = _resolve_dirs(args.baseline)
if len(baseline_dirs) > 1:
print(f"Parsing baseline: {len(baseline_dirs)} iterations from {args.baseline}")
baseline_data, baseline_iqr = collect_variant_multi(baseline_dirs)
else:
print(f"Parsing baseline: {baseline_dirs[0]}")
baseline_data = collect_variant(baseline_dirs[0])
print(f" Found {len(baseline_data)} tests")
variant_datasets: dict[str, dict[str, pd.DataFrame]] = {}
variant_iqr: dict[str, dict[str, dict]] = {}
for vname, vdirs in variant_dir_lists.items():
if len(vdirs) > 1:
print(f"Parsing variant '{vname}': {len(vdirs)} iterations")
variant_datasets[vname], variant_iqr[vname] = collect_variant_multi(vdirs)
elif len(vdirs) == 1:
print(f"Parsing variant '{vname}': {vdirs[0]}")
variant_datasets[vname] = collect_variant(vdirs[0])
variant_iqr[vname] = {}
else:
print(f"Skipping variant '{vname}': no directories found")
continue
print(f" Found {len(variant_datasets[vname])} tests")
# Find tests common to baseline and at least one variant
all_test_names = set(baseline_data.keys())
for vdata in variant_datasets.values():
all_test_names &= set(vdata.keys())
print(f"\n{len(all_test_names)} tests common across all variants")
# Generate per-test plots and text summaries
plot_dir = args.outdir / "per_test"
plot_dir.mkdir(parents=True, exist_ok=True)
for test_name in sorted(all_test_names):
vdfs = {vn: vd[test_name] for vn, vd in variant_datasets.items()
if test_name in vd}
plot_latency(test_name, baseline_data[test_name], vdfs, plot_dir)
write_per_test_txt(
test_name,
baseline_data[test_name],
vdfs,
baseline_iqr.get(test_name, {}),
{vn: variant_iqr.get(vn, {}).get(test_name, {}) for vn in vdfs},
plot_dir,
)
print(f"Generated {len(all_test_names)} per-test plots and summaries in {plot_dir}/")
# Generate summary heatmap
make_heatmap(baseline_data, variant_datasets, args.outdir)
# Print summary table to stdout and save to file
_RAW_FMT = {
"latency_us": "{:.3f}",
"peak_bw_gbs": "{:.2f}",
}
# Column layout: value in VAL_W chars, pct as "+NNN.N%" in PCT_W chars.
# Both parts are fixed-width so values and percentages align across rows.
VAL_W = 8 # e.g. " 0.523" or " 24.50"
PCT_W = 7 # e.g. " +4.2%" or "-100.0%" — sign + 5.1f + "%"
COL_W = VAL_W + 1 + PCT_W # variant columns; baseline uses VAL_W only
summary_lines = []
def _p(line=""):
print(line)
summary_lines.append(line)
_p("\n" + "=" * 80)
_p("SUMMARY: metrics for tests common to all variants")
_p("=" * 80)
header = f"{'Test':<30} {'Metric':<12} {'baseline':>{VAL_W}}"
for vname in variant_datasets:
header += f" {vname:>{COL_W}}"
_p(header)
_p("-" * len(header))
for test_name in sorted(all_test_names):
bdf = baseline_data[test_name]
for metric_name, metric_fn in METRICS.items():
bval = metric_fn(bdf)
raw_fmt = _RAW_FMT.get(metric_name)
if raw_fmt:
braw = raw_fmt.format(bval)
row = f"{test_name:<30} {metric_name:<12} {braw:>{VAL_W}}"
else:
row = f"{test_name:<30} {metric_name:<12} {'':>{VAL_W}}"
for vname in variant_datasets:
if test_name in variant_datasets[vname]:
vval = metric_fn(variant_datasets[vname][test_name])
if bval != 0:
pct = (vval - bval) / abs(bval) * 100
if raw_fmt:
vraw = f"{raw_fmt.format(vval):>{VAL_W}}"
vpct = f"{pct:+6.1f}%" # always PCT_W chars
row += f" {vraw} {vpct}"
else:
row += f" {'':>{VAL_W}} {pct:+6.1f}%"
else:
row += f" {'N/A':>{COL_W}}"
else:
row += f" {'missing':>{COL_W}}"
_p(row)
summary_path = args.outdir / "heatmap_summary.txt"
summary_path.write_text("\n".join(summary_lines) + "\n")
print(f"Summary saved to {summary_path}")
if __name__ == "__main__":
main()