[software] 添加16DOF早期训练仿真与Sim2Real闭环
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# mjlab Nightly Benchmarks
|
||||
|
||||
This directory contains scripts for automated nightly benchmarking of mjlab.
|
||||
|
||||
## Overview
|
||||
|
||||
The nightly benchmark system:
|
||||
1. Trains a tracking policy on the latest commit
|
||||
2. Evaluates the policy across 1024 trials
|
||||
3. Measures simulation throughput
|
||||
4. Generates an HTML report with historical trends
|
||||
5. Publishes results to GitHub Pages
|
||||
|
||||
## Usage
|
||||
|
||||
### Run the full nightly benchmark
|
||||
|
||||
```bash
|
||||
./scripts/benchmarks/nightly_train.sh
|
||||
```
|
||||
|
||||
### Skip training (regenerate report only)
|
||||
|
||||
```bash
|
||||
SKIP_TRAINING=1 ./scripts/benchmarks/nightly_train.sh
|
||||
```
|
||||
|
||||
### Skip training and throughput
|
||||
|
||||
```bash
|
||||
SKIP_TRAINING=1 SKIP_THROUGHPUT=1 ./scripts/benchmarks/nightly_train.sh
|
||||
```
|
||||
|
||||
### Regenerate report directly (no git operations)
|
||||
|
||||
```bash
|
||||
uv run python scripts/benchmarks/generate_report.py \
|
||||
--entity gcbc_researchers \
|
||||
--tag nightly \
|
||||
--output-dir benchmark_results
|
||||
```
|
||||
|
||||
### Measure throughput only
|
||||
|
||||
```bash
|
||||
uv run python scripts/benchmarks/measure_throughput.py \
|
||||
--num-envs 4096 \
|
||||
--output-dir benchmark_results
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables for `nightly_train.sh`:
|
||||
|
||||
- `CUDA_DEVICE` - GPU device to use (default: 0)
|
||||
- `WANDB_TAGS` - Comma-separated tags for the run (default: nightly)
|
||||
- `SKIP_TRAINING` - Set to "1" to skip training
|
||||
- `SKIP_THROUGHPUT` - Set to "1" to skip throughput benchmarking
|
||||
|
||||
## Automated Setup
|
||||
|
||||
See [systemd/README.md](systemd/README.md) for instructions on setting up automated nightly runs using systemd timers.
|
||||
|
||||
## Report Options
|
||||
|
||||
The `generate_report.py` script supports:
|
||||
|
||||
- `--eval-limit N` - Maximum number of NEW runs to evaluate per invocation (default: 10)
|
||||
- Set to 0 for no limit
|
||||
- Historical cached results are always preserved
|
||||
- `--tag TAG` - Filter runs by WandB tag (default: "nightly")
|
||||
- `--num-envs N` - Number of parallel environments for evaluation (default: 1024)
|
||||
|
||||
## Viewing Results
|
||||
|
||||
Reports are published to: https://mujocolab.github.io/mjlab/nightly/
|
||||
@@ -0,0 +1,796 @@
|
||||
"""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;
|
||||
}}
|
||||
.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="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 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 = [];
|
||||
|
||||
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);
|
||||
|
||||
charts.push(new Chart(card.querySelector('canvas'), {{
|
||||
type: 'line',
|
||||
data: {{
|
||||
datasets: [
|
||||
{{
|
||||
label: label,
|
||||
data: data,
|
||||
borderColor: color,
|
||||
backgroundColor: color + '20',
|
||||
borderWidth: 2,
|
||||
pointRadius: 4,
|
||||
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 }}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}));
|
||||
}});
|
||||
|
||||
// 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: 4,
|
||||
tension: 0.1,
|
||||
fill: true
|
||||
}},
|
||||
{{
|
||||
label: 'Physics SPS',
|
||||
data: physicsData,
|
||||
borderColor: '#3fb950',
|
||||
backgroundColor: '#3fb95020',
|
||||
borderWidth: 2,
|
||||
pointRadius: 4,
|
||||
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);
|
||||
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>';
|
||||
}}
|
||||
</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:
|
||||
result = evaluate_run(run_path, num_envs)
|
||||
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}"
|
||||
result = evaluate_run(run_path, num_envs)
|
||||
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)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Measure environment throughput for regression tracking.
|
||||
|
||||
This script measures physics and environment step throughput across canonical tasks
|
||||
to catch performance regressions in the manager-based API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
import tyro
|
||||
import wandb
|
||||
|
||||
import mjlab
|
||||
import mjlab.tasks # noqa: F401 - registers tasks
|
||||
from mjlab.envs import ManagerBasedRlEnv
|
||||
from mjlab.tasks.registry import load_env_cfg
|
||||
from mjlab.tasks.tracking.mdp.commands import MotionCommandCfg
|
||||
|
||||
|
||||
@dataclass
|
||||
class BenchmarkResult:
|
||||
"""Results from a single benchmark run."""
|
||||
|
||||
task: str
|
||||
num_envs: int
|
||||
num_steps: int
|
||||
decimation: int
|
||||
physics_sps: float
|
||||
env_sps: float
|
||||
overhead_pct: float
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
f"{self.task} (dec={self.decimation}):\n"
|
||||
f" Physics SPS: {self.physics_sps:,.0f}\n"
|
||||
f" Env SPS: {self.env_sps:,.0f}\n"
|
||||
f" Overhead: {self.overhead_pct:.1f}%"
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThroughputConfig:
|
||||
"""Configuration for throughput benchmarking."""
|
||||
|
||||
num_envs: int = 4096
|
||||
"""Number of parallel environments."""
|
||||
|
||||
num_steps: int = 200
|
||||
"""Number of steps to measure (after warmup)."""
|
||||
|
||||
warmup_steps: int = 50
|
||||
"""Number of warmup steps before measuring."""
|
||||
|
||||
device: str = "cuda:0"
|
||||
"""Device to run on."""
|
||||
|
||||
tasks: list[str] = field(
|
||||
default_factory=lambda: [
|
||||
"Mjlab-Velocity-Flat-Unitree-Go1",
|
||||
"Mjlab-Tracking-Flat-Unitree-G1",
|
||||
"Mjlab-Lift-Cube-Yam",
|
||||
]
|
||||
)
|
||||
"""Tasks to benchmark."""
|
||||
|
||||
tracking_motion: str = "rll_humanoid/wandb-registry-Motions/lafan_cartwheel:latest"
|
||||
"""W&B artifact path for tracking task motion (entity/project/name:alias)."""
|
||||
|
||||
output_dir: Path | None = None
|
||||
"""Output directory for JSON results. If None, results are only printed."""
|
||||
|
||||
|
||||
def measure_physics_sps(env: ManagerBasedRlEnv, num_steps: int) -> float:
|
||||
"""Measure raw physics stepping in env steps per second.
|
||||
|
||||
Runs num_steps worth of physics (i.e., num_steps * decimation sim.step calls)
|
||||
and reports throughput in env steps/sec for direct comparison with env.step().
|
||||
"""
|
||||
decimation = env.cfg.decimation
|
||||
total_physics_steps = num_steps * decimation
|
||||
|
||||
torch.cuda.synchronize()
|
||||
start = time.perf_counter()
|
||||
|
||||
for _ in range(total_physics_steps):
|
||||
env.sim.step()
|
||||
|
||||
torch.cuda.synchronize()
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
# Report in env steps/sec (not physics steps/sec) for fair comparison.
|
||||
return (num_steps * env.num_envs) / elapsed
|
||||
|
||||
|
||||
def measure_env_sps(env: ManagerBasedRlEnv, num_steps: int) -> float:
|
||||
"""Measure full environment step throughput in env steps per second."""
|
||||
action_dim = sum(env.action_manager.action_term_dim)
|
||||
action = torch.zeros(env.num_envs, action_dim, device=env.device)
|
||||
|
||||
torch.cuda.synchronize()
|
||||
start = time.perf_counter()
|
||||
|
||||
for _ in range(num_steps):
|
||||
env.step(action)
|
||||
|
||||
torch.cuda.synchronize()
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
return (num_steps * env.num_envs) / elapsed
|
||||
|
||||
|
||||
def benchmark_task(task: str, cfg: ThroughputConfig) -> BenchmarkResult:
|
||||
"""Benchmark a single task."""
|
||||
print(f"\nBenchmarking {task}...")
|
||||
|
||||
env_cfg = load_env_cfg(task)
|
||||
env_cfg.scene.num_envs = cfg.num_envs
|
||||
|
||||
# Handle tracking task motion file.
|
||||
if len(env_cfg.commands) > 0:
|
||||
motion_cmd = env_cfg.commands.get("motion")
|
||||
if isinstance(motion_cmd, MotionCommandCfg):
|
||||
api = wandb.Api()
|
||||
artifact = api.artifact(cfg.tracking_motion)
|
||||
motion_dir = artifact.download()
|
||||
motion_cmd.motion_file = str(Path(motion_dir) / "motion.npz")
|
||||
|
||||
env = ManagerBasedRlEnv(cfg=env_cfg, device=cfg.device)
|
||||
env.reset()
|
||||
|
||||
# Warmup.
|
||||
action_dim = sum(env.action_manager.action_term_dim)
|
||||
action = torch.zeros(env.num_envs, action_dim, device=env.device)
|
||||
for _ in range(cfg.warmup_steps):
|
||||
env.step(action)
|
||||
torch.cuda.synchronize()
|
||||
|
||||
decimation = env.cfg.decimation
|
||||
physics_sps = measure_physics_sps(env, cfg.num_steps)
|
||||
|
||||
env.reset()
|
||||
torch.cuda.synchronize()
|
||||
|
||||
env_sps = measure_env_sps(env, cfg.num_steps)
|
||||
|
||||
overhead_pct = 100 * (1 - env_sps / physics_sps)
|
||||
|
||||
env.close()
|
||||
|
||||
return BenchmarkResult(
|
||||
task=task,
|
||||
num_envs=cfg.num_envs,
|
||||
num_steps=cfg.num_steps,
|
||||
decimation=decimation,
|
||||
physics_sps=physics_sps,
|
||||
env_sps=env_sps,
|
||||
overhead_pct=overhead_pct,
|
||||
)
|
||||
|
||||
|
||||
def get_git_commit() -> str:
|
||||
"""Get current git commit SHA."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()[:7]
|
||||
except subprocess.CalledProcessError:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def save_results(results: list[BenchmarkResult], output_dir: Path) -> None:
|
||||
"""Save benchmark results to JSON, appending to existing data."""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
data_file = output_dir / "throughput_data.json"
|
||||
|
||||
# Load existing data.
|
||||
existing: list[dict] = []
|
||||
if data_file.exists():
|
||||
with open(data_file) as f:
|
||||
existing = json.load(f)
|
||||
|
||||
# Create new run entry.
|
||||
run_entry = {
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"commit": get_git_commit(),
|
||||
"results": [r.to_dict() for r in results],
|
||||
}
|
||||
|
||||
existing.append(run_entry)
|
||||
|
||||
with open(data_file, "w") as f:
|
||||
json.dump(existing, f, indent=2)
|
||||
|
||||
print(f"\nResults saved to {data_file}")
|
||||
|
||||
|
||||
def main(cfg: ThroughputConfig) -> list[BenchmarkResult]:
|
||||
"""Run throughput benchmarks on all configured tasks."""
|
||||
print("Throughput Benchmark")
|
||||
print(f" Envs: {cfg.num_envs}")
|
||||
print(f" Steps: {cfg.num_steps} (+ {cfg.warmup_steps} warmup)")
|
||||
print(f" Device: {cfg.device}")
|
||||
|
||||
results = []
|
||||
for task in cfg.tasks:
|
||||
result = benchmark_task(task, cfg)
|
||||
results.append(result)
|
||||
print(result)
|
||||
|
||||
print("\n" + "=" * 74)
|
||||
print("Summary (all values in env steps per second):")
|
||||
print(" Physics SPS: sim.step() only (×decimation per env step)")
|
||||
print(" Env SPS: full env.step() including managers")
|
||||
print(" Overhead: time spent on non-physics work (observations, rewards, etc.)")
|
||||
print("=" * 74)
|
||||
print(f"{'Task':<35} {'Dec':>4} {'Physics SPS':>12} {'Env SPS':>12} {'Overhead':>8}")
|
||||
print("-" * 74)
|
||||
for r in results:
|
||||
task_short = r.task.replace("Mjlab-", "").replace("-Unitree-", "-")
|
||||
print(
|
||||
f"{task_short:<35} {r.decimation:>4} {r.physics_sps:>12,.0f} {r.env_sps:>12,.0f} {r.overhead_pct:>7.1f}%"
|
||||
)
|
||||
|
||||
if cfg.output_dir:
|
||||
save_results(results, cfg.output_dir)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cfg = tyro.cli(ThroughputConfig, config=mjlab.TYRO_FLAGS)
|
||||
main(cfg)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 195 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 213 KiB |
@@ -0,0 +1,152 @@
|
||||
#!/bin/bash
|
||||
# Nightly training script for mjlab benchmarks
|
||||
#
|
||||
# This script clones mjlab fresh, runs the tracking benchmark, and generates a report.
|
||||
# It is designed to be called by a systemd timer or cron job.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/benchmarks/nightly_train.sh
|
||||
#
|
||||
# Environment variables:
|
||||
# CUDA_DEVICE: GPU device to use (default: 0)
|
||||
# WANDB_TAGS: Comma-separated tags for the run (default: nightly)
|
||||
# SKIP_TRAINING: Set to "1" to skip training and only generate report
|
||||
# SKIP_THROUGHPUT: Set to "1" to skip throughput benchmarking
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configuration
|
||||
CUDA_DEVICE="${CUDA_DEVICE:-0}"
|
||||
WANDB_TAGS="${WANDB_TAGS:-(\"nightly\",)}"
|
||||
SKIP_TRAINING="${SKIP_TRAINING:-0}"
|
||||
SKIP_THROUGHPUT="${SKIP_THROUGHPUT:-0}"
|
||||
|
||||
# Training configuration
|
||||
TASK="Mjlab-Tracking-Flat-Unitree-G1"
|
||||
NUM_ENVS=4096
|
||||
MAX_ITERATIONS=6000
|
||||
REGISTRY_NAME="rll_humanoid/wandb-registry-Motions/side_kick_test"
|
||||
|
||||
REPO_URL="git@github.com:mujocolab/mjlab.git"
|
||||
GH_PAGES_BRANCH="gh-pages"
|
||||
WORK_DIR="/tmp/mjlab-nightly-$$"
|
||||
GH_PAGES_DIR="/tmp/mjlab-gh-pages-$$"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
}
|
||||
|
||||
error() {
|
||||
log "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
clear_gpu() {
|
||||
local gpu_device="$1"
|
||||
log "Clearing GPU $gpu_device..."
|
||||
gpu_pids=$(nvidia-smi --query-compute-apps=pid --format=csv,noheader,nounits -i "$gpu_device" 2>/dev/null || true)
|
||||
if [[ -n "$gpu_pids" ]]; then
|
||||
for pid in $gpu_pids; do
|
||||
log "Killing process $pid on GPU $gpu_device"
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
done
|
||||
sleep 2 # Wait for processes to fully terminate
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ -d "$WORK_DIR" ]]; then
|
||||
log "Cleaning up work directory..."
|
||||
rm -rf "$WORK_DIR"
|
||||
fi
|
||||
if [[ -d "$GH_PAGES_DIR" ]]; then
|
||||
log "Cleaning up gh-pages clone..."
|
||||
rm -rf "$GH_PAGES_DIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
export GIT_SSH_COMMAND="ssh -i \"$HOME/.ssh/mjlab_nightly_ed25519\" \
|
||||
-o IdentitiesOnly=yes \
|
||||
-o StrictHostKeyChecking=accept-new"
|
||||
|
||||
# Clone fresh copy of mjlab
|
||||
log "Cloning mjlab..."
|
||||
git clone "$REPO_URL" "$WORK_DIR"
|
||||
cd "$WORK_DIR"
|
||||
|
||||
log "Starting nightly benchmark run"
|
||||
log "Task: $TASK"
|
||||
log "GPU: $CUDA_DEVICE"
|
||||
log "Commit: $(git rev-parse HEAD)"
|
||||
|
||||
# Run training
|
||||
if [[ "$SKIP_TRAINING" != "1" ]]; then
|
||||
log "Starting training..."
|
||||
|
||||
clear_gpu "$CUDA_DEVICE"
|
||||
|
||||
CUDA_VISIBLE_DEVICES="$CUDA_DEVICE" uv run train "$TASK" \
|
||||
--env.scene.num-envs "$NUM_ENVS" \
|
||||
--agent.max-iterations "$MAX_ITERATIONS" \
|
||||
--registry-name "$REGISTRY_NAME" \
|
||||
--agent.wandb-tags "$WANDB_TAGS"
|
||||
|
||||
log "Training completed"
|
||||
else
|
||||
log "Skipping training (SKIP_TRAINING=1)"
|
||||
fi
|
||||
|
||||
# Clone gh-pages branch (shallow clone for speed)
|
||||
log "Cloning gh-pages branch..."
|
||||
if git ls-remote --exit-code --heads origin "$GH_PAGES_BRANCH" > /dev/null 2>&1; then
|
||||
git clone --branch "$GH_PAGES_BRANCH" --depth 1 "$REPO_URL" "$GH_PAGES_DIR"
|
||||
else
|
||||
# Create new gh-pages branch
|
||||
mkdir -p "$GH_PAGES_DIR"
|
||||
cd "$GH_PAGES_DIR"
|
||||
git init
|
||||
git remote add origin "$REPO_URL"
|
||||
git checkout -b "$GH_PAGES_BRANCH"
|
||||
cd "$WORK_DIR"
|
||||
fi
|
||||
|
||||
# Copy cached data if exists
|
||||
REPORT_DIR="$GH_PAGES_DIR/nightly"
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
# Run throughput benchmark
|
||||
if [[ "$SKIP_THROUGHPUT" != "1" ]]; then
|
||||
log "Running throughput benchmark..."
|
||||
|
||||
clear_gpu "$CUDA_DEVICE"
|
||||
|
||||
CUDA_VISIBLE_DEVICES="$CUDA_DEVICE" uv run python scripts/benchmarks/measure_throughput.py \
|
||||
--num-envs "$NUM_ENVS" \
|
||||
--output-dir "$REPORT_DIR"
|
||||
log "Throughput benchmark completed"
|
||||
else
|
||||
log "Skipping throughput benchmark (SKIP_THROUGHPUT=1)"
|
||||
fi
|
||||
|
||||
# Generate report (uses cached data.json if present, only evaluates new runs)
|
||||
log "Generating benchmark report..."
|
||||
uv run python scripts/benchmarks/generate_report.py \
|
||||
--entity gcbc_researchers \
|
||||
--tag nightly \
|
||||
--output-dir "$REPORT_DIR"
|
||||
|
||||
log "Report generated"
|
||||
|
||||
# Commit and push
|
||||
cd "$GH_PAGES_DIR"
|
||||
git add -A
|
||||
if git diff --staged --quiet; then
|
||||
log "No changes to commit"
|
||||
else
|
||||
git commit -m "Update nightly tracking benchmark $(date '+%Y-%m-%d')"
|
||||
git push origin "$GH_PAGES_BRANCH" || log "Failed to push"
|
||||
log "Deployed to GitHub Pages"
|
||||
fi
|
||||
|
||||
log "Nightly benchmark complete"
|
||||
@@ -0,0 +1,65 @@
|
||||
# Systemd Setup for Nightly Benchmarks
|
||||
|
||||
This directory contains systemd user service and timer files for running nightly
|
||||
mjlab benchmarks.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# 1. Create user systemd directory if it doesn't exist
|
||||
mkdir -p ~/.config/systemd/user
|
||||
|
||||
# 2. Copy the service and timer files
|
||||
cp mjlab-nightly.service ~/.config/systemd/user/
|
||||
cp mjlab-nightly.timer ~/.config/systemd/user/
|
||||
|
||||
# 3. Edit the service file to set your WANDB_API_KEY
|
||||
# Get your key from: https://wandb.ai/authorize
|
||||
nano ~/.config/systemd/user/mjlab-nightly.service
|
||||
|
||||
# 4. Reload systemd
|
||||
systemctl --user daemon-reload
|
||||
|
||||
# 5. Enable and start the timer
|
||||
systemctl --user enable mjlab-nightly.timer
|
||||
systemctl --user start mjlab-nightly.timer
|
||||
|
||||
# 6. Enable lingering (so timer runs even when you're not logged in)
|
||||
sudo loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```bash
|
||||
# Check timer status
|
||||
systemctl --user status mjlab-nightly.timer
|
||||
|
||||
# List all timers and when they'll run next
|
||||
systemctl --user list-timers
|
||||
|
||||
# Run the benchmark manually (without waiting for timer)
|
||||
systemctl --user start mjlab-nightly.service
|
||||
|
||||
# View logs
|
||||
journalctl --user -u mjlab-nightly.service -f
|
||||
|
||||
# View recent logs
|
||||
journalctl --user -u mjlab-nightly.service --since "1 hour ago"
|
||||
|
||||
# Disable the timer
|
||||
systemctl --user disable mjlab-nightly.timer
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `~/.config/systemd/user/mjlab-nightly.service` to customize:
|
||||
|
||||
- `Environment="CUDA_DEVICE=0"` - Which GPU to use
|
||||
- `Environment="WANDB_API_KEY=..."` - Your WandB API key
|
||||
- `MemoryMax=32G` - Memory limit for the training job
|
||||
|
||||
Edit `~/.config/systemd/user/mjlab-nightly.timer` to change the schedule:
|
||||
|
||||
- `OnCalendar=*-*-* 02:00:00` - Default: 2 AM daily
|
||||
- `OnCalendar=Mon *-*-* 02:00:00` - Example: Mondays only at 2 AM
|
||||
- `OnCalendar=*-*-* 02,14:00:00` - Example: Twice daily at 2 AM and 2 PM
|
||||
@@ -0,0 +1,38 @@
|
||||
# mjlab Nightly Benchmark Service
|
||||
#
|
||||
# Installation:
|
||||
# 1. Copy this file to ~/.config/systemd/user/mjlab-nightly.service
|
||||
# 2. Edit the paths below to match your setup
|
||||
# 3. Enable with: systemctl --user enable mjlab-nightly.timer
|
||||
# 4. Start with: systemctl --user start mjlab-nightly.timer
|
||||
#
|
||||
# To run manually: systemctl --user start mjlab-nightly.service
|
||||
# To check logs: journalctl --user -u mjlab-nightly.service
|
||||
|
||||
[Unit]
|
||||
Description=mjlab Nightly Training Benchmark
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
|
||||
# Update these paths to match your setup
|
||||
Environment="PATH=/home/kevin/.local/bin:/home/kevin/.cargo/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
Environment="HOME=/home/kevin"
|
||||
Environment="CUDA_DEVICE=0"
|
||||
Environment="WANDB_API_KEY="
|
||||
|
||||
WorkingDirectory=/home/kevin/dev/mjlab
|
||||
ExecStart=/home/kevin/dev/mjlab/scripts/benchmarks/nightly_train.sh
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# Resource limits (adjust as needed)
|
||||
Nice=10
|
||||
MemoryMax=32G
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -0,0 +1,28 @@
|
||||
# mjlab Nightly Benchmark Timer
|
||||
#
|
||||
# This timer triggers the nightly training run.
|
||||
# Default: 2:00 AM daily
|
||||
#
|
||||
# Installation:
|
||||
# 1. Copy to ~/.config/systemd/user/mjlab-nightly.timer
|
||||
# 2. Enable: systemctl --user enable mjlab-nightly.timer
|
||||
# 3. Start: systemctl --user start mjlab-nightly.timer
|
||||
#
|
||||
# Check status: systemctl --user list-timers
|
||||
# Check next run: systemctl --user status mjlab-nightly.timer
|
||||
|
||||
[Unit]
|
||||
Description=Run mjlab nightly benchmarks
|
||||
|
||||
[Timer]
|
||||
# Run at 2:00 AM every day
|
||||
OnCalendar=*-*-* 02:00:00
|
||||
|
||||
# Add randomized delay up to 30 minutes to avoid thundering herd
|
||||
RandomizedDelaySec=1800
|
||||
|
||||
# If missed (e.g., machine was off), run on next boot
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
Reference in New Issue
Block a user