#!/usr/bin/env python3 """ Collect benchmark results from an EvalScope output directory and write a summary Excel/CSV similar to: P800模型能力评测结果 - DS4-Flash-INT8-NO-Thinking-2.0-FULL.csv Rules: - Scores are averaged across seeds / multi-runs. - Perf metrics (latency, TTFT, TPOT, TPS, tokens) are recomputed from raw predictions across all seeds / multi-runs, so resuming from a checkpoint no longer resets cumulative statistics. """ import argparse import json import statistics from pathlib import Path import numpy as np import pandas as pd # Map benchmark -> capability domain (same as the reference CSV) BENCHMARK_DOMAIN = { 'bigcodebench': '代码与工程', 'humaneval': '代码与工程', 'live_code_bench': '代码与工程', 'aime24': '推理与数学', 'aime25': '推理与数学', 'aime26': '推理与数学', 'hmmt26': '推理与数学', 'imo_answerbench': '推理与数学', 'gsm8k': '推理与数学', 'competition_math': '推理与数学', 'bbh': '推理与数学', 'drop': '推理与数学', 'gpqa_diamond': '知识与语言理解', 'hle': '知识与语言理解', 'hle': '知识与语言理解', 'hle_low': '知识与语言理解', 'mmlu_pro': '知识与语言理解', 'simple_qa': '知识与语言理解', 'super_gpqa': '知识与语言理解', 'mmlu': '知识与语言理解', 'cmmlu': '知识与语言理解', 'arc': '知识与语言理解', 'hellaswag': '知识与语言理解', 'trivia_qa': '知识与语言理解', 'winogrande': '知识与语言理解', 'longbench_v2': '长上下文', 'openai_mrcr': '长上下文', 'tau2_bench': '智能体与工具', 'general_fc': '智能体与工具', 'bfcl_v3': '智能体与工具', } # Column order matching the reference CSV OUTPUT_COLUMNS = [ '分类', 'Benchmark', '得分', '实测时间(h)', '总样本数', '延迟_mean(s)', '输出TPS', '请求QPS', '输入tokens_mean', '输出tokens_mean', '累计总tokens', 'TTFT_mean(s)', 'TTFT P90', 'TTFT P99', 'TPOT_mean(s)', 'TPOT P90', 'TPOT P99', ] # Some evalscope output directory names do not match the canonical benchmark # name (e.g. the hle benchmark writes to the `hle_low` directory because of # subset naming). Map them to the canonical name here so the summary uses a # consistent label. BENCHMARK_NAME_ALIAS = { 'hle_low': 'hle', } def percentile(values, q): """Return the q-th percentile using numpy's linear interpolation.""" if not values: return np.nan return float(np.percentile(values, q)) def read_predictions(pred_file: Path): """Yield perf_metrics dicts from a predictions JSONL file.""" for obj in read_predictions_with_index(pred_file): if obj['perf_metrics'] is not None: yield obj['perf_metrics'] def read_predictions_with_index(pred_file: Path): """Yield ``{'index', 'perf_metrics'}`` dicts from a predictions JSONL file.""" if not pred_file.exists(): return with open(pred_file, '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 model_output = obj.get('model_output', {}) pm = model_output.get('perf_metrics') if not pm and 'choices' in model_output: choices = model_output['choices'] if choices and 'message' in choices[0]: pm = choices[0]['message'].get('perf_metrics') yield {'index': obj.get('index'), 'perf_metrics': pm} def load_backup_summary(output_dir: Path, benchmark: str, model_name: str): """Load the durable ``perf_stats_backup/__.json``. Returns the ``summary`` dict (with ``latency`` / ``ttft`` / ``tpot`` / ``throughput`` / ``usage`` sub-dicts) or ``None`` if no backup exists. """ try: from perf_backup import get_backup_paths except ImportError: return None perf_path, _ = get_backup_paths(Path(output_dir), benchmark, model_name) if not perf_path.exists(): return None try: payload = json.loads(perf_path.read_text(encoding='utf-8')) return payload.get('summary') except Exception: return None def find_report(output_dir: Path, benchmark: str, model_name: str): """Find the first report JSON for a benchmark/model under output_dir.""" bench_dir = output_dir / benchmark if not bench_dir.exists(): return None for seed_dir in sorted(bench_dir.iterdir()): if not seed_dir.is_dir(): continue for candidate in find_report_candidates(seed_dir, benchmark, model_name): if candidate.exists(): return candidate return None def find_report_candidates(seed_dir: Path, benchmark: str, model_name: str): """Return candidate report paths, accepting variant naming patterns.""" # The actual report filename may not match the directory name exactly # (e.g. hle_low directory holds hle.json). Try all .json files under # the model directory. candidates = [ seed_dir / 'reports' / model_name / f'{benchmark}.json', seed_dir / 'reports' / f'{benchmark}.json', ] for p in (seed_dir / 'reports' / model_name).glob('*.json'): candidates.append(p) for p in (seed_dir / 'reports').glob('*.json'): candidates.append(p) return candidates def find_all_reports(output_dir: Path, benchmark: str, model_name: str): """Find all report JSONs for a benchmark/model (across seeds/runs).""" bench_dir = output_dir / benchmark reports = [] if not bench_dir.exists(): return reports for seed_dir in sorted(bench_dir.iterdir()): if not seed_dir.is_dir(): continue for candidate in find_report_candidates(seed_dir, benchmark, model_name): if candidate.exists() and candidate not in reports: reports.append(candidate) break return reports def find_archive_predictions(output_dir: Path, benchmark: str, model_name: str): """Return the durable per-sample archive maintained by ``perf_backup.py``. The archive contains every sample ever produced across all runs and breakpoints for this benchmark/model (deduplicated by ``index``). When present, the summary aggregator should prefer it over the raw ``predictions/*.jsonl`` files because the latter can be overwritten on restart. """ try: from perf_backup import get_backup_paths except ImportError: return [] _, archive_path = get_backup_paths(output_dir, benchmark, model_name) if archive_path.exists(): return [archive_path] return [] def find_all_predictions(output_dir: Path, benchmark: str, model_name: str): """Find all predictions JSONL files for a benchmark/model. The durable ``predictions_archive/__.jsonl`` (if present) is returned **first** so its deduplicated per-sample records dominate any smaller predictions that a fresh run may have written. """ files = list(find_archive_predictions(output_dir, benchmark, model_name)) bench_dir = output_dir / benchmark if not bench_dir.exists(): return files for seed_dir in sorted(bench_dir.iterdir()): if not seed_dir.is_dir(): continue pred_dir = seed_dir / 'predictions' / model_name if pred_dir.exists(): files.extend(sorted(pred_dir.rglob('*.jsonl'))) return files def parse_log_duration(log_file: Path): """Parse first and last timestamp from eval_log.log and return duration in hours.""" if not log_file.exists(): return np.nan from datetime import datetime first_dt = None last_dt = None fmt = '%Y-%m-%d %H:%M:%S' with open(log_file, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if len(line) < 19: continue try: dt = datetime.strptime(line[:19], fmt) except ValueError: continue if first_dt is None: first_dt = dt last_dt = dt if first_dt is None or last_dt is None or last_dt <= first_dt: return np.nan return (last_dt - first_dt).total_seconds() / 3600.0 def extract_score(report_data: dict) -> float: """Extract the top-level score from a report JSON.""" score = report_data.get('score') if score is not None: return float(score) metrics = report_data.get('metrics', []) for m in metrics: if m.get('name') == 'mean_acc': return float(m.get('score', m.get('macro_score', 0.0))) return 0.0 def collect_benchmark(output_dir: Path, benchmark: str, model_name: str): """Collect aggregated results for one benchmark.""" reports = find_all_reports(output_dir, benchmark, model_name) if not reports: return None scores = [] summary0 = None n_samples_unique = 0 for report in reports: try: data = json.loads(report.read_text(encoding='utf-8')) scores.append(extract_score(data)) if summary0 is None: summary0 = data.get('perf_metrics', {}).get('summary', {}) n_samples_unique = summary0.get('n_samples', 0) except Exception: continue avg_score = float(np.mean(scores)) if scores else 0.0 # Aggregate raw prediction perf metrics across all seeds/runs. # This fixes the breakpoint-resume issue where cumulative stats are reset. # Predictions are deduplicated by sample `index` so the archive (which # spans every run) and the latest ``predictions/*.jsonl`` don't double # count the same sample. pred_files = find_all_predictions(output_dir, benchmark, model_name) seen_indexes = set() latencies = [] ttfts = [] tpots = [] input_tokens = [] output_tokens = [] sample_indexes = [] for pf in pred_files: for obj in read_predictions_with_index(pf): idx = obj['index'] if idx is None or idx in seen_indexes: # Still keep perf data even when we lack an index, so older # benchmark files without `index` don't get dropped. pm = obj['perf_metrics'] else: seen_indexes.add(idx) sample_indexes.append(idx) pm = obj['perf_metrics'] if pm is None: continue if 'latency' in pm: latencies.append(float(pm['latency'])) if 'ttft' in pm: ttfts.append(float(pm['ttft'])) if 'tpot' in pm: tpots.append(float(pm['tpot'])) itok = pm.get('input_tokens') otok = pm.get('output_tokens') if itok is None and 'usage' in pm: itok = pm['usage'].get('input_tokens') otok = pm['usage'].get('output_tokens') if itok is not None: input_tokens.append(int(itok)) if otok is not None: output_tokens.append(int(otok)) # If we don't have raw predictions but have a perf_stats backup, that # represents a known-good summary captured right after a clean run — # preferable to summary0 (which may be the just-reset run). backup_summary = None if not latencies: backup_summary = load_backup_summary(output_dir, benchmark, model_name) if latencies: latency_mean = float(np.mean(latencies)) total_compute_time = float(np.sum(latencies)) total_output_tokens = sum(output_tokens) avg_output_tps = total_output_tokens / total_compute_time if total_compute_time > 0 else np.nan avg_req_ps = len(latencies) / total_compute_time if total_compute_time > 0 else np.nan input_tok_mean = float(np.mean(input_tokens)) if input_tokens else np.nan output_tok_mean = float(np.mean(output_tokens)) if output_tokens else np.nan total_tokens = sum(input_tokens) + sum(output_tokens) ttft_mean = float(np.mean(ttfts)) if ttfts else np.nan ttft_p90 = percentile(ttfts, 90) ttft_p99 = percentile(ttfts, 99) tpot_mean = float(np.mean(tpots)) if tpots else np.nan tpot_p90 = percentile(tpots, 90) tpot_p99 = percentile(tpots, 99) # The actual sample count we just rebuilt from raw predictions is more # reliable than the (possibly reset) report summary's n_samples. if sample_indexes: n_samples_unique = len(sample_indexes) elif summary0: # Fallback to report summary if raw predictions are unavailable latency_mean = summary0.get('latency', {}).get('mean', np.nan) avg_output_tps = summary0.get('throughput', {}).get('avg_output_tps', np.nan) avg_req_ps = summary0.get('throughput', {}).get('avg_req_ps', np.nan) input_tok_mean = summary0.get('tokens', {}).get('input_tokens', {}).get('mean', np.nan) output_tok_mean = summary0.get('tokens', {}).get('output_tokens', {}).get('mean', np.nan) total_tokens = summary0.get('tokens', {}).get('total_tokens_count', np.nan) ttft_mean = summary0.get('ttft', {}).get('mean', np.nan) ttft_p90 = summary0.get('ttft', {}).get('90%', np.nan) ttft_p99 = summary0.get('ttft', {}).get('99%', np.nan) tpot_mean = summary0.get('tpot', {}).get('mean', np.nan) tpot_p90 = summary0.get('tpot', {}).get('90%', np.nan) tpot_p99 = summary0.get('tpot', {}).get('99%', np.nan) # If the just-read report looks like a freshly-reset run (smaller # n_samples than the durable backup), prefer the backup's summary so # the cumulative numbers are not lost. try: from perf_backup import get_backup_paths backup_path, _ = get_backup_paths(output_dir, benchmark, model_name) if backup_path.exists(): payload = json.loads(backup_path.read_text(encoding='utf-8')) if (payload.get('n_samples') or 0) > (summary0.get('n_samples') or 0): backup_summary = payload.get('summary') or {} latency_mean = backup_summary.get('latency', {}).get('mean', latency_mean) avg_output_tps = backup_summary.get('throughput', {}).get('avg_output_tps', avg_output_tps) avg_req_ps = backup_summary.get('throughput', {}).get('avg_req_ps', avg_req_ps) input_tok_mean = backup_summary.get('usage', {}).get('input_tokens', {}).get('mean', input_tok_mean) output_tok_mean = backup_summary.get('usage', {}).get('output_tokens', {}).get('mean', output_tok_mean) total_tokens = backup_summary.get('usage', {}).get('total_tokens_count', total_tokens) ttft_mean = backup_summary.get('ttft', {}).get('mean', ttft_mean) ttft_p90 = backup_summary.get('ttft', {}).get('90%', ttft_p90) ttft_p99 = backup_summary.get('ttft', {}).get('99%', ttft_p99) tpot_mean = backup_summary.get('tpot', {}).get('mean', tpot_mean) tpot_p90 = backup_summary.get('tpot', {}).get('90%', tpot_p90) tpot_p99 = backup_summary.get('tpot', {}).get('99%', tpot_p99) n_samples_unique = payload.get('n_samples') or n_samples_unique except Exception: pass elif backup_summary: latency_mean = backup_summary.get('latency', {}).get('mean', np.nan) avg_output_tps = backup_summary.get('throughput', {}).get('avg_output_tps', np.nan) avg_req_ps = backup_summary.get('throughput', {}).get('avg_req_ps', np.nan) input_tok_mean = backup_summary.get('usage', {}).get('input_tokens', {}).get('mean', np.nan) output_tok_mean = backup_summary.get('usage', {}).get('output_tokens', {}).get('mean', np.nan) total_tokens = backup_summary.get('usage', {}).get('total_tokens_count', np.nan) ttft_mean = backup_summary.get('ttft', {}).get('mean', np.nan) ttft_p90 = backup_summary.get('ttft', {}).get('90%', np.nan) ttft_p99 = backup_summary.get('ttft', {}).get('99%', np.nan) tpot_mean = backup_summary.get('tpot', {}).get('mean', np.nan) tpot_p90 = backup_summary.get('tpot', {}).get('90%', np.nan) tpot_p99 = backup_summary.get('tpot', {}).get('99%', np.nan) else: return None # Duration: prefer the durable active timer maintained by perf_backup.py, # which only counts time when run_task() is actually executing. This avoids # counting idle gaps caused by manual interruption/resume. # If no active timer exists, fall back to log duration or latency estimate. active_time_hours = np.nan try: from perf_backup import load_active_time active_seconds = load_active_time(output_dir, benchmark, model_name) if active_seconds > 0: active_time_hours = active_seconds / 3600.0 except Exception: pass if not np.isnan(active_time_hours): duration_hours = active_time_hours else: # Legacy fallback: sum of wall-clock durations from eval_log.log. # For sandbox benchmarks the main log may not cover sandbox execution, # so we also estimate wall time as compute_time/batch_size and take max. duration_hours = 0.0 batch_size = 4 for report in reports: log_file = report.parent.parent.parent / 'logs' / 'eval_log.log' d = parse_log_duration(log_file) if not np.isnan(d): duration_hours += d cfg_file = report.parent.parent.parent / 'configs' / 'task_config.yaml' if cfg_file.exists(): try: import yaml cfg = yaml.safe_load(cfg_file.read_text(encoding='utf-8')) batch_size = int(cfg.get('eval_batch_size', batch_size)) except Exception: pass compute_wall_estimate = (total_compute_time / batch_size / 3600.0) if latencies and batch_size > 0 else np.nan if duration_hours <= 0: duration_hours = compute_wall_estimate else: duration_hours = max(duration_hours, compute_wall_estimate) return { '分类': BENCHMARK_DOMAIN.get(benchmark, '其他'), 'Benchmark': BENCHMARK_NAME_ALIAS.get(benchmark, benchmark), '得分': round(avg_score, 4), '实测时间(h)': round(duration_hours, 4) if not np.isnan(duration_hours) else np.nan, '总样本数': n_samples_unique, '延迟_mean(s)': round(latency_mean, 5) if not np.isnan(latency_mean) else np.nan, '输出TPS': round(avg_output_tps, 2) if not np.isnan(avg_output_tps) else np.nan, '请求QPS': round(avg_req_ps, 4) if not np.isnan(avg_req_ps) else np.nan, '输入tokens_mean': round(input_tok_mean, 2) if not np.isnan(input_tok_mean) else np.nan, '输出tokens_mean': round(output_tok_mean, 2) if not np.isnan(output_tok_mean) else np.nan, '累计总tokens': total_tokens if not np.isnan(total_tokens) else np.nan, 'TTFT_mean(s)': round(ttft_mean, 5) if not np.isnan(ttft_mean) else np.nan, 'TTFT P90': round(ttft_p90, 5) if not np.isnan(ttft_p90) else np.nan, 'TTFT P99': round(ttft_p99, 5) if not np.isnan(ttft_p99) else np.nan, 'TPOT_mean(s)': round(tpot_mean, 5) if not np.isnan(tpot_mean) else np.nan, 'TPOT P90': round(tpot_p90, 5) if not np.isnan(tpot_p90) else np.nan, 'TPOT P99': round(tpot_p99, 5) if not np.isnan(tpot_p99) else np.nan, } def eval_benchmark(benchmark_names: list, output_dir: Path, model_name: str, out_name: str = None, excel_output_dir: Path = None): """Collect results for a specific list of benchmarks. This is the public entry point intended for use by ``run.py`` and other scripts that already know which benchmarks they ran. Args: benchmark_names: List of canonical benchmark names to aggregate (e.g. ``['aime24', 'gsm8k', 'arc']``). Only benchmarks with reports on disk will appear in the summary. output_dir: EvalScope output root directory. model_name: Model name to look up reports/predictions for. out_name: Output file name (without extension). Defaults to the safe model name. excel_output_dir: Optional separate directory for the Excel summary. If provided, the Excel file is written here while the CSV stays under ``output_dir/results//``. Returns: (csv_path, xlsx_path) tuple. """ return collect_all(output_dir, model_name, out_name, include_benchmarks=benchmark_names, excel_output_dir=excel_output_dir) def collect_all(output_dir: Path, model_name: str, out_name: str = None, include_benchmarks: list = None, excel_output_dir: Path = None): """Collect benchmarks under ``output_dir`` and write summary Excel/CSV.""" if not output_dir.exists(): raise FileNotFoundError(f'Output directory not found: {output_dir}') safe_model = model_name.replace('/', '_').replace('\\', '_').replace(' ', '_') # Discover benchmarks if include_benchmarks is not None: # Whitelist mode: only aggregate benchmarks that were actually run in # this invocation. Directory names may differ from canonical names # (e.g. evalscope writes the hle benchmark to the hle_low directory), # so we canonicalize each directory name via BENCHMARK_NAME_ALIAS and # check membership in the caller's list. Order follows the caller's # include_benchmarks list. include_set = set(include_benchmarks) discovered = [] # list of (dir_name, canonical_name) for bench_dir in output_dir.iterdir(): if not bench_dir.is_dir() or bench_dir.name == safe_model: continue if not find_all_reports(output_dir, bench_dir.name, model_name): continue canonical = BENCHMARK_NAME_ALIAS.get(bench_dir.name, bench_dir.name) if canonical in include_set or bench_dir.name in include_set: discovered.append((bench_dir.name, canonical)) order = {name: idx for idx, name in enumerate(include_benchmarks)} discovered.sort(key=lambda x: order.get(x[1], len(include_benchmarks))) benchmarks = [dir_name for dir_name, _ in discovered] else: # Legacy behaviour: scan everything on disk. benchmarks = [] for bench_dir in sorted(output_dir.iterdir()): if not bench_dir.is_dir() or bench_dir.name == safe_model: continue reports = find_all_reports(output_dir, bench_dir.name, model_name) if reports: benchmarks.append(bench_dir.name) rows = [] for benchmark in benchmarks: row = collect_benchmark(output_dir, benchmark, model_name) if row: rows.append(row) if not rows: print(f'No results found for model {model_name} under {output_dir}') return None, None df = pd.DataFrame(rows, columns=OUTPUT_COLUMNS) df = df.drop_duplicates(subset=['Benchmark'], keep='first') # Add total row total_score = df['得分'].mean() total_time = df['实测时间(h)'].sum() total_row = { '分类': '总计', 'Benchmark': '', '得分': round(total_score, 4), '实测时间(h)': round(total_time, 4), } for col in OUTPUT_COLUMNS: if col not in total_row: total_row[col] = np.nan df = pd.concat([df, pd.DataFrame([total_row], columns=OUTPUT_COLUMNS)], ignore_index=True) summary_dir = output_dir / 'results' / safe_model summary_dir.mkdir(parents=True, exist_ok=True) if out_name is None: out_name = safe_model csv_path = summary_dir / f'{out_name}.csv' # Excel can optionally be written to a separate project-level results dir # so that the latest summary per model is easy to find independently of # the per-run output directory. if excel_output_dir is not None: xlsx_dir = Path(excel_output_dir) xlsx_dir.mkdir(parents=True, exist_ok=True) xlsx_path = xlsx_dir / f'{out_name}.xlsx' else: xlsx_path = summary_dir / f'{out_name}.xlsx' df.to_csv(csv_path, index=False, encoding='utf-8-sig') df.to_excel(xlsx_path, index=False) print(f'Summary written to:') print(f' CSV: {csv_path}') print(f' Excel: {xlsx_path}') return csv_path, xlsx_path def main(): parser = argparse.ArgumentParser(description='Collect EvalScope benchmark results into Excel/CSV') parser.add_argument('--output-dir', required=True, help='EvalScope output directory') parser.add_argument('--model', default='DeepSeek-V4-Flash-Int8', help='Model name used in reports') parser.add_argument('--out-name', default=None, help='Output file name (without extension); defaults to safe model name') parser.add_argument('--include', default=None, help='Comma-separated benchmark whitelist (default: all on disk)') parser.add_argument('--benchmarks', '--benchmarks', dest='benchmarks', default=None, help='Alias for --include') parser.add_argument('--excel-output-dir', default=None, help='Separate directory for the Excel summary; defaults to /results') args = parser.parse_args() # Default Excel output dir to project-level results/ so the latest summary # per model is easy to find independently of the per-run output directory. if args.excel_output_dir is None: excel_output_dir = Path(__file__).parent.parent / 'results' else: excel_output_dir = Path(args.excel_output_dir) include_benchmarks = None if args.include: include_benchmarks = [b.strip() for b in args.include.split(',') if b.strip()] elif args.benchmarks: include_benchmarks = [b.strip() for b in args.benchmarks.split(',') if b.strip()] if include_benchmarks is not None: eval_benchmark(include_benchmarks, Path(args.output_dir), args.model, args.out_name, excel_output_dir=excel_output_dir) else: collect_all(Path(args.output_dir), args.model, args.out_name, excel_output_dir=excel_output_dir) if __name__ == '__main__': main()