Skip to content

Commit e16ead6

Browse files
charles-typfacebook-github-bot
authored andcommitted
Emit WDL per-stage overall metrics for Manifold (facebookresearch#684)
Summary: D108110315 teaches the benchpress perf hook to collect PMU + sysstat data in per-WDL-sub-benchmark stage directories for WDL prod_set. The raw staged CSVs are preserved by perfpub's recursive Manifold upload, but users still need a single processed summary per sub-benchmark, analogous to the normal `overall-metrics.csv` that perfpub emits for a single benchmark. This change adds stage-aware summary generation to perfpub: benchmark_metrics_<run_id>/ memcpy_benchmark/ overall-metrics.csv # NEW perf-stat.csv nv-perf-collector-summary.csv ... hash_hash_benchmark/ overall-metrics.csv # NEW ... wdl_stage_overall_metrics.csv # NEW aggregate index wdl_stage_overall_metrics.json # NEW aggregate index wdl_stage_perf_summary.csv # generic numeric summary from prior patch wdl_stage_perf_summary.json Implementation details: - For each immediate stage subdir that contains CSVs, perfpub temporarily chdirs into that subdir and reuses the same reader functions as the top-level path (`read_mpstat`, `read_memstat`, `read_cpufreq_*`, `read_perfstat`, `read_nv_perf_collector`, `read_arm_perf_collector`, etc.). This gives each stage the same processed metric lines as a normal single benchmark's `overall-metrics.csv`. - Adds the WDL sub-benchmark score at the top when the score exists in the parent benchmark metrics JSON. - Writes per-stage `overall-metrics.csv`, plus top-level CSV/JSON indices for easy discovery in Manifold. - No XDB/dashboard changes. The goal is Manifold artifact usability. - `sample_avg_from_csv()` now tolerates partial CSV schemas by selecting only requested metric columns that exist and warning about missing ones. This is useful for stage dirs where a monitor didn't emit the full standard set of columns. Differential Revision: D108195608
1 parent 5a8746d commit e16ead6

1 file changed

Lines changed: 328 additions & 3 deletions

File tree

perfpub/utils.py

Lines changed: 328 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
197511
def 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

Comments
 (0)