Skip to content

Commit fe74972

Browse files
committed
[script] Renew the runBenchmarks_script.py
1 parent 8b82499 commit fe74972

1 file changed

Lines changed: 302 additions & 79 deletions

File tree

scripts/runBenchmarks_script.py

Lines changed: 302 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,302 @@
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

Comments
 (0)