|
1 | | -import os, re, subprocess, csv, argparse |
2 | | -from git import Repo |
3 | | -from statistics import mean |
4 | | - |
5 | | -# To run this script properly, you must set up your benchmarking subcases using git |
6 | | -# Naming is important: |
7 | | -# You must be on branch 'your_branch_name' - the script will not run your scene on this branch |
8 | | -# And must have named your benchmark branches as 'your_branch_name-test1', 'your_branch_name-test2' etc |
9 | | -# The script will check out these branches, run your scene and accumulate results |
10 | | - |
11 | | -# Edit to your system |
12 | | -parser = argparse.ArgumentParser(description="Run benchmarks for a scene") |
13 | | -parser.add_argument("-sofaExe", type=str, default="runSofa", help="Path to runSofa executable in your system") |
14 | | -parser.add_argument("-scene", type=str, help="Path to scene file you wish to run") |
15 | | -parser.add_argument("-iterations", type=int, default=100, help="Number of ODE solver iterations to perform") |
16 | | -parser.add_argument("-tests", type=int, default=3, help="Number of tests to run") |
17 | | -args = parser.parse_args() |
18 | | - |
19 | | -# get arguments |
20 | | -runSofa=args.sofaExe |
21 | | -# Scene name |
22 | | -xml_name = args.scene |
23 | | -# Runtime setup |
24 | | -n_iterations = args.iterations |
25 | | -# Number of tests to run for each case |
26 | | -n_tests = args.tests |
27 | | - |
28 | | -# Dictionary to store results |
29 | | -benchmarks = {} |
30 | | -results = {'time': [] , 'fps' : [], 'iterations' : n_iterations, 'git-branch' : ''} |
31 | | - |
32 | | -# Get git info to find branches |
33 | | -repo = Repo(search_parent_directories=True) |
34 | | -branch_prefix = repo.active_branch.name |
35 | | -benchmark_branches = [ |
36 | | - branch for branch in repo.branches if branch.name.startswith(branch_prefix + '-')] |
37 | | - |
38 | | -print(f'Running {xml_name} spawned from {branch_prefix} with {n_iterations} iterations') |
39 | | - |
40 | | -output_filename = 'log.performance.csv' |
41 | | -with open(output_filename, mode='w', newline='') as csv_file: |
42 | | - csv_file.write(branch_prefix + ', time [s], fps\n') |
43 | | - |
44 | | - for branch in benchmark_branches: |
45 | | - repo.git.checkout(branch.name) |
46 | | - git_tag = branch.name[len(branch_prefix + '-'):] |
47 | | - benchmarks[git_tag] = results |
48 | | - benchmarks[git_tag]['git-branch'] = branch.name |
49 | | - |
50 | | - for i in range(n_tests): |
51 | | - print(f'Git tag: {git_tag} - test {i+1}/{n_tests}') |
52 | | - |
53 | | - # This is the way to measure performance |
54 | | - output = subprocess.run([runSofa, "-g", "batch", "-n", str(n_iterations), xml_name], shell=False, capture_output=True, text=True) |
55 | | - for line in output.stdout.splitlines(): |
56 | | - if "iterations done in" in line: |
57 | | - numbers = re.findall(r"\d+\.\d+", line) |
58 | | - time_taken, fps = float(numbers[-2]), float(numbers[-1]) |
59 | | - benchmarks[git_tag]['time'].append(time_taken) |
60 | | - benchmarks[git_tag]['fps'].append(fps) |
61 | | - break |
62 | | - |
63 | | - ## This is to troubleshoot in case SOFA crashes and no message is available |
64 | | - #output = subprocess.Popen([runSofa, "-g", "batch", "-n", str(n_iterations), xml_name], shell=False, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) |
65 | | - #for line in output.stdout: |
66 | | - # print(line, end="") |
67 | | - # if "iterations done in" in line: |
68 | | - # numbers = re.findall(r"\d+\.\d+", line) |
69 | | - # time_taken, fps = numbers[-2], numbers[-1] |
70 | | - # scenarios[scenario]['time'].append(time_taken) |
71 | | - # scenarios[scenario]['fps'].append(fps) |
72 | | - # break |
73 | | - #output.wait() |
74 | | - |
75 | | - mean_time = mean(benchmarks[git_tag]['time']) |
76 | | - mean_fps = mean(benchmarks[git_tag]['fps'] ) |
77 | | - csv_file.write(f'{git_tag}, {mean_time}, {mean_fps}\n') |
78 | | - |
79 | | -repo.git.checkout(branch_prefix) |
| 1 | +#!/usr/bin/env python3 |
| 2 | +import os |
| 3 | +import re |
| 4 | +import sys |
| 5 | +import json |
| 6 | +import shutil |
| 7 | +import argparse |
| 8 | +import subprocess |
| 9 | +import csv |
| 10 | +from datetime import datetime |
| 11 | +from statistics import mean, stdev |
| 12 | +from dataclasses import dataclass, field |
| 13 | +from typing import List, Optional, Dict, Any, Tuple |
| 14 | + |
| 15 | + |
| 16 | +# ── Data types ───────────────────────────────────────────────────────────────── |
| 17 | + |
| 18 | +@dataclass |
| 19 | +class RunResult: |
| 20 | + time_s: float |
| 21 | + fps: float |
| 22 | + |
| 23 | + |
| 24 | +@dataclass |
| 25 | +class CaseResults: |
| 26 | + name: str |
| 27 | + scene: str |
| 28 | + results: List[RunResult] = field(default_factory=list) |
| 29 | + failures: List[str] = field(default_factory=list) |
| 30 | + |
| 31 | + @property |
| 32 | + def n_success(self) -> int: |
| 33 | + return len(self.results) |
| 34 | + |
| 35 | + @property |
| 36 | + def n_failures(self) -> int: |
| 37 | + return len(self.failures) |
| 38 | + |
| 39 | + def stats(self) -> Optional[Dict[str, float]]: |
| 40 | + if not self.results: |
| 41 | + return None |
| 42 | + times = [r.time_s for r in self.results] |
| 43 | + fpss = [r.fps for r in self.results] |
| 44 | + return { |
| 45 | + 'mean_time': mean(times), |
| 46 | + 'stddev_time': stdev(times) if len(times) > 1 else 0.0, |
| 47 | + 'min_time': min(times), |
| 48 | + 'max_time': max(times), |
| 49 | + 'mean_fps': mean(fpss), |
| 50 | + } |
| 51 | + |
| 52 | + |
| 53 | +# ── Config ───────────────────────────────────────────────────────────────────── |
| 54 | + |
| 55 | +DEFAULTS: Dict[str, Any] = { |
| 56 | + 'sofa_exe': 'runSofa', |
| 57 | + 'warmup': 1, |
| 58 | + 'output': 'log.benchmark', |
| 59 | +} |
| 60 | + |
| 61 | +# Must be explicitly set in config or via CLI — no silent defaults. |
| 62 | +REQUIRED_KEYS = ('iterations', 'n_tests', 'timeout') |
| 63 | + |
| 64 | +# All keys overrideable via CLI — must match build_parser arguments exactly. |
| 65 | +OVERRIDE_KEYS = (*DEFAULTS.keys(), *REQUIRED_KEYS) |
| 66 | + |
| 67 | + |
| 68 | +def load_config(config_path: str, overrides: Dict[str, Any]) -> Dict[str, Any]: |
| 69 | + with open(config_path) as f: |
| 70 | + config = json.load(f) |
| 71 | + for key, val in DEFAULTS.items(): |
| 72 | + config.setdefault(key, val) |
| 73 | + for key, val in overrides.items(): |
| 74 | + if val is not None: |
| 75 | + config[key] = val |
| 76 | + return config |
| 77 | + |
| 78 | + |
| 79 | +def validate_config(config: Dict[str, Any]) -> List[str]: |
| 80 | + errors = [] |
| 81 | + |
| 82 | + for key in REQUIRED_KEYS: |
| 83 | + if key not in config: |
| 84 | + errors.append(f"'{key}' is required but not set in config or CLI") |
| 85 | + |
| 86 | + sofa_exe = config.get('sofa_exe') |
| 87 | + if not sofa_exe: |
| 88 | + errors.append("'sofa_exe' is required but not set") |
| 89 | + elif not shutil.which(sofa_exe): |
| 90 | + errors.append(f"sofa_exe not found: '{sofa_exe}'") |
| 91 | + |
| 92 | + cases = config.get('cases') |
| 93 | + if not cases: |
| 94 | + errors.append("'cases' list is missing or empty") |
| 95 | + return errors |
| 96 | + |
| 97 | + for i, case in enumerate(cases): |
| 98 | + tag = f"Case '{case['name']}'" if 'name' in case else f"Case {i}" |
| 99 | + if 'name' not in case: |
| 100 | + errors.append(f"Case {i}: missing 'name'") |
| 101 | + if 'scene' not in case: |
| 102 | + errors.append(f"{tag}: missing 'scene'") |
| 103 | + elif not os.path.isfile(case['scene']): |
| 104 | + errors.append(f"{tag}: scene file not found: '{case['scene']}'") |
| 105 | + |
| 106 | + for key in REQUIRED_KEYS: |
| 107 | + val = config.get(key) |
| 108 | + if val is not None and (not isinstance(val, int) or val < 1): |
| 109 | + errors.append(f"'{key}' must be a positive integer, got: {val!r}") |
| 110 | + warmup = config.get('warmup') |
| 111 | + if warmup is not None and (not isinstance(warmup, int) or warmup < 0): |
| 112 | + errors.append(f"'warmup' must be a non-negative integer, got: {warmup!r}") |
| 113 | + |
| 114 | + return errors |
| 115 | + |
| 116 | + |
| 117 | +# ── Runner ───────────────────────────────────────────────────────────────────── |
| 118 | + |
| 119 | +_TIMING_RE = re.compile(r'(\d+(?:\.\d+)?)\s+s\s*\(\s*(\d+(?:\.\d+)?)\s*FPS\s*\)', re.IGNORECASE) |
| 120 | +_NUMBER_RE = re.compile(r'\d+\.\d+') |
| 121 | + |
| 122 | + |
| 123 | +def _parse_timing_line(line: str) -> Optional[RunResult]: |
| 124 | + m = _TIMING_RE.search(line) |
| 125 | + if m: |
| 126 | + return RunResult(float(m.group(1)), float(m.group(2))) |
| 127 | + # Fallback: last two decimal numbers in the line |
| 128 | + numbers = _NUMBER_RE.findall(line) |
| 129 | + if len(numbers) >= 2: |
| 130 | + return RunResult(float(numbers[-2]), float(numbers[-1])) |
| 131 | + return None |
| 132 | + |
| 133 | + |
| 134 | +def run_single( |
| 135 | + sofa_exe: str, scene: str, iterations: int, timeout: int |
| 136 | +) -> Tuple[Optional[RunResult], Optional[str]]: |
| 137 | + """Returns (RunResult, None) on success, (None, error_message) on failure. Never raises.""" |
| 138 | + try: |
| 139 | + proc = subprocess.run( |
| 140 | + [sofa_exe, '-g', 'batch', '-n', str(iterations), scene], |
| 141 | + capture_output=True, |
| 142 | + text=True, |
| 143 | + timeout=timeout, |
| 144 | + ) |
| 145 | + except subprocess.TimeoutExpired: |
| 146 | + return None, f'Timeout after {timeout}s' |
| 147 | + except Exception as e: |
| 148 | + return None, f'Failed to launch: {e}' |
| 149 | + |
| 150 | + for line in proc.stdout.splitlines(): |
| 151 | + if 'iterations done in' in line: |
| 152 | + result = _parse_timing_line(line) |
| 153 | + if result: |
| 154 | + return result, None |
| 155 | + return None, f'Found timing line but could not parse it: {line!r}' |
| 156 | + |
| 157 | + error_detail = f'exit={proc.returncode}, no timing line in output' |
| 158 | + last_stderr = proc.stderr.strip().rsplit('\n', 1)[-1][:200] |
| 159 | + if last_stderr: |
| 160 | + error_detail += f' | stderr: {last_stderr}' |
| 161 | + return None, error_detail |
| 162 | + |
| 163 | + |
| 164 | +def run_case(config: Dict[str, Any], case: Dict[str, str]) -> CaseResults: |
| 165 | + sofa_exe = config['sofa_exe'] |
| 166 | + iterations = config['iterations'] |
| 167 | + timeout = config['timeout'] |
| 168 | + n_tests = config['n_tests'] |
| 169 | + warmup = config['warmup'] |
| 170 | + scene = case['scene'] |
| 171 | + name = case['name'] |
| 172 | + |
| 173 | + cr = CaseResults(name=name, scene=scene) |
| 174 | + |
| 175 | + for i in range(warmup + n_tests): |
| 176 | + is_warmup = i < warmup |
| 177 | + label = f'warmup {i + 1}/{warmup}' if is_warmup else f'test {i - warmup + 1}/{n_tests}' |
| 178 | + print(f' [{name}] {label} ... ', end='', flush=True) |
| 179 | + |
| 180 | + result, error = run_single(sofa_exe, scene, iterations, timeout) |
| 181 | + |
| 182 | + if result is not None: |
| 183 | + suffix = ' [warmup, discarded]' if is_warmup else '' |
| 184 | + print(f'{result.time_s:.3f}s ({result.fps:.1f} FPS){suffix}') |
| 185 | + if not is_warmup: |
| 186 | + cr.results.append(result) |
| 187 | + else: |
| 188 | + suffix = ' [warmup]' if is_warmup else '' |
| 189 | + print(f'FAILED{suffix}: {error}') |
| 190 | + if not is_warmup: |
| 191 | + cr.failures.append(error) |
| 192 | + |
| 193 | + return cr |
| 194 | + |
| 195 | + |
| 196 | +# ── Reporting ────────────────────────────────────────────────────────────────── |
| 197 | + |
| 198 | +def print_table(case_results: List[CaseResults], all_stats: List[Optional[Dict]]) -> None: |
| 199 | + col = 24 |
| 200 | + header = ( |
| 201 | + f'{"Case":<{col}} {"Ok":>4} {"Fail":>4}' |
| 202 | + f' {"Mean (s)":>9} {"Std":>7} {"Min":>7} {"Max":>7} {"FPS":>7}' |
| 203 | + ) |
| 204 | + width = len(header) |
| 205 | + print() |
| 206 | + print('=' * width) |
| 207 | + print(header) |
| 208 | + print('-' * width) |
| 209 | + for cr, s in zip(case_results, all_stats): |
| 210 | + name = cr.name[:col] |
| 211 | + if s: |
| 212 | + print( |
| 213 | + f'{name:<{col}} {cr.n_success:>4} {cr.n_failures:>4}' |
| 214 | + f' {s["mean_time"]:>9.3f} {s["stddev_time"]:>7.3f}' |
| 215 | + f' {s["min_time"]:>7.3f} {s["max_time"]:>7.3f}' |
| 216 | + f' {s["mean_fps"]:>7.1f}' |
| 217 | + ) |
| 218 | + else: |
| 219 | + print(f'{name:<{col}} {0:>4} {cr.n_failures:>4} ALL FAILED') |
| 220 | + print('=' * width) |
| 221 | + |
| 222 | + |
| 223 | +def write_csv(output_prefix: str, case_results: List[CaseResults], all_stats: List[Optional[Dict]]) -> str: |
| 224 | + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') |
| 225 | + path = f'{output_prefix}.{timestamp}.csv' |
| 226 | + with open(path, 'w', newline='') as f: |
| 227 | + writer = csv.writer(f) |
| 228 | + writer.writerow([ |
| 229 | + 'name', 'scene', 'n_success', 'n_failures', |
| 230 | + 'mean_time_s', 'stddev_time_s', 'min_time_s', 'max_time_s', 'mean_fps', |
| 231 | + ]) |
| 232 | + for cr, s in zip(case_results, all_stats): |
| 233 | + if s: |
| 234 | + writer.writerow([ |
| 235 | + cr.name, cr.scene, cr.n_success, cr.n_failures, |
| 236 | + s['mean_time'], s['stddev_time'], s['min_time'], s['max_time'], s['mean_fps'], |
| 237 | + ]) |
| 238 | + else: |
| 239 | + writer.writerow([cr.name, cr.scene, 0, cr.n_failures, '', '', '', '', '']) |
| 240 | + return path |
| 241 | + |
| 242 | + |
| 243 | +# ── CLI ──────────────────────────────────────────────────────────────────────── |
| 244 | + |
| 245 | +def build_parser() -> argparse.ArgumentParser: |
| 246 | + p = argparse.ArgumentParser( |
| 247 | + description='Benchmark SOFA scenes', |
| 248 | + formatter_class=argparse.RawDescriptionHelpFormatter, |
| 249 | + epilog=__doc__, |
| 250 | + ) |
| 251 | + p.add_argument('-config', required=True, help='Path to JSON config file') |
| 252 | + p.add_argument('-sofa_exe', default=None, help='Override: path to runSofa executable') |
| 253 | + p.add_argument('-iterations', default=None, type=int, help='Override: ODE iterations per run') |
| 254 | + p.add_argument('-n_tests', default=None, type=int, help='Override: timed test runs per case') |
| 255 | + p.add_argument('-warmup', default=None, type=int, help='Override: warmup runs (discarded from stats)') |
| 256 | + p.add_argument('-timeout', default=None, type=int, help='Override: per-run timeout in seconds') |
| 257 | + p.add_argument('-output', default=None, help='Override: output CSV filename prefix') |
| 258 | + return p |
| 259 | + |
| 260 | + |
| 261 | +def main() -> None: |
| 262 | + args = build_parser().parse_args() |
| 263 | + |
| 264 | + overrides = {k: getattr(args, k) for k in OVERRIDE_KEYS} |
| 265 | + |
| 266 | + try: |
| 267 | + config = load_config(args.config, overrides) |
| 268 | + except FileNotFoundError: |
| 269 | + print(f'Error: config file not found: {args.config}', file=sys.stderr) |
| 270 | + sys.exit(1) |
| 271 | + except json.JSONDecodeError as e: |
| 272 | + print(f'Error: invalid JSON in config: {e}', file=sys.stderr) |
| 273 | + sys.exit(1) |
| 274 | + |
| 275 | + errors = validate_config(config) |
| 276 | + if errors: |
| 277 | + print('Config errors:', file=sys.stderr) |
| 278 | + for e in errors: |
| 279 | + print(f' - {e}', file=sys.stderr) |
| 280 | + sys.exit(1) |
| 281 | + |
| 282 | + print( |
| 283 | + f'Benchmark: {len(config["cases"])} case(s), ' |
| 284 | + f'{config["n_tests"]} tests + {config["warmup"]} warmup, ' |
| 285 | + f'{config["iterations"]} iterations, ' |
| 286 | + f'timeout={config["timeout"]}s' |
| 287 | + ) |
| 288 | + |
| 289 | + case_results: List[CaseResults] = [] |
| 290 | + for case in config['cases']: |
| 291 | + print(f'\nCase: {case["name"]} -> {case["scene"]}') |
| 292 | + case_results.append(run_case(config, case)) |
| 293 | + |
| 294 | + all_stats = [cr.stats() for cr in case_results] |
| 295 | + print_table(case_results, all_stats) |
| 296 | + |
| 297 | + csv_path = write_csv(config['output'], case_results, all_stats) |
| 298 | + print(f'\nResults written to: {csv_path}') |
| 299 | + |
| 300 | + |
| 301 | +if __name__ == '__main__': |
| 302 | + main() |
0 commit comments