@@ -194,6 +194,320 @@ def find_closest_timestamp_index(metric_times, target_datetime):
194194 return closest_idx
195195
196196
197+ STAGE_PERF_SUMMARY_CSV = "stage_perf_summary.csv"
198+ STAGE_PERF_SUMMARY_JSON = "stage_perf_summary.json"
199+
200+
201+ def _numeric_series (series ):
202+ """Coerce a pandas Series to numeric values, tolerating comma separators."""
203+ if series .dtype == object :
204+ series = series .astype (str ).str .replace ("," , "" , regex = False )
205+ return pd .to_numeric (series , errors = "coerce" ).dropna ()
206+
207+
208+ def _summarize_numeric_series (series ):
209+ values = _numeric_series (series )
210+ if values .empty :
211+ return None
212+ return {
213+ "count" : int (values .count ()),
214+ "mean" : float (values .mean ()),
215+ "min" : float (values .min ()),
216+ "p50" : float (values .quantile (0.50 )),
217+ "p95" : float (values .quantile (0.95 )),
218+ "max" : float (values .max ()),
219+ }
220+
221+
222+ def generate_stage_perf_summary ():
223+ """Generate compact summary artifacts for per-stage perf data.
224+
225+ The benchpress perf hook's stage-aware mode writes monitor CSVs under
226+ immediate subdirectories of ``benchmark_metrics_<run_id>/``:
227+
228+ benchmark_metrics_<run_id>/<stage>/perf-stat.csv
229+ benchmark_metrics_<run_id>/<stage>/mpstat.csv
230+ benchmark_metrics_<run_id>/<stage>/nv-perf-collector-summary.csv
231+ ...
232+
233+ PerfPub already uploads the whole directory tree to Manifold via
234+ ``manifold putr``. This helper adds two easy-to-discover top-level
235+ artifacts before that upload happens:
236+
237+ stage_perf_summary.csv
238+ stage_perf_summary.json
239+
240+ No XDB schema change is required. The summary is intentionally generic:
241+ for every immediate subdirectory, every CSV file, every numeric column,
242+ compute count/mean/min/p50/p95/max.
243+
244+ Returns:
245+ List of generated summary filenames. Empty when no per-stage CSVs
246+ are found.
247+ """
248+ rows = []
249+ json_summary = {"stages" : {}}
250+
251+ # Do not recurse into nested timestamp dirs produced by some monitors;
252+ # stage-aware perf writes stage dirs directly under cwd.
253+ for stage in sorted (
254+ d for d in os .listdir ("." ) if os .path .isdir (d ) and not d .startswith ("." )
255+ ):
256+ csv_files = sorted (
257+ f
258+ for f in os .listdir (stage )
259+ if f .endswith (".csv" )
260+ and f not in {STAGE_PERF_SUMMARY_CSV , STAGE_PERF_SUMMARY_JSON }
261+ )
262+ if not csv_files :
263+ continue
264+
265+ stage_json = {"files" : {}}
266+ for csv_file in csv_files :
267+ path = os .path .join (stage , csv_file )
268+ try :
269+ df = pd .read_csv (path )
270+ except Exception as e :
271+ print (f"Warning: failed to read stage perf CSV { path } : { e } " )
272+ continue
273+
274+ file_json = {"rows" : int (len (df )), "metrics" : {}}
275+ for col in df .columns :
276+ stats = _summarize_numeric_series (df [col ])
277+ if stats is None :
278+ continue
279+ file_json ["metrics" ][col ] = stats
280+ rows .append (
281+ {
282+ "stage" : stage ,
283+ "file" : csv_file ,
284+ "metric" : col ,
285+ ** stats ,
286+ }
287+ )
288+ if file_json ["metrics" ]:
289+ stage_json ["files" ][csv_file ] = file_json
290+
291+ if stage_json ["files" ]:
292+ json_summary ["stages" ][stage ] = stage_json
293+
294+ if not rows :
295+ return []
296+
297+ with open (STAGE_PERF_SUMMARY_JSON , "w" ) as f :
298+ json .dump (json_summary , f , indent = 2 , sort_keys = True )
299+
300+ pd .DataFrame (rows ).to_csv (STAGE_PERF_SUMMARY_CSV , index = False )
301+ print (
302+ f"Generated per-stage perf summaries: { STAGE_PERF_SUMMARY_CSV } , "
303+ f"{ STAGE_PERF_SUMMARY_JSON } ({ len (json_summary ['stages' ])} stages, "
304+ f"{ len (rows )} numeric metrics)"
305+ )
306+ return [STAGE_PERF_SUMMARY_CSV , STAGE_PERF_SUMMARY_JSON ]
307+
308+
309+ STAGE_OVERALL_METRICS_CSV = "stage_overall_metrics.csv"
310+ STAGE_OVERALL_METRICS_JSON = "stage_overall_metrics.json"
311+ STAGE_OVERALL_METRICS_FILENAME = "overall-metrics.csv"
312+
313+
314+ def _build_stage_overall_metrics_text (stage , bm_metrics , interval ):
315+ """Build an overall-metrics.csv-style text blob for one stage directory.
316+
317+ This mirrors the normal top-level overall-metrics.csv generated by
318+ process_metrics(), but is intentionally limited to the perf/sysstat data
319+ available inside one stage-aware benchmark stage directory. The
320+ caller must chdir into the stage directory before calling this helper.
321+ """
322+ res = ""
323+ # Include the stage score at the top when the benchmark parser
324+ # reported one in the top-level metrics JSON.
325+ try :
326+ metrics = bm_metrics .get ("metrics" , {}) if bm_metrics else {}
327+ if stage in metrics :
328+ res += f'score,"{ metrics [stage ]} "\n '
329+ except Exception :
330+ pass
331+
332+ # Stage directories already represent the exact sub-benchmark window, so
333+ # process the whole CSV (last_secs=0, skip_last_secs=0) instead of slicing
334+ # by the parent benchmark's breakdown.csv window.
335+ last_secs = 0
336+ skip_last_secs = 0
337+ bm_epoch = None
338+ start_time = None
339+ end_time = None
340+
341+ res += read_mpstat (
342+ interval , last_secs , skip_last_secs , start_time , end_time , bm_epoch
343+ )
344+ res += read_memstat (
345+ interval , last_secs , skip_last_secs , start_time , end_time , bm_epoch
346+ )
347+ res += read_cpufreq_scaling (
348+ interval , last_secs , skip_last_secs , start_time , end_time , bm_epoch
349+ )
350+ res += read_cpufreq_cpuinfo (
351+ interval , last_secs , skip_last_secs , start_time , end_time , bm_epoch
352+ )
353+ res += read_netstat (
354+ interval , last_secs , skip_last_secs , start_time , end_time , bm_epoch
355+ )
356+ res += read_perfstat (
357+ interval , last_secs , skip_last_secs , start_time , end_time , bm_epoch
358+ )
359+ res += read_amd_perf_collector (
360+ interval , last_secs , skip_last_secs , start_time , end_time , bm_epoch
361+ )
362+ res += read_amd_zen4_perf_collector (
363+ interval , last_secs , skip_last_secs , start_time , end_time , bm_epoch
364+ )
365+ res += read_amd_zen5_perf_collector (
366+ interval , last_secs , skip_last_secs , start_time , end_time , bm_epoch
367+ )
368+ res += read_nv_perf_collector (
369+ interval , last_secs , skip_last_secs , start_time , end_time , bm_epoch
370+ )
371+ res += read_arm_perf_collector (
372+ interval , last_secs , skip_last_secs , start_time , end_time , bm_epoch
373+ )
374+ res += read_neoversev3_perf_collector (
375+ interval , last_secs , skip_last_secs , start_time , end_time , bm_epoch
376+ )
377+ res += read_intel_perfspect (
378+ interval , last_secs , skip_last_secs , start_time , end_time , bm_epoch
379+ )
380+ # Include collector-provided PMU summaries (cache MPKI, TopDown %, BW,
381+ # latency, etc.) such as nv-perf-collector-summary.csv.
382+ res += _read_stage_summary_csvs ()
383+ return res
384+
385+
386+ def _read_stage_summary_csvs ():
387+ """Read *-summary.csv files in the current stage directory and emit
388+ overall-metrics-style key,value lines.
389+
390+ Stage-aware WDL perf collection produces collector summaries such as
391+ nv-perf-collector-summary.csv. Those files contain PMU-derived metrics like
392+ cache MPKI, TopDown BackendBound %, memory bandwidth, etc. The existing
393+ perfpub readers primarily consume timeseries files, so without this helper
394+ the per-stage overall-metrics.csv misses the most useful PMU summary rows.
395+
396+ For each row in each summary CSV, emit only:
397+ metric,<mean>
398+
399+ This matches the existing perfpub overall-metrics.csv convention (one
400+ representative value per metric). The richer count/min/p50/p95/max
401+ distribution remains available in the top-level stage_perf_summary
402+ artifacts, not in each stage's overall-metrics.csv.
403+ """
404+ res = ""
405+ summary_files = sorted (
406+ f
407+ for f in os .listdir ("." )
408+ if f .endswith ("-summary.csv" ) or f .endswith ("_summary.csv" )
409+ )
410+ for summary_file in summary_files :
411+ try :
412+ df = pd .read_csv (summary_file )
413+ except Exception as e :
414+ print (f"Warning: failed to read stage summary CSV { summary_file } : { e } " )
415+ continue
416+ if "metric" not in df .columns or "mean" not in df .columns :
417+ continue
418+ for _ , row in df .iterrows ():
419+ metric = str (row .get ("metric" , "" )).strip ()
420+ if not metric :
421+ continue
422+ # Preserve the simple key for the mean because that's what users
423+ # expect in overall-metrics.csv.
424+ mean = row .get ("mean" )
425+ if pd .notna (mean ):
426+ res += f'{ metric } ,"{ mean } "\n '
427+ return res
428+
429+
430+ def _parse_overall_metrics_text (text ):
431+ """Parse key,value lines from an overall-metrics.csv-style blob."""
432+ out = {}
433+ for line in text .splitlines ():
434+ if not line or "," not in line :
435+ continue
436+ key , value = line .split ("," , 1 )
437+ key = key .strip ()
438+ value = value .strip ().strip ('"' )
439+ if not key :
440+ continue
441+ out [key ] = value
442+ return out
443+
444+
445+ def generate_stage_overall_metrics (bm_metrics = None , interval = 5 ):
446+ """Generate per-stage overall-metrics artifacts for WDL prod_set.
447+
448+ For every immediate subdirectory with perf/sysstat CSV files, write:
449+
450+ <stage>/overall-metrics.csv
451+
452+ and also create top-level aggregate indices:
453+
454+ stage_overall_metrics.csv
455+ stage_overall_metrics.json
456+
457+ This gives Manifold users the same processed PMU/sysstat summary they get
458+ from a normal single-benchmark perfpub run, but scoped to each WDL
459+ sub-benchmark stage.
460+
461+ Returns:
462+ List of generated files (top-level aggregate files plus per-stage
463+ overall-metrics.csv paths). Empty when no stages are found.
464+ """
465+ generated = []
466+ summary_rows = []
467+ summary_json = {"stages" : {}}
468+ root = os .getcwd ()
469+
470+ for stage in sorted (
471+ d for d in os .listdir ("." ) if os .path .isdir (d ) and not d .startswith ("." )
472+ ):
473+ csv_files = [f for f in os .listdir (stage ) if f .endswith (".csv" )]
474+ if not csv_files :
475+ continue
476+
477+ try :
478+ os .chdir (os .path .join (root , stage ))
479+ text = _build_stage_overall_metrics_text (stage , bm_metrics or {}, interval )
480+ finally :
481+ os .chdir (root )
482+
483+ if not text .strip ():
484+ continue
485+
486+ stage_overall_path = os .path .join (stage , STAGE_OVERALL_METRICS_FILENAME )
487+ with open (stage_overall_path , "w" ) as f :
488+ f .write (text )
489+ generated .append (stage_overall_path )
490+
491+ metrics = _parse_overall_metrics_text (text )
492+ summary_json ["stages" ][stage ] = metrics
493+ for metric , value in metrics .items ():
494+ summary_rows .append ({"stage" : stage , "metric" : metric , "value" : value })
495+
496+ if not summary_rows :
497+ return generated
498+
499+ with open (STAGE_OVERALL_METRICS_JSON , "w" ) as f :
500+ json .dump (summary_json , f , indent = 2 , sort_keys = True )
501+ pd .DataFrame (summary_rows ).to_csv (STAGE_OVERALL_METRICS_CSV , index = False )
502+ generated .extend ([STAGE_OVERALL_METRICS_CSV , STAGE_OVERALL_METRICS_JSON ])
503+ print (
504+ f"Generated per-stage overall metrics: { STAGE_OVERALL_METRICS_CSV } , "
505+ f"{ STAGE_OVERALL_METRICS_JSON } ({ len (summary_json ['stages' ])} stages, "
506+ f"{ len (summary_rows )} metrics)"
507+ )
508+ return generated
509+
510+
197511def read_benchmark_metrics ():
198512 metrics_jsons = glob .glob ("*_metrics_*.json" )
199513 if len (metrics_jsons ) == 0 :
@@ -392,7 +706,15 @@ def sample_avg_from_csv(
392706 return ""
393707 samples .to_csv (filename .split ("." , maxsplit = 1 )[0 ] + ".sampled.csv" )
394708 if metrics :
395- samples = samples [metrics ]
709+ present_metrics = [metric for metric in metrics if metric in samples .columns ]
710+ missing_metrics = [
711+ metric for metric in metrics if metric not in samples .columns
712+ ]
713+ if missing_metrics :
714+ print (f"Columns { missing_metrics } not found in { filename } " )
715+ if not present_metrics :
716+ return ""
717+ samples = samples [present_metrics ]
396718 if exclude_columns :
397719 for excl in exclude_columns :
398720 if excl in samples :
@@ -659,12 +981,15 @@ def process_metrics(
659981 if os .path .exists (breakdown_path ):
660982 start_time , end_time = parse_breakdown_csv (breakdown_path )
661983
662- columns = "("
663- # values = "("
664984 db_fields = {}
665985 bm_metrics = read_benchmark_metrics ()
666986 if not bm_metrics :
667987 return ""
988+ # Generate compact per-stage perf summary artifacts before internal
989+ # processing uploads the whole benchmark_metrics directory to Manifold.
990+ # This is a no-op for benchmarks without stage-aware perf subdirectories.
991+ generate_stage_perf_summary ()
992+ generate_stage_overall_metrics (bm_metrics , args .interval )
668993 bm_name = bm_metrics ["benchmark_name" ]
669994 db_fields ["benchmark_name" ] = f'"{ bm_name } "'
670995 bm_epoch = datetime .fromtimestamp (bm_metrics ["timestamp" ])
0 commit comments