mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-11 03:52:02 +00:00
baee9236dd
Resolve the source root via metadata only instead of instantiating a full LeRobotDataset (which memory-maps the entire frame index just to read a path), and download videos only when the run actually recomputes image/video stats (derived from --skip-image-video). This avoids the multi-TB video download for numeric-only runs.
490 lines
19 KiB
Python
490 lines
19 KiB
Python
#!/usr/bin/env python
|
|
|
|
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""
|
|
SLURM-distributed recomputation of a LeRobotDataset's ``meta/stats.json``.
|
|
|
|
Modified copy of lerobot's examples/dataset/slurm_recompute_stats.py
|
|
(feat/recompute-stats-readonly-and-visual branch) with cluster-friendly additions:
|
|
|
|
1. --qos : pass a SLURM QoS through to every worker's sbatch.
|
|
2. --venv-path : activate a venv on each worker before the python step.
|
|
3. --env-command : raw shell snippet injected before the python step (e.g. to
|
|
export HF_LEROBOT_HOME). Runs in addition to --venv-path.
|
|
4. --chain-aggregate : submit ``aggregate`` with an afterok dependency on
|
|
``compute`` so it only runs once all shards exist
|
|
(no manual squeue-wait, no gap/overlap race).
|
|
5. --update-episode-stats : in ``aggregate``, also rewrite the per-episode stats in the
|
|
episodes parquet so they stay consistent with meta/stats.json
|
|
(default: only stats.json is written).
|
|
|
|
Data access: no filesystem mount. Point HF_LEROBOT_HOME at a node-visible shared
|
|
cache (e.g. /fsx/$USER/.cache) so the dataset downloads once and all workers read
|
|
it. This is the download route; the source dataset is fetched from the Hub on the
|
|
CPU workers.
|
|
|
|
IMPORTANT — how to run (do NOT sbatch this file):
|
|
Run it as a normal python process on the LOGIN node. datatrove submits the
|
|
workers for you. The reference copy (--new-root) is built on the login node and
|
|
references the shared HF cache, so /fsx must be visible there (it is).
|
|
|
|
Requires: pip install 'lerobot[dataset]' datatrove
|
|
|
|
Example (single command, compute then dependent aggregate):
|
|
|
|
export HF_LEROBOT_HOME=/fsx/$USER/.cache
|
|
|
|
python slurm_recompute_stats_patched.py compute \
|
|
--repo-id behavior-1k/2026-challenge-demos \
|
|
--new-root /fsx/$USER/behavior-1k_recomputed \
|
|
--shard-dir /fsx/$USER/behavior-1k_recomputed/stats_shards \
|
|
--logs-dir /fsx/$USER/logs/recompute \
|
|
--skip-image-video 0 \
|
|
--workers 250 \
|
|
--partition hopper-cpu \
|
|
--qos normal \
|
|
--cpus-per-task 8 --mem-per-cpu 4G \
|
|
--venv-path /fsx/$USER/venvs/lerobot/bin/activate \
|
|
--env-command 'export HF_LEROBOT_HOME=/fsx/'"$USER"'/.cache' \
|
|
--chain-aggregate
|
|
|
|
REHEARSE FIRST with --workers 2 --skip-image-video 1 and inspect one worker's log
|
|
under --logs-dir to confirm QoS was accepted and a numeric stats.json is written.
|
|
"""
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
from datatrove.executor import LocalPipelineExecutor
|
|
from datatrove.executor.slurm import SlurmPipelineExecutor
|
|
from datatrove.pipeline.base import PipelineStep
|
|
|
|
class ComputeEpisodeStatsShards(PipelineStep):
|
|
"""Each worker computes per-episode stats for its ``episodes[rank::world_size]`` shard."""
|
|
|
|
def __init__(self, repo_id, root, new_root, skip_image_video, shard_dir, video_backend=None):
|
|
super().__init__()
|
|
self.repo_id = repo_id
|
|
self.root = root
|
|
self.new_root = new_root
|
|
self.skip_image_video = skip_image_video
|
|
self.shard_dir = shard_dir
|
|
self.video_backend = video_backend
|
|
|
|
def run(self, data=None, rank: int = 0, world_size: int = 1):
|
|
# NOTE: this method is pickled and executed on a worker, where this script's module
|
|
# globals are NOT available. Keep it self-contained: import locally and don't reference
|
|
# module-level helpers/constants.
|
|
import logging
|
|
import pickle
|
|
from pathlib import Path
|
|
|
|
from lerobot.datasets import LeRobotDataset, compute_dataset_episode_stats
|
|
from lerobot.utils.utils import init_logging
|
|
|
|
init_logging()
|
|
load_kwargs = {"video_backend": self.video_backend} if self.video_backend else {}
|
|
root = self.new_root if self.new_root and Path(self.new_root).exists() else self.root
|
|
dataset = LeRobotDataset(self.repo_id, root=root, **load_kwargs)
|
|
|
|
my_episodes = list(range(dataset.meta.total_episodes))[rank::world_size]
|
|
if not my_episodes:
|
|
logging.info(f"Rank {rank}: no episodes assigned")
|
|
return
|
|
logging.info(f"Rank {rank}: {len(my_episodes)} / {dataset.meta.total_episodes} episodes")
|
|
|
|
episode_stats = compute_dataset_episode_stats(
|
|
dataset,
|
|
episode_indices=my_episodes,
|
|
skip_image_video=self.skip_image_video,
|
|
)
|
|
|
|
shard_dir = Path(self.shard_dir)
|
|
shard_dir.mkdir(parents=True, exist_ok=True)
|
|
out = shard_dir / f"episode_stats_{rank:05d}.pkl"
|
|
with open(out, "wb") as f:
|
|
pickle.dump(episode_stats, f)
|
|
logging.info(f"Rank {rank}: saved {len(episode_stats)} episode stats to {out}")
|
|
|
|
|
|
class AggregateEpisodeStats(PipelineStep):
|
|
"""Merge all per-episode stat shards into meta/stats.json."""
|
|
|
|
def __init__(
|
|
self,
|
|
repo_id,
|
|
root,
|
|
new_root,
|
|
shard_dir,
|
|
push_to_hub=False,
|
|
video_backend=None,
|
|
update_episode_stats=False,
|
|
):
|
|
super().__init__()
|
|
self.repo_id = repo_id
|
|
self.root = root
|
|
self.new_root = new_root
|
|
self.shard_dir = shard_dir
|
|
self.push_to_hub = push_to_hub
|
|
self.video_backend = video_backend
|
|
self.update_episode_stats = update_episode_stats
|
|
|
|
def run(self, data=None, rank: int = 0, world_size: int = 1):
|
|
# NOTE: pickled and executed on a worker; keep self-contained (see ComputeEpisodeStatsShards.run).
|
|
import logging
|
|
import pickle
|
|
from pathlib import Path
|
|
|
|
from lerobot.datasets import LeRobotDataset, aggregate_episode_stats
|
|
from lerobot.utils.utils import init_logging
|
|
|
|
init_logging()
|
|
if rank != 0:
|
|
return
|
|
|
|
shard_dir = Path(self.shard_dir)
|
|
shards = sorted(shard_dir.glob("episode_stats_*.pkl"))
|
|
if not shards:
|
|
raise FileNotFoundError(f"No episode stat shards found in {shard_dir}")
|
|
|
|
# Shards map episode_index -> stats; merging by key makes a dropped shard show up as a
|
|
# missing episode and a re-run shard overwrite rather than double-count.
|
|
all_episode_stats = {}
|
|
for shard in shards:
|
|
with open(shard, "rb") as f:
|
|
all_episode_stats.update(pickle.load(f))
|
|
logging.info(f"Aggregating {len(all_episode_stats)} episode stats from {len(shards)} shards")
|
|
|
|
load_kwargs = {"video_backend": self.video_backend} if self.video_backend else {}
|
|
root = self.new_root if self.new_root and Path(self.new_root).exists() else self.root
|
|
dataset = LeRobotDataset(self.repo_id, root=root, **load_kwargs)
|
|
|
|
# Aggregation is order-independent, so the only way sharding changes the result is a
|
|
# gap (dropped shard) or an overlap (episode counted twice). Verify the shards cover
|
|
# every episode exactly once before writing stats.json.
|
|
expected_episodes = dataset.meta.total_episodes
|
|
if len(all_episode_stats) != expected_episodes:
|
|
raise ValueError(
|
|
f"Expected {expected_episodes} per-episode stats (one per episode) but got "
|
|
f"{len(all_episode_stats)} across {len(shards)} shards. A compute shard is likely "
|
|
"missing or was written more than once; re-run the failed shards before aggregating."
|
|
)
|
|
|
|
# Frame-count check catches the case where a duplicate and a gap cancel out in the
|
|
# episode count: summed per-episode frame counts must equal the dataset's total frames.
|
|
stats_values = list(all_episode_stats.values())
|
|
numeric_key = next(
|
|
(
|
|
k
|
|
for k, v in dataset.meta.features.items()
|
|
if v["dtype"] not in ("image", "video", "string") and stats_values and k in stats_values[0]
|
|
),
|
|
None,
|
|
)
|
|
if numeric_key is not None:
|
|
total_frames = sum(int(s[numeric_key]["count"][0]) for s in stats_values)
|
|
if total_frames != dataset.meta.total_frames:
|
|
raise ValueError(
|
|
f"Summed frame count from shards ({total_frames}) != dataset total_frames "
|
|
f"({dataset.meta.total_frames}); episodes are double-counted or missing."
|
|
)
|
|
|
|
new_stats = aggregate_episode_stats(
|
|
dataset, all_episode_stats, update_episode_stats=self.update_episode_stats
|
|
)
|
|
if new_stats is None:
|
|
raise RuntimeError("Aggregation produced no stats")
|
|
logging.info(f"Wrote stats for features: {list(new_stats.keys())} to {dataset.root}")
|
|
|
|
if self.push_to_hub:
|
|
logging.info(f"Pushing {self.repo_id} to hub")
|
|
dataset.push_to_hub()
|
|
|
|
|
|
def _mem_gb(mem: str) -> int:
|
|
"""Parse '4G' / '4GB' / '4' into an int number of GB for datatrove's mem_per_cpu_gb."""
|
|
s = str(mem).strip().lower().rstrip("b").rstrip("g")
|
|
return int(float(s))
|
|
|
|
|
|
def _make_executor(
|
|
pipeline,
|
|
logs_dir,
|
|
job_name,
|
|
slurm,
|
|
workers,
|
|
tasks,
|
|
time,
|
|
partition,
|
|
cpus,
|
|
mem,
|
|
qos=None,
|
|
env_command=None,
|
|
venv_path=None,
|
|
depends=None,
|
|
):
|
|
kwargs = {"pipeline": pipeline, "logging_dir": str(Path(logs_dir) / job_name)}
|
|
if slurm:
|
|
kwargs.update(
|
|
{
|
|
"job_name": job_name,
|
|
"tasks": tasks,
|
|
"workers": workers,
|
|
"time": time,
|
|
"partition": partition,
|
|
"cpus_per_task": cpus,
|
|
"mem_per_cpu_gb": _mem_gb(mem), # datatrove's native field (int GB)
|
|
"sbatch_args": {},
|
|
}
|
|
)
|
|
if qos:
|
|
kwargs["qos"] = qos # -> "#SBATCH --qos=<qos>" on every worker
|
|
if venv_path:
|
|
kwargs["venv_path"] = venv_path # datatrove sources this before the python step
|
|
if env_command:
|
|
kwargs["env_command"] = env_command # extra raw snippet before python (composes with venv_path)
|
|
if depends is not None:
|
|
kwargs["depends"] = depends # chains --dependency=afterok:<compute jobid>
|
|
return SlurmPipelineExecutor(**kwargs)
|
|
kwargs.update({"tasks": tasks, "workers": 1})
|
|
return LocalPipelineExecutor(**kwargs)
|
|
|
|
|
|
def _maybe_reference_copy(repo_id, root, new_root, download_videos):
|
|
"""Create the read-only-safe reference copy once, before submitting workers.
|
|
|
|
Loads metadata only (to resolve the source root and revision) instead of a full
|
|
``LeRobotDataset``, which would also memory-map the entire frame index just to read a
|
|
path. Fetches the source into the shared cache so the copy's symlinks point at real
|
|
files and workers don't each re-download, pulling videos only when the run needs them
|
|
(i.e. when image/video stats are being recomputed).
|
|
"""
|
|
if not new_root:
|
|
return
|
|
from huggingface_hub import snapshot_download
|
|
|
|
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata
|
|
from lerobot.scripts.lerobot_edit_dataset import _reference_copy_dataset
|
|
from lerobot.utils.constants import HF_LEROBOT_HUB_CACHE
|
|
|
|
new_root_path = Path(new_root)
|
|
if new_root_path.exists():
|
|
return
|
|
|
|
meta = LeRobotDatasetMetadata(repo_id, root=Path(root) if root else None)
|
|
ignore_patterns = None if download_videos else "videos/"
|
|
if root:
|
|
snapshot_download(
|
|
repo_id,
|
|
repo_type="dataset",
|
|
revision=meta.revision,
|
|
local_dir=meta.root,
|
|
ignore_patterns=ignore_patterns,
|
|
)
|
|
src_root = Path(meta.root)
|
|
else:
|
|
src_root = Path(
|
|
snapshot_download(
|
|
repo_id,
|
|
repo_type="dataset",
|
|
revision=meta.revision,
|
|
cache_dir=HF_LEROBOT_HUB_CACHE,
|
|
ignore_patterns=ignore_patterns,
|
|
)
|
|
)
|
|
_reference_copy_dataset(src_root, new_root_path)
|
|
|
|
|
|
def _add_shared_args(p):
|
|
p.add_argument("--repo-id", type=str, required=True, help="Dataset identifier, e.g. 'user/dataset'.")
|
|
p.add_argument("--root", type=str, default=None, help="Source dataset root (defaults to the Hub cache).")
|
|
p.add_argument(
|
|
"--new-root",
|
|
type=str,
|
|
default=None,
|
|
help="Writable output root; a read-only-safe reference copy of --root. If omitted, stats "
|
|
"are written in place at --root.",
|
|
)
|
|
p.add_argument("--shard-dir", type=Path, default=Path("stats_shards"), help="Per-rank shard dir.")
|
|
p.add_argument("--logs-dir", type=Path, default=Path("logs"), help="datatrove logs dir.")
|
|
p.add_argument("--job-name", type=str, default=None, help="SLURM job name.")
|
|
p.add_argument("--slurm", type=int, default=1, help="1 = submit via SLURM; 0 = run locally.")
|
|
p.add_argument("--partition", type=str, default=None, help="SLURM partition, e.g. 'hopper-cpu'.")
|
|
p.add_argument("--qos", type=str, default=None, help="SLURM QoS, e.g. 'normal'. Passed to every worker.")
|
|
p.add_argument("--cpus-per-task", type=int, default=4, help="CPUs per SLURM task.")
|
|
p.add_argument("--mem-per-cpu", type=str, default="4G", help="Memory per CPU, e.g. '4G'.")
|
|
p.add_argument(
|
|
"--video-backend",
|
|
type=str,
|
|
default=None,
|
|
help="Video decoding backend (e.g. 'pyav', 'torchcodec'). Defaults to the dataset's default; "
|
|
"use 'pyav' if torchcodec fails to load locally.",
|
|
)
|
|
p.add_argument("--venv-path", type=str, default=None, help="venv activate script sourced on each worker.")
|
|
p.add_argument(
|
|
"--env-command",
|
|
type=str,
|
|
default=None,
|
|
help="Raw shell snippet injected into each worker's sbatch before the python step "
|
|
"(e.g. to export HF_LEROBOT_HOME). Runs in addition to --venv-path.",
|
|
)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="PATCHED SLURM-distributed LeRobotDataset stats recomputation",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
cp = sub.add_parser("compute", help="Distribute per-episode stats across SLURM workers.")
|
|
_add_shared_args(cp)
|
|
cp.add_argument("--workers", type=int, default=50, help="Number of parallel SLURM tasks.")
|
|
cp.add_argument(
|
|
"--skip-image-video",
|
|
type=int,
|
|
default=1,
|
|
help="1 = numeric features only (fast); 0 = also recompute image/video stats (decodes frames).",
|
|
)
|
|
cp.add_argument(
|
|
"--chain-aggregate",
|
|
action="store_true",
|
|
help="After building compute, submit aggregate with an afterok dependency (single command).",
|
|
)
|
|
cp.add_argument("--push-to-hub", action="store_true", help="For the chained aggregate: push after done.")
|
|
cp.add_argument(
|
|
"--update-episode-stats",
|
|
action="store_true",
|
|
help="For the chained aggregate: also rewrite per-episode stats in the episodes parquet.",
|
|
)
|
|
|
|
ap = sub.add_parser("aggregate", help="Merge shards into meta/stats.json.")
|
|
_add_shared_args(ap)
|
|
ap.add_argument("--push-to-hub", action="store_true", help="Push the dataset after aggregation.")
|
|
ap.add_argument(
|
|
"--update-episode-stats",
|
|
action="store_true",
|
|
help="Also rewrite per-episode stats in the episodes parquet to match stats.json.",
|
|
)
|
|
ap.add_argument(
|
|
"--depends-job-id",
|
|
type=str,
|
|
default=None,
|
|
help="Optional SLURM job id; aggregate waits for it (afterok) before running.",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
slurm = args.slurm == 1
|
|
|
|
if args.command == "compute":
|
|
# The reference copy (if any) is created once on the submitting node so workers
|
|
# can all load --new-root without racing to build it. Videos are only fetched when
|
|
# image/video stats are being recomputed.
|
|
_maybe_reference_copy(
|
|
args.repo_id, args.root, args.new_root, download_videos=not bool(args.skip_image_video)
|
|
)
|
|
|
|
compute_exec = _make_executor(
|
|
pipeline=[
|
|
ComputeEpisodeStatsShards(
|
|
args.repo_id,
|
|
args.root,
|
|
args.new_root,
|
|
bool(args.skip_image_video),
|
|
str(args.shard_dir),
|
|
args.video_backend,
|
|
)
|
|
],
|
|
logs_dir=args.logs_dir,
|
|
job_name=args.job_name or "recompute_stats_compute",
|
|
slurm=slurm,
|
|
workers=args.workers,
|
|
tasks=args.workers,
|
|
time="24:00:00",
|
|
partition=args.partition,
|
|
cpus=args.cpus_per_task,
|
|
mem=args.mem_per_cpu,
|
|
qos=args.qos,
|
|
env_command=args.env_command,
|
|
venv_path=args.venv_path,
|
|
)
|
|
|
|
if args.chain_aggregate and slurm:
|
|
# Build aggregate depending on compute. datatrove launches the dependency
|
|
# (compute) first, then submits aggregate with --dependency=afterok:<jobid>.
|
|
aggregate_exec = _make_executor(
|
|
pipeline=[
|
|
AggregateEpisodeStats(
|
|
args.repo_id,
|
|
args.root,
|
|
args.new_root,
|
|
str(args.shard_dir),
|
|
args.push_to_hub,
|
|
args.video_backend,
|
|
args.update_episode_stats,
|
|
)
|
|
],
|
|
logs_dir=args.logs_dir,
|
|
job_name="recompute_stats_aggregate",
|
|
slurm=slurm,
|
|
workers=1,
|
|
tasks=1,
|
|
time="02:00:00",
|
|
partition=args.partition,
|
|
cpus=args.cpus_per_task,
|
|
mem=args.mem_per_cpu,
|
|
qos=args.qos,
|
|
env_command=args.env_command,
|
|
venv_path=args.venv_path,
|
|
depends=compute_exec,
|
|
)
|
|
aggregate_exec.run()
|
|
else:
|
|
compute_exec.run()
|
|
else:
|
|
aggregate_exec = _make_executor(
|
|
pipeline=[
|
|
AggregateEpisodeStats(
|
|
args.repo_id,
|
|
args.root,
|
|
args.new_root,
|
|
str(args.shard_dir),
|
|
args.push_to_hub,
|
|
args.video_backend,
|
|
args.update_episode_stats,
|
|
)
|
|
],
|
|
logs_dir=args.logs_dir,
|
|
job_name=args.job_name or "recompute_stats_aggregate",
|
|
slurm=slurm,
|
|
workers=1,
|
|
tasks=1,
|
|
time="02:00:00",
|
|
partition=args.partition,
|
|
cpus=args.cpus_per_task,
|
|
mem=args.mem_per_cpu,
|
|
qos=args.qos,
|
|
env_command=args.env_command,
|
|
venv_path=args.venv_path,
|
|
)
|
|
if args.depends_job_id is not None:
|
|
aggregate_exec.depends_job_id = args.depends_job_id
|
|
aggregate_exec.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|