22"""
33generate_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
910import 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+
86378def 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
203495def 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 →</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 →</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 ↓</p>
584+ { chart_body }
276585<footer>
277586 Generated by <code>scripts/generate_report.py</code> —
278587 <a href="https://github.com/CROCODILE-CESM/SeaSloth">SeaSloth</a>
0 commit comments