#!/usr/bin/env python3 """ Perf-stats backup helpers. EvalScope writes the per-benchmark cumulative perf summary into ``report.json`` under ``perf_metrics.summary`` and the raw per-sample records into ``predictions/*.jsonl``. Both are overwritten on every run, so a checkpoint restart resets cumulative stats and loses prior samples. This module maintains **two durable files** under ``/`` so the breakpoint-resume issue can be reconstructed: 1. ``perf_stats_backup/__.json`` - The latest complete ``perf_metrics.summary`` captured right after each ``run_task()`` finishes. 2. ``predictions_archive/__.jsonl`` - All per-sample records ever seen, deduplicated by sample ``index``. ``bash.collect_results`` prefers these archive files when aggregating metrics. """ import json import shutil from datetime import datetime from pathlib import Path PERF_STATS_BACKUP_DIRNAME = 'perf_stats_backup' PREDICTIONS_ARCHIVE_DIRNAME = 'predictions_archive' ACTIVE_TIME_DIRNAME = 'active_time' def _safe(s: str) -> str: return s.replace('/', '_').replace('\\', '_').replace(' ', '_') def get_backup_paths(output_dir: Path, benchmark: str, model_name: str): """Return the (perf_stats_backup, predictions_archive) paths for a benchmark/model.""" safe = _safe(model_name) backup_dir = Path(output_dir) / PERF_STATS_BACKUP_DIRNAME archive_dir = Path(output_dir) / PREDICTIONS_ARCHIVE_DIRNAME perf_stats_path = backup_dir / f'{_safe(benchmark)}__{safe}.json' predictions_path = archive_dir / f'{_safe(benchmark)}__{safe}.jsonl' return perf_stats_path, predictions_path def backup_perf_stats(output_dir: Path, benchmark: str, model_name: str, report_json: Path) -> Path: """Copy ``perf_metrics.summary`` from a finished report to the durable backup. Only writes if ``report_json`` exists and contains a ``perf_metrics.summary``. Returns the backup path. """ if not report_json or not report_json.exists(): return None try: data = json.loads(report_json.read_text(encoding='utf-8')) except Exception: return None summary = (data.get('perf_metrics') or {}).get('summary') if not summary: return None perf_stats_path, _ = get_backup_paths(Path(output_dir), benchmark, model_name) perf_stats_path.parent.mkdir(parents=True, exist_ok=True) payload = { 'benchmark': benchmark, 'model': model_name, 'updated_at': datetime.now().isoformat(timespec='seconds'), 'n_samples': summary.get('n_samples'), 'summary': summary, } # Don't overwrite a strictly larger backup with a smaller one — that # would be the symptom of a reset run and we want to keep the larger. if perf_stats_path.exists(): try: old = json.loads(perf_stats_path.read_text(encoding='utf-8')) old_n = old.get('n_samples') or 0 new_n = summary.get('n_samples') or 0 if new_n < old_n: # Don't clobber the larger historical backup. return perf_stats_path except Exception: pass perf_stats_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding='utf-8') return perf_stats_path def archive_predictions(output_dir: Path, benchmark: str, model_name: str, predictions_dir: Path) -> Path: """Append the latest predictions into the durable archive. Reads every ``*.jsonl`` under ``predictions_dir``, parses each line, and appends each sample to the archive file (deduplicated by ``index``). If the same ``index`` was already archived with a different perf payload, the newer record wins. Returns the archive path. """ _, archive_path = get_backup_paths(Path(output_dir), benchmark, model_name) archive_path.parent.mkdir(parents=True, exist_ok=True) if not predictions_dir or not predictions_dir.exists(): return archive_path # Load existing archive (index -> record) existing = {} if archive_path.exists(): with archive_path.open('r', encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue try: obj = json.loads(line) except json.JSONDecodeError: continue idx = obj.get('index') if idx is not None: existing[idx] = obj # Add new predictions new_count = 0 updated_count = 0 for jsonl in sorted(predictions_dir.rglob('*.jsonl')): with jsonl.open('r', encoding='utf-8') as f: for line in f: line = line.strip() if not line: continue try: obj = json.loads(line) except json.JSONDecodeError: continue idx = obj.get('index') if idx is None: continue if idx in existing: updated_count += 1 else: new_count += 1 existing[idx] = obj # Write back atomically tmp_path = archive_path.with_suffix('.jsonl.tmp') with tmp_path.open('w', encoding='utf-8') as f: for idx in sorted(existing.keys()): f.write(json.dumps(existing[idx], ensure_ascii=False)) f.write('\n') shutil.move(str(tmp_path), str(archive_path)) return archive_path def restore_from_backup(output_dir: Path, benchmark: str, model_name: str, target_predictions_dir: Path, target_report_json: Path): """If both ``perf_stats_backup`` and ``predictions_archive`` exist for a benchmark/model, materialise them into ``target_predictions_dir`` and ``target_report_json`` before the next run starts, so evalscope resumes from the larger historical state instead of overwriting it. Returns ``True`` if a backup was restored, ``False`` otherwise. """ perf_stats_path, archive_path = get_backup_paths(Path(output_dir), benchmark, model_name) if not perf_stats_path.exists() and not archive_path.exists(): return False # Restore the archive into the predictions directory so evalscope's # use_cache mechanism sees the previously-completed samples. if archive_path.exists(): target_predictions_dir.mkdir(parents=True, exist_ok=True) target_jsonl = target_predictions_dir / archive_path.name shutil.copy2(archive_path, target_jsonl) # Restore the perf summary into the report file so the cumulative stats # survive. We do NOT overwrite score/metrics — only the perf section. if perf_stats_path.exists() and target_report_json.exists(): try: payload = json.loads(perf_stats_path.read_text(encoding='utf-8')) summary = payload.get('summary') if not summary: return True report = json.loads(target_report_json.read_text(encoding='utf-8')) report.setdefault('perf_metrics', {})['summary'] = summary target_report_json.write_text( json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8' ) except Exception: pass return True # ============================================================ # Active run-time tracking (excludes idle gaps between resumes) # ============================================================ def get_active_time_path(output_dir: Path, benchmark: str, model_name: str) -> Path: """Return the path to the durable active-time record for a benchmark/model.""" safe = _safe(model_name) active_dir = Path(output_dir) / ACTIVE_TIME_DIRNAME return active_dir / f'{_safe(benchmark)}__{safe}.json' def record_active_time(output_dir: Path, benchmark: str, model_name: str, seconds: float) -> Path: """Accumulate ``seconds`` of active run time for a benchmark/model. This is intended to measure only the time ``run_task()`` was actually executing, excluding idle gaps caused by manual interruption/resume. """ path = get_active_time_path(Path(output_dir), benchmark, model_name) path.parent.mkdir(parents=True, exist_ok=True) data = {'total_seconds': 0.0} if path.exists(): try: data = json.loads(path.read_text(encoding='utf-8')) except Exception: pass data['total_seconds'] = float(data.get('total_seconds', 0.0)) + float(seconds) data['updated_at'] = datetime.now().isoformat(timespec='seconds') data['benchmark'] = benchmark data['model'] = model_name path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8') return path def load_active_time(output_dir: Path, benchmark: str, model_name: str) -> float: """Return accumulated active run time in seconds, or 0.0 if no record.""" path = get_active_time_path(Path(output_dir), benchmark, model_name) if not path.exists(): return 0.0 try: data = json.loads(path.read_text(encoding='utf-8')) return float(data.get('total_seconds', 0.0)) except Exception: return 0.0