evalstone/bash/perf_backup.py
sora 2ee0af0728 docs: add resource prep section and sync docker/bash helpers
- Rewrite myread.md with proper markdown and four download sources:
  code, data, evalscope-complete-py312 image, execution images.
- Add bash/collect_results.py, perf_backup.py, pull_swe_bench_images.py
  for result aggregation, breakpoint perf recovery, and SWE-bench
  image pre-pulling.
- Update bash/run.py with multi-run suites, perf backup/restore, and
  whitelist-based summary.
- Update config/dpv4-int8_nothinking.yaml benchmark parameters.
- Ignore /docker_images and /results in .gitignore.
2026-07-23 02:18:15 +00:00

242 lines
9.1 KiB
Python

#!/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 ``<output_dir>/`` so the
breakpoint-resume issue can be reconstructed:
1. ``perf_stats_backup/<benchmark>__<model>.json``
- The latest complete ``perf_metrics.summary`` captured right after
each ``run_task()`` finishes.
2. ``predictions_archive/<benchmark>__<model>.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