Skip to content

Commit 33bdccb

Browse files
committed
Add narrative summary section to benchmark report
Adds a human-readable prose section at the top of the HTML report that translates raw benchmark numbers into plain-English sentences (e.g. timing ranges, memory footprints, speedup factors). Also calls out which benchmarks have no results yet (OBC, runoff mapping) and explains that xESMF/ESMF are external libraries whose performance won't change commit-to-commit on CROC repos.
1 parent 2a785e4 commit 33bdccb

1 file changed

Lines changed: 314 additions & 5 deletions

File tree

scripts/generate_report.py

Lines changed: 314 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
"""
33
generate_report.py — Build a standalone HTML benchmark report from ASV result JSONs.
44
5-
Shows bar charts per benchmark class (parameters on X-axis, last param dimension as
6-
color groups), without commit-timeline context. Output: .asv/html/report.html
5+
Shows a human-readable narrative summary of key findings, followed by bar charts per
6+
benchmark class (parameters on X-axis, last param dimension as color groups).
7+
Output: .asv/html/index.html
78
"""
89

910
import base64
@@ -83,6 +84,297 @@ def load_all_results():
8384
return latest
8485

8586

87+
def _lookup(results, params, combo):
88+
"""Return the result value for a specific parameter combination, or None."""
89+
combos = list(itertools.product(*params))
90+
for c, r in zip(combos, results):
91+
if c == combo:
92+
return r
93+
return None
94+
95+
96+
def _valid_values(results, params):
97+
"""Return list of (combo, value) for all non-null results."""
98+
combos = list(itertools.product(*params))
99+
return [
100+
(c, r)
101+
for c, r in zip(combos, results)
102+
if r is not None and not (isinstance(r, float) and math.isnan(r))
103+
]
104+
105+
106+
def fmt_time(v):
107+
"""Format a time value in seconds to a human-readable string."""
108+
if v is None:
109+
return "n/a"
110+
if v >= 60:
111+
return f"{v/60:.1f} min"
112+
if v >= 1:
113+
return f"{v:.1f} s"
114+
if v >= 1e-3:
115+
return f"{v*1e3:.0f} ms"
116+
if v >= 1e-6:
117+
return f"{v*1e6:.0f} μs"
118+
return f"{v*1e9:.0f} ns"
119+
120+
121+
def fmt_mb(v):
122+
"""Format MB value."""
123+
if v is None:
124+
return "n/a"
125+
if v >= 1024:
126+
return f"{v/1024:.1f} GB"
127+
return f"{v:.0f} MB"
128+
129+
130+
def extract_summary(all_results):
131+
"""Pull key numbers from results dict for use in the narrative."""
132+
s = {}
133+
134+
# --- Bathymetry ---
135+
topo_key = "mom6_forge.bench_topo.TopoSetFromDataset.time_set_from_dataset"
136+
topo_mem_key = "mom6_forge.bench_topo.TopoSetFromDataset.track_rss_mb"
137+
if topo_key in all_results:
138+
results, params = all_results[topo_key]
139+
vals = [v for _, v in _valid_values(results, params)]
140+
s["topo_min_s"] = min(vals)
141+
s["topo_max_s"] = max(vals)
142+
s["topo_grid_sizes"] = [short(p[0]) for p in params[0]]
143+
if topo_mem_key in all_results:
144+
results, params = all_results[topo_mem_key]
145+
vals = [v for _, v in _valid_values(results, params)]
146+
s["topo_mem_mb"] = sum(vals) / len(vals)
147+
148+
# --- xESMF weight generation ---
149+
xwt_key = "xesmf.bench_weights_generate.XESMFWeightsGenerate.time_generate_weights"
150+
xwt_mem_key = "xesmf.bench_weights_generate.XESMFWeightsGenerate.track_rss_mb"
151+
if xwt_key in all_results:
152+
results, params = all_results[xwt_key]
153+
pairs = _valid_values(results, params)
154+
# smallest: (300,300) src -> (150,150) dst, bilinear
155+
s["xwt_small_bilinear"] = _lookup(results, params, ("(300, 300)", "(150, 150)", "'bilinear'"))
156+
s["xwt_large_bilinear"] = _lookup(results, params, ("(1500, 700)", "(700, 350)", "'bilinear'"))
157+
s["xwt_small_conservative"] = _lookup(results, params, ("(300, 300)", "(150, 150)", "'conservative'"))
158+
s["xwt_large_conservative"] = _lookup(results, params, ("(1500, 700)", "(700, 350)", "'conservative'"))
159+
if xwt_mem_key in all_results:
160+
results, params = all_results[xwt_mem_key]
161+
vals = [v for _, v in _valid_values(results, params)]
162+
s["xwt_mem_min_mb"] = min(vals)
163+
s["xwt_mem_max_mb"] = max(vals)
164+
165+
# --- xESMF locstream weight generation ---
166+
xloc_key = "xesmf.bench_weights_generate.XESMFWeightsGenerateLocstream.time_generate_weights"
167+
if xloc_key in all_results:
168+
results, params = all_results[xloc_key]
169+
vals = [v for _, v in _valid_values(results, params)]
170+
s["xloc_min_s"] = min(vals)
171+
s["xloc_max_s"] = max(vals)
172+
173+
# --- xESMF regrid apply ---
174+
xapp_key = "xesmf.bench_regrid_apply.XESMFRegridApply.time_apply"
175+
if xapp_key in all_results:
176+
results, params = all_results[xapp_key]
177+
# 1 timestep, small grid, nearest_s2d (fastest)
178+
s["xapp_fast"] = _lookup(results, params, ("(300, 300)", "(150, 150)", "1", "'nearest_s2d'"))
179+
# 60 timesteps, large grid, bilinear (slowest)
180+
s["xapp_slow"] = _lookup(results, params, ("(1500, 700)", "(700, 350)", "60", "'bilinear'"))
181+
# speedup nearest vs bilinear (average across grid sizes, 60 timesteps)
182+
bilinear_60 = [
183+
_lookup(results, params, (src, dst, "60", "'bilinear'"))
184+
for src in ("(300, 300)", "(800, 600)", "(1500, 700)")
185+
for dst in ("(150, 150)", "(400, 300)", "(700, 350)")
186+
]
187+
nn_60 = [
188+
_lookup(results, params, (src, dst, "60", "'nearest_s2d'"))
189+
for src in ("(300, 300)", "(800, 600)", "(1500, 700)")
190+
for dst in ("(150, 150)", "(400, 300)", "(700, 350)")
191+
]
192+
ratios = [b / n for b, n in zip(bilinear_60, nn_60) if b and n]
193+
s["xapp_nn_speedup"] = sum(ratios) / len(ratios) if ratios else None
194+
195+
# --- ESMF weight generation comparison ---
196+
ewt_key = "esmf.bench_weights_generate.ESMFWeightsGenerate.time_generate_weights"
197+
if ewt_key in all_results:
198+
results, params = all_results[ewt_key]
199+
s["ewt_small_bilinear"] = _lookup(results, params, ("(300, 300)", "(150, 150)", "'bilinear'"))
200+
s["ewt_large_bilinear"] = _lookup(results, params, ("(1500, 700)", "(700, 350)", "'bilinear'"))
201+
202+
# --- Module imports ---
203+
imp_key = "crocodash.bench_imports.CrocoDashImports.time_import"
204+
if imp_key in all_results:
205+
results, params = all_results[imp_key]
206+
s["import_crocodash"] = _lookup(results, params, ("'CrocoDash.case'",))
207+
s["import_grid"] = _lookup(results, params, ("'mom6_forge.grid'",))
208+
s["import_topo"] = _lookup(results, params, ("'mom6_forge.topo'",))
209+
s["import_vgrid"] = _lookup(results, params, ("'mom6_forge.vgrid'",))
210+
211+
# --- Data access health ---
212+
health_key = "crocodash.bench_raw_data_access.DataAccessHealth.track_accessible"
213+
if health_key in all_results:
214+
results, params = all_results[health_key]
215+
pairs = _valid_values(results, params)
216+
s["health_ok"] = sum(1 for _, v in pairs if v == 1.0)
217+
s["health_total"] = len(pairs)
218+
219+
return s
220+
221+
222+
def build_narrative_html(all_results):
223+
"""
224+
Build a human-readable summary section from the extracted benchmark stats.
225+
Returns an HTML string for a <section> element.
226+
"""
227+
s = extract_summary(all_results)
228+
229+
# Determine which suites have no results at all
230+
present_suites = {k.split(".")[0] for k in all_results}
231+
missing_benchmarks = []
232+
if "crocodash" not in present_suites or not any(
233+
"bench_obc" in k for k in all_results
234+
):
235+
missing_benchmarks.append(
236+
"<b>OBC forcing pipeline</b> (<code>bench_obc.py</code>) — requires pre-staged "
237+
"GLORYS files and a CrocoDash case config. Set <code>obc_config_path</code> and "
238+
"<code>obc_step_days_dirs</code> in <code>data_config.json</code> to enable."
239+
)
240+
if not any("bench_runoff" in k for k in all_results):
241+
missing_benchmarks.append(
242+
"<b>Runoff mapping</b> (<code>bench_runoff_mapping.py</code>) — requires ESMF mesh "
243+
"NetCDF files. Set <code>mesh_pairs</code> in <code>data_config.json</code> to enable."
244+
)
245+
246+
# Build paragraphs
247+
paras = []
248+
249+
# Bathymetry
250+
if "topo_min_s" in s:
251+
mem_str = fmt_mb(s.get("topo_mem_mb"))
252+
paras.append(
253+
f"""<h3>Bathymetry — <code>Topo.set_from_dataset()</code></h3>
254+
<p>Loading GEBCO bathymetry and regridding it onto a model grid takes
255+
<b>{fmt_time(s['topo_min_s'])}{fmt_time(s['topo_max_s'])}</b> on Derecho,
256+
and this time is nearly <em>independent of destination grid size</em> across the
257+
tested range (100×100 to 1000×600 points).
258+
The bottleneck is reading the full global GEBCO_2024 dataset, not the interpolation step itself —
259+
which is why a 1000×600 grid finishes in roughly the same time as a 100×100 one.
260+
Memory usage during the operation averages <b>~{mem_str}</b>, dominated by the in-memory
261+
GEBCO array.</p>"""
262+
)
263+
264+
# xESMF / ESMF weight generation
265+
if "xwt_small_bilinear" in s:
266+
conservative_overhead = None
267+
if s.get("xwt_small_bilinear") and s.get("xwt_small_conservative"):
268+
conservative_overhead = s["xwt_small_conservative"] / s["xwt_small_bilinear"]
269+
conservative_str = (
270+
f" Conservative interpolation takes roughly {conservative_overhead:.1f}× "
271+
f"longer than bilinear at the same grid size."
272+
if conservative_overhead else ""
273+
)
274+
esmf_note = ""
275+
if "ewt_small_bilinear" in s and s.get("xwt_small_bilinear"):
276+
ratio = s["ewt_small_bilinear"] / s["xwt_small_bilinear"]
277+
esmf_note = (
278+
f" Raw ESMF weight generation is similar in cost ({ratio:.2f}× relative to xESMF "
279+
f"for the same grid pair), confirming that xESMF's overhead is negligible — "
280+
f"it is a thin Python wrapper around the same ESMF C library."
281+
)
282+
mem_range = ""
283+
if "xwt_mem_min_mb" in s:
284+
mem_range = (
285+
f" Weight files themselves occupy {fmt_mb(s['xwt_mem_min_mb'])}–"
286+
f"{fmt_mb(s['xwt_mem_max_mb'])} of RSS memory."
287+
)
288+
paras.append(
289+
f"""<h3>Regridding weights — <code>xe.Regridder()</code> / raw ESMF</h3>
290+
<p>Computing interpolation weights (the one-time setup cost before any regridding can happen)
291+
scales with grid size. For a <b>300×300 source → 150×150 destination</b> grid, bilinear weight
292+
generation takes <b>{fmt_time(s['xwt_small_bilinear'])}</b>; scaling up to
293+
<b>1500×700 → 700×350</b> takes <b>{fmt_time(s['xwt_large_bilinear'])}</b>.{conservative_str}{esmf_note}{mem_range}</p>"""
294+
)
295+
296+
# Locstream (OBC-style)
297+
if "xloc_min_s" in s:
298+
paras.append(
299+
f"""<h3>OBC-style (locstream) weight generation</h3>
300+
<p>When the destination is a boundary line of points rather than a full grid
301+
(the pattern used for open-boundary conditions), weight generation is substantially
302+
faster: <b>{fmt_time(s['xloc_min_s'])}{fmt_time(s['xloc_max_s'])}</b> across the
303+
tested source grid sizes. This is because the destination has far fewer points than
304+
a full 2-D grid of similar extent.</p>"""
305+
)
306+
307+
# xESMF apply
308+
if "xapp_fast" in s:
309+
speedup_str = ""
310+
if s.get("xapp_nn_speedup"):
311+
speedup_str = (
312+
f" On average, <code>nearest_s2d</code> is "
313+
f"<b>{s['xapp_nn_speedup']:.1f}×</b> faster than bilinear during application."
314+
)
315+
paras.append(
316+
f"""<h3>Applying pre-computed weights — <code>regridder(ds)</code></h3>
317+
<p>Once weights are built, applying them to a data array is fast regardless of grid size.
318+
A single timestep on a small grid takes <b>{fmt_time(s['xapp_fast'])}</b>;
319+
60 timesteps on the largest tested grid takes <b>{fmt_time(s['xapp_slow'])}</b>.
320+
The cost is dominated by the number of destination points and time steps,
321+
not the source grid size.{speedup_str}</p>"""
322+
)
323+
324+
# Imports
325+
if "import_crocodash" in s:
326+
paras.append(
327+
f"""<h3>Module import times</h3>
328+
<p>Importing CrocoDash and mom6_forge is fast enough to be negligible in any workflow:
329+
<code>CrocoDash.case</code> loads in <b>{fmt_time(s['import_crocodash'])}</b>,
330+
<code>mom6_forge.topo</code> in <b>{fmt_time(s['import_topo'])}</b>,
331+
and <code>mom6_forge.grid</code> / <code>mom6_forge.vgrid</code> in
332+
<b>{fmt_time(s['import_grid'])}</b> / <b>{fmt_time(s['import_vgrid'])}</b>.</p>"""
333+
)
334+
335+
# Data health
336+
if "health_ok" in s:
337+
all_ok = s["health_ok"] == s["health_total"]
338+
status = "All" if all_ok else f"{s['health_ok']} of {s['health_total']}"
339+
color = "#1a7a3e" if all_ok else "#b85c00"
340+
paras.append(
341+
f"""<h3>Data source availability</h3>
342+
<p><span style="color:{color};font-weight:600">{status} checked data sources are accessible</span>
343+
(GLORYS, GEBCO, GloFAS, MOM6 output, SeaWIFS methods tested on Derecho/GLADE).
344+
This check runs each time benchmarks are executed and reflects live filesystem access.</p>"""
345+
)
346+
347+
# Missing benchmarks
348+
if missing_benchmarks:
349+
items = "".join(f"<li>{m}</li>" for m in missing_benchmarks)
350+
paras.append(
351+
f"""<h3>Not yet measured</h3>
352+
<p>The following benchmark suites exist in the codebase but have no results yet because
353+
they depend on data files that are not configured in <code>data_config.json</code>:</p>
354+
<ul>{items}</ul>"""
355+
)
356+
357+
# xESMF/ESMF stability note
358+
paras.append(
359+
"""<h3>A note on xESMF and ESMF benchmarks</h3>
360+
<p>xESMF and ESMF are external libraries — they are not part of CrocoDash or mom6_forge and
361+
their performance will <em>not</em> change from commit to commit on those repos.
362+
These benchmarks serve as a stable reference: they tell you how fast the underlying regridding
363+
engine is on this machine, independently of any CROC code changes.
364+
If these numbers change significantly between runs, suspect a different library version,
365+
different node type, or different CPU load — not a regression in CROC code.</p>"""
366+
)
367+
368+
inner = "\n".join(paras)
369+
return f"""
370+
<section id="summary">
371+
<h2>Summary</h2>
372+
<div class="narrative">
373+
{inner}
374+
</div>
375+
</section>"""
376+
377+
86378
def make_chart(bench_key, results, params):
87379
"""
88380
Create a grouped bar chart for one benchmark.
@@ -201,6 +493,8 @@ def param_table_html(params):
201493

202494

203495
def build_html(all_results):
496+
narrative = build_narrative_html(all_results)
497+
204498
# Group by suite
205499
by_suite = {}
206500
for key, (results, params) in sorted(all_results.items()):
@@ -238,7 +532,7 @@ def build_html(all_results):
238532
</section>"""
239533
)
240534

241-
body = "\n".join(sections) if sections else "<p>No benchmark results found.</p>"
535+
chart_body = "\n".join(sections) if sections else "<p>No benchmark results found.</p>"
242536

243537
return f"""<!DOCTYPE html>
244538
<html lang="en">
@@ -264,15 +558,30 @@ def build_html(all_results):
264558
.card img {{ max-width: 100%; height: auto; display: block; }}
265559
footer {{ padding: 1.5rem 2rem; font-size: 0.8rem; color: #888; }}
266560
footer a {{ color: #4c78a8; }}
561+
/* Narrative summary styles */
562+
#summary {{ background: #fff; border-bottom: 1px solid #dde4ee; }}
563+
.narrative {{ max-width: 820px; }}
564+
.narrative h3 {{ font-size: 1rem; color: #1a3a5c; margin: 1.4rem 0 0.35rem; }}
565+
.narrative h3:first-child {{ margin-top: 0; }}
566+
.narrative p {{ margin: 0 0 0.5rem; line-height: 1.65; font-size: 0.92rem; color: #333; }}
567+
.narrative ul {{ margin: 0.4rem 0 0.8rem 1.2rem; padding: 0; }}
568+
.narrative li {{ font-size: 0.92rem; line-height: 1.6; color: #333; margin-bottom: 0.3rem; }}
569+
.narrative code {{ background: #eef1f6; padding: 0.1em 0.35em;
570+
border-radius: 3px; font-size: 0.85em; }}
571+
.divider {{ border: none; border-top: 2px solid #c8d6e5; margin: 0.5rem 0 0; }}
572+
#charts-heading {{ padding: 0.75rem 2rem 0; font-size: 1rem; color: #555; }}
267573
</style>
268574
</head>
269575
<body>
270576
<header>
271577
<h1>SeaSloth Benchmark Report</h1>
272578
<p>Performance snapshot — benchmarks run on Derecho/GLADE.
273-
<a href="asv_timeline.html" style="color:#9fc3e8">Regression timeline &rarr;</a> <span style="opacity:0.5;font-size:0.8rem">(needs 2+ commits to show data)</span></p>
579+
<a href="asv_timeline.html" style="color:#9fc3e8">Regression timeline &rarr;</a>
580+
<span style="opacity:0.5;font-size:0.8rem">(needs 2+ commits to show data)</span></p>
274581
</header>
275-
{body}
582+
{narrative}
583+
<p id="charts-heading" style="color:#888;font-size:0.85rem">Detailed charts below &darr;</p>
584+
{chart_body}
276585
<footer>
277586
Generated by <code>scripts/generate_report.py</code> &mdash;
278587
<a href="https://github.com/CROCODILE-CESM/SeaSloth">SeaSloth</a>

0 commit comments

Comments
 (0)