Buckets:
| #!/usr/bin/env python | |
| """scripts/run_sr.py — Python success-rate eval driver for ONF LIBERO-Plus cells. | |
| One Python entrypoint for launching+scoring a ladder cell (suite x axis x recovery mode): sets up | |
| the OSMesa render env, polls the policy server's port for readiness, sets PYTHONNOUSERSITE to dodge | |
| a user-site torch that shadows the pinned stablevla one, writes into the | |
| `results/plus_<suite>/<Axis_With_Underscores>/<TAG>/` layout the mp4 filenames encode success into, | |
| and passes through QNDF_DIR / LIBERO_INSTANCE_ID_FILTER. It: | |
| * resolves a ladder cell (suite, axis, recovery mode) into an env + CLI recipe by reading | |
| ``configs/sr_ladder.yaml`` (the cell table: expected episode counts, comparator dirs, gates, | |
| GPU hints) and PARSING ``evals/common/modes.sh`` for the recovery-mode env recipes (rather than | |
| re-declaring them — a parsed value can never drift from the bash launchers it replaces); | |
| * launches one GPU-resident StableVLA policy server + N sharded sim clients per cell via | |
| ``subprocess``, always tearing the server down (``finally`` + a signal handler, so Ctrl-C can't | |
| orphan a GPU-resident process); | |
| * is polite about shared hardware: refuses a GPU with too little free memory | |
| (``torch.cuda.mem_get_info`` — never ``nvidia-smi``, which is broken on this box) and refuses a | |
| large launch when the box is under load (``/proc/loadavg``); | |
| * enforces completeness (mp4 count vs. the yaml's expected episode count) rather than assuming it, | |
| and treats a partial result dir as poison for ``score.py`` — ``--resume`` purges and reruns it; | |
| * appends one append-only record per cell attempt to ``outputs/sr/ledger.jsonl`` (see | |
| :mod:`onf.graph.report`'s ``StageLogger`` for the run-dir/manifest convention this mirrors); | |
| * scores with the EXISTING ``evals/libero_plus/score.py`` / ``mcnemar.py`` (imported, not | |
| reimplemented) and prints a paired McNemar discordance alongside the marginal rate — on this | |
| deterministic bench, effects are 1-4 episodes and a marginal rate alone is not interpretable. | |
| Usage: | |
| python scripts/run_sr.py --check-env | |
| python scripts/run_sr.py --rung R4 --gpus 0,1 --dry-run | |
| python scripts/run_sr.py --rung R4 --gpus 0,1 [--mode sentinel] [--resume] | |
| python scripts/run_sr.py --cells goal:Background_Textures,long:Camera_Viewpoints --gpus 0,1 --mode sentinel | |
| python scripts/run_sr.py --score --rung R4 | |
| python scripts/run_sr.py --ledger | |
| Exit codes: 0 = ok / all gates met; 1 = operational error (preflight refusal, crash, nothing to do); | |
| 2 = ran/scored fine but a gate was NOT met (mirrors the graph CLI convention: a clean run that fails | |
| its bar is not the same failure mode as a broken run). | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import atexit | |
| import ast | |
| import dataclasses | |
| import itertools | |
| import json | |
| import os | |
| import re | |
| import shutil | |
| import signal | |
| import socket | |
| import subprocess | |
| import sys | |
| import threading | |
| import time | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any | |
| import yaml | |
| REPO_ROOT = Path(__file__).resolve().parents[1] | |
| LADDER_YAML = REPO_ROOT / "configs" / "sr_ladder.yaml" | |
| STABLEVLA_CONDA_ENV = "stablevla" # fixed operational fact: the conda env name this box uses | |
| STABLEVLA_ROOT = REPO_ROOT / "stablevla" | |
| ONF_SRC = REPO_ROOT / "src" | |
| # ===================================================================================================== | |
| # yaml loading | |
| # ===================================================================================================== | |
| def load_yaml(path: Path) -> dict: | |
| with open(path) as f: | |
| return yaml.safe_load(f) or {} | |
| def load_ladder(path: Path | None = None) -> dict: | |
| return load_yaml(path or LADDER_YAML) | |
| def resolve_paths(ladder_doc: dict) -> dict[str, Path]: | |
| """``ladder_doc["paths"]`` (all relative to $ONF_ROOT) -> absolute Paths. The single place the | |
| driver learns where score.py / mcnemar.py / modes.sh / the policy server / the harness live — | |
| nothing about them is hardcoded outside this file's bootstrap constants (REPO_ROOT, LADDER_YAML).""" | |
| p = ladder_doc["paths"] | |
| return {k: REPO_ROOT / v for k, v in p.items()} | |
| class SuiteInfo: | |
| name: str | |
| bench: str | |
| stablevla_ckpt: Path | |
| fwm_dir: Path | |
| light_filter: Path | |
| def load_suites(paths: dict[str, Path]) -> dict[str, SuiteInfo]: | |
| doc = load_yaml(paths["suites_yaml"]) | |
| out = {} | |
| for name, info in doc["suites"].items(): | |
| out[name] = SuiteInfo( | |
| name=name, | |
| bench=info["task_suite"], | |
| stablevla_ckpt=REPO_ROOT / info["stablevla_ckpt"], | |
| fwm_dir=REPO_ROOT / "data" / info["fwm_dir"], | |
| light_filter=REPO_ROOT / info["light_filter"], | |
| ) | |
| return out | |
| # ===================================================================================================== | |
| # reuse evals/libero_plus/score.py + mcnemar.py rather than reimplementing the mp4-glob scorer / the | |
| # paired McNemar test. Both are standalone scripts (no package __init__), so load them by file path. | |
| # ===================================================================================================== | |
| _module_cache: dict[str, Any] = {} | |
| def _load_module(name: str, path: Path): | |
| cached = _module_cache.get(str(path)) | |
| if cached is not None: | |
| return cached | |
| import importlib.util | |
| spec = importlib.util.spec_from_file_location(name, path) | |
| mod = importlib.util.module_from_spec(spec) | |
| sys.modules[name] = mod # register before exec so dataclasses/typing self-references resolve | |
| spec.loader.exec_module(mod) | |
| _module_cache[str(path)] = mod | |
| return mod | |
| def score_mod(paths: dict[str, Path]): | |
| return _load_module("sr_score", paths["score_py"]) | |
| def mcnemar_mod(paths: dict[str, Path]): | |
| return _load_module("sr_mcnemar", paths["mcnemar_py"]) | |
| def default_results_root(paths: dict[str, Path]) -> Path: | |
| return Path(score_mod(paths).results_root()) | |
| # ===================================================================================================== | |
| # evals/common/modes.sh parser — the recovery-mode env recipes are PARSED from the bash file (the | |
| # single source of truth for onf_set_mode()), not re-declared in yaml, so this driver can never drift | |
| # out of sync with the launchers it replaces. See tests/test_run_sr.py for a check that the parser's | |
| # output matches the CONTRACT values documented in modes.sh's own header comment. | |
| # ===================================================================================================== | |
| _CASE_BLOCK_RE = re.compile(r'case\s+"\$\{mode\}"\s+in\n(.*?)\n[ \t]*esac', re.DOTALL) | |
| _ARM_SPLIT_RE = re.compile(r'^[ \t]*([A-Za-z_]\w*(?:\|[A-Za-z_]\w*)*)\)[ \t]*\n', re.MULTILINE) | |
| _ASSIGN_RE = re.compile(r'^([A-Za-z_]\w*)=(.*)$') | |
| _PARAM_DEFAULT_RE = re.compile(r'^"?\$\{[A-Za-z_]\w*:-([^}]*)\}"?$') | |
| _mode_recipes_cache: dict[str, dict[str, dict[str, str]]] = {} | |
| def parse_mode_recipes(modes_sh_text: str) -> dict[str, dict[str, str]]: | |
| """Parse ``onf_set_mode()``'s ``case "${mode}" in ... esac`` block into | |
| ``{mode_name: {ENV_VAR: value, ...}}``. Every recipe includes the ``JOINT_RECOVERY=0 SENTINEL=0`` | |
| baseline the function always exports before dispatch (mirrors ``export JOINT_RECOVERY=0 | |
| SENTINEL=0`` at the top of ``onf_set_mode``), then whatever the matched arm overrides.""" | |
| m = _CASE_BLOCK_RE.search(modes_sh_text) | |
| if not m: | |
| raise ValueError('modes.sh: could not find `case "${mode}" in ... esac` block') | |
| body = m.group(1) | |
| recipes: dict[str, dict[str, str]] = {} | |
| parts = _ARM_SPLIT_RE.split(body) | |
| # parts[0] is text before the first arm header (comments); then alternating (pattern, arm_text). | |
| for i in range(1, len(parts), 2): | |
| pattern, arm_text = parts[i], parts[i + 1] | |
| env = {"JOINT_RECOVERY": "0", "SENTINEL": "0"} | |
| env.update(_parse_arm_exports(arm_text)) | |
| for name in pattern.split("|"): | |
| recipes[name] = env | |
| return recipes | |
| def _parse_arm_exports(arm_text: str) -> dict[str, str]: | |
| text = arm_text.split(";;")[0] # stop at the arm terminator | |
| lines = [ln.split("#", 1)[0] for ln in text.splitlines()] # strip comments (no '#' in any value) | |
| joined = "" | |
| for ln in lines: | |
| stripped = ln.rstrip() | |
| joined += (stripped[:-1] + " ") if stripped.endswith("\\") else (stripped + " ") | |
| out: dict[str, str] = {} | |
| for tok in joined.split(): | |
| if tok == "export": | |
| continue | |
| m = _ASSIGN_RE.match(tok) | |
| if m: | |
| out[m.group(1)] = _expand_value(m.group(2)) | |
| return out | |
| def _expand_value(val: str) -> str: | |
| m = _PARAM_DEFAULT_RE.match(val) | |
| if m: | |
| return m.group(1) | |
| return val.strip('"') | |
| def mode_env(mode: str, paths: dict[str, Path]) -> dict[str, str]: | |
| modes_sh = paths["modes_sh"] | |
| key = str(modes_sh) | |
| if key not in _mode_recipes_cache: | |
| _mode_recipes_cache[key] = parse_mode_recipes(modes_sh.read_text()) | |
| recipes = _mode_recipes_cache[key] | |
| if mode not in recipes: | |
| raise ValueError(f"unknown MODE={mode!r}; not found in {modes_sh} (known: {sorted(recipes)})") | |
| return dict(recipes[mode]) | |
| # ===================================================================================================== | |
| # ladder cell model | |
| # ===================================================================================================== | |
| class Comparator: | |
| label: str | |
| tag: str | |
| successes: int | |
| n: int | |
| def rate(self) -> float: | |
| return 100.0 * self.successes / self.n if self.n else float("nan") | |
| class Gate: | |
| kind: str # min_successes | max_regress_pp | exact_match | |
| vs: str = "base" | |
| min_successes: int | None = None | |
| max_regress_pp: float | None = None | |
| def from_dict(cls, d: dict) -> Gate: | |
| return cls(kind=d["kind"], vs=d.get("vs", "base"), | |
| min_successes=d.get("min_successes"), max_regress_pp=d.get("max_regress_pp")) | |
| class Cell: | |
| rung: str | |
| suite: str | |
| axis: str | |
| mode: str | |
| expected_n: int | |
| filter_file: Path | None = None | |
| extra_env: dict[str, str] = dataclasses.field(default_factory=dict) | |
| comparators: list[Comparator] = dataclasses.field(default_factory=list) | |
| gate: Gate | None = None | |
| num_clients: int = 8 | |
| policy: str = "stablevla" | |
| variant: str = "" | |
| def tag(self) -> str: | |
| return cell_tag(self.policy, self.mode, self.suite, self.axis, self.variant) | |
| def bench(self, paths: dict[str, Path]) -> str: | |
| return score_mod(paths).SUITE_BENCH[self.suite] | |
| def category_value(self) -> str: | |
| return self.axis.replace("_", " ") | |
| def result_dir(self, results_root: Path, paths: dict[str, Path]) -> Path: | |
| return results_root / f"plus_{self.bench(paths)}" / self.axis / self.tag | |
| def comparator(self, label: str) -> Comparator | None: | |
| for c in self.comparators: | |
| if c.label == label: | |
| return c | |
| return None | |
| def default_mode_for_axis(axis: str, ladder_doc: dict) -> str: | |
| m = ladder_doc["axis_default_mode"] | |
| return m.get(axis, m["default"]) | |
| def resolve_filter(filt: str | None, axis: str, suite: str, suites: dict[str, SuiteInfo]) -> Path | None: | |
| if filt in (None, "auto"): | |
| if filt == "auto" or axis == "Light_Conditions": | |
| return suites[suite].light_filter | |
| return None | |
| return REPO_ROOT / filt | |
| def cell_tag(policy: str, mode: str, suite: str, axis: str, variant: str = "") -> str: | |
| """The one on-disk name for a cell: ``{policy}_{mode}_{suite}_{axis}[_{variant}]``. | |
| Both drivers (this one and ``evals/gr00t/run_gr00t.py``) route every result dir, log dir and | |
| ledger row through this function, so a name always says which policy and which mode produced it. | |
| Baking the mode in is what stops ``--resume``'s ledger lookup from reporting one mode's numbers | |
| under another's; ``variant`` separates a filtered smoke pool from the full axis it subsets. | |
| """ | |
| tag = f"{policy}_{mode}_{suite}_{axis}" | |
| return f"{tag}_{variant}" if variant else tag | |
| def cell_with_mode(cell: "Cell", mode: str) -> "Cell": | |
| """Swap ``cell``'s mode. The tag follows automatically -- it is derived, not stored.""" | |
| return dataclasses.replace(cell, mode=mode) | |
| def build_cell(rung_name: str, cell_doc: dict, ladder_doc: dict, suites: dict[str, SuiteInfo]) -> Cell: | |
| suite, axis = cell_doc["suite"], cell_doc["axis"] | |
| mode = cell_doc.get("mode") or default_mode_for_axis(axis, ladder_doc) | |
| comparators = [Comparator(**c) for c in cell_doc.get("comparators", [])] | |
| gate_doc = cell_doc.get("gate") or ladder_doc.get("default_gate") | |
| return Cell( | |
| rung=rung_name, suite=suite, axis=axis, mode=mode, variant=cell_doc.get("variant", ""), | |
| expected_n=int(cell_doc["expected_n"]), | |
| filter_file=resolve_filter(cell_doc.get("filter"), axis, suite, suites), | |
| extra_env={k: str(v) for k, v in (cell_doc.get("env") or {}).items()}, | |
| comparators=comparators, gate=Gate.from_dict(gate_doc) if gate_doc else None, | |
| num_clients=int(cell_doc.get("num_clients", ladder_doc["defaults"]["num_clients"])), | |
| ) | |
| def resolve_r4_comparators(suite: str, axis: str, results_root: Path, paths: dict[str, Path]) -> list[Comparator]: | |
| """R4's comparators aren't hand-enumerated (28 cells would go stale the moment a new baseline | |
| lands): probe whichever PRE-EXISTING baseline result directories are already on disk, so a run | |
| never re-measures a baseline that has been measured. Those directory names are historical, from | |
| whichever run produced them -- they are disk facts, not modes this repo can produce. Reuses | |
| score.py's SUITE_BENCH/count_dir so this can never disagree with `score.py` about what a tag | |
| directory scores.""" | |
| sm = score_mod(paths) | |
| bench = sm.SUITE_BENCH[suite] | |
| if axis == "Robot_Initial_States": | |
| candidates = [("base", f"onfbase_{bench}_{axis}"), ("onf", f"onfv1_{bench}_{axis}")] | |
| else: | |
| candidates = [("base", f"cat_{axis}_base"), ("sentinel", f"cat_{axis}_sentinel"), | |
| ("base", f"lp_{bench}_{axis}"), ("prior", f"cndf_{bench}_{axis}")] | |
| out, seen = [], set() | |
| for label, tag in candidates: | |
| if (label, tag) in seen: | |
| continue | |
| d = results_root / f"plus_{bench}" / axis / tag | |
| if d.is_dir(): | |
| s, n = sm.count_dir(str(d)) | |
| if n: | |
| out.append(Comparator(label=label, tag=tag, successes=s, n=n)) | |
| seen.add((label, tag)) | |
| return out | |
| def generate_full_grid_cells(rung_name: str, rung_doc: dict, ladder_doc: dict, suites: dict[str, SuiteInfo], | |
| results_root: Path, paths: dict[str, Path]) -> list[Cell]: | |
| cells = [] | |
| sm = score_mod(paths) | |
| gate = Gate.from_dict(rung_doc["gate"]) | |
| for suite in ("object", "spatial", "goal", "long"): | |
| for axis in sm.AXES: | |
| mode = default_mode_for_axis(axis, ladder_doc) | |
| cells.append(Cell( | |
| rung=rung_name, suite=suite, axis=axis, mode=mode, | |
| expected_n=int(ladder_doc["expected_episodes"][suite][axis]), | |
| filter_file=resolve_filter(None, axis, suite, suites), | |
| comparators=resolve_r4_comparators(suite, axis, results_root, paths), | |
| gate=gate, num_clients=ladder_doc["defaults"]["num_clients"], | |
| )) | |
| return cells | |
| def rung_cells(ladder_doc: dict, rung_name: str, suites: dict[str, SuiteInfo], | |
| results_root: Path, paths: dict[str, Path]) -> list[Cell]: | |
| rung_doc = ladder_doc["rungs"][rung_name] | |
| if rung_doc.get("auto_generate") == "full_grid": | |
| return generate_full_grid_cells(rung_name, rung_doc, ladder_doc, suites, results_root, paths) | |
| return [build_cell(rung_name, c, ladder_doc, suites) for c in rung_doc["cells"]] | |
| def find_rung_cell(ladder_doc: dict, suite: str, axis: str, suites: dict[str, SuiteInfo]) -> Cell | None: | |
| """Best-effort lookup used by ``--cells``: if an existing rung already declares this exact | |
| (suite, axis) pair, reuse its comparators/gate so an ad hoc cell isn't scored in a vacuum.""" | |
| for rung_name, rung_doc in ladder_doc["rungs"].items(): | |
| if rung_doc.get("auto_generate"): | |
| continue | |
| for cell_doc in rung_doc["cells"]: | |
| if cell_doc["suite"] == suite and cell_doc["axis"] == axis: | |
| return build_cell(rung_name, cell_doc, ladder_doc, suites) | |
| return None | |
| def parse_cells_arg(cells_arg: str, mode: str | None, ladder_doc: dict, suites: dict[str, SuiteInfo], | |
| results_root: Path, paths: dict[str, Path]) -> list[Cell]: | |
| sm = score_mod(paths) | |
| out = [] | |
| for token in (t.strip() for t in cells_arg.split(",")): | |
| if not token: | |
| continue | |
| suite, _, axis = token.partition(":") | |
| if suite not in suites: | |
| raise ValueError(f"--cells: unknown suite {suite!r} in {token!r} (known: {sorted(suites)})") | |
| if axis not in sm.AXES: | |
| raise ValueError(f"--cells: unknown axis {axis!r} in {token!r} (known: {sm.AXES})") | |
| resolved_mode = mode or default_mode_for_axis(axis, ladder_doc) | |
| expected_n = int(ladder_doc["expected_episodes"][suite][axis]) | |
| matched = find_rung_cell(ladder_doc, suite, axis, suites) | |
| comparators = matched.comparators if matched else resolve_r4_comparators(suite, axis, results_root, paths) | |
| gate = matched.gate if matched else Gate.from_dict(ladder_doc["default_gate"]) | |
| out.append(Cell( | |
| rung="adhoc", suite=suite, axis=axis, mode=resolved_mode, expected_n=expected_n, | |
| filter_file=resolve_filter(None, axis, suite, suites), comparators=comparators, gate=gate, | |
| num_clients=ladder_doc["defaults"]["num_clients"], | |
| )) | |
| return out | |
| # ===================================================================================================== | |
| # env composition (see the module docstring). This dict (minus the per-process | |
| # CUDA_VISIBLE_DEVICES/PYTHONPATH layered on at launch time) is what gets recorded verbatim in the | |
| # ledger for reproducibility. | |
| # ===================================================================================================== | |
| def resolve_conda_env_prefix(name: str) -> Path | None: | |
| """Resolve a conda env's prefix without sourcing a shell: $CONDA_EXE parent-of-parent, then the | |
| usual install prefixes.""" | |
| candidates = [] | |
| conda_exe = os.environ.get("CONDA_EXE") | |
| if conda_exe: | |
| candidates.append(Path(conda_exe).resolve().parent.parent / "envs" / name) | |
| for base in (Path.home() / "miniconda3", Path.home() / "anaconda3", Path.home() / "miniforge3", | |
| Path("/opt/conda"), Path("/opt/miniconda3")): | |
| candidates.append(base / "envs" / name) | |
| for c in candidates: | |
| if (c / "bin" / "python").exists(): | |
| return c | |
| return None | |
| def resolve_stablevla_python() -> Path | None: | |
| prefix = resolve_conda_env_prefix(STABLEVLA_CONDA_ENV) | |
| return (prefix / "bin" / "python") if prefix else None | |
| # This box's kernel nvidia driver module and userspace NVML library are mismatched (measured: | |
| # kernel 570.172.08, userspace 580.173) -- `nvidia-smi` itself is unusable without preloading a | |
| # userspace NVML that matches the kernel module, and so is torch's CUDA caching allocator whenever it | |
| # needs to grow its pool (nvmlInit_v2 hard-asserts on the mismatch -- see the ONF_ROOT-external | |
| # HAMLET-Isaac-GR00T repo's own vulkan570_env.sh fix for the identical class of problem). `ONF_NVML_PRELOAD` | |
| # overrides the path outright; failing that, the known-good file from that sibling repo's fix is used if | |
| # present on this exact box, so a checkout elsewhere without that sibling repo just silently gets no | |
| # LD_PRELOAD (compose_cell_env degrades to the OLD, occasionally-crashing behaviour, not a hard failure). | |
| _NVML_PRELOAD_CANDIDATE = Path( | |
| "/srv/data/HAMLET-Isaac-GR00T/.vkenv/nvidia570/lib/libnvidia-ml.so.570.172.08" | |
| ) | |
| def nvml_preload_path() -> str | None: | |
| override = os.environ.get("ONF_NVML_PRELOAD") | |
| if override: | |
| return override if Path(override).exists() else None | |
| return str(_NVML_PRELOAD_CANDIDATE) if _NVML_PRELOAD_CANDIDATE.exists() else None | |
| def libero_home_dir() -> Path: | |
| return Path(os.environ.get("LIBERO_HOME", str(REPO_ROOT / "external" / "LIBERO-plus"))) | |
| # ===================================================================================================== | |
| # graph-artifact resolution -- ONE resolved directory per suite, shared by JQ_GRAPH_DIR (entry, | |
| # onf.recovery.targets) and SN_GRAPH_DIR (sentinel, onf.sentinel.sentinel), instead of letting each | |
| # fall through its OWN independent default. Before this, a cell that never set either var still got | |
| # lucky (both defaulted through the same onf.config.Paths.graph(suite) call) -- but that agreement was | |
| # incidental, not enforced, and evals/gr00t/run_gr00t.py's independent SN_GRAPH_DIR override (see | |
| # run_gr00t.require_graph_artifacts) is exactly the case where it silently stopped holding. | |
| # ===================================================================================================== | |
| def resolve_graph_dir(suite: str) -> Path: | |
| """The graph-artifact dir (``g_nodes.npz`` / ``g_edges.npz`` / ``g_head.npz`` / ``g_track.npz``) | |
| for ``suite`` -- the SAME resolution ``onf.recovery.targets`` and ``onf.sentinel.sentinel`` fall | |
| back to internally (``onf.config.Paths.graph(suite)``, which itself honors the ``GR_GRAPH_DIR`` | |
| override). Computed here so both drivers can bind ``JQ_GRAPH_DIR``/``SN_GRAPH_DIR`` to the exact | |
| same path up front, and log/record it, rather than letting two independently-resolved env vars | |
| drift apart.""" | |
| from onf.config import default_paths | |
| return default_paths().graph(suite) | |
| def graph_artifact_status(graph_dir: Path) -> dict: | |
| """Inspect a resolved graph dir for the two artifacts the recovery ↔ sentinel share: | |
| ``g_head.npz`` (the trained readout head, entry's WHERE) and ``g_track.npz`` (the fitted belief | |
| filter, sentinel's WHEN/WHERE). Returns | |
| ``{"graph_dir", "ok", "missing": [...], "graph_hash", "g_head_mtime"}`` -- ``graph_hash`` is read | |
| straight off ``g_head.npz``'s own stamp (no need to load nodes/edges/the network just to log it), | |
| ``g_head_mtime`` is that file's mtime as an ISO-8601 UTC string. Never raises: a suite with no | |
| graph built yet gets ``ok=False`` and both fields None, not a crash -- callers (both drivers) fail | |
| loud on ``not ok`` for any non-``base`` mode before launching anything GPU-resident.""" | |
| import numpy as np | |
| from onf.graph import schema as graph_schema | |
| graph_dir = Path(graph_dir) | |
| head = graph_dir / graph_schema.HEAD_NPZ | |
| track = graph_dir / graph_schema.TRACK_NPZ | |
| missing = [str(p) for p in (head, track) if not p.exists()] | |
| graph_hash = None | |
| g_head_mtime = None | |
| g_track_mtime = None | |
| if head.exists(): | |
| try: | |
| with np.load(head, allow_pickle=False) as raw: | |
| if "graph_hash" in raw.files: | |
| graph_hash = str(raw["graph_hash"]) | |
| except Exception as e: # noqa: BLE001 -- report, never crash a status probe | |
| missing.append(f"{head} (unreadable: {e})") | |
| g_head_mtime = datetime.fromtimestamp( | |
| head.stat().st_mtime, tz=timezone.utc).isoformat(timespec="seconds") | |
| if track.exists(): | |
| g_track_mtime = datetime.fromtimestamp( | |
| track.stat().st_mtime, tz=timezone.utc).isoformat(timespec="seconds") | |
| return {"graph_dir": str(graph_dir), "ok": not missing, "missing": missing, | |
| "graph_hash": graph_hash, "g_head_mtime": g_head_mtime, "g_track_mtime": g_track_mtime} | |
| def compose_cell_env(cell: Cell, suites: dict[str, SuiteInfo], paths: dict[str, Path]) -> dict[str, str]: | |
| libero_home = libero_home_dir() | |
| task_classification = libero_home / "libero" / "libero" / "benchmark" / "task_classification.json" | |
| env: dict[str, str] = { | |
| "PYTHONNOUSERSITE": "1", | |
| "OMP_NUM_THREADS": "4", "MKL_NUM_THREADS": "4", "OPENBLAS_NUM_THREADS": "4", | |
| "LOAD_VISION": "0", | |
| "QNDF_DIR": str(suites[cell.suite].fwm_dir), | |
| "LIBERO_HOME": str(libero_home), | |
| "LIBERO_CONFIG_PATH": str(libero_home / "libero"), | |
| "LIBERO_PLUS_TASK_CLASSIFICATION": str(task_classification), | |
| "MUJOCO_GL": "osmesa", "PYOPENGL_PLATFORM": "osmesa", | |
| } | |
| conda_prefix = resolve_conda_env_prefix(STABLEVLA_CONDA_ENV) | |
| if conda_prefix is not None: | |
| env["MAGICK_HOME"] = str(conda_prefix) | |
| ld = os.environ.get("LD_LIBRARY_PATH", "") | |
| env["LD_LIBRARY_PATH"] = f"{conda_prefix / 'lib'}:{ld}" if ld else str(conda_prefix / "lib") | |
| nvml_preload = nvml_preload_path() | |
| if nvml_preload is not None: | |
| # This box's kernel nvidia module (570.172.08) and userspace NVML (580.173) are mismatched -- | |
| # torch's CUDA caching allocator calls nvmlInit_v2 whenever it needs to GROW its pool and | |
| # hard-asserts on failure (a nondeterministic, memory-pressure-dependent crash: only the shard | |
| # that happens to need more pool at the wrong moment dies -- see graph/track-mode runs, which | |
| # allocate more and hit this far more often than a plain `base` rollout ever does). The SAME | |
| # preload that makes `nvidia-smi` usable on this box fixes it for every child process too. | |
| # Every launched process (server AND client shards) inherits this via compose_cell_env, not an | |
| # ad-hoc one-off export on a single command. | |
| existing = os.environ.get("LD_PRELOAD", "") | |
| env["LD_PRELOAD"] = f"{nvml_preload}:{existing}" if existing else nvml_preload | |
| if cell.filter_file is not None: | |
| env["LIBERO_INSTANCE_ID_FILTER"] = str(cell.filter_file) | |
| env.update(mode_env(cell.mode, paths)) | |
| # Bind JQ_GRAPH_DIR (entry) and SN_GRAPH_DIR (sentinel) to the SAME resolved directory -- see | |
| # resolve_graph_dir's docstring. Set unconditionally (cheap: no I/O beyond the eventual mtime | |
| # stat in graph_artifact_status); `base` mode reads neither var so this is a no-op for it. | |
| resolved_graph_dir = str(resolve_graph_dir(cell.suite)) | |
| env["JQ_GRAPH_DIR"] = resolved_graph_dir | |
| env["SN_GRAPH_DIR"] = resolved_graph_dir | |
| env.update(cell.extra_env) # explicit per-cell overrides (a yaml cell's `env:` block) win last | |
| return env | |
| def _pythonpath_for(dirs: list[Path]) -> str: | |
| parts = [str(d) for d in dirs] | |
| existing = os.environ.get("PYTHONPATH") | |
| if existing: | |
| parts.append(existing) | |
| return os.pathsep.join(parts) | |
| def server_pythonpath(paths: dict[str, Path]) -> str: | |
| return _pythonpath_for([STABLEVLA_ROOT, paths["harness_dir"], ONF_SRC]) | |
| def client_pythonpath(paths: dict[str, Path]) -> str: | |
| return _pythonpath_for([paths["harness_dir"], STABLEVLA_ROOT, libero_home_dir(), ONF_SRC, paths["shim_dir"]]) | |
| # ===================================================================================================== | |
| # GPU / load preflight — torch.cuda ONLY. nvidia-smi is broken on this box (NVML library/kernel | |
| # version mismatch); torch.cuda.device_count()/mem_get_info() work fine regardless. | |
| # ===================================================================================================== | |
| class GpuStatus: | |
| index: int | |
| free_gib: float | |
| total_gib: float | |
| ok: bool | |
| reason: str = "" | |
| def gpu_report(indices: list[int], min_free_gib: float) -> list[GpuStatus]: | |
| import torch | |
| out = [] | |
| n = torch.cuda.device_count() | |
| for i in indices: | |
| if i >= n or i < 0: | |
| out.append(GpuStatus(i, 0.0, 0.0, False, f"GPU {i} does not exist (device_count={n})")) | |
| continue | |
| free, total = torch.cuda.mem_get_info(i) | |
| free_gib, total_gib = free / 1024**3, total / 1024**3 | |
| ok = free_gib >= min_free_gib | |
| reason = "" if ok else f"only {free_gib:.1f} GiB free (< {min_free_gib:.1f} GiB threshold)" | |
| out.append(GpuStatus(i, free_gib, total_gib, ok, reason)) | |
| return out | |
| class LoadStatus: | |
| load1: float | |
| ncpu: int | |
| threshold: float | |
| ok: bool | |
| def load_report(max_factor: float) -> LoadStatus: | |
| load1 = os.getloadavg()[0] | |
| ncpu = os.cpu_count() or 1 | |
| threshold = ncpu * max_factor | |
| return LoadStatus(load1=load1, ncpu=ncpu, threshold=threshold, ok=load1 <= threshold) | |
| def check_libero_plus(timeout_s: float = 120.0, ladder_doc: dict | None = None, | |
| paths: dict[str, Path] | None = None) -> tuple[bool, str]: | |
| ladder_doc = ladder_doc or load_ladder() | |
| paths = paths or resolve_paths(ladder_doc) | |
| libero_home = libero_home_dir() | |
| benchmark_init = libero_home / "libero" / "libero" / "benchmark" / "__init__.py" | |
| if not libero_home.is_dir() or not benchmark_init.exists(): | |
| return False, (f"external/LIBERO-plus missing/empty at {libero_home} " | |
| f"(no libero/libero/benchmark/__init__.py) -- run: bash scripts/setup_external.sh") | |
| py = resolve_stablevla_python() | |
| if py is None: | |
| return False, f"cannot locate the `{STABLEVLA_CONDA_ENV}` conda env's python" | |
| snippet = ("from libero.libero.benchmark import get_ids_by_category as g; " | |
| "d = g('Robot Initial States'); print({k: len(v[0]) for k, v in d.items()})") | |
| env = dict(os.environ) | |
| env["LIBERO_HOME"] = str(libero_home) | |
| env["LIBERO_CONFIG_PATH"] = str(libero_home / "libero") | |
| env["PYTHONPATH"] = _pythonpath_for([libero_home]) | |
| env["PYTHONNOUSERSITE"] = "1" | |
| env["OMP_NUM_THREADS"] = env["MKL_NUM_THREADS"] = env["OPENBLAS_NUM_THREADS"] = "4" | |
| try: | |
| out = subprocess.run([str(py), "-c", snippet], cwd=REPO_ROOT, env=env, | |
| capture_output=True, text=True, timeout=timeout_s) | |
| except Exception as e: # report, don't crash --check-env | |
| return False, f"LIBERO-Plus check crashed: {e}" | |
| if out.returncode != 0: | |
| tail = "\n".join(out.stderr.strip().splitlines()[-15:]) | |
| return False, f"LIBERO-Plus check FAILED (rc={out.returncode}):\n{tail}" | |
| lines = out.stdout.strip().splitlines() | |
| m = re.search(r"\{.*\}", lines[-1]) if lines else None | |
| if not m: | |
| return False, f"LIBERO-Plus check: could not parse output: {out.stdout!r}" | |
| try: | |
| counts = ast.literal_eval(m.group(0)) | |
| except Exception as e: | |
| return False, f"LIBERO-Plus check: could not parse counts dict: {e}" | |
| # Expected "Robot Initial States" counts per bench, read from the ladder yaml's | |
| # expected_episodes table (via SUITE_BENCH) rather than a second hardcoded copy. | |
| sm = score_mod(paths) | |
| expected = {sm.SUITE_BENCH[s]: n["Robot_Initial_States"] for s, n in ladder_doc["expected_episodes"].items()} | |
| mismatches = {k: (counts.get(k), v) for k, v in expected.items() if counts.get(k) != v} | |
| if mismatches: | |
| return False, f"LIBERO-Plus counts mismatch: got {counts}, expected {expected}" | |
| return True, f"counts OK: {counts}" | |
| # ===================================================================================================== | |
| # ledger — outputs/sr/ledger.jsonl, one append-only record per cell attempt (a "running" record at | |
| # launch, a final complete/incomplete/crashed record after scoring). Uses onf.config.Paths.outputs() | |
| # so it honors ONF_OUTPUTS like every other StageLogger-based run dir in this repo. | |
| # ===================================================================================================== | |
| _ledger_lock = threading.Lock() | |
| def default_ledger_path() -> Path: | |
| from onf.config import default_paths | |
| return default_paths().outputs("sr", "ledger.jsonl") | |
| def append_ledger(record: dict, path: Path | None = None) -> None: | |
| path = path or default_ledger_path() | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| line = json.dumps(record, default=str) | |
| with _ledger_lock, open(path, "a") as f: | |
| f.write(line + "\n") | |
| def read_ledger(path: Path | None = None) -> list[dict]: | |
| path = path or default_ledger_path() | |
| if not path.exists(): | |
| return [] | |
| out = [] | |
| with open(path) as f: | |
| for line in f: | |
| line = line.strip() | |
| if line: | |
| out.append(json.loads(line)) | |
| return out | |
| def latest_matching_record(records: list[dict], tag: str, mode: str | None = None) -> dict | None: | |
| """Last (most recent -- JSONL is append-ordered) record for `tag`, or None. | |
| ``mode``, if given, must ALSO match the record's ``mode`` field. Tags now bake the mode into their | |
| own string (see :func:`cell_tag`), so this is defense in depth -- it guards a ledger record written | |
| before that fix landed (an old-style tag with no mode suffix) from being mistaken for a match by a | |
| tag string that happens to collide.""" | |
| rec = None | |
| for r in records: | |
| if r.get("tag") == tag and (mode is None or r.get("mode") == mode): | |
| rec = r | |
| return rec | |
| def latest_record(tag: str, path: Path | None = None, mode: str | None = None) -> dict | None: | |
| return latest_matching_record(read_ledger(path), tag, mode) | |
| # ===================================================================================================== | |
| # log-directory naming — shared by BOTH drivers (run_sr.py's own run_one_cell and | |
| # evals/gr00t/run_gr00t.py, which imports this module by file path and calls this function directly) | |
| # so a StableVLA cell and a GR00T cell can never collide, and so mode/suite/axis are always legible | |
| # from the directory name instead of just a tag suffix a human has to decode. Before this, run_sr.py | |
| # stamped LOCAL time while run_gr00t.py stamped UTC, and run_gr00t.py's own root additionally used the | |
| # literal "gr00tn17" instead of its own --tag-prefix -- two runs differing only by mode/suite/axis | |
| # truncated each other's shard logs into the same directory. | |
| # ===================================================================================================== | |
| def utc_stamp() -> str: | |
| return datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") | |
| def make_log_dir(policy: str, mode: str, suite: str, axis: str, variant: str = "", | |
| root: Path = REPO_ROOT) -> Path: | |
| """``logs/{UTC ts}_{tag}/`` for this cell's :func:`cell_tag` -- the log dir and the result dir | |
| therefore carry the SAME name, so one is findable from the other. Axis is normalized to | |
| underscores so a space-separated category name (``"Sensor Noise"``) stays one path component.""" | |
| tag = cell_tag(policy, mode, suite, axis.replace(" ", "_"), variant) | |
| return root / "logs" / f"{utc_stamp()}_{tag}" | |
| # ===================================================================================================== | |
| # run.json provenance — written into EVERY result directory by both drivers so any success-rate number | |
| # can later be traced back to the exact code + artifacts that produced it (policy, mode, suite, axis, | |
| # expected episode count, resolved graph dir, its graph_hash + g_head.npz mtime, the git commit, and | |
| # the full mode env). | |
| # ===================================================================================================== | |
| def write_run_json(result_dir: Path, **fields: object) -> Path: | |
| result_dir = Path(result_dir) | |
| result_dir.mkdir(parents=True, exist_ok=True) | |
| payload = {"written_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), **fields} | |
| out = result_dir / "run.json" | |
| with open(out, "w") as f: | |
| json.dump(payload, f, indent=2, default=str) | |
| return out | |
| def _git_sha() -> str | None: | |
| try: | |
| out = subprocess.run(["git", "rev-parse", "HEAD"], cwd=REPO_ROOT, | |
| capture_output=True, text=True, timeout=10) | |
| except (OSError, subprocess.SubprocessError): | |
| return None | |
| return out.stdout.strip() if out.returncode == 0 else None | |
| def _git_dirty() -> bool | None: | |
| try: | |
| out = subprocess.run(["git", "status", "--porcelain"], cwd=REPO_ROOT, | |
| capture_output=True, text=True, timeout=10) | |
| except (OSError, subprocess.SubprocessError): | |
| return None | |
| return bool(out.stdout.strip()) if out.returncode == 0 else None | |
| # ===================================================================================================== | |
| # scoring / completeness / gates — reuse score.py's count_dir + mcnemar.py's load()/mcnemar_exact() | |
| # ===================================================================================================== | |
| class CellScore: | |
| cell: Cell | |
| successes: int | |
| actual_n: int | |
| complete: bool | |
| rate: float | |
| def score_cell(cell: Cell, results_root: Path, paths: dict[str, Path]) -> CellScore: | |
| d = cell.result_dir(results_root, paths) | |
| s, n = score_mod(paths).count_dir(str(d)) if d.exists() else (0, 0) | |
| rate = 100.0 * s / n if n else float("nan") | |
| return CellScore(cell=cell, successes=s, actual_n=n, complete=(n == cell.expected_n), rate=rate) | |
| class GateVerdict: | |
| passed: bool | |
| message: str | |
| def evaluate_gate(score: CellScore, allow_partial: bool) -> GateVerdict: | |
| cell = score.cell | |
| if not score.complete and not allow_partial: | |
| return GateVerdict(False, f"INCOMPLETE: {score.actual_n}/{cell.expected_n} episodes " | |
| f"(excluded from totals; pass --allow-partial to score anyway)") | |
| gate = cell.gate | |
| if gate is None: | |
| return GateVerdict(True, "no gate configured") | |
| comp = cell.comparator(gate.vs) | |
| if comp is None: | |
| return GateVerdict(True, f"SKIP: no comparator labeled {gate.vs!r} to gate against") | |
| if gate.kind == "exact_match": | |
| ok = score.successes == comp.successes and score.actual_n == comp.n | |
| return GateVerdict(ok, f"exact_match vs {comp.label} ({comp.successes}/{comp.n}): " | |
| f"got {score.successes}/{score.actual_n}") | |
| if gate.kind == "min_successes": | |
| ok = score.successes >= gate.min_successes | |
| return GateVerdict(ok, f"min_successes={gate.min_successes} vs {comp.label}: got {score.successes}") | |
| if gate.kind == "max_regress_pp": | |
| delta = score.rate - comp.rate | |
| ok = delta >= -gate.max_regress_pp | |
| return GateVerdict(ok, f"delta vs {comp.label} = {delta:+.1f}pp (floor -{gate.max_regress_pp:.1f}pp)") | |
| raise ValueError(f"unknown gate kind {gate.kind!r}") | |
| def mcnemar_report(cell: Cell, results_root: Path, paths: dict[str, Path], label: str | None = None) -> str | None: | |
| comp_label = label or (cell.gate.vs if cell.gate else None) | |
| if comp_label is None: | |
| return None | |
| comp = cell.comparator(comp_label) | |
| if comp is None: | |
| return None | |
| mc = mcnemar_mod(paths) | |
| comp_dir = results_root / f"plus_{cell.bench(paths)}" / cell.axis / comp.tag | |
| cell_dir = cell.result_dir(results_root, paths) | |
| if not comp_dir.exists() or not cell_dir.exists(): | |
| return None | |
| a, b = mc.load(str(comp_dir)), mc.load(str(cell_dir)) | |
| keys = sorted(set(a) & set(b)) | |
| if not keys: | |
| return f"McNemar vs {comp.label}: no shared episodes" | |
| import numpy as np | |
| av = np.array([a[k] for k in keys]) | |
| bv = np.array([b[k] for k in keys]) | |
| b_only = int((av & (~bv)).sum()) # comparator-only success | |
| c_only = int(((~av) & bv).sum()) # this cell-only success | |
| p = mc.mcnemar_exact(b_only, c_only) | |
| return f"McNemar b/c={b_only}/{c_only} p={p:.2e} n={len(keys)}" | |
| def format_result_line(score: CellScore, verdict: GateVerdict, mcnemar_str: str | None) -> str: | |
| cell = score.cell | |
| comp = cell.comparator(cell.gate.vs) if cell.gate else (cell.comparators[0] if cell.comparators else None) | |
| delta_str = f", delta vs {comp.label} = {score.rate - comp.rate:+.1f}pp" if (comp and score.actual_n) else "" | |
| mc_str = f", {mcnemar_str}" if mcnemar_str else "" | |
| rate_str = f"{score.rate:.1f}%" if score.actual_n else "--" | |
| verdict_str = "PASS" if verdict.passed else "FAIL" | |
| return (f"RESULT {cell.tag}: {score.successes}/{score.actual_n} = {rate_str}" | |
| f" ({delta_str.lstrip(', ')}{mc_str}) [{verdict_str}: {verdict.message}]") | |
| # ===================================================================================================== | |
| # scheduling — longest-processing-time (order by expected_n desc, greedily assign to the | |
| # currently-lightest GPU track) + a rolling episodes/min estimate for the --dry-run makespan. | |
| # ===================================================================================================== | |
| def lpt_schedule(cells: list[Cell], gpus: list[int]) -> dict[int, list[Cell]]: | |
| order = sorted(cells, key=lambda c: c.expected_n, reverse=True) | |
| load = dict.fromkeys(gpus, 0) | |
| assign: dict[int, list[Cell]] = {g: [] for g in gpus} | |
| for c in order: | |
| g = min(load, key=lambda k: (load[k], k)) | |
| assign[g].append(c) | |
| load[g] += c.expected_n | |
| return assign | |
| def rolling_episodes_per_min(cell: Cell, ladder_doc: dict, ledger_records: list[dict]) -> float: | |
| matches = [r for r in ledger_records | |
| if r.get("suite") == cell.suite and r.get("axis") == cell.axis and r.get("mode") == cell.mode | |
| and r.get("status") == "complete" and r.get("episodes_per_min")] | |
| if matches: | |
| vals = [float(r["episodes_per_min"]) for r in matches[-5:]] | |
| return sum(vals) / len(vals) | |
| return float(ladder_doc["defaults"]["episodes_per_min"]) | |
| def project_makespan(assign: dict[int, list[Cell]], ladder_doc: dict, ledger_records: list[dict]) -> dict: | |
| per_gpu_minutes: dict[int, float] = {} | |
| cell_lines = [] | |
| for gpu, cells in assign.items(): | |
| total = 0.0 | |
| for c in cells: | |
| epm = rolling_episodes_per_min(c, ladder_doc, ledger_records) | |
| mins = c.expected_n / epm if epm else float("inf") | |
| total += mins | |
| cell_lines.append((gpu, c, mins)) | |
| per_gpu_minutes[gpu] = total | |
| makespan = max(per_gpu_minutes.values()) if per_gpu_minutes else 0.0 | |
| return {"per_gpu_minutes": per_gpu_minutes, "cell_lines": cell_lines, "makespan_min": makespan} | |
| # ===================================================================================================== | |
| # resume — skip a cell only when the ledger says complete AND the mp4 count still matches; otherwise | |
| # purge the (partial/stale) result dir, because a partial dir silently poisons score.py's glob count. | |
| # | |
| # `plan_cell_action` is a PURE decision (no filesystem writes) so `--dry-run` can preview it safely; | |
| # `apply_cell_action` performs the purge and is only ever called from the real (non-dry-run) path. | |
| # ===================================================================================================== | |
| def plan_cell_action(cell: Cell, results_root: Path, paths: dict[str, Path], | |
| resume: bool, ledger_records: list[dict] | None = None) -> tuple[str, str]: | |
| """Returns (action, message) where action is one of: | |
| ``run`` (nothing there yet), ``skip`` (ledger-confirmed complete, matches expected_n), | |
| ``purge`` (partial/stale content -- apply_cell_action must remove it before re-running), | |
| ``error`` (content exists and --resume was not passed).""" | |
| d = cell.result_dir(results_root, paths) | |
| has_content = d.exists() and any(d.rglob("*.mp4")) | |
| if not has_content: | |
| return "run", "no existing result dir" | |
| if not resume: | |
| return "error", f"result dir already has content at {d} (pass --resume)" | |
| records = ledger_records if ledger_records is not None else read_ledger() | |
| rec = latest_matching_record(records, cell.tag, cell.mode) | |
| if rec and rec.get("status") == "complete": | |
| _s, n = score_mod(paths).count_dir(str(d)) | |
| if n == cell.expected_n: | |
| return "skip", f"ledger says complete and {n} mp4s match expected_n={cell.expected_n}" | |
| return "purge", "partial/stale result dir -- would be purged and re-run" | |
| def apply_cell_action(action: str, cell: Cell, results_root: Path, paths: dict[str, Path]) -> None: | |
| """Perform the filesystem side effect for a ``purge`` verdict. Never called on the --dry-run path.""" | |
| if action == "purge": | |
| shutil.rmtree(cell.result_dir(results_root, paths)) | |
| # ===================================================================================================== | |
| # launch orchestration — one GPU-resident policy server + N sharded sim clients per cell. | |
| # ===================================================================================================== | |
| _active_procs_lock = threading.Lock() | |
| _active_procs: set[subprocess.Popen] = set() | |
| def _register_proc(p: subprocess.Popen) -> None: | |
| with _active_procs_lock: | |
| _active_procs.add(p) | |
| def _unregister_proc(p: subprocess.Popen) -> None: | |
| with _active_procs_lock: | |
| _active_procs.discard(p) | |
| def _kill_all_active(signum, _frame) -> None: # signal handler signature (frame unused) | |
| with _active_procs_lock: | |
| procs = list(_active_procs) | |
| for p in procs: | |
| try: | |
| p.terminate() | |
| except Exception: # best-effort teardown, never let this handler raise | |
| pass | |
| time.sleep(2) | |
| for p in procs: | |
| try: | |
| if p.poll() is None: | |
| p.kill() | |
| except Exception: | |
| pass | |
| sys.exit(128 + signum) | |
| def install_signal_handlers() -> None: | |
| signal.signal(signal.SIGINT, _kill_all_active) | |
| signal.signal(signal.SIGTERM, _kill_all_active) | |
| def _wait_for_server(proc: subprocess.Popen, host: str, port: int, log_path: Path, | |
| timeout_s: float, poll_every: float = 2.0) -> None: | |
| deadline = time.time() + timeout_s | |
| while time.time() < deadline: | |
| if proc.poll() is not None: | |
| tail = "" | |
| if log_path.exists(): | |
| tail = "\n".join(log_path.read_text(errors="replace").splitlines()[-40:]) | |
| raise RuntimeError(f"policy server exited before ready (rc={proc.returncode}); log tail:\n{tail}") | |
| try: | |
| with socket.create_connection((host, port), timeout=1): | |
| return | |
| except OSError: | |
| time.sleep(poll_every) | |
| raise RuntimeError(f"policy server not ready after {timeout_s:.0f}s on {host}:{port}") | |
| class LaunchResult: | |
| wall_s: float | |
| client_returncodes: list[int] | |
| crashed: bool | |
| def launch_cell(cell: Cell, gpu: int, port: int, suites: dict[str, SuiteInfo], log_dir: Path, | |
| results_root: Path, ladder_doc: dict, paths: dict[str, Path]) -> LaunchResult: | |
| t0 = time.time() | |
| result_dir = cell.result_dir(results_root, paths) | |
| result_dir.mkdir(parents=True, exist_ok=True) | |
| log_dir.mkdir(parents=True, exist_ok=True) | |
| stablevla_python = resolve_stablevla_python() | |
| if stablevla_python is None: | |
| raise RuntimeError(f"cannot locate the `{STABLEVLA_CONDA_ENV}` conda env's python") | |
| shared_env = dict(os.environ) | |
| shared_env.update(compose_cell_env(cell, suites, paths)) | |
| server_env = dict(shared_env) | |
| server_env["CUDA_VISIBLE_DEVICES"] = str(gpu) | |
| server_env["PYTHONPATH"] = server_pythonpath(paths) | |
| ckpt = str(suites[cell.suite].stablevla_ckpt) | |
| server_log = open(log_dir / "server.log", "w") | |
| server_proc = subprocess.Popen( | |
| [str(stablevla_python), str(paths["policy_server"]), "--ckpt_path", ckpt, | |
| "--port", str(port), "--cuda", "0", "--use_bf16"], | |
| cwd=REPO_ROOT, env=server_env, stdout=server_log, stderr=subprocess.STDOUT, | |
| ) | |
| _register_proc(server_proc) | |
| try: | |
| timeout_s = float(ladder_doc["defaults"]["server_ready_timeout_s"]) | |
| _wait_for_server(server_proc, "127.0.0.1", port, log_dir / "server.log", timeout_s) | |
| client_env = dict(shared_env) | |
| client_env["CUDA_VISIBLE_DEVICES"] = str(gpu) | |
| client_env["PYTHONPATH"] = client_pythonpath(paths) | |
| client_procs = [] | |
| for k in range(cell.num_clients): | |
| log_f = open(log_dir / f"client_shard{k}.log", "w") | |
| p = subprocess.Popen( | |
| [str(stablevla_python), str(paths["harness_dir"] / "eval_libero.py"), | |
| "--args.pretrained-path", ckpt, "--args.host", "127.0.0.1", "--args.port", str(port), | |
| "--args.task-suite-name", cell.bench(paths), "--args.category_value", cell.category_value, | |
| "--args.num-trials-per-task", "1", "--args.with_state", "true", | |
| "--args.shard_index", str(k), "--args.num_shards", str(cell.num_clients), | |
| "--args.video-out-path", str(result_dir)], | |
| cwd=REPO_ROOT, env=client_env, stdout=log_f, stderr=subprocess.STDOUT, | |
| ) | |
| _register_proc(p) | |
| client_procs.append(p) | |
| rcs = [] | |
| for p in client_procs: | |
| rcs.append(p.wait()) | |
| _unregister_proc(p) | |
| return LaunchResult(wall_s=time.time() - t0, client_returncodes=rcs, crashed=any(rc != 0 for rc in rcs)) | |
| finally: | |
| _unregister_proc(server_proc) | |
| server_proc.terminate() | |
| try: | |
| server_proc.wait(timeout=15) | |
| except subprocess.TimeoutExpired: | |
| server_proc.kill() | |
| server_proc.wait(timeout=15) | |
| server_log.close() | |
| # ===================================================================================================== | |
| # execution — one worker thread per GPU, each running its LPT-assigned cells sequentially. A thread | |
| # (not process) pool is fine here: each cell's work is dominated by subprocess I/O wait. | |
| # ===================================================================================================== | |
| _port_lock = threading.Lock() | |
| _port_counter = itertools.count() | |
| def _port_is_free(port: int) -> bool: | |
| """True iff nothing is listening on ``port`` — tested by actually binding it, not by connecting | |
| (a connect-probe races with a server that is still loading its checkpoint and not yet accepting).""" | |
| with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: | |
| s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | |
| try: | |
| s.bind(("0.0.0.0", port)) | |
| return True | |
| except OSError: | |
| return False | |
| def _port_reservation_dir() -> Path: | |
| d = REPO_ROOT / "logs" / ".ports" | |
| d.mkdir(parents=True, exist_ok=True) | |
| return d | |
| def _reserve_port(port: int) -> bool: | |
| """Cross-process claim on ``port``: create ``logs/.ports/<port>`` exclusively, holding this pid. | |
| A stale file (owner pid gone) is reclaimed. Returns False if a LIVE process already holds it. | |
| """ | |
| f = _port_reservation_dir() / str(port) | |
| try: | |
| fd = os.open(f, os.O_CREAT | os.O_EXCL | os.O_WRONLY) | |
| except FileExistsError: | |
| try: | |
| owner = int(f.read_text().strip() or -1) | |
| except (ValueError, OSError): | |
| owner = -1 | |
| try: | |
| os.kill(owner, 0) # signal 0 = liveness probe, sends nothing | |
| return False # a live run_sr owns this port | |
| except (ProcessLookupError, PermissionError, OSError): | |
| pass # stale reservation from a dead run -- take it over | |
| try: | |
| f.write_text(f"{os.getpid()}\n") | |
| return True | |
| except OSError: | |
| return False | |
| with os.fdopen(fd, "w") as fh: | |
| fh.write(f"{os.getpid()}\n") | |
| return True | |
| def claim_port(ladder_doc: dict) -> int: | |
| """Next port at or above ``defaults.base_port`` that is both unbound AND unreserved. | |
| The counter alone is not enough: it is process-local, so two concurrent `run_sr.py` invocations | |
| (a rung on one GPU plus an ad-hoc --cells run on another) both start at base_port and the second | |
| one's clients then connect to the FIRST one's policy server. That is silent and it mis-scores -- | |
| caught in practice with a `goal` Robot-Init cell whose clients reached a `long`-checkpoint server | |
| on the shared port and produced 121 episodes under the wrong policy, with nothing in any log | |
| saying so. | |
| A bind-probe alone is not enough either: a policy server takes minutes to load its checkpoint | |
| before it binds, so a second process probing in that window still sees the port as free. Hence the | |
| filesystem reservation, which is taken the instant the port is chosen and outlives the gap until | |
| the server actually listens. | |
| A reservation lasts as long as the owning process: it is dropped by the atexit hook below, and one | |
| left behind by a killed run is reclaimed by the next caller's pid-liveness check. Nothing needs to | |
| release a port mid-run -- ``_port_counter`` never hands the same number out twice anyway. | |
| """ | |
| with _port_lock: | |
| base = int(ladder_doc["defaults"]["base_port"]) | |
| for _ in range(256): | |
| port = base + next(_port_counter) | |
| if _port_is_free(port) and _reserve_port(port): | |
| _reserved_ports.add(port) | |
| return port | |
| raise RuntimeError(f"no free port in [{base}, {base + 256}) -- is something leaking servers?") | |
| _reserved_ports: set[int] = set() | |
| atexit.register(lambda: [(_port_reservation_dir() / str(p)).unlink(missing_ok=True) | |
| for p in list(_reserved_ports)]) | |
| def run_one_cell(cell: Cell, gpu: int, ladder_doc: dict, suites: dict[str, SuiteInfo], | |
| results_root: Path, paths: dict[str, Path], allow_partial: bool) -> int: | |
| port = claim_port(ladder_doc) | |
| ts = datetime.now(timezone.utc).isoformat(timespec="seconds") | |
| log_dir = make_log_dir(cell.policy, cell.mode, cell.suite, cell.axis, cell.variant) | |
| env = compose_cell_env(cell, suites, paths) | |
| sha, dirty = _git_sha(), _git_dirty() | |
| # One startup line naming the resolved graph dir (bound into env["JQ_GRAPH_DIR"] == | |
| # env["SN_GRAPH_DIR"] by compose_cell_env), its mode, its graph_hash, and g_head.npz/g_track.npz | |
| # mtimes. FAIL HARD (same policy as evals/gr00t/run_gr00t.py's require_graph_artifacts) for any | |
| # non-`base` mode missing an artifact -- a missing g_track.npz means an unfit belief-filter kernel | |
| # and onf.sentinel.track.BasinReadout raising ValueError at the first fire, hours into a | |
| # GPU-resident run, rather than a fast, readable failure here. | |
| gstatus = graph_artifact_status(Path(env["SN_GRAPH_DIR"])) | |
| print(f"[run_sr] {cell.tag}: mode={cell.mode} graph_dir={gstatus['graph_dir']} " | |
| f"graph_hash={gstatus['graph_hash']} g_head_mtime={gstatus['g_head_mtime']} " | |
| f"g_track_mtime={gstatus['g_track_mtime']}") | |
| if not gstatus["ok"] and cell.mode != "base": | |
| raise RuntimeError( | |
| f"{cell.tag}: resolved graph dir {gstatus['graph_dir']} is missing required artifacts: " | |
| f"{gstatus['missing']} -- train the graph for this suite first, or point GR_GRAPH_DIR at " | |
| f"a suite that already has g_head.npz + g_track.npz." | |
| ) | |
| result_dir = cell.result_dir(results_root, paths) | |
| write_run_json( | |
| result_dir, policy="stablevla", mode=cell.mode, suite=cell.suite, axis=cell.axis, | |
| expected_n=cell.expected_n, graph_dir=gstatus["graph_dir"], graph_hash=gstatus["graph_hash"], | |
| g_head_mtime=gstatus["g_head_mtime"], git_commit=sha, git_dirty=dirty, mode_env=env, | |
| ) | |
| base_record = { | |
| "tag": cell.tag, "suite": cell.suite, "bench": cell.bench(paths), "axis": cell.axis, | |
| "mode": cell.mode, "gpu": gpu, "port": port, "expected_n": cell.expected_n, | |
| "git_sha": sha, "git_dirty": dirty, "env": env, "graph_dir": gstatus["graph_dir"], | |
| "graph_hash": gstatus["graph_hash"], "g_head_mtime": gstatus["g_head_mtime"], | |
| "result_dir": str(result_dir), "log_dir": str(log_dir), | |
| } | |
| append_ledger({**base_record, "ts": ts, "actual_n": None, "successes": None, "rate": None, | |
| "wall_s": None, "episodes_per_min": None, "status": "running"}) | |
| try: | |
| launch = launch_cell(cell, gpu, port, suites, log_dir, results_root, ladder_doc, paths) | |
| except Exception as e: # record the crash, don't take the whole schedule down | |
| append_ledger({**base_record, "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), | |
| "actual_n": None, "successes": None, "rate": None, "wall_s": None, | |
| "episodes_per_min": None, "status": "crashed", "error": str(e)}) | |
| print(f"RESULT {cell.tag}: CRASHED -- {e}", file=sys.stderr) | |
| return 1 | |
| score = score_cell(cell, results_root, paths) | |
| epm = (score.actual_n / (launch.wall_s / 60.0)) if launch.wall_s > 0 and score.actual_n else 0.0 | |
| status = "crashed" if launch.crashed else ("complete" if score.complete else "incomplete") | |
| append_ledger({**base_record, "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), | |
| "actual_n": score.actual_n, "successes": score.successes, "rate": score.rate, | |
| "wall_s": launch.wall_s, "episodes_per_min": epm, "status": status}) | |
| verdict = evaluate_gate(score, allow_partial) | |
| mc = mcnemar_report(cell, results_root, paths) if cell.gate else None | |
| print(format_result_line(score, verdict, mc)) | |
| if status != "complete" and not allow_partial: | |
| return 1 | |
| return 0 if verdict.passed else 2 | |
| def execute_schedule(assign: dict[int, list[Cell]], ladder_doc: dict, suites: dict[str, SuiteInfo], | |
| results_root: Path, paths: dict[str, Path], allow_partial: bool) -> int: | |
| def run_track(gpu: int, cells: list[Cell]) -> list[int]: | |
| return [run_one_cell(c, gpu, ladder_doc, suites, results_root, paths, allow_partial) for c in cells] | |
| codes: list[int] = [] | |
| with ThreadPoolExecutor(max_workers=max(1, len(assign))) as ex: | |
| futures = {ex.submit(run_track, gpu, cells): gpu for gpu, cells in assign.items()} | |
| for fut in as_completed(futures): | |
| codes.extend(fut.result()) | |
| if any(c == 2 for c in codes): | |
| return 2 | |
| if any(c not in (0, 2) for c in codes): | |
| return 1 | |
| return 0 | |
| # ===================================================================================================== | |
| # CLI | |
| # ===================================================================================================== | |
| def build_argparser() -> argparse.ArgumentParser: | |
| p = argparse.ArgumentParser(prog="run_sr.py", description="ONF LIBERO-Plus success-rate eval driver.") | |
| p.add_argument("--rung", help="ladder rung name (R1, R4)") | |
| p.add_argument("--cells", help="comma-separated suite:axis pairs, " | |
| "e.g. goal:Background_Textures,long:Camera_Viewpoints") | |
| p.add_argument("--gpus", help="comma-separated physical GPU indices, e.g. 0,1") | |
| p.add_argument("--mode", help="recovery mode override (onf_set_mode name)") | |
| p.add_argument("--dry-run", action="store_true", help="print the schedule/projection; launch nothing") | |
| p.add_argument("--resume", action="store_true", help="skip complete cells, purge+rerun partial ones") | |
| p.add_argument("--score", action="store_true", help="score existing results only; launch nothing") | |
| p.add_argument("--ledger", action="store_true", help="print the ledger as a table and exit") | |
| p.add_argument("--check-env", action="store_true", help="GPU/load/LIBERO-Plus preflight report") | |
| p.add_argument("--allow-partial", action="store_true", help="include incomplete cells in totals/gates") | |
| p.add_argument("--ladder", default=str(LADDER_YAML), help="path to the ladder yaml") | |
| p.add_argument("--results-root", default=None, help="override the results/ root (else score.py's default)") | |
| return p | |
| def parse_gpu_list(s: str | None) -> list[int]: | |
| if not s: | |
| return [] | |
| return [int(x) for x in s.split(",") if x.strip() != ""] | |
| def resolve_cells(args: argparse.Namespace, ladder_doc: dict, suites: dict[str, SuiteInfo], | |
| results_root: Path, paths: dict[str, Path]) -> list[Cell]: | |
| if args.rung: | |
| if args.rung not in ladder_doc["rungs"]: | |
| raise SystemExit(f"unknown rung {args.rung!r}; known: {sorted(ladder_doc['rungs'])}") | |
| cells = rung_cells(ladder_doc, args.rung, suites, results_root, paths) | |
| if args.cells: | |
| # `--rung R --cells suite:axis,...` runs a SUBSET of the rung, keeping each cell's own | |
| # expected_n / filter / comparators / gate. Without this, re-running one cell means | |
| # `--cells` alone, which rebuilds it as an ad hoc full-axis cell and silently drops the | |
| # rung's episode filter -- a 12-episode smoke would become a 393-episode run. | |
| wanted = {t.strip() for t in args.cells.split(",") if t.strip()} | |
| unknown = wanted - {f"{c.suite}:{c.axis}" for c in cells} | |
| if unknown: | |
| raise SystemExit(f"--cells: {sorted(unknown)} not in rung {args.rung} " | |
| f"(has: {sorted(f'{c.suite}:{c.axis}' for c in cells)})") | |
| cells = [c for c in cells if f"{c.suite}:{c.axis}" in wanted] | |
| if args.mode: | |
| # cell_with_mode (not a bare dataclasses.replace) so the tag's mode suffix is re-keyed | |
| # too -- see cell_tag's docstring for the wrong-number hazard a stale tag creates. | |
| cells = [cell_with_mode(c, args.mode) for c in cells] | |
| return cells | |
| if args.cells: | |
| return parse_cells_arg(args.cells, args.mode, ladder_doc, suites, results_root, paths) | |
| return [] | |
| def cmd_check_env(ladder_doc: dict) -> int: | |
| pf = ladder_doc["preflight"] | |
| ok = True | |
| print("=== GPU ===") | |
| try: | |
| import torch | |
| n = torch.cuda.device_count() | |
| if n == 0: | |
| print(" no CUDA devices visible") | |
| ok = False | |
| for i in range(n): | |
| free, total = torch.cuda.mem_get_info(i) | |
| free_g, total_g = free / 1024**3, total / 1024**3 | |
| busy = free_g < pf["min_free_gib"] | |
| ok = ok and not busy | |
| print(f" gpu{i}: {free_g:6.1f} / {total_g:6.1f} GiB free [{'BUSY' if busy else 'OK'}]") | |
| except Exception as e: | |
| print(f" GPU check FAILED: {e}") | |
| ok = False | |
| print("=== load ===") | |
| load1, _load5, _load15 = os.getloadavg() | |
| ncpu = os.cpu_count() or 1 | |
| threshold = ncpu * pf["max_loadavg_factor"] | |
| busy = load1 > threshold | |
| print(f" loadavg(1m)={load1:.2f} ncpu={ncpu} threshold={threshold:.1f} [{'BUSY' if busy else 'OK'}]") | |
| ok = ok and not busy | |
| print("=== LIBERO-Plus ===") | |
| libero_ok, libero_msg = check_libero_plus(ladder_doc=ladder_doc, paths=resolve_paths(ladder_doc)) | |
| print(f" {libero_msg}") | |
| ok = ok and libero_ok | |
| print(f"\n[check-env] overall: {'OK' if ok else 'NOT READY'}") | |
| return 0 if ok else 1 | |
| def cmd_print_ledger(path: Path | None = None) -> int: | |
| records = read_ledger(path) | |
| if not records: | |
| print(f"[run_sr] ledger is empty (no runs recorded yet): {path or default_ledger_path()}") | |
| return 0 | |
| cols = ["ts", "tag", "suite", "axis", "mode", "status", "actual_n", "expected_n", "successes", | |
| "rate", "wall_s", "episodes_per_min", "gpu"] | |
| rows = [{c: ("" if r.get(c) is None else r.get(c)) for c in cols} for r in records] | |
| widths = {c: max(len(c), *(len(str(row[c])) for row in rows)) for c in cols} | |
| header = " ".join(c.ljust(widths[c]) for c in cols) | |
| print(header) | |
| print("-" * len(header)) | |
| for row in rows: | |
| print(" ".join(str(row[c]).ljust(widths[c]) for c in cols)) | |
| return 0 | |
| def cmd_score(cells: list[Cell], results_root: Path, paths: dict[str, Path], allow_partial: bool) -> int: | |
| any_gate_failed = False | |
| any_scored = False | |
| for cell in cells: | |
| score = score_cell(cell, results_root, paths) | |
| if score.actual_n == 0: | |
| print(f"RESULT {cell.tag}: NO DATA (expected {cell.expected_n} episodes at " | |
| f"{cell.result_dir(results_root, paths)})") | |
| continue | |
| any_scored = True | |
| verdict = evaluate_gate(score, allow_partial) | |
| mc = mcnemar_report(cell, results_root, paths) if cell.gate else None | |
| print(format_result_line(score, verdict, mc)) | |
| any_gate_failed = any_gate_failed or not verdict.passed | |
| if not any_scored: | |
| return 1 | |
| return 2 if any_gate_failed else 0 | |
| def cmd_run(cells: list[Cell], gpus: list[int], ladder_doc: dict, suites: dict[str, SuiteInfo], | |
| results_root: Path, paths: dict[str, Path], args: argparse.Namespace) -> int: | |
| pf = ladder_doc["preflight"] | |
| gstatus = gpu_report(gpus, pf["min_free_gib"]) | |
| for g in gstatus: | |
| if not g.ok: | |
| print(f"[preflight] SKIP gpu{g.index}: {g.reason}") | |
| sys.stdout.flush() # keep stdout ahead of the stderr refusal below when interleaved (e.g. `2>&1`) | |
| usable_gpus = [g.index for g in gstatus if g.ok] | |
| if not usable_gpus: | |
| print("[preflight] no usable GPUs -- refusing to start anything", file=sys.stderr) | |
| return 1 | |
| total_expected = sum(c.expected_n for c in cells) | |
| is_large = total_expected > pf["large_rung_episodes"] | |
| lstatus = load_report(pf["max_loadavg_factor"]) | |
| print(f"[preflight] load1={lstatus.load1:.2f} ncpu={lstatus.ncpu} threshold={lstatus.threshold:.1f} " | |
| f"large_launch={is_large} (total_expected={total_expected})") | |
| sys.stdout.flush() | |
| if is_large and not lstatus.ok: | |
| print(f"[preflight] REFUSING: a large launch ({total_expected} episodes) and loadavg(1m)=" | |
| f"{lstatus.load1:.2f} exceeds {lstatus.threshold:.1f} -- box looks busy with a " | |
| f"neighbouring job", file=sys.stderr) | |
| return 1 | |
| for c in cells: | |
| if c.filter_file is not None and not c.filter_file.exists(): | |
| print(f"[preflight] WARNING: {c.tag}: filter file missing at {c.filter_file}") | |
| ledger_records = read_ledger() | |
| runnable = [] | |
| for c in cells: | |
| action, msg = plan_cell_action(c, results_root, paths, args.resume, ledger_records) | |
| print(f"[plan] {c.tag}: {action} ({msg})") | |
| if action == "error": | |
| print(f"[run_sr] {c.tag}: {msg}", file=sys.stderr) | |
| return 1 | |
| if action in ("run", "purge"): | |
| runnable.append((c, action)) | |
| if not runnable: | |
| print("[run_sr] nothing left to run (everything already complete)") | |
| return 0 | |
| assign = lpt_schedule([c for c, _ in runnable], usable_gpus) | |
| proj = project_makespan(assign, ladder_doc, ledger_records) | |
| print("\n=== schedule ===") | |
| for gpu, cell, mins in proj["cell_lines"]: | |
| print(f" gpu{gpu}: {cell.tag:<45} n={cell.expected_n:<5} mode={cell.mode:<16} ~{mins:6.1f} min") | |
| print(f"projected makespan: {proj['makespan_min']:.1f} min ({proj['makespan_min'] / 60.0:.1f} h) " | |
| f"across {len(usable_gpus)} GPU(s)") | |
| if args.dry_run: | |
| print("\n[dry-run] no subprocess launched, no filesystem changes made.") | |
| return 0 | |
| # Apply any purges now -- never before this point, so --dry-run (even with --resume) is a pure | |
| # read-only preview and can never delete a partial result dir. | |
| for c, action in runnable: | |
| apply_cell_action(action, c, results_root, paths) | |
| return execute_schedule(assign, ladder_doc, suites, results_root, paths, args.allow_partial) | |
| def main(argv: list[str] | None = None) -> int: | |
| args = build_argparser().parse_args(argv) | |
| ladder_doc = load_ladder(Path(args.ladder)) | |
| if args.check_env: | |
| return cmd_check_env(ladder_doc) | |
| if args.ledger: | |
| return cmd_print_ledger() | |
| paths = resolve_paths(ladder_doc) | |
| suites = load_suites(paths) | |
| results_root = Path(args.results_root) if args.results_root else default_results_root(paths) | |
| cells = resolve_cells(args, ladder_doc, suites, results_root, paths) | |
| if not cells: | |
| print("[run_sr] nothing to do -- pass --rung or --cells", file=sys.stderr) | |
| return 1 | |
| if args.score: | |
| return cmd_score(cells, results_root, paths, args.allow_partial) | |
| gpus = parse_gpu_list(args.gpus) | |
| if not gpus: | |
| print("[run_sr] --gpus is required to launch or dry-run a schedule", file=sys.stderr) | |
| return 1 | |
| install_signal_handlers() | |
| return cmd_run(cells, gpus, ladder_doc, suites, results_root, paths, args) | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |
Xet Storage Details
- Size:
- 68.8 kB
- Xet hash:
- af987bfdd9daa0e076361852f81a03e45683af12807762e65272b6abb543aed8
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.