mjlab/scripts/benchmarks/generate_report.py
Upstream Snapshot 32a241c28f
Some checks failed
nightly / Test against latest dependencies (py3.10) (push) Has been cancelled
nightly / Test against latest dependencies (py3.13) (push) Has been cancelled
tests / tests (3.13, locked) (push) Has been cancelled
tests / tests (3.13, unlocked) (push) Has been cancelled
tests / pyright (3.10) (push) Has been cancelled
tests / lint-format (push) Has been cancelled
tests / tests (3.10, locked) (push) Has been cancelled
tests / tests (3.11, locked) (push) Has been cancelled
tests / tests (3.12, locked) (push) Has been cancelled
tests / pyright (3.11) (push) Has been cancelled
tests / pyright (3.12) (push) Has been cancelled
tests / pyright (3.13) (push) Has been cancelled
tests / ty-check (3.10) (push) Has been cancelled
tests / ty-check (3.11) (push) Has been cancelled
tests / ty-check (3.12) (push) Has been cancelled
tests / ty-check (3.13) (push) Has been cancelled
tests / stubs (push) Has been cancelled
tests / smoke-test (push) Has been cancelled
Docker / check_paths (push) Has been cancelled
docs / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Import upstream snapshot c19f713c415a699a79d71cd96aa13c3104a05047
Upstream: https://github.com/michaelgillett/mjlab
Upstream-Commit: c19f713c415a699a79d71cd96aa13c3104a05047
Upstream-Branch: main
2026-08-28 15:42:17 +08:00

863 lines
32 KiB
Python

"""Generate benchmark report from evaluation metrics.
This script runs policy evaluation on nightly runs and generates a static HTML
dashboard for tracking policy performance over time.
"""
from __future__ import annotations
import json
import shutil
from datetime import datetime
from pathlib import Path
import tyro
import wandb
import mjlab
from mjlab.tasks.tracking.scripts.evaluate import EvaluateConfig, run_evaluate
# Metrics to display: (key, label, unit, scale, higher_is_better)
METRICS = [
("success_rate", "Success Rate", "%", 100, True),
("mpkpe", "MPKPE", "m", 1, False),
("r_mpkpe", "R-MPKPE", "m", 1, False),
("ee_pos_error", "EE Position Error", "m", 1, False),
("ee_ori_error", "EE Orientation Error", "rad", 1, False),
("joint_vel_error", "Joint Velocity Error", "rad/s", 1, False),
]
def evaluate_run(run_path: str, num_envs: int = 1024) -> dict:
"""Evaluate a single run and return metrics with metadata."""
api = wandb.Api()
run = api.run(run_path)
print(f"Evaluating run: {run.name} ({run.id})")
cfg = EvaluateConfig(wandb_run_path=run_path, num_envs=num_envs)
metrics = run_evaluate("Mjlab-Tracking-Flat-Unitree-G1", cfg)
# Get commit SHA from run metadata.
commit = run.commit or run.config.get("commit", "unknown")
return {
"id": run.id,
"name": run.name,
"url": run.url,
"created_at": run.created_at,
"commit": commit[:7] if len(commit) > 7 else commit,
"metrics": metrics,
}
def load_throughput_data(output_dir: Path) -> list[dict]:
"""Load throughput benchmark data if available."""
data_file = output_dir / "throughput_data.json"
if not data_file.exists():
return []
with open(data_file) as f:
return json.load(f)
def generate_html_report(runs: list[dict], output_dir: Path) -> None:
"""Generate static HTML dashboard from evaluation data."""
output_dir.mkdir(parents=True, exist_ok=True)
# Save raw data.
with open(output_dir / "data.json", "w") as f:
json.dump(runs, f, indent=2, default=str)
# Copy task images for the throughput dashboard.
images_src = Path(__file__).parent / "nightly_images"
if images_src.is_dir():
images_dst = output_dir / "images"
if images_dst.exists():
shutil.rmtree(images_dst)
shutil.copytree(images_src, images_dst)
# Load throughput data if available.
throughput_data = load_throughput_data(output_dir)
html = generate_dashboard_html(runs, throughput_data)
with open(output_dir / "index.html", "w") as f:
f.write(html)
print(f"Report generated at {output_dir / 'index.html'}")
def generate_dashboard_html(runs: list[dict], throughput_data: list[dict]) -> str:
"""Generate the HTML dashboard content."""
runs_json = json.dumps(runs, default=str)
metrics_json = json.dumps(METRICS)
throughput_json = json.dumps(throughput_data, default=str)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
github_repo = "https://github.com/mujocolab/mjlab"
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mjlab Nightly Benchmark</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script>
<style>
:root {{
--bg: #ffffff;
--bg-card: #f6f8fa;
--text: #1f2328;
--text-dim: #656d76;
--border: #d0d7de;
--accent: #0969da;
--green: #1a7f37;
--red: #cf222e;
}}
@media (prefers-color-scheme: dark) {{
:root:not([data-theme="light"]) {{
--bg: #0d1117;
--bg-card: #161b22;
--text: #c9d1d9;
--text-dim: #8b949e;
--border: #30363d;
--accent: #58a6ff;
--green: #3fb950;
--red: #f85149;
}}
}}
:root[data-theme="dark"] {{
--bg: #0d1117;
--bg-card: #161b22;
--text: #c9d1d9;
--text-dim: #8b949e;
--border: #30363d;
--accent: #58a6ff;
--green: #3fb950;
--red: #f85149;
}}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}}
header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}}
h1 {{ font-size: 1.5rem; }}
.subtitle {{
font-size: 0.875rem;
color: var(--text-dim);
margin-top: 0.25rem;
}}
.timestamp {{ color: var(--text-dim); font-size: 0.875rem; }}
.charts {{
display: grid;
grid-template-columns: 1fr;
gap: 1.5rem;
margin-bottom: 2rem;
}}
.chart-card {{
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}}
.chart-title {{
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
display: flex;
justify-content: space-between;
}}
.chart-value {{ color: var(--text-dim); }}
.chart-container {{ height: 350px; }}
a {{ color: var(--accent); text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
.theme-toggle {{
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem;
cursor: pointer;
color: var(--text);
font-size: 1rem;
line-height: 1;
}}
.theme-toggle:hover {{ border-color: var(--accent); }}
.header-right {{ display: flex; align-items: center; gap: 1rem; }}
.tabs {{
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}}
.tab {{
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 1rem;
cursor: pointer;
color: var(--text);
font-size: 0.875rem;
font-weight: 500;
}}
.tab:hover {{ border-color: var(--accent); }}
.tab.active {{
background: var(--accent);
border-color: var(--accent);
color: white;
}}
.range-selector {{
display: flex;
gap: 0.4rem;
margin-bottom: 1rem;
}}
.range-btn {{
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.3rem 0.75rem;
cursor: pointer;
color: var(--text);
font-size: 0.8rem;
font-weight: 500;
}}
.range-btn:hover {{ border-color: var(--accent); }}
.range-btn.active {{
background: var(--accent);
border-color: var(--accent);
color: white;
}}
.tab-content {{ display: none; }}
.tab-content.active {{ display: block; }}
.tab-description {{
font-size: 0.875rem;
color: var(--text-dim);
margin-bottom: 1.5rem;
line-height: 1.5;
}}
.task-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}}
.task-card {{
background: var(--bg-card);
border: 2px solid var(--border);
border-radius: 10px;
padding: 0.75rem;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
text-align: center;
}}
.task-card:hover {{ border-color: var(--accent); }}
.task-card.active {{
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}}
.task-card img {{
width: 100%;
aspect-ratio: 16/10;
object-fit: cover;
border-radius: 6px;
margin-bottom: 0.5rem;
background: var(--border);
}}
.task-card .task-name {{
font-size: 0.85rem;
font-weight: 600;
}}
.task-card .task-stat {{
font-size: 0.75rem;
color: var(--text-dim);
margin-top: 0.2rem;
}}
.task-chart-area {{
display: none;
}}
.task-chart-area.active {{
display: block;
}}
footer {{
margin-top: 3rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
font-size: 0.8rem;
color: var(--text-dim);
line-height: 1.6;
}}
@media (max-width: 600px) {{
body {{ padding: 1rem; }}
h1 {{ font-size: 1.25rem; }}
.tabs {{ flex-wrap: wrap; }}
.task-grid {{ grid-template-columns: 1fr; }}
}}
</style>
</head>
<body>
<header>
<div>
<h1><a href="{github_repo}" style="color:inherit;text-decoration:none">mjlab</a> Nightly Benchmark</h1>
<div class="subtitle">Performance tracking over time</div>
</div>
<div class="header-right">
<span class="timestamp">Updated: {timestamp}</span>
<button class="theme-toggle" id="theme-toggle" title="Toggle theme">
<span id="theme-icon"></span>
</button>
</div>
</header>
<div class="tabs">
<button class="tab active" data-tab="tracking">Tracking Eval</button>
<button class="tab" data-tab="throughput">Throughput</button>
</div>
<div id="tracking" class="tab-content active">
<p class="tab-description">Nightly motion imitation training and evaluation on Unitree G1 (1024 trials per run).</p>
<div class="range-selector" id="range-selector">
<button class="range-btn" data-days="30">30d</button>
<button class="range-btn active" data-days="90">90d</button>
<button class="range-btn" data-days="180">180d</button>
<button class="range-btn" data-days="0">All</button>
</div>
<div class="charts" id="charts"></div>
</div>
<div id="throughput" class="tab-content">
<p class="tab-description">Physics simulation throughput across tasks (4096 parallel envs, NVIDIA RTX 5090).</p>
<div class="task-grid" id="task-grid"></div>
<div class="range-selector" id="range-selector-tp">
<button class="range-btn" data-days="30">30d</button>
<button class="range-btn active" data-days="90">90d</button>
<button class="range-btn" data-days="180">180d</button>
<button class="range-btn" data-days="0">All</button>
</div>
<div id="task-chart-panels"></div>
</div>
<footer>
These benchmarks run nightly using the latest commit.<br>
GPU: NVIDIA RTX 5090
</footer>
<script>
// Theme toggle logic
const themeToggle = document.getElementById('theme-toggle');
const themeIcon = document.getElementById('theme-icon');
const root = document.documentElement;
function getSystemTheme() {{
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}}
function getEffectiveTheme() {{
const stored = localStorage.getItem('theme');
if (stored === 'dark' || stored === 'light') return stored;
return getSystemTheme();
}}
function updateThemeIcon() {{
const stored = localStorage.getItem('theme');
if (!stored) {{
themeIcon.textContent = '\u2699\ufe0f'; // gear for auto
themeToggle.title = 'Theme: System (click to toggle)';
}} else if (stored === 'dark') {{
themeIcon.textContent = '\U0001f319'; // moon
themeToggle.title = 'Theme: Dark (click to toggle)';
}} else {{
themeIcon.textContent = '\u2600\ufe0f'; // sun
themeToggle.title = 'Theme: Light (click to toggle)';
}}
}}
function applyTheme() {{
const stored = localStorage.getItem('theme');
if (stored) {{
root.setAttribute('data-theme', stored);
}} else {{
root.removeAttribute('data-theme');
}}
updateThemeIcon();
updateChartColors();
}}
function cycleTheme() {{
const stored = localStorage.getItem('theme');
if (!stored) {{
// auto -> dark
localStorage.setItem('theme', 'dark');
}} else if (stored === 'dark') {{
// dark -> light
localStorage.setItem('theme', 'light');
}} else {{
// light -> auto
localStorage.removeItem('theme');
}}
applyTheme();
}}
themeToggle.addEventListener('click', cycleTheme);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
const runs = {runs_json};
const METRICS = {metrics_json};
const GITHUB_REPO = '{github_repo}';
// Sort by date ascending for charts.
runs.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
const colors = {{
success_rate: '#3fb950',
mpkpe: '#58a6ff',
r_mpkpe: '#a371f7',
ee_pos_error: '#f0883e',
ee_ori_error: '#f85149',
joint_vel_error: '#79c0ff'
}};
let charts = [];
let trackingCharts = [];
let throughputCharts = [];
function updateChartColors() {{
const style = getComputedStyle(root);
const textDim = style.getPropertyValue('--text-dim').trim();
const border = style.getPropertyValue('--border').trim();
const isDark = getEffectiveTheme() === 'dark';
const gridColor = isDark ? '#3a424b' : '#d0d7de';
Chart.defaults.color = textDim;
Chart.defaults.borderColor = gridColor;
charts.forEach(c => c.update());
}}
// Initialize theme before creating charts so grid colors are correct.
applyTheme();
// Charts
const chartsContainer = document.getElementById('charts');
// Compute a rolling average over the last `window` points.
function rollingAvg(data, window) {{
return data.map((d, i) => {{
const start = Math.max(0, i - window + 1);
const slice = data.slice(start, i + 1);
const avg = slice.reduce((s, p) => s + p.y, 0) / slice.length;
return {{ x: d.x, y: avg }};
}});
}}
const AVG_WINDOW = 7;
METRICS.forEach(([key, label, unit, scale, higherIsBetter]) => {{
const data = runs.map(r => ({{
x: new Date(r.created_at),
y: r.metrics[key] * scale,
commit: r.commit,
name: r.name
}}));
const avgData = rollingAvg(data, AVG_WINDOW);
const color = colors[key] || '#58a6ff';
const latestVal = data[data.length - 1]?.y;
const arrow = higherIsBetter ? '\u2191' : '\u2193';
const tooltip = higherIsBetter ? 'Higher is better' : 'Lower is better';
const card = document.createElement('div');
card.className = 'chart-card';
card.innerHTML = `
<div class="chart-title">
<span>${{label}} <span title="${{tooltip}}" style="cursor:help;opacity:0.6">${{arrow}}</span></span>
<span class="chart-value">${{latestVal?.toFixed(3)}} ${{unit}}</span>
</div>
<div class="chart-container"><canvas></canvas></div>
`;
chartsContainer.appendChild(card);
const chart = new Chart(card.querySelector('canvas'), {{
type: 'line',
data: {{
datasets: [
{{
label: label,
data: data,
borderColor: color,
backgroundColor: color + '20',
borderWidth: 2,
pointRadius: 2,
pointHoverRadius: 5,
tension: 0.1,
fill: true
}},
{{
label: `${{AVG_WINDOW}}-run avg`,
data: avgData,
borderColor: color,
borderWidth: 2,
borderDash: [6, 4],
pointRadius: 0,
tension: 0.3,
fill: false
}}
]
}},
options: {{
responsive: true,
maintainAspectRatio: false,
onClick: (event, elements) => {{
if (elements.length > 0 && elements[0].datasetIndex === 0) {{
const d = data[elements[0].index];
if (d?.commit && d.commit !== 'unknown') {{
window.open(`${{GITHUB_REPO}}/commit/${{d.commit}}`, '_blank');
}}
}}
}},
plugins: {{
legend: {{
display: true,
position: 'bottom',
labels: {{ usePointStyle: true, pointStyle: 'line', boxHeight: 1 }}
}},
tooltip: {{
filter: (item) => item.datasetIndex === 0,
callbacks: {{
title: (items) => {{
const d = items[0]?.raw;
return d ? `${{d.name}} (${{d.commit}})` : '';
}},
label: (item) => {{
const d = item.raw;
return `${{label}}: ${{d.y?.toFixed(4)}} ${{unit}}`;
}},
footer: (items) => {{
const d = items[0]?.raw;
return d?.commit && d.commit !== 'unknown' ? 'Click to view commit' : '';
}}
}}
}}
}},
scales: {{
x: {{
type: 'time',
time: {{ unit: 'day' }},
ticks: {{ maxTicksLimit: 5 }},
title: {{
display: true,
text: 'Date',
font: {{ size: 11 }}
}}
}},
y: {{
ticks: {{ maxTicksLimit: 5 }},
title: {{
display: true,
text: unit,
font: {{ size: 11 }}
}}
}}
}}
}}
}});
charts.push(chart);
trackingCharts.push(chart);
}});
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {{
tab.addEventListener('click', () => {{
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById(tab.dataset.tab).classList.add('active');
// Update URL hash
history.replaceState(null, '', '#' + tab.dataset.tab);
}});
}});
// Handle URL hash on load
if (window.location.hash) {{
const tab = document.querySelector(`.tab[data-tab="${{window.location.hash.slice(1)}}"]`);
if (tab) tab.click();
}}
// Throughput data and task cards
const throughputData = {throughput_json};
const taskGrid = document.getElementById('task-grid');
const taskChartPanels = document.getElementById('task-chart-panels');
// Task metadata: display name and image path (relative to nightly/)
const taskMeta = {{
'Mjlab-Velocity-Flat-Unitree-Go1': {{ name: 'Velocity \u2014 Go1', img: 'images/velocity_go1.png' }},
'Mjlab-Tracking-Flat-Unitree-G1': {{ name: 'Tracking \u2014 G1', img: 'images/tracking_g1.png' }},
'Mjlab-Lift-Cube-Yam': {{ name: 'Lift Cube \u2014 Yam', img: 'images/lift_cube_yam.png' }}
}};
const throughputChartInstances = {{}};
if (throughputData.length > 0) {{
const tasks = [...new Set(throughputData.flatMap(d => d.results.map(r => r.task)))];
const latestRun = throughputData[throughputData.length - 1];
tasks.forEach((task, i) => {{
const meta = taskMeta[task] || {{ name: task, img: '' }};
const latestResult = latestRun?.results.find(r => r.task === task);
const latestSps = latestResult ? `${{(latestResult.env_sps / 1000).toFixed(0)}}K env steps/s` : '';
// Card
const card = document.createElement('div');
card.className = 'task-card' + (i === 0 ? ' active' : '');
card.dataset.task = task;
card.innerHTML = `
<img src="${{meta.img}}" alt="${{meta.name}}" onerror="this.style.display='none'">
<div class="task-name">${{meta.name}}</div>
<div class="task-stat">${{latestSps}}</div>
`;
taskGrid.appendChild(card);
// Chart panel
const panel = document.createElement('div');
panel.className = 'task-chart-area' + (i === 0 ? ' active' : '');
panel.id = `task-panel-${{i}}`;
panel.innerHTML = `
<div class="chart-card">
<div class="chart-title">
<span>${{meta.name}} \u2014 Throughput</span>
</div>
<div class="chart-container"><canvas></canvas></div>
</div>
`;
taskChartPanels.appendChild(panel);
// Build datasets
const envData = [];
const physicsData = [];
throughputData.forEach(run => {{
const result = run.results.find(r => r.task === task);
if (!result) return;
const point = {{ x: new Date(run.created_at), commit: run.commit }};
envData.push({{ ...point, y: result.env_sps / 1000 }});
physicsData.push({{ ...point, y: result.physics_sps / 1000 }});
}});
const chart = new Chart(panel.querySelector('canvas'), {{
type: 'line',
data: {{
datasets: [
{{
label: 'Env SPS',
data: envData,
borderColor: '#58a6ff',
backgroundColor: '#58a6ff20',
borderWidth: 2,
pointRadius: 2,
pointHoverRadius: 5,
tension: 0.1,
fill: true
}},
{{
label: 'Physics SPS',
data: physicsData,
borderColor: '#3fb950',
backgroundColor: '#3fb95020',
borderWidth: 2,
pointRadius: 2,
pointHoverRadius: 5,
tension: 0.1,
fill: true
}}
]
}},
options: {{
responsive: true,
maintainAspectRatio: false,
onClick: (event, elements) => {{
if (elements.length > 0) {{
const di = elements[0].datasetIndex;
const idx = elements[0].index;
const d = di === 0 ? envData[idx] : physicsData[idx];
if (d?.commit && d.commit !== 'unknown') {{
window.open(`${{GITHUB_REPO}}/commit/${{d.commit}}`, '_blank');
}}
}}
}},
plugins: {{
legend: {{ display: true, position: 'bottom' }},
tooltip: {{
mode: 'index',
callbacks: {{
title: (items) => {{
const d = items[0]?.raw;
return d ? `Commit: ${{d.commit}}` : '';
}},
label: (item) => {{
return `${{item.dataset.label}}: ${{item.raw.y?.toFixed(0)}}K steps/s`;
}},
afterBody: (items) => {{
if (items.length >= 2) {{
const envY = items[0]?.raw?.y || 0;
const physY = items[1]?.raw?.y || 0;
const overhead = physY > 0 ? ((1 - envY / physY) * 100).toFixed(1) : '?';
return `Overhead: ${{overhead}}%`;
}}
return '';
}},
footer: (items) => {{
const d = items[0]?.raw;
return d?.commit && d.commit !== 'unknown' ? 'Click to view commit' : '';
}}
}}
}}
}},
scales: {{
x: {{
type: 'time',
time: {{ unit: 'day' }},
ticks: {{ maxTicksLimit: 5 }},
title: {{ display: true, text: 'Date', font: {{ size: 11 }} }}
}},
y: {{
ticks: {{ maxTicksLimit: 5 }},
title: {{
display: true,
text: 'K steps/s',
font: {{ size: 11 }}
}}
}}
}}
}}
}});
charts.push(chart);
throughputCharts.push(chart);
throughputChartInstances[task] = {{ chart, panelId: `task-panel-${{i}}` }};
// Card click handler
card.addEventListener('click', () => {{
document.querySelectorAll('.task-card').forEach(c => c.classList.remove('active'));
document.querySelectorAll('.task-chart-area').forEach(p => p.classList.remove('active'));
card.classList.add('active');
panel.classList.add('active');
// Trigger resize so chart renders at correct size
throughputChartInstances[task].chart.resize();
}});
}});
}} else {{
taskGrid.innerHTML = '<p style="color: var(--text-dim)">No throughput data available. Run measure_throughput.py to generate data.</p>';
}}
// Date-range windowing across both tracking and throughput charts.
// Setting min and clearing max also resets any zoom/pan.
function setRange(days) {{
const min = days > 0 ? Date.now() - days * 86400000 : undefined;
[...trackingCharts, ...throughputCharts].forEach(c => {{
c.options.scales.x.min = min;
c.options.scales.x.max = undefined;
c.update();
}});
document.querySelectorAll('.range-btn').forEach(b =>
b.classList.toggle('active', parseInt(b.dataset.days) === days));
}}
document.querySelectorAll('.range-btn').forEach(btn => {{
btn.addEventListener('click', () => setRange(parseInt(btn.dataset.days)));
}});
setRange(90);
</script>
</body>
</html>
"""
def load_cached_results(output_dir: Path) -> dict[str, dict]:
"""Load previously evaluated results from cache."""
data_file = output_dir / "data.json"
if not data_file.exists():
return {}
with open(data_file) as f:
runs = json.load(f)
return {run["id"]: run for run in runs}
def main(
run_paths: list[str] | None = None,
entity: str = "gcbc_researchers",
project: str = "mjlab",
tag: str = "nightly",
eval_limit: int = 0,
num_envs: int = 1024,
output_dir: Path = Path("benchmark_results"),
) -> None:
"""Generate benchmark report by evaluating nightly runs.
Args:
run_paths: Specific run paths to evaluate (entity/project/run_id).
entity: WandB entity.
project: WandB project name.
tag: Filter runs by tag.
eval_limit: Maximum number of NEW runs to evaluate per invocation (0 = no limit).
num_envs: Number of envs for evaluation.
output_dir: Output directory for generated report.
"""
# Load cached results to avoid re-evaluating old runs.
cached = load_cached_results(output_dir)
print(f"Loaded {len(cached)} cached evaluation results")
# Start with all cached results (preserves historical data).
eval_results_by_id: dict[str, dict] = dict(cached)
new_evals = 0
if run_paths:
for run_path in run_paths:
run_id = run_path.split("/")[-1]
if run_id in eval_results_by_id:
print(f"Using cached result for {run_id}")
else:
try:
result = evaluate_run(run_path, num_envs)
except RuntimeError as e:
print(f"Skipping {run_path}: {e}")
continue
eval_results_by_id[run_id] = result
new_evals += 1
else:
api = wandb.Api()
print(f"Fetching runs from {entity}/{project} with tag '{tag}'...")
runs = api.runs(f"{entity}/{project}", filters={"tags": tag}, order="-created_at")
for run in runs:
if run.state != "finished":
continue
if run.id in eval_results_by_id:
print(f"Using cached result for {run.name} ({run.id})")
else:
if eval_limit > 0 and new_evals >= eval_limit:
print(f"Reached eval limit ({eval_limit}), skipping remaining new runs")
break
run_path = f"{entity}/{project}/{run.id}"
try:
result = evaluate_run(run_path, num_envs)
except RuntimeError as e:
print(f"Skipping {run.name} ({run.id}): {e}")
continue
eval_results_by_id[run.id] = result
new_evals += 1
eval_results = list(eval_results_by_id.values())
print(f"Total runs: {len(eval_results)} ({new_evals} newly evaluated)")
generate_html_report(eval_results, output_dir)
if __name__ == "__main__":
tyro.cli(main, config=mjlab.TYRO_FLAGS)