From 3d70b21aacc9f2507fa6f01913a41dc39bf094c6 Mon Sep 17 00:00:00 2001 From: Pepijn Date: Thu, 23 Jul 2026 16:10:39 +0200 Subject: [PATCH] feat(dataset): integrate episode streaming into training --- docs/source/_toctree.yml | 2 + docs/source/lerobot-dataset-v3.mdx | 59 +- docs/source/training_dataset_streaming.mdx | 127 +++ examples/training/train_with_streaming.py | 24 +- pyproject.toml | 3 + scripts/bench_episode_byte_cache.py | 119 +-- scripts/bench_streaming_dataset.py | 179 ++++ scripts/build_mp4_sidecar.py | 76 +- src/lerobot/common/train_utils.py | 28 +- src/lerobot/configs/default.py | 14 + src/lerobot/datasets/episode_parquet.py | 106 ++ src/lerobot/datasets/factory.py | 60 +- src/lerobot/datasets/streaming_dataset.py | 996 ++++++++---------- src/lerobot/datasets/streaming_sidecar.py | 141 +++ src/lerobot/datasets/video_utils.py | 15 +- src/lerobot/scripts/lerobot_train.py | 81 +- src/lerobot/streaming/__init__.py | 9 + .../episode_video.py} | 224 ++-- src/lerobot/{datasets => streaming}/mp4.py | 2 + src/lerobot/streaming/sidecar.py | 176 ++++ tests/configs/test_default.py | 13 + tests/datasets/test_depth.py | 19 +- tests/datasets/test_episode_parquet_reader.py | 110 ++ .../datasets/test_episode_video_streaming.py | 147 ++- tests/datasets/test_exact_coverage_pool.py | 2 +- tests/datasets/test_language.py | 14 + tests/datasets/test_streaming.py | 99 +- tests/datasets/test_streaming_factory.py | 58 + tests/datasets/test_streaming_production.py | 519 +++++++++ .../test_streaming_sidecar_adapter.py | 47 + tests/test_streaming_core_imports.py | 39 + tests/test_streaming_sidecar.py | 188 ++++ tests/utils/test_train_utils.py | 11 + uv.lock | 6 + 34 files changed, 2771 insertions(+), 942 deletions(-) create mode 100644 docs/source/training_dataset_streaming.mdx create mode 100644 scripts/bench_streaming_dataset.py create mode 100644 src/lerobot/datasets/episode_parquet.py create mode 100644 src/lerobot/datasets/streaming_sidecar.py create mode 100644 src/lerobot/streaming/__init__.py rename src/lerobot/{datasets/episode_video_streaming.py => streaming/episode_video.py} (87%) rename src/lerobot/{datasets => streaming}/mp4.py (99%) create mode 100644 src/lerobot/streaming/sidecar.py create mode 100644 tests/datasets/test_episode_parquet_reader.py create mode 100644 tests/datasets/test_streaming_factory.py create mode 100644 tests/datasets/test_streaming_production.py create mode 100644 tests/datasets/test_streaming_sidecar_adapter.py create mode 100644 tests/test_streaming_core_imports.py create mode 100644 tests/test_streaming_sidecar.py diff --git a/docs/source/_toctree.yml b/docs/source/_toctree.yml index 7f7a34e6a..9651266cd 100644 --- a/docs/source/_toctree.yml +++ b/docs/source/_toctree.yml @@ -37,6 +37,8 @@ - sections: - local: lerobot-dataset-v3 title: Using LeRobotDataset + - local: training_dataset_streaming + title: Training Dataset Streaming - local: porting_datasets_v3 title: Porting Large Datasets - local: using_dataset_tools diff --git a/docs/source/lerobot-dataset-v3.mdx b/docs/source/lerobot-dataset-v3.mdx index 0647af0b0..0f672c255 100644 --- a/docs/source/lerobot-dataset-v3.mdx +++ b/docs/source/lerobot-dataset-v3.mdx @@ -131,15 +131,68 @@ for batch in data_loader: # model.forward(batch) ``` -## Stream a dataset (no downloads) +## Stream a dataset during training -Use `StreamingLeRobotDataset` to iterate directly from the Hub without local copies. This allows to stream large datasets without the need to downloading them onto disk or loading them onto memory, and is a key feature of the new dataset format. +Enable training-time streaming with the public `--dataset.streaming=true` flag: + +```bash +lerobot-train \ + --dataset.repo_id=yaak-ai/L2D-v3 \ + --dataset.streaming=true \ + --policy.type=act \ + --output_dir=outputs/train/act_streaming +``` + +This is separate from `--dataset.streaming_encoding=true`, which controls video encoding while +recording. Training-time streaming leaves the map-style `LeRobotDataset` path unchanged. + +`StreamingLeRobotDataset` assigns complete episodes disjointly across distributed ranks and +DataLoader workers, reads only the selected episode rows from Parquet, and keeps a bounded pool of +episode video bytes. Every selected frame is visited exactly once per streaming epoch. + +On first use, LeRobot looks for a revision-matched MP4 index sidecar. If none is published with the +dataset, it builds one in the revision-keyed local LeRobot cache under a process lock and installs it +atomically. Training never uploads this sidecar. Dataset maintainers can build and publish one +explicitly with `scripts/build_mp4_sidecar.py --push`. + +The default memory cap is 8 GiB per DataLoader worker. It can be adjusted along with episode mixing +and prefetch: + +```bash +lerobot-train \ + --dataset.repo_id=yaak-ai/L2D-v3 \ + --dataset.streaming=true \ + --dataset.streaming_byte_budget_gb=4 \ + --dataset.streaming_episode_pool_size=16 \ + --dataset.streaming_prefetch_episodes=4 \ + --policy.type=act \ + --output_dir=outputs/train/act_streaming +``` + +Use `--dataset.streaming_data_root=hf://buckets/OWNER/BUCKET/PREFIX` when metadata lives in a +dataset repository but Parquet and MP4 data are mirrored in an HF Bucket. + +To capture comparable data-pipeline results on a training host: + +```bash +uv run python scripts/bench_streaming_dataset.py \ + --repo-id=yaak-ai/L2D-v3 \ + --batch-size=16 \ + --num-workers=4 \ + --summary-json=streaming-benchmark.json +``` + +The JSON records the code and dataset revisions, initialization/first-batch time, steady-state +samples per second, batch-wait p50/p95, duplicate indices, and the exact cache/worker settings. +Run end-to-end `lerobot-train` separately to measure GPU utilization and full training step time. + +The Python API uses the same implementation: ```python from lerobot.datasets import StreamingLeRobotDataset repo_id = "yaak-ai/L2D-v3" -dataset = StreamingLeRobotDataset(repo_id) # streams directly from the Hub +dataset = StreamingLeRobotDataset(repo_id) ```
diff --git a/docs/source/training_dataset_streaming.mdx b/docs/source/training_dataset_streaming.mdx new file mode 100644 index 000000000..52bc3b45c --- /dev/null +++ b/docs/source/training_dataset_streaming.mdx @@ -0,0 +1,127 @@ +# Training Dataset Streaming + +Training-time dataset streaming lets `lerobot-train` consume a LeRobotDataset v3 without first +downloading its complete Parquet and video payload. Enable it through the existing public switch: + +```bash +lerobot-train \ + --dataset.repo_id=OWNER/DATASET \ + --dataset.streaming=true \ + --policy.type=act \ + --output_dir=outputs/train/act_streaming +``` + +This feature is independent of `--dataset.streaming_encoding=true`. `streaming_encoding` controls +how videos are written while recording; `dataset.streaming` controls how an existing dataset is read +during training. Recording and rollout encoders are not used by this training path. + +## How an epoch is read + +Each distributed rank and DataLoader worker owns a disjoint set of complete episodes. Within that +set, an exact-coverage sampler mixes a bounded pool of episodes while visiting every selected frame +once per epoch. Parquet columns and compressed MP4 byte ranges are prefetched from the same episode +admission frontier. Temporal history and future windows are resolved inside the complete episode, +including the same boundary padding masks as map-style loading. + +The default map-style `LeRobotDataset` behavior is unchanged when `--dataset.streaming=false`. + +## MP4 sidecars and the first run + +Video streaming uses a small MP4 index sidecar. Dataset initialization first checks the +revision-keyed local cache, then looks for a valid published sidecar. If neither is available, +LeRobot builds the sidecar locally while holding a process lock and installs it atomically. A +failed or interrupted build does not replace the previous valid file. + +Training is read-only: it never uploads a sidecar or modifies the dataset repository. On a cluster +with node-local caches, the first job may build once per node. A shared LeRobot cache avoids that +duplication. + +Dataset maintainers can build a sidecar ahead of time: + +```bash +uv run python scripts/build_mp4_sidecar.py \ + --repo-id=OWNER/DATASET \ + --revision=COMMIT_SHA \ + --data-root=hf://datasets/OWNER/DATASET@COMMIT_SHA \ + --output=/tmp/dataset-mp4-sidecar.npz +``` + +Publication is always explicit. Add `--push` only after validating the complete-dataset sidecar. +Subset sidecars cannot be published. + +## Configuration + +The production defaults are: + +| Option | Default | Meaning | +| ----------------------------- | ------: | --------------------------------------------------- | +| `streaming_episode_pool_size` | 32 | Complete episodes mixed by each worker | +| `streaming_prefetch_episodes` | 8 | Episodes fetched ahead of the active pool | +| `streaming_byte_budget_gb` | 8 | Maximum synthesized MP4 bytes per worker | +| `streaming_data_root` | unset | Optional local, Hub, bucket, or fsspec payload root | + +Memory limits are per DataLoader worker. For example, four workers with the default byte budget can +use up to 32 GiB for compressed episode video bytes, plus Parquet batches, decoders, and framework +overhead. Start with fewer workers or a smaller budget on memory-constrained hosts: + +```bash +lerobot-train \ + --dataset.repo_id=OWNER/DATASET \ + --dataset.streaming=true \ + --dataset.streaming_episode_pool_size=16 \ + --dataset.streaming_prefetch_episodes=4 \ + --dataset.streaming_byte_budget_gb=4 \ + --num_workers=2 \ + --policy.type=act \ + --output_dir=outputs/train/act_streaming +``` + +If metadata remains in a dataset repository while payload files are mirrored elsewhere, set +`--dataset.streaming_data_root`. Supported values include local paths, revision-qualified +`hf://datasets/...` roots, `hf://buckets/...` roots, and fsspec URLs. + +## Resume and shuffle migration + +The earlier streaming reader used a bounded row shuffle buffer. The episode reader instead has +deterministic exact-coverage ordering derived from the seed and epoch. Checkpoint resume restores the +per-rank sample offset using the checkpoint batch size. Changing distributed world size or +DataLoader worker count changes episode ownership; changing batch size changes the batch boundaries. +For sample-exact comparisons, resume with the same world size, worker count, and batch size. + +## Benchmarking + +Measure the integrated path on the same hosts used for training: + +```bash +uv run python scripts/bench_streaming_dataset.py \ + --repo-id=OWNER/DATASET \ + --revision=COMMIT_SHA \ + --batch-size=16 \ + --num-workers=4 \ + --summary-json=streaming-benchmark.json +``` + +The output records source revisions, startup and first-batch latency, steady-state throughput, +batch-wait percentiles, duplicate indices, memory high-water marks, and the exact settings. Run a +separate end-to-end training A/B to include policy compute, device transfer, and optimizer time. +Do not compare results unless the code revision, dataset revision, hardware, and settings match. + +For lower-level byte-fetch, retry, decoder-open, and refill diagnostics, build the sidecar explicitly +and run `scripts/bench_episode_byte_cache.py` with the same `--repo-id`, `--revision`, +`--data-root`, and `--sidecar-path`. Treat this as a stage profiler; throughput claims should come +from the integrated dataset benchmark and end-to-end training A/B. + +## Troubleshooting + +- **The first batch takes a long time:** check logs for a sidecar build. Reuse a shared cache or + publish a validated complete sidecar explicitly. +- **A sidecar lock times out:** another process may still be indexing the same revision. Confirm it + is healthy before removing a stale lock. +- **A rank owns no data:** reduce the number of ranks or workers so every rank owns at least one + selected episode. +- **The byte budget is exceeded:** lower the episode pool, increase the per-worker byte budget, or + use fewer/larger episodes per worker. +- **Remote reads cannot authenticate:** verify the normal Hugging Face token or fsspec credentials + are available in every worker environment. Credentials are never embedded in the sidecar. +- **Refill stalls are high:** compare p95/p99 batch wait, reduce network contention, raise prefetch + gradually, and verify that the dataset sidecar matches the exact revision. diff --git a/examples/training/train_with_streaming.py b/examples/training/train_with_streaming.py index 4629bcda6..9cf4cd764 100644 --- a/examples/training/train_with_streaming.py +++ b/examples/training/train_with_streaming.py @@ -12,8 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""This script demonstrates how to train a Diffusion Policy on the PushT environment, -using a dataset processed in streaming mode.""" +"""Train directly from episode-scoped Parquet and MP4 streams. + +For normal training, prefer ``lerobot-train --dataset.streaming=true`` so distributed sharding, +checkpoint resume, and device placement are configured by the training pipeline. This lower-level +example shows the underlying Python API. +""" from pathlib import Path @@ -64,9 +68,17 @@ def main(): ACTION: [t / dataset_metadata.fps for t in range(cfg.n_action_steps)], } - # Instantiating the training dataset in streaming mode allows to not consume up memory as the data is fetched - # iteratively rather than being load into memory all at once. Retrieved frames are shuffled across epochs - dataset = StreamingLeRobotDataset(dataset_id, delta_timestamps=delta_timestamps, tolerance_s=1e-3) + # The first run resolves or locally builds a revision-safe MP4 index sidecar. It is never + # uploaded implicitly. Episode rows and video byte ranges are then prefetched together. + dataset = StreamingLeRobotDataset( + dataset_id, + delta_timestamps=delta_timestamps, + tolerance_s=1e-3, + episode_pool_size=16, + prefetch_episodes=4, + byte_budget_gb=4, + repeat=True, + ) optimizer = torch.optim.Adam(policy.parameters(), lr=1e-4) dataloader = torch.utils.data.DataLoader( @@ -74,8 +86,8 @@ def main(): num_workers=4, batch_size=16, pin_memory=device.type != "cpu", - drop_last=True, prefetch_factor=2, # loads batches with multiprocessing while policy trains + persistent_workers=True, ) # Run training loop. diff --git a/pyproject.toml b/pyproject.toml index 937f48a86..345e0b544 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,9 @@ dependencies = [ # Config & Hub "draccus==0.10.0", # TODO: Relax version constraint "huggingface-hub>=1.0.0,<2.0.0", + "filelock>=3.12.0,<4.0.0", + "fsspec>=2023.5.0,<2027.0.0", + "httpx>=0.27.0,<1.0.0", "requests>=2.32.0,<3.0.0", # Environments diff --git a/scripts/bench_episode_byte_cache.py b/scripts/bench_episode_byte_cache.py index 41b27e551..45b07e478 100644 --- a/scripts/bench_episode_byte_cache.py +++ b/scripts/bench_episode_byte_cache.py @@ -16,7 +16,6 @@ import os import random import resource import socket -import tempfile import threading import time from collections.abc import Sequence @@ -30,27 +29,24 @@ import pyarrow.compute as pc import pyarrow.parquet as pq from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata -from lerobot.datasets.episode_video_streaming import ( - EpisodeByteCache, - ExactCoveragePool, - EpisodeVideoManifest, - NativeHTTPRangeFetcher, - assert_hf_hub_range_cache_branch, -) from lerobot.datasets.video_utils import VideoDecoderCache, decode_video_frames_torchcodec - -DEFAULT_REPO = "allenai/MolmoAct2-BimanualYAM-Dataset" -DEFAULT_REVISION = "e9f21ae15074330839f2ac25ed4b49d76dfa1f9c" -DEFAULT_DATA_ROOT = "hf://buckets/pepijn223/MolmoAct2-BimanualYAM-Dataset-bucket" -SIDECAR_CACHE_DIR = Path(tempfile.gettempdir()) / "lerobot-sidecars" -FULL_SIDECAR_NAME = "molmoact2-full.npz" +from lerobot.streaming.episode_video import ( + EpisodeByteCache, + EpisodeVideoManifest, + ExactCoveragePool, +) def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Benchmark episode-level streaming mini-MP4 cache.") - parser.add_argument("--repo-id", default=DEFAULT_REPO) - parser.add_argument("--revision", default=DEFAULT_REVISION) - parser.add_argument("--data-root", default=DEFAULT_DATA_ROOT) + parser = argparse.ArgumentParser(description="Profile the episode-level streaming mini-MP4 cache.") + parser.add_argument("--repo-id", required=True) + parser.add_argument("--revision", default=None) + parser.add_argument("--data-root", required=True) + parser.add_argument( + "--sidecar-path", + default=None, + help="Optional validated sidecar built by scripts/build_mp4_sidecar.py.", + ) parser.add_argument( "--strategy", choices=("both", "full", "indexed", "remote-decoder", "native-http"), @@ -144,7 +140,6 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--in-memory", action="store_true", help="Accepted for compatibility; manifest is always in memory." ) - parser.add_argument("--no-hub-branch-assert", action="store_true") return parser.parse_args() @@ -461,7 +456,6 @@ def run_exact_coverage_stream( elapsed = time.perf_counter() - start result = { - "coverage_mode": "exact", "target_samples_s": target_samples_s, "actual_samples_s": samples_done / elapsed if elapsed > 0 else float("inf"), "stream_wall_s": elapsed, @@ -584,74 +578,6 @@ def _root_join(data_root: str, relative_path: str) -> str: return str(Path(data_root) / relative_path) -def _find_or_download_sidecar(data_root: str, manifest_episode_count: int) -> Path | None: - _ = manifest_episode_count - local = SIDECAR_CACHE_DIR / FULL_SIDECAR_NAME - if _valid_sidecar(local): - return local - if local.exists(): - print(f"mp4_sidecar_invalid_local: {local}") - local.unlink() - remote_relative = f"meta/mp4-sidecars/{FULL_SIDECAR_NAME}" - remote = _root_join(data_root, remote_relative) - protocol = "hf" if data_root.startswith("hf://") else "file" - fs = fsspec.filesystem(protocol) - if not fs.exists(remote): - return None - local.parent.mkdir(parents=True, exist_ok=True) - print(f"downloading_mp4_sidecar: {remote} -> {local}") - if data_root.startswith("hf://"): - _download_sidecar_native_http(data_root, remote_relative, local) - else: - fs.get(remote, str(local)) - return local - - -def _valid_sidecar(path: Path) -> bool: - if not path.exists(): - return False - try: - with np.load(path, allow_pickle=False) as data: - return "manifest_json" in data - except Exception: - return False - - -def _download_sidecar_native_http(data_root: str, relative_path: str, local: Path) -> None: - fetcher = NativeHTTPRangeFetcher(data_root, max_connections=16) - tmp = local.with_suffix(local.suffix + ".tmp") - try: - size = fetcher.info_size(relative_path) - chunk_size = 16 * 1024 * 1024 - ranges = [(offset, min(chunk_size, size - offset)) for offset in range(0, size, chunk_size)] - with tmp.open("wb") as out_file: - out_file.truncate(size) - - def read_chunk(offset_length: tuple[int, int]) -> tuple[int, bytes]: - offset, length = offset_length - return offset, fetcher.read_range(relative_path, offset, length) - - start = time.perf_counter() - done = 0 - with ThreadPoolExecutor(max_workers=8) as pool: - futures = [pool.submit(read_chunk, item) for item in ranges] - with tmp.open("r+b") as rw_file: - for future in futures: - offset, data = future.result() - rw_file.seek(offset) - rw_file.write(data) - done += len(data) - elapsed = max(time.perf_counter() - start, 1e-9) - print( - f"sidecar_download: {done / 1024**2:.1f}/{size / 1024**2:.1f} MiB " - f"({done / elapsed / 1024**2:.1f} MiB/s)", - flush=True, - ) - tmp.replace(local) - finally: - fetcher.close() - - class EpisodeParquetReader: def __init__(self, meta: LeRobotDatasetMetadata, data_root: str): self.meta = meta @@ -1369,15 +1295,14 @@ def main() -> None: os.environ["LEROBOT_HTTP_FAILURE_LOG"] = args.http_failure_log print(f"http_failure_log: {args.http_failure_log}") data_root = args.data_root - if data_root.startswith("hf://") and not args.no_hub_branch_assert: - assert_hf_hub_range_cache_branch() - meta = LeRobotDatasetMetadata(args.repo_id, revision=args.revision) meta.ensure_readable() parquet_reader = EpisodeParquetReader(meta, data_root) manifest_episode_count = args.manifest_episodes or int(meta.total_episodes) manifest_episode_count = min(manifest_episode_count, int(meta.total_episodes), args.num_episodes) - sidecar_path = _find_or_download_sidecar(data_root, manifest_episode_count) + sidecar_path = Path(args.sidecar_path).expanduser() if args.sidecar_path else None + if sidecar_path is not None and not sidecar_path.is_file(): + raise FileNotFoundError(f"MP4 sidecar not found: {sidecar_path}") if sidecar_path is not None: print(f"using_mp4_sidecar: {sidecar_path}") @@ -1419,15 +1344,7 @@ def main() -> None: ) return if args.strategy == "both": - expected_sidecar = SIDECAR_CACHE_DIR / FULL_SIDECAR_NAME - expected_remote = _root_join(data_root, f"meta/mp4-sidecars/{FULL_SIDECAR_NAME}") - print(f"mp4_sidecar_missing_local: {expected_sidecar}") - print(f"mp4_sidecar_missing_remote: {expected_remote}") - print( - "build_mp4_sidecar: " - "uv run --no-sync python scripts/build_mp4_sidecar.py " - f"--workers {args.workers} --range-backend native-http --output {expected_sidecar}" - ) + print("mp4_sidecar: none (pass --sidecar-path to profile a prebuilt sidecar)") print("running_without_mp4_sidecar: indexed variants will build MP4 indexes online") print() diff --git a/scripts/bench_streaming_dataset.py b/scripts/bench_streaming_dataset.py new file mode 100644 index 000000000..dc14cf070 --- /dev/null +++ b/scripts/bench_streaming_dataset.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python + +# Copyright 2026 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 + +"""Benchmark the production StreamingLeRobotDataset path used by lerobot-train.""" + +from __future__ import annotations + +import argparse +import json +import platform +import resource +import shutil +import socket +import statistics +import subprocess +import sys +import time +from pathlib import Path + +import torch + +from lerobot.datasets import StreamingLeRobotDataset + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-id", required=True) + parser.add_argument("--revision", default=None) + parser.add_argument("--root", default=None) + parser.add_argument("--data-root", default=None) + parser.add_argument("--episodes", type=int, default=None, help="Use the first N episodes.") + parser.add_argument("--batch-size", type=int, default=16) + parser.add_argument("--num-workers", type=int, default=4) + parser.add_argument("--prefetch-factor", type=int, default=2) + parser.add_argument("--episode-pool-size", type=int, default=32) + parser.add_argument("--prefetch-episodes", type=int, default=8) + parser.add_argument("--byte-budget-gb", type=float, default=8.0) + parser.add_argument("--warmup-batches", type=int, default=8) + parser.add_argument("--measure-batches", type=int, default=128) + parser.add_argument("--summary-json", type=Path, default=None) + return parser.parse_args() + + +def percentile(values: list[float], quantile: float) -> float: + if not values: + return 0.0 + ordered = sorted(values) + index = round((len(ordered) - 1) * quantile) + return ordered[index] + + +def git_commit() -> str | None: + git = shutil.which("git") + if git is None: + return None + try: + return subprocess.run( + [git, "rev-parse", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + except (OSError, subprocess.CalledProcessError): + return None + + +def main_process_max_rss_mb() -> float: + rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + return rss / 1024**2 if sys.platform == "darwin" else rss / 1024 + + +def child_process_max_rss_mb() -> float: + rss = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss + return rss / 1024**2 if sys.platform == "darwin" else rss / 1024 + + +def main() -> None: + args = parse_args() + episodes = list(range(args.episodes)) if args.episodes is not None else None + + init_start = time.perf_counter() + dataset = StreamingLeRobotDataset( + args.repo_id, + root=args.root, + episodes=episodes, + revision=args.revision, + data_root=args.data_root, + episode_pool_size=args.episode_pool_size, + prefetch_episodes=args.prefetch_episodes, + byte_budget_gb=args.byte_budget_gb, + max_num_shards=max(1, args.num_workers), + return_uint8=True, + ) + dataset_init_s = time.perf_counter() - init_start + + loader = torch.utils.data.DataLoader( + dataset, + batch_size=args.batch_size, + num_workers=args.num_workers, + pin_memory=torch.cuda.is_available(), + prefetch_factor=args.prefetch_factor if args.num_workers else None, + persistent_workers=args.num_workers > 0, + ) + iterator = iter(loader) + waits: list[float] = [] + measured_samples = 0 + measured_indices: list[int] = [] + first_batch_s = 0.0 + exhausted = False + + try: + for batch_index in range(args.warmup_batches + args.measure_batches): + wait_start = time.perf_counter() + try: + batch = next(iterator) + except StopIteration: + exhausted = True + break + wait_s = time.perf_counter() - wait_start + if batch_index == 0: + first_batch_s = wait_s + if batch_index >= args.warmup_batches: + waits.append(wait_s) + indices = batch["index"].reshape(-1).tolist() + measured_indices.extend(int(index) for index in indices) + measured_samples += len(indices) + finally: + shutdown = getattr(iterator, "_shutdown_workers", None) + if shutdown is not None: + shutdown() + + measured_wall_s = sum(waits) + summary = { + "repo_id": args.repo_id, + "revision": str(dataset.revision), + "git_commit": git_commit(), + "host": socket.gethostname(), + "platform": platform.platform(), + "torch_version": torch.__version__, + "dataset_init_s": dataset_init_s, + "first_batch_s": first_batch_s, + "measured_batches": len(waits), + "measured_samples": measured_samples, + "measured_wall_s": measured_wall_s, + "samples_s": measured_samples / measured_wall_s if measured_wall_s else 0.0, + "batch_wait_mean_ms": statistics.fmean(waits) * 1000 if waits else 0.0, + "batch_wait_p50_ms": percentile(waits, 0.50) * 1000, + "batch_wait_p95_ms": percentile(waits, 0.95) * 1000, + "batch_wait_p99_ms": percentile(waits, 0.99) * 1000, + "duplicate_indices": measured_samples - len(set(measured_indices)), + "epoch_exhausted": exhausted, + "main_process_max_rss_mb": main_process_max_rss_mb(), + "worker_process_max_rss_mb": child_process_max_rss_mb(), + "config": { + "batch_size": args.batch_size, + "num_workers": args.num_workers, + "prefetch_factor": args.prefetch_factor, + "episode_pool_size": args.episode_pool_size, + "prefetch_episodes": args.prefetch_episodes, + "byte_budget_gb": args.byte_budget_gb, + "warmup_batches": args.warmup_batches, + "measure_batches": args.measure_batches, + }, + } + print(json.dumps(summary, indent=2, sort_keys=True)) + if args.summary_json is not None: + args.summary_json.parent.mkdir(parents=True, exist_ok=True) + args.summary_json.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") + + +if __name__ == "__main__": + main() diff --git a/scripts/build_mp4_sidecar.py b/scripts/build_mp4_sidecar.py index 3fcb9ed8f..783daca00 100644 --- a/scripts/build_mp4_sidecar.py +++ b/scripts/build_mp4_sidecar.py @@ -17,76 +17,78 @@ from pathlib import Path import fsspec from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata -from lerobot.datasets.episode_video_streaming import EpisodeVideoManifest, assert_hf_hub_range_cache_branch - -DEFAULT_REPO = "allenai/MolmoAct2-BimanualYAM-Dataset" -DEFAULT_REVISION = "e9f21ae15074330839f2ac25ed4b49d76dfa1f9c" -DEFAULT_DATA_ROOT = "hf://buckets/pepijn223/MolmoAct2-BimanualYAM-Dataset-bucket" +from lerobot.datasets.streaming_sidecar import ( + build_mp4_sidecar, + make_sidecar_spec, + published_sidecar_url, + range_backend_for_root, +) +from lerobot.streaming.sidecar import SidecarSpec def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build a reusable MP4 byte-index sidecar for streaming.") - parser.add_argument("--repo-id", default=DEFAULT_REPO) - parser.add_argument("--revision", default=DEFAULT_REVISION) - parser.add_argument("--data-root", default=DEFAULT_DATA_ROOT) + parser.add_argument("--repo-id", required=True) + parser.add_argument("--revision", default=None) + parser.add_argument("--data-root", required=True) parser.add_argument("--output", required=True) parser.add_argument("--episodes", type=int, default=None) parser.add_argument("--workers", type=int, default=8) - parser.add_argument("--range-backend", choices=("fsspec", "native-http"), default="native-http") + parser.add_argument("--range-backend", choices=("fsspec", "native-http"), default=None) parser.add_argument("--max-probe-mb", type=int, default=64) - parser.add_argument( - "--no-push", action="store_true", help="Do not upload the sidecar to data_root/meta/mp4-sidecars." - ) - parser.add_argument("--no-hub-branch-assert", action="store_true") + parser.add_argument("--push", action="store_true", help="Explicitly publish the sidecar to data_root.") return parser.parse_args() -def push_sidecar(local_path: str, data_root: str) -> list[str]: - if not data_root.startswith("hf://"): - return [] +def push_sidecar(local_path: str, spec: SidecarSpec) -> list[str]: + if not spec.data_root.startswith("hf://"): + raise ValueError("--push currently supports only hf:// data roots") - local = Path(local_path) fs = fsspec.filesystem("hf") - remote_dir = f"{data_root.rstrip('/')}/meta/mp4-sidecars" - remote_paths = [f"{remote_dir}/{local.name}"] - - for remote in remote_paths: - fs.put(str(local), remote) - return remote_paths + remote = published_sidecar_url(spec) + fs.put(str(Path(local_path)), remote) + return [remote] def main() -> None: args = parse_args() - if args.data_root.startswith("hf://") and not args.no_hub_branch_assert: - assert_hf_hub_range_cache_branch() meta = LeRobotDatasetMetadata(args.repo_id, revision=args.revision) meta.ensure_readable() total = ( int(meta.total_episodes) if args.episodes is None else min(args.episodes, int(meta.total_episodes)) ) - rel_paths = sorted( - {str(meta.get_video_file_path(ep_idx, key)) for ep_idx in range(total) for key in meta.video_keys} - ) + spec = make_sidecar_spec(meta, args.data_root) + if total != int(meta.total_episodes): + selected_paths = { + str(meta.get_video_file_path(ep_idx, key)) for ep_idx in range(total) for key in meta.video_keys + } + spec = SidecarSpec( + repo_id=spec.repo_id, + revision=spec.revision, + data_root=spec.data_root, + source_files=tuple(item for item in spec.source_files if item[0] in selected_paths), + ) start = time.perf_counter() - EpisodeVideoManifest.write_file_sidecar( + build_mp4_sidecar( args.output, - rel_paths, - args.data_root, - range_backend=args.range_backend, + spec, + range_backend=args.range_backend or range_backend_for_root(args.data_root), workers=args.workers, max_probe_bytes=args.max_probe_mb * 1024 * 1024, ) elapsed = time.perf_counter() - start print(f"wrote {args.output}") - print(f"episodes={total} files={len(rel_paths)} elapsed_s={elapsed:.2f}") - if args.no_push: - print("push_skipped: --no-push") - else: - pushed = push_sidecar(args.output, args.data_root) + print(f"episodes={total} files={len(spec.source_files)} elapsed_s={elapsed:.2f}") + if args.push: + if total != int(meta.total_episodes): + raise ValueError("Only a complete dataset sidecar can be published") + pushed = push_sidecar(args.output, spec) for remote in pushed: print(f"pushed {remote}") + else: + print("push_skipped: pass --push for explicit publication") if __name__ == "__main__": diff --git a/src/lerobot/common/train_utils.py b/src/lerobot/common/train_utils.py index b26196f14..4213e18c0 100644 --- a/src/lerobot/common/train_utils.py +++ b/src/lerobot/common/train_utils.py @@ -53,7 +53,11 @@ def get_step_checkpoint_dir(output_dir: Path, total_steps: int, step: int) -> Pa def save_training_step( - step: int, save_dir: Path, num_processes: int | None = None, batch_size: int | None = None + step: int, + save_dir: Path, + num_processes: int | None = None, + batch_size: int | None = None, + num_workers: int | None = None, ) -> None: state: dict = {"step": step} # num_processes and batch_size are recorded so a resumed run can detect a changed world size or @@ -64,6 +68,8 @@ def save_training_step( state["num_processes"] = num_processes if batch_size is not None: state["batch_size"] = batch_size + if num_workers is not None: + state["num_workers"] = num_workers write_json(state, save_dir / TRAINING_STEP) @@ -82,6 +88,11 @@ def load_training_batch_size(checkpoint_dir: Path) -> int | None: return load_json(checkpoint_dir / TRAINING_STATE_DIR / TRAINING_STEP).get("batch_size") +def load_training_num_workers(checkpoint_dir: Path) -> int | None: + """DataLoader worker count recorded at checkpoint time, or None for older checkpoints.""" + return load_json(checkpoint_dir / TRAINING_STATE_DIR / TRAINING_STEP).get("num_workers") + + def update_last_checkpoint(checkpoint_dir: Path) -> Path: last_checkpoint_dir = checkpoint_dir.parent / LAST_CHECKPOINT_LINK if last_checkpoint_dir.is_symlink(): @@ -101,6 +112,7 @@ def save_checkpoint( postprocessor: PolicyProcessorPipeline | None = None, num_processes: int | None = None, batch_size: int | None = None, + num_workers: int | None = None, model_state_dict: dict | None = None, optim_state_dict: dict | None = None, ) -> None: @@ -132,6 +144,8 @@ def save_checkpoint( resume. Defaults to None (not recorded). batch_size (int | None, optional): Per-process batch size to record for sample-exact resume. Defaults to None (not recorded). + num_workers (int | None, optional): Per-process DataLoader worker count to record for + sample-exact streaming resume. Defaults to None (not recorded). model_state_dict: Pre-gathered full (unsharded) model state dict. Required under FSDP, where `policy.state_dict()` would return sharded tensors; the caller gathers it via a cross-rank collective and passes it here so rank 0 can write it directly. It holds @@ -160,6 +174,7 @@ def save_checkpoint( scheduler, num_processes=num_processes, batch_size=batch_size, + num_workers=num_workers, optim_state_dict=optim_state_dict, ) @@ -171,6 +186,7 @@ def save_training_state( scheduler: LRScheduler | None = None, num_processes: int | None = None, batch_size: int | None = None, + num_workers: int | None = None, optim_state_dict: dict | None = None, ) -> None: """ @@ -185,12 +201,20 @@ def save_training_state( Defaults to None. num_processes (int | None, optional): Distributed world size to record. Defaults to None. batch_size (int | None, optional): Per-process batch size to record. Defaults to None. + num_workers (int | None, optional): Per-process DataLoader worker count to record. + Defaults to None. optim_state_dict: Pre-gathered full optimizer state dict (for FSDP). Saved instead of `optimizer.state_dict()` when provided. Defaults to None. """ save_dir = checkpoint_dir / TRAINING_STATE_DIR save_dir.mkdir(parents=True, exist_ok=True) - save_training_step(train_step, save_dir, num_processes=num_processes, batch_size=batch_size) + save_training_step( + train_step, + save_dir, + num_processes=num_processes, + batch_size=batch_size, + num_workers=num_workers, + ) save_rng_state(save_dir) if optimizer is not None: save_optimizer_state(optimizer, save_dir, optim_state_dict=optim_state_dict) diff --git a/src/lerobot/configs/default.py b/src/lerobot/configs/default.py index 38991a665..da27610fa 100644 --- a/src/lerobot/configs/default.py +++ b/src/lerobot/configs/default.py @@ -44,6 +44,14 @@ class DatasetConfig: # Has no effect on datasets without depth cameras. depth_output_unit: str = DEFAULT_DEPTH_UNIT streaming: bool = False + # Optional data-plane root used by training-time streaming. Metadata still resolves from repo_id/root. + streaming_data_root: str | None = None + # Number of complete episodes mixed by the exact-coverage sampler in each DataLoader worker. + streaming_episode_pool_size: int = 32 + # Complete episodes fetched ahead of the current admission frontier. + streaming_prefetch_episodes: int = 8 + # Hard per-worker cap for synthesized episode-video bytes. + streaming_byte_budget_gb: float = 8.0 # Fraction of episodes held out per task for offline evaluation (0.0 = disabled). eval_split: float = 0.0 @@ -54,6 +62,12 @@ class DatasetConfig: ) if not (0.0 <= self.eval_split < 1.0): raise ValueError(f"eval_split must be in [0.0, 1.0), got {self.eval_split}") + if self.streaming_episode_pool_size <= 0: + raise ValueError("streaming_episode_pool_size must be positive") + if self.streaming_prefetch_episodes < 0: + raise ValueError("streaming_prefetch_episodes must be non-negative") + if self.streaming_byte_budget_gb <= 0: + raise ValueError("streaming_byte_budget_gb must be positive") if self.episodes is not None: if any(ep < 0 for ep in self.episodes): raise ValueError( diff --git a/src/lerobot/datasets/episode_parquet.py b/src/lerobot/datasets/episode_parquet.py new file mode 100644 index 000000000..fc24798ef --- /dev/null +++ b/src/lerobot/datasets/episode_parquet.py @@ -0,0 +1,106 @@ +# Copyright 2026 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 + +"""Episode-scoped Parquet reads for training-time dataset streaming.""" + +from __future__ import annotations + +import posixpath +from collections.abc import Sequence +from pathlib import Path + +import fsspec +import pyarrow as pa +import pyarrow.compute as pc +import pyarrow.parquet as pq + + +class EpisodeParquetReader: + """Read complete episodes with column projection from local or fsspec roots.""" + + def __init__(self, data_root: str | Path, *, columns: Sequence[str]): + if not columns: + raise ValueError("EpisodeParquetReader requires at least one projected column") + self.columns = tuple(dict.fromkeys(columns)) + self._read_columns = ( + self.columns if "episode_index" in self.columns else (*self.columns, "episode_index") + ) + self._filesystem, self._root_path = fsspec.core.url_to_fs(str(data_root)) + + def read_episode( + self, + relative_path: str | Path, + *, + episode_index: int, + expected_rows: int, + ) -> pa.Table: + if expected_rows <= 0: + raise ValueError(f"Episode {episode_index} must contain at least one row") + + path = posixpath.join(self._root_path.rstrip("/"), str(relative_path).lstrip("/")) + with self._filesystem.open(path, "rb") as source: + parquet = pq.ParquetFile(source) + available = set(parquet.schema_arrow.names) + missing = sorted(set(self._read_columns) - available) + if missing: + raise ValueError(f"Parquet file {relative_path} is missing projected columns: {missing}") + row_group = self._matching_row_group(parquet, episode_index) + table = ( + parquet.read_row_group(row_group, columns=list(self._read_columns)) + if row_group is not None + else parquet.read(columns=list(self._read_columns)) + ) + + if row_group is None: + table = table.filter(pc.equal(table.column("episode_index"), episode_index)) + self._validate_complete_episode(table, episode_index, expected_rows, relative_path) + if "episode_index" not in self.columns: + table = table.drop_columns(["episode_index"]) + return table + + @staticmethod + def _matching_row_group(parquet: pq.ParquetFile, episode_index: int) -> int | None: + episode_column = next( + index + for index in range(parquet.metadata.num_columns) + if parquet.metadata.schema.column(index).path == "episode_index" + ) + matches = [] + for row_group in range(parquet.metadata.num_row_groups): + statistics = parquet.metadata.row_group(row_group).column(episode_column).statistics + if ( + statistics is not None + and statistics.has_min_max + and int(statistics.min) == episode_index + and int(statistics.max) == episode_index + ): + matches.append(row_group) + return matches[0] if len(matches) == 1 else None + + @staticmethod + def _validate_complete_episode( + table: pa.Table, + episode_index: int, + expected_rows: int, + relative_path: str | Path, + ) -> None: + actual_rows = len(table) + if actual_rows != expected_rows: + raise ValueError( + f"Parquet episode {episode_index} in {relative_path}: " + f"expected {expected_rows} rows, found {actual_rows}" + ) + episodes = table.column("episode_index").to_pylist() + if any(int(value) != episode_index for value in episodes): + raise ValueError(f"Parquet file {relative_path} returned rows outside episode {episode_index}") + if "frame_index" in table.column_names: + frame_indices = [int(value) for value in table.column("frame_index").to_pylist()] + if frame_indices != list(range(expected_rows)): + raise ValueError( + f"Parquet episode {episode_index} in {relative_path} has non-contiguous frame indices" + ) diff --git a/src/lerobot/datasets/factory.py b/src/lerobot/datasets/factory.py index da7b4365a..b5a3c8db6 100644 --- a/src/lerobot/datasets/factory.py +++ b/src/lerobot/datasets/factory.py @@ -66,7 +66,9 @@ def resolve_delta_timestamps( return delta_timestamps -def make_dataset(cfg: TrainPipelineConfig) -> LeRobotDataset | MultiLeRobotDataset: +def make_dataset( + cfg: TrainPipelineConfig, +) -> LeRobotDataset | StreamingLeRobotDataset | MultiLeRobotDataset: """Handles the logic of setting up delta timestamps and image transforms before creating a dataset. Args: @@ -108,9 +110,15 @@ def make_dataset(cfg: TrainPipelineConfig) -> LeRobotDataset | MultiLeRobotDatas delta_timestamps=delta_timestamps, image_transforms=image_transforms, revision=cfg.dataset.revision, - max_num_shards=cfg.num_workers, + max_num_shards=max(1, cfg.num_workers), tolerance_s=cfg.tolerance_s, return_uint8=True, + depth_output_unit=cfg.dataset.depth_output_unit, + data_root=cfg.dataset.streaming_data_root, + episode_pool_size=cfg.dataset.streaming_episode_pool_size, + prefetch_episodes=cfg.dataset.streaming_prefetch_episodes, + byte_budget_gb=cfg.dataset.streaming_byte_budget_gb, + repeat=True, ) else: raise NotImplementedError("The MultiLeRobotDataset isn't supported for now.") @@ -138,7 +146,10 @@ def make_dataset(cfg: TrainPipelineConfig) -> LeRobotDataset | MultiLeRobotDatas def make_train_eval_datasets( cfg: TrainPipelineConfig, -) -> tuple[LeRobotDataset | MultiLeRobotDataset, LeRobotDataset | None]: +) -> tuple[ + LeRobotDataset | StreamingLeRobotDataset | MultiLeRobotDataset, + LeRobotDataset | None, +]: """Create train and optional eval datasets by splitting episodes based on eval_split. The last ceil(n_episodes * eval_split) episodes per task are held out for evaluation. @@ -181,17 +192,37 @@ def make_train_eval_datasets( ImageTransforms(cfg.dataset.image_transforms) if cfg.dataset.image_transforms.enable else None ) - train_dataset = LeRobotDataset( - cfg.dataset.repo_id, - root=cfg.dataset.root, - episodes=train_episodes, - delta_timestamps=delta_timestamps, - image_transforms=train_image_transforms, - revision=cfg.dataset.revision, - video_backend=cfg.dataset.video_backend, - return_uint8=True, - tolerance_s=cfg.tolerance_s, - ) + if cfg.dataset.streaming: + train_dataset = StreamingLeRobotDataset( + cfg.dataset.repo_id, + root=cfg.dataset.root, + episodes=train_episodes, + delta_timestamps=delta_timestamps, + image_transforms=train_image_transforms, + revision=cfg.dataset.revision, + max_num_shards=max(1, cfg.num_workers), + tolerance_s=cfg.tolerance_s, + return_uint8=True, + depth_output_unit=cfg.dataset.depth_output_unit, + data_root=cfg.dataset.streaming_data_root, + episode_pool_size=cfg.dataset.streaming_episode_pool_size, + prefetch_episodes=cfg.dataset.streaming_prefetch_episodes, + byte_budget_gb=cfg.dataset.streaming_byte_budget_gb, + repeat=True, + ) + else: + train_dataset = LeRobotDataset( + cfg.dataset.repo_id, + root=cfg.dataset.root, + episodes=train_episodes, + delta_timestamps=delta_timestamps, + image_transforms=train_image_transforms, + revision=cfg.dataset.revision, + video_backend=cfg.dataset.video_backend, + return_uint8=True, + depth_output_unit=cfg.dataset.depth_output_unit, + tolerance_s=cfg.tolerance_s, + ) eval_dataset = LeRobotDataset( cfg.dataset.repo_id, @@ -202,6 +233,7 @@ def make_train_eval_datasets( revision=cfg.dataset.revision, video_backend=cfg.dataset.video_backend, return_uint8=True, + depth_output_unit=cfg.dataset.depth_output_unit, tolerance_s=cfg.tolerance_s, ) diff --git a/src/lerobot/datasets/streaming_dataset.py b/src/lerobot/datasets/streaming_dataset.py index 14d4a52a4..2fb4c7b28 100644 --- a/src/lerobot/datasets/streaming_dataset.py +++ b/src/lerobot/datasets/streaming_dataset.py @@ -13,208 +13,46 @@ # 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. -from collections import deque -from collections.abc import Callable, Generator, Iterable, Iterator +import io +import os +from collections.abc import Callable, Iterator +from concurrent.futures import Future, ThreadPoolExecutor from pathlib import Path import datasets import numpy as np import torch -from datasets import load_dataset from lerobot.configs import DEFAULT_DEPTH_UNIT, DEPTH_METER_UNIT, DepthEncoderConfig -from lerobot.utils.constants import HF_LEROBOT_HOME, LOOKAHEAD_BACKTRACKTABLE, LOOKBACK_BACKTRACKTABLE +from lerobot.streaming.episode_video import EpisodeByteCache, EpisodeVideoManifest, ExactCoveragePool +from lerobot.utils.constants import HF_LEROBOT_HOME from .dataset_metadata import CODEBASE_VERSION, LeRobotDatasetMetadata from .depth_utils import MM_PER_METRE, dequantize_depth -from .feature_utils import get_delta_indices -from .io_utils import item_to_torch -from .utils import ( - check_version_compatibility, - find_float_index, - is_float_in_list, - safe_shard, +from .episode_parquet import EpisodeParquetReader +from .feature_utils import check_delta_timestamps, get_delta_indices, get_hf_features_from_features +from .io_utils import hf_transform_to_torch +from .streaming_sidecar import ( + ensure_dataset_mp4_sidecar, + range_backend_for_root, + streaming_data_root, ) -from .video_utils import ( - VideoDecoderCache, - decode_video_frames, - decode_video_frames_torchcodec, -) - - -class LookBackError(Exception): - """ - Exception raised when trying to look back in the history of a Backtrackable object. - """ - - pass - - -class LookAheadError(Exception): - """ - Exception raised when trying to look ahead in the future of a Backtrackable object. - """ - - pass - - -class Backtrackable[T]: - """ - Wrap any iterator/iterable so you can step back up to `history` items - and look ahead up to `lookahead` items. - - This is useful for streaming datasets where you need to access previous and future items - but can't load the entire dataset into memory. - - Example: - ------- - ```python - ds = load_dataset("c4", "en", streaming=True, split="train") - rev = Backtrackable(ds, history=3, lookahead=2) - - x0 = next(rev) # forward - x1 = next(rev) - x2 = next(rev) - - # Look ahead - x3_peek = rev.peek_ahead(1) # next item without moving cursor - x4_peek = rev.peek_ahead(2) # two items ahead - - # Look back - x1_again = rev.peek_back(1) # previous item without moving cursor - x0_again = rev.peek_back(2) # two items back - - # Move backward - x1_back = rev.prev() # back one step - next(rev) # returns x2, continues forward from where we were - ``` - """ - - __slots__ = ("_source", "_back_buf", "_ahead_buf", "_cursor", "_history", "_lookahead") - - def __init__(self, iterable: Iterable[T], *, history: int = 1, lookahead: int = 0): - if history < 1: - raise ValueError("history must be >= 1") - if lookahead <= 0: - raise ValueError("lookahead must be > 0") - - self._source: Iterator[T] = iter(iterable) - self._back_buf: deque[T] = deque(maxlen=history) - self._ahead_buf: deque[T] = deque(maxlen=lookahead) if lookahead > 0 else deque() - self._cursor: int = 0 - self._history = history - self._lookahead = lookahead - - def __iter__(self) -> "Backtrackable[T]": - return self - - def __next__(self) -> T: - # If we've stepped back, consume from back buffer first - if self._cursor < 0: # -1 means "last item", etc. - self._cursor += 1 - return self._back_buf[self._cursor] - - # If we have items in the ahead buffer, use them first - item = self._ahead_buf.popleft() if self._ahead_buf else next(self._source) - - # Add current item to back buffer and reset cursor - self._back_buf.append(item) - self._cursor = 0 - return item - - def prev(self) -> T: - """ - Step one item back in history and return it. - Raises IndexError if already at the oldest buffered item. - """ - if len(self._back_buf) + self._cursor <= 1: - raise LookBackError("At start of history") - - self._cursor -= 1 - return self._back_buf[self._cursor] - - def peek_back(self, n: int = 1) -> T: - """ - Look `n` items back (n=1 == previous item) without moving the cursor. - """ - if n < 0 or n + 1 > len(self._back_buf) + self._cursor: - raise LookBackError("peek_back distance out of range") - - return self._back_buf[self._cursor - (n + 1)] - - def peek_ahead(self, n: int = 1) -> T: - """ - Look `n` items ahead (n=1 == next item) without moving the cursor. - Fills the ahead buffer if necessary. - """ - if n < 1: - raise LookAheadError("peek_ahead distance must be 1 or more") - elif n > self._lookahead: - raise LookAheadError("peek_ahead distance exceeds lookahead limit") - - # Fill ahead buffer if we don't have enough items - while len(self._ahead_buf) < n: - try: - item = next(self._source) - self._ahead_buf.append(item) - - except StopIteration as err: - raise LookAheadError("peek_ahead: not enough items in source") from err - - return self._ahead_buf[n - 1] - - def history(self) -> list[T]: - """ - Return a copy of the buffered history (most recent last). - The list length ≤ `history` argument passed at construction. - """ - if self._cursor == 0: - return list(self._back_buf) - - # When cursor<0, slice so the order remains chronological - return list(self._back_buf)[: self._cursor or None] - - def can_peek_back(self, steps: int = 1) -> bool: - """ - Check if we can go back `steps` items without raising an IndexError. - """ - return steps <= len(self._back_buf) + self._cursor - - def can_peek_ahead(self, steps: int = 1) -> bool: - """ - Check if we can peek ahead `steps` items. - This may involve trying to fill the ahead buffer. - """ - if self._lookahead > 0 and steps > self._lookahead: - return False - - # Try to fill ahead buffer to check if we can peek that far - try: - while len(self._ahead_buf) < steps: - if self._lookahead > 0 and len(self._ahead_buf) >= self._lookahead: - return False - item = next(self._source) - self._ahead_buf.append(item) - return True - except StopIteration: - return False +from .utils import check_version_compatibility +from .video_utils import decode_video_frames_pyav class StreamingLeRobotDataset(torch.utils.data.IterableDataset): - """LeRobotDataset with streaming capabilities. + """Episode-scoped streaming reader for LeRobot datasets. - This class extends LeRobotDataset to add streaming functionality, allowing data to be streamed - rather than loaded entirely into memory. This is especially useful for large datasets that may - not fit in memory or when you want to quickly explore a dataset without downloading it completely. - - The key innovation is using a Backtrackable iterator that maintains a bounded buffer of recent - items, allowing us to access previous frames for delta timestamps without loading the entire - dataset into memory. + Metadata is cached locally, while each worker reads only the Parquet rows and MP4 byte ranges + needed for the complete episodes it owns. Episode ownership is disjoint across distributed + ranks and dataloader workers, and every selected frame is yielded exactly once per iteration. + MP4 sidecars are resolved automatically and built in a revision-keyed local cache when absent. Example: Basic usage: ```python - from lerobot.common.datasets.streaming_dataset import StreamingLeRobotDataset + from lerobot.datasets.streaming_dataset import StreamingLeRobotDataset # Create a streaming dataset with delta timestamps delta_timestamps = { @@ -225,8 +63,6 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset): dataset = StreamingLeRobotDataset( repo_id="your-dataset-repo-id", delta_timestamps=delta_timestamps, - streaming=True, - buffer_size=1000, ) # Iterate over the dataset @@ -244,7 +80,7 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset): root: str | Path | None = None, episodes: list[int] | None = None, image_transforms: Callable | None = None, - delta_timestamps: dict[list[float]] | None = None, + delta_timestamps: dict[str, list[float]] | None = None, tolerance_s: float = 1e-4, revision: str | None = None, force_cache_sync: bool = False, @@ -256,6 +92,11 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset): shuffle: bool = True, return_uint8: bool = False, depth_output_unit: str = DEFAULT_DEPTH_UNIT, + data_root: str | Path | None = None, + episode_pool_size: int | None = None, + prefetch_episodes: int = 8, + byte_budget_gb: float = 8.0, + repeat: bool = False, ): """Initialize a StreamingLeRobotDataset. @@ -270,14 +111,22 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset): tolerance_s (float, optional): Tolerance in seconds for timestamp matching. revision (str, optional): Git revision id (branch name, tag, or commit hash). force_cache_sync (bool, optional): Flag to sync and refresh local files first. - streaming (bool, optional): Whether to stream the dataset or load it all. Defaults to True. - buffer_size (int, optional): Buffer size for shuffling when streaming. Defaults to 1000. - max_num_shards (int, optional): Number of shards to re-shard the input dataset into. Defaults to 16. + streaming (bool, optional): Compatibility flag retained by the public API. + buffer_size (int, optional): Compatibility setting used to derive the default episode pool size. + max_num_shards (int, optional): Maximum number of concurrent episode loading workers. seed (int, optional): Reproducibility random seed. rng (np.random.Generator | None, optional): Random number generator. shuffle (bool, optional): Whether to shuffle the dataset across exhaustions. Defaults to True. depth_output_unit (str, optional): Physical unit depth maps are dequantized to ("m" or "mm"). Defaults to "mm". + data_root (str | Path | None, optional): Dataset payload root. Supports local paths, ``hf://``, + and fsspec URLs. + episode_pool_size (int | None, optional): Number of complete episodes in the sampling pool. + prefetch_episodes (int, optional): Episodes prefetched beyond the active pool. + byte_budget_gb (float, optional): Per-worker upper bound for synthesized episode video bytes. + repeat (bool, optional): Repeat worker-local exact-coverage epochs without yielding a + short final training batch. The training factory enables this; direct iteration is + finite by default. """ super().__init__() self.repo_id = repo_id @@ -295,11 +144,28 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset): self.streaming = streaming self.buffer_size = buffer_size + self.max_num_shards = max_num_shards self._return_uint8 = return_uint8 self._depth_output_unit = depth_output_unit - - # We cache the video decoders to avoid re-initializing them at each frame (avoiding a ~10x slowdown) - self.video_decoder_cache = None + if buffer_size <= 0: + raise ValueError("buffer_size must be positive") + if max_num_shards <= 0: + raise ValueError("max_num_shards must be positive") + if episode_pool_size is not None and episode_pool_size <= 0: + raise ValueError("episode_pool_size must be positive") + if prefetch_episodes < 0: + raise ValueError("prefetch_episodes must be non-negative") + if byte_budget_gb <= 0: + raise ValueError("byte_budget_gb must be positive") + self.episode_pool_size = episode_pool_size or min(buffer_size, 32) + self.prefetch_episodes = prefetch_episodes + self.byte_budget = int(byte_budget_gb * 1024**3) + self.repeat = repeat + self._next_epoch = 0 + self._active_epoch = 0 + self._resume_offset = 0 + self._resume_batch_size = 1 + self._state_offset = 0 if self._requested_root is not None: self.root.mkdir(exist_ok=True, parents=True) @@ -326,34 +192,53 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset): if key in self.meta.image_keys } - self.delta_timestamps = None - self.delta_indices = None + selected_episodes = list(range(self.meta.total_episodes)) if episodes is None else list(episodes) + if len(set(selected_episodes)) != len(selected_episodes): + raise ValueError("episodes must not contain duplicates") + invalid_episodes = [ + episode for episode in selected_episodes if episode < 0 or episode >= self.meta.total_episodes + ] + if invalid_episodes: + raise ValueError( + f"Episode indices out of range for dataset with {self.meta.total_episodes} episodes: " + f"{invalid_episodes}" + ) + self._selected_episodes = selected_episodes + + self.delta_timestamps: dict[str, list[float]] | None = None + self.delta_indices: dict[str, list[int]] | None = None if delta_timestamps is not None: - self._validate_delta_timestamp_keys(delta_timestamps) # raises ValueError if invalid + check_delta_timestamps(delta_timestamps, self.fps, tolerance_s) self.delta_timestamps = delta_timestamps self.delta_indices = get_delta_indices(self.delta_timestamps, self.fps) - self.hf_dataset: datasets.IterableDataset = load_dataset( - self.repo_id if not self.streaming_from_local else str(self.root), - split="train", - streaming=self.streaming, - data_files="data/*/*.parquet", - revision=self.revision, + self._data_root = streaming_data_root( + self.meta, + requested_root=self._requested_root, + configured_data_root=str(data_root) if data_root is not None else None, ) - - self.num_shards = min(self.hf_dataset.num_shards, max_num_shards) + sidecar_backend = range_backend_for_root(self._data_root) + self._sidecar_path = ensure_dataset_mp4_sidecar( + self.meta, + self._data_root, + workers=max_num_shards, + range_backend=sidecar_backend, + ) + self._hf_features = get_hf_features_from_features(self.meta.features) + self._projected_columns = tuple(self._hf_features) + self.num_shards = min(max_num_shards, max(1, len(self._selected_episodes))) @property - def num_frames(self): - return self.meta.total_frames + def num_frames(self) -> int: + return sum(self._episode_frame_count(episode) for episode in self._selected_episodes) @property - def num_episodes(self): - return self.meta.total_episodes + def num_episodes(self) -> int: + return len(self._selected_episodes) @property - def fps(self): + def fps(self) -> int: return self.meta.fps @property @@ -361,397 +246,354 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset): """Physical unit (``"m"`` or ``"mm"``) depth maps are returned in on read.""" return self._depth_output_unit - @staticmethod - def _iter_random_indices( - rng: np.random.Generator, buffer_size: int, random_batch_size=100 - ) -> Iterator[int]: - while True: - yield from (int(i) for i in rng.integers(0, buffer_size, size=random_batch_size)) - - @staticmethod - def _infinite_generator_over_elements(rng: np.random.Generator, elements: list[int]) -> Iterator[int]: - while True: - yield rng.choice(elements) - - # TODO(fracapuano): Implement multi-threaded prefetching to accelerate data loading. - # The current sequential iteration is a bottleneck. A producer-consumer pattern - # could be used with a ThreadPoolExecutor to run `make_frame` (especially video decoding) - # in parallel, feeding a queue from which this iterator will yield processed items. def __iter__(self) -> Iterator[dict[str, torch.Tensor]]: - if self.video_decoder_cache is None: - self.video_decoder_cache = VideoDecoderCache() - - # keep the same seed across exhaustions if shuffle is False, otherwise shuffle data across exhaustions - rng = np.random.default_rng(self.seed) if not self.shuffle else self.rng - - buffer_indices_generator = self._iter_random_indices(rng, self.buffer_size) - - idx_to_backtrack_dataset = { - idx: self._make_backtrackable_dataset(safe_shard(self.hf_dataset, idx, self.num_shards)) - for idx in range(self.num_shards) - } - - # This buffer is populated while iterating on the dataset's shards - # the logic is to add 2 levels of randomness: - # (1) sample one shard at random from the ones available, and - # (2) sample one frame from the shard sampled at (1) - frames_buffer = [] - while available_shards := list(idx_to_backtrack_dataset.keys()): - shard_key = next(self._infinite_generator_over_elements(rng, available_shards)) - backtrack_dataset = idx_to_backtrack_dataset[shard_key] # selects which shard to iterate on + if self.repeat: + return self._repeat_iterator() + return self._iter_once() + def _repeat_iterator(self) -> Iterator[dict[str, torch.Tensor]]: + while True: + iterator = self._iter_once() try: - for frame in self.make_frame(backtrack_dataset): - if len(frames_buffer) == self.buffer_size: - i = next(buffer_indices_generator) # samples a element from the buffer - yield frames_buffer[i] - frames_buffer[i] = frame - else: - frames_buffer.append(frame) - break # random shard sampled, switch shard - except ( - RuntimeError, - StopIteration, - ): # NOTE: StopIteration inside a generator throws a RuntimeError since python 3.7 - del idx_to_backtrack_dataset[shard_key] # Remove exhausted shard, onto another shard + first = next(iterator) + except StopIteration: + return + yield first + yield from iterator - # Once shards are all exhausted, shuffle the buffer and yield the remaining frames - rng.shuffle(frames_buffer) - yield from frames_buffer + def _iter_once(self) -> Iterator[dict[str, torch.Tensor]]: + epoch = self._next_epoch if self.shuffle else 0 + self._active_epoch = epoch - def _get_window_steps( - self, delta_timestamps: dict[str, list[float]] | None = None, dynamic_bounds: bool = False + worker = torch.utils.data.get_worker_info() + worker_index = worker.id if worker is not None else 0 + worker_count = worker.num_workers if worker is not None else 1 + resume_offset = self._resume_offset + self._resume_offset = 0 + worker_resume_offset, logical_worker_index = self._worker_resume_state( + resume_offset, + batch_size=self._resume_batch_size, + worker_index=worker_index, + worker_count=worker_count, + ) + consumer_episodes, _consumer_index, _consumer_count = self._consumer_episodes( + worker_id=logical_worker_index, + num_workers=worker_count, + ) + consumer_frame_count = sum(self._episode_frame_count(episode) for episode in consumer_episodes) + if consumer_frame_count: + worker_epoch_delta, worker_resume_offset = divmod( + worker_resume_offset, + consumer_frame_count, + ) + else: + worker_epoch_delta = 0 + epoch += worker_epoch_delta + self._active_epoch = epoch + if self.shuffle: + self._next_epoch = epoch + 1 + planner = ExactCoveragePool( + [(episode, self._episode_frame_count(episode)) for episode in consumer_episodes], + pool_size=self.episode_pool_size, + seed=self.seed, + epoch=epoch, + ) + for _ in range(worker_resume_offset): + try: + next(planner) + except StopIteration: + return + planner.newly_admitted.clear() + planner.evicted.clear() + + parquet_reader = EpisodeParquetReader(self._data_root, columns=self._projected_columns) + max_workers = min(self.max_num_shards, max(1, self.episode_pool_size + self.prefetch_episodes)) + executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="lerobot-parquet") + episode_futures: dict[int, Future[datasets.Dataset]] = {} + scheduled_cursor = 0 + video_cache = self._make_video_cache(consumer_episodes, max_workers) + retained_video_episodes: set[int] = set() + if video_cache is not None: + for episode_index in planner.resident: + video_cache.retain_episode(episode_index) + retained_video_episodes.add(episode_index) + + def submit(episode_index: int) -> Future[datasets.Dataset]: + future = episode_futures.get(episode_index) + if future is None: + future = executor.submit(self._load_episode_dataset, parquet_reader, episode_index) + episode_futures[episode_index] = future + return future + + def schedule_frontier() -> None: + nonlocal scheduled_cursor + target = min( + len(planner.admission_order), + planner.admitted_count + self.prefetch_episodes, + ) + while scheduled_cursor < target: + episode_index = planner.admission_order[scheduled_cursor] + submit(episode_index) + if video_cache is not None: + video_cache.submit_prefetch(episode_index) + scheduled_cursor += 1 + + schedule_frontier() + try: + while True: + try: + episode_index, frame_index = next(planner) + except StopIteration: + self._active_epoch = epoch + 1 if self.shuffle else 0 + self._state_offset = 0 + break + + episode_dataset = submit(episode_index).result() + item = self._make_episode_item( + episode_dataset, + episode_index, + frame_index, + video_cache=video_cache, + ) + + for evicted_episode in planner.evicted: + episode_futures.pop(evicted_episode, None) + if video_cache is not None and evicted_episode in retained_video_episodes: + video_cache.release_episode(evicted_episode) + retained_video_episodes.remove(evicted_episode) + if video_cache is not None: + for admitted_episode in planner.newly_admitted: + if admitted_episode not in retained_video_episodes: + video_cache.retain_episode(admitted_episode) + retained_video_episodes.add(admitted_episode) + planner.evicted.clear() + planner.newly_admitted.clear() + schedule_frontier() + + self._state_offset += 1 + yield item + finally: + for future in episode_futures.values(): + future.cancel() + executor.shutdown(wait=True, cancel_futures=True) + if video_cache is not None: + video_cache.close() + + def _consumer_episodes( + self, + *, + worker_id: int | None = None, + num_workers: int | None = None, + ) -> tuple[list[int], int, int]: + if torch.distributed.is_available() and torch.distributed.is_initialized(): + rank = torch.distributed.get_rank() + world_size = torch.distributed.get_world_size() + else: + rank = int(os.environ.get("RANK", "0")) + world_size = int(os.environ.get("WORLD_SIZE", "1")) + if world_size <= 0 or rank < 0 or rank >= world_size: + raise ValueError(f"Invalid distributed rank/world size: rank={rank}, world_size={world_size}") + + worker = torch.utils.data.get_worker_info() + if worker_id is None: + worker_id = worker.id if worker is not None else 0 + if num_workers is None: + num_workers = worker.num_workers if worker is not None else 1 + consumer_index = rank * num_workers + worker_id + consumer_count = world_size * num_workers + return self._selected_episodes[consumer_index::consumer_count], consumer_index, consumer_count + + @staticmethod + def _worker_resume_state( + offset: int, + *, + batch_size: int, + worker_index: int, + worker_count: int, ) -> tuple[int, int]: - if delta_timestamps is None: - return 1, 1 + completed_batches, partial_batch = divmod(offset, batch_size) + completed_cycles, next_logical_worker = divmod(completed_batches, worker_count) + logical_worker = (worker_index + next_logical_worker) % worker_count + worker_batches = completed_cycles + int(logical_worker < next_logical_worker) + worker_offset = worker_batches * batch_size + if partial_batch and logical_worker == next_logical_worker: + worker_offset += partial_batch + return worker_offset, logical_worker - if not dynamic_bounds: - # Fix the windows - lookback = LOOKBACK_BACKTRACKTABLE - lookahead = LOOKAHEAD_BACKTRACKTABLE - else: - # Dynamically adjust the windows based on the given delta_timesteps - all_timestamps = sum(delta_timestamps.values(), []) - lookback = min(all_timestamps) * self.fps - lookahead = max(all_timestamps) * self.fps + def _episode_frame_count(self, episode_index: int) -> int: + episode = self.meta.episodes[episode_index] + return int(episode["dataset_to_index"] - episode["dataset_from_index"]) - # When lookback is >=0 it means no negative timesteps have been provided - lookback = 0 if lookback >= 0 else (lookback * -1) + def num_frames_for_rank(self, rank: int, world_size: int, num_workers: int) -> int: + """Return frames owned by one training rank under episode-scoped worker sharding.""" + if world_size <= 0 or rank < 0 or rank >= world_size: + raise ValueError(f"Invalid distributed rank/world size: rank={rank}, world_size={world_size}") + workers = max(1, num_workers) + consumer_count = world_size * workers + consumer_indices = set(range(rank * workers, (rank + 1) * workers)) + return sum( + self._episode_frame_count(episode) + for position, episode in enumerate(self._selected_episodes) + if position % consumer_count in consumer_indices + ) - return lookback, lookahead - - def _make_backtrackable_dataset(self, dataset: datasets.IterableDataset) -> Backtrackable: - lookback, lookahead = self._get_window_steps(self.delta_timestamps) - return Backtrackable(dataset, history=lookback, lookahead=lookahead) - - def _make_timestamps_from_indices( - self, start_ts: float, indices: dict[str, list[int]] | None = None - ) -> dict[str, list[float]]: - if indices is not None: - return { - key: ( - start_ts + torch.tensor(indices[key]) / self.fps - ).tolist() # NOTE: why not delta_timestamps directly? - for key in self.delta_timestamps - } - else: - return dict.fromkeys(self.meta.video_keys, [start_ts]) - - def _make_padding_camera_frame(self, camera_key: str): - """Variable-shape padding frame for given camera keys, given in (H, W, C)""" - return torch.zeros(self.meta.info.features[camera_key]["shape"]).permute(-1, 0, 1) - - def _get_video_frame_padding_mask( + def _load_episode_dataset( self, - video_frames: dict[str, torch.Tensor], - query_timestamps: dict[str, list[float]], - original_timestamps: dict[str, list[float]], - ) -> dict[str, torch.BoolTensor]: - padding_mask = {} + reader: EpisodeParquetReader, + episode_index: int, + ) -> datasets.Dataset: + table = reader.read_episode( + self.meta.get_data_file_path(episode_index), + episode_index=episode_index, + expected_rows=self._episode_frame_count(episode_index), + ) + # from_dict applies the declared HF feature encoders (images, nested language/JSON fields) + # while retaining the episode-sized memory bound. + dataset = datasets.Dataset.from_dict(table.to_pydict(), features=self._hf_features) + dataset.set_transform(hf_transform_to_torch) + return dataset - for video_key, timestamps in original_timestamps.items(): - if video_key not in video_frames: - continue # only padding on video keys that are available - frames = [] - mask = [] - padding_frame = self._make_padding_camera_frame(video_key) - for ts in timestamps: - if is_float_in_list(ts, query_timestamps[video_key]): - idx = find_float_index(ts, query_timestamps[video_key]) - frames.append(video_frames[video_key][idx, :]) - mask.append(False) - else: - frames.append(padding_frame) - mask.append(True) + def _make_video_cache( + self, + episode_indices: list[int], + workers: int, + ) -> EpisodeByteCache | None: + if self._sidecar_path is None or not episode_indices: + return None + range_backend = range_backend_for_root(self._data_root) + manifest = EpisodeVideoManifest.build( + self.meta, + self._data_root, + episode_indices=episode_indices, + range_backend=range_backend, + workers=workers, + sidecar_path=self._sidecar_path, + ) + decoder_limit = max(1, min(64, self.episode_pool_size * max(1, len(self.meta.video_keys)))) + return EpisodeByteCache( + manifest, + self._data_root, + byte_budget=self.byte_budget, + workers=workers, + range_backend=range_backend, + max_open_decoders=decoder_limit, + ) - padding_mask[f"{video_key}_is_pad"] = torch.BoolTensor(mask) + def _make_episode_item( + self, + episode_dataset: datasets.Dataset, + episode_index: int, + frame_index: int, + *, + video_cache: EpisodeByteCache | None, + ) -> dict: + item = episode_dataset[frame_index] + episode = self.meta.episodes[episode_index] + episode_start = int(episode["dataset_from_index"]) - return padding_mask - - def make_frame(self, dataset_iterator: Backtrackable) -> Generator: - """Makes a frame starting from a dataset iterator""" - item = next(dataset_iterator) - item = item_to_torch(item) - - updates = [] # list of "updates" to apply to the item retrieved from hf_dataset (w/o camera features) - - # Get episode index from the item - ep_idx = item["episode_index"] - - # "timestamp" restarts from 0 for each episode, whereas we need a global timestep within the single .mp4 file (given by index/fps) - current_ts = item["index"] / self.fps - - episode_boundaries_ts = { - key: ( - self.meta.episodes[ep_idx][f"videos/{key}/from_timestamp"], - self.meta.episodes[ep_idx][f"videos/{key}/to_timestamp"], - ) - for key in self.meta.video_keys - } - - # Apply delta querying logic if necessary if self.delta_indices is not None: - query_result, padding = self._get_delta_frames(dataset_iterator, item) - updates.append(query_result) - updates.append(padding) - - # Load video frames, when needed - if len(self.meta.video_keys) > 0: - original_timestamps = self._make_timestamps_from_indices(current_ts, self.delta_indices) - - # Some timestamps might not result available considering the episode's boundaries - query_timestamps = self._get_query_timestamps( - current_ts, self.delta_indices, episode_boundaries_ts - ) - video_frames = self._query_videos(query_timestamps, ep_idx) - - if self.image_transforms is not None: - image_keys = self.meta.camera_keys - for cam in image_keys: - video_frames[cam] = self.image_transforms(video_frames[cam]) - - updates.append(video_frames) - - if self.delta_indices is not None: - # We always return the same number of frames. Unavailable frames are padded. - padding_mask = self._get_video_frame_padding_mask( - video_frames, query_timestamps, original_timestamps + for key, delta_indices in self.delta_indices.items(): + target_indices = [ + max(0, min(len(episode_dataset) - 1, frame_index + delta)) for delta in delta_indices + ] + item[f"{key}_is_pad"] = torch.BoolTensor( + [ + frame_index + delta < 0 or frame_index + delta >= len(episode_dataset) + for delta in delta_indices + ] ) - updates.append(padding_mask) + if key not in self.meta.video_keys: + item[key] = torch.stack(episode_dataset[target_indices][key]) - result = item.copy() - for update in updates: - result.update(update) + if self.meta.video_keys: + if video_cache is None: + raise RuntimeError("Video dataset streaming requires an episode byte cache") + episode_metadata = self.meta.episodes[episode_index] + for video_key in self.meta.video_keys: + if self.delta_indices is not None and video_key in self.delta_indices: + target_indices = [ + max(0, min(len(episode_dataset) - 1, frame_index + delta)) + for delta in self.delta_indices[video_key] + ] + else: + target_indices = [frame_index] + local_timestamps = [ + float(episode_dataset[index]["timestamp"].item()) for index in target_indices + ] + from_timestamp = float(episode_metadata[f"videos/{video_key}/from_timestamp"]) + query_timestamps = [from_timestamp + timestamp for timestamp in local_timestamps] + if video_key in self.meta.depth_keys: + source_start = video_cache.manifest.lookup(episode_index, video_key).source_start_pts + frames = decode_video_frames_pyav( + io.BytesIO(video_cache.get_bytes(episode_index, video_key)), + [timestamp - source_start for timestamp in query_timestamps], + self.tolerance_s, + return_uint8=False, + is_depth=True, + ) + depth_encoder = self._depth_encoder_configs[video_key] + frames = dequantize_depth( + frames, + depth_min=depth_encoder.depth_min, + depth_max=depth_encoder.depth_max, + shift=depth_encoder.shift, + use_log=depth_encoder.use_log, + output_unit=self._depth_output_unit, + ) + else: + frames = video_cache.get_frames(episode_index, video_key, query_timestamps) + if not self._return_uint8: + frames = frames.to(torch.float32) / 255.0 + item[video_key] = frames.squeeze(0) + + if self.image_transforms is not None: + for camera_key in self.meta.camera_keys: + if camera_key in self.meta.depth_keys: + continue + item[camera_key] = self.image_transforms(item[camera_key]) - # Convert raw-image depth features to the output unit (video depth is already converted). for key, stored_unit in self._image_depth_units.items(): - if key in result and stored_unit is not None and stored_unit != self._depth_output_unit: - result[key] = ( - result[key] * MM_PER_METRE - if stored_unit == DEPTH_METER_UNIT - else result[key] / MM_PER_METRE + if key in item and stored_unit is not None and stored_unit != self._depth_output_unit: + item[key] = ( + item[key] * MM_PER_METRE if stored_unit == DEPTH_METER_UNIT else item[key] / MM_PER_METRE ) - result["task"] = self.meta.tasks.iloc[item["task_index"]].name - - yield result - - def _get_query_timestamps( - self, - current_ts: float, - query_indices: dict[str, list[int]] | None = None, - episode_boundaries_ts: dict[str, tuple[float, float]] | None = None, - ) -> dict[str, list[float]]: - query_timestamps = {} - keys_to_timestamps = self._make_timestamps_from_indices(current_ts, query_indices) - for key in self.meta.video_keys: - if query_indices is not None and key in query_indices: - timestamps = keys_to_timestamps[key] - # Clamp out timesteps outside of episode boundaries - query_timestamps[key] = torch.clamp( - torch.tensor(timestamps), *episode_boundaries_ts[key] - ).tolist() - - else: - query_timestamps[key] = [current_ts] - - return query_timestamps - - def _query_videos(self, query_timestamps: dict[str, list[float]], ep_idx: int) -> dict: - """Note: When using data workers (e.g. DataLoader with num_workers>0), do not call this function - in the main process (e.g. by using a second Dataloader with num_workers=0). It will result in a - Segmentation Fault. This probably happens because a memory reference to the video loader is created in - the main process and a subprocess fails to access it. - """ - - item = {} - for video_key, query_ts in query_timestamps.items(): - root = self.meta.url_root if self.streaming and not self.streaming_from_local else self.root - video_path = f"{root}/{self.meta.get_video_file_path(ep_idx, video_key)}" - if video_key in self.meta.depth_keys: - # Depth maps are 12-bit quantized and only decodable via pyav; dequantize back - # to physical units to match the non-streaming reader. - frames = decode_video_frames( - video_path, - query_ts, - self.tolerance_s, - backend="pyav", - return_uint8=False, - is_depth=True, - ) - depth_encoder = self._depth_encoder_configs[video_key] - frames = dequantize_depth( - frames, - depth_min=depth_encoder.depth_min, - depth_max=depth_encoder.depth_max, - shift=depth_encoder.shift, - use_log=depth_encoder.use_log, - output_unit=self._depth_output_unit, - ) - else: - frames = decode_video_frames_torchcodec( - video_path, - query_ts, - self.tolerance_s, - decoder_cache=self.video_decoder_cache, - return_uint8=self._return_uint8, - ) - - item[video_key] = frames.squeeze(0) if len(query_ts) == 1 else frames - + task_index = int(item["task_index"].item()) + item["task"] = self.meta.tasks.iloc[task_index].name + if int(item["episode_index"].item()) != episode_index: + raise RuntimeError(f"Episode reader returned episode {item['episode_index']} for {episode_index}") + if int(item["index"].item()) != episode_start + frame_index: + raise RuntimeError( + f"Episode {episode_index} frame {frame_index} has unexpected absolute index {item['index']}" + ) return item - def _get_delta_frames(self, dataset_iterator: Backtrackable, current_item: dict): - # TODO(fracapuano): Modularize this function, refactor the code - """Get frames with delta offsets using the backtrackable iterator. + def state_dict(self) -> dict[str, int]: + return { + "epoch": self._active_epoch, + "offset": self._state_offset, + "batch_size": self._resume_batch_size, + } - Args: - current_item (dict): Current item from the iterator. - ep_idx (int): Episode index. - - Returns: - tuple: (query_result, padding) - frames at delta offsets and padding info. - """ - current_episode_idx = current_item["episode_index"] - - # Prepare results - query_result = {} - padding = {} - - for key, delta_indices in self.delta_indices.items(): - if key in self.meta.video_keys: - continue # visual frames are decoded separately - - target_frames = [] - is_pad = [] - - # Create a results dictionary to store frames in processing order, then reconstruct original order for stacking - delta_results = {} - - # Separate and sort deltas by difficulty (easier operations first) - negative_deltas = sorted([d for d in delta_indices if d < 0], reverse=True) # [-1, -2, -3, ...] - positive_deltas = sorted([d for d in delta_indices if d > 0]) # [1, 2, 3, ...] - zero_deltas = [d for d in delta_indices if d == 0] - - # Process zero deltas (current frame) - for delta in zero_deltas: - delta_results[delta] = ( - current_item[key], - False, - ) - - # Process negative deltas in order of increasing difficulty - lookback_failed = False - - last_successful_frame = current_item[key] - - for delta in negative_deltas: - if lookback_failed: - delta_results[delta] = (last_successful_frame, True) - continue - - try: - steps_back = abs(delta) - if dataset_iterator.can_peek_back(steps_back): - past_item = dataset_iterator.peek_back(steps_back) - past_item = item_to_torch(past_item) - - if past_item["episode_index"] == current_episode_idx: - delta_results[delta] = (past_item[key], False) - last_successful_frame = past_item[key] - - else: - raise LookBackError("Retrieved frame is from different episode!") - else: - raise LookBackError("Cannot go back further than the history buffer!") - - except LookBackError: - delta_results[delta] = (last_successful_frame, True) - lookback_failed = True # All subsequent negative deltas will also fail - - # Process positive deltas in order of increasing difficulty - lookahead_failed = False - last_successful_frame = current_item[key] - - for delta in positive_deltas: - if lookahead_failed: - delta_results[delta] = (last_successful_frame, True) - continue - - try: - if dataset_iterator.can_peek_ahead(delta): - future_item = dataset_iterator.peek_ahead(delta) - future_item = item_to_torch(future_item) - - if future_item["episode_index"] == current_episode_idx: - delta_results[delta] = (future_item[key], False) - last_successful_frame = future_item[key] - - else: - raise LookAheadError("Retrieved frame is from different episode!") - else: - raise LookAheadError("Cannot go ahead further than the lookahead buffer!") - - except LookAheadError: - delta_results[delta] = (last_successful_frame, True) - lookahead_failed = True # All subsequent positive deltas will also fail - - # Reconstruct original order for stacking - for delta in delta_indices: - frame, is_padded = delta_results[delta] - - # add batch dimension for stacking - target_frames.append(frame) # frame.unsqueeze(0)) - is_pad.append(is_padded) - - # Stack frames and add to results - if target_frames: - query_result[key] = torch.stack(target_frames) - padding[f"{key}_is_pad"] = torch.BoolTensor(is_pad) - - return query_result, padding - - def _validate_delta_timestamp_keys(self, delta_timestamps: dict[list[float]]) -> None: - """ - Validate that all keys in delta_timestamps correspond to actual features in the dataset. - - Raises: - ValueError: If any delta timestamp key doesn't correspond to a dataset feature. - """ - if delta_timestamps is None: - return - - # Get all available feature keys from the dataset metadata - available_features = set(self.meta.features.keys()) - - # Get all keys from delta_timestamps - delta_keys = set(delta_timestamps.keys()) - - # Find any keys that don't correspond to features - invalid_keys = delta_keys - available_features - - if invalid_keys: + def load_state_dict(self, state: dict[str, int]) -> None: + epoch = int(state.get("epoch", 0)) + offset = int(state.get("offset", 0)) + batch_size = int(state.get("batch_size", 1)) + if epoch < 0 or offset < 0 or batch_size <= 0: raise ValueError( - f"The following delta_timestamp keys do not correspond to dataset features: {invalid_keys}. " - f"Available features are: {sorted(available_features)}" + "Streaming dataset epoch/offset must be non-negative and batch_size must be positive" ) + self._next_epoch = epoch + self._active_epoch = epoch + self._resume_offset = offset + self._resume_batch_size = batch_size + self._state_offset = offset + + def set_epoch(self, epoch: int) -> None: + if epoch < 0: + raise ValueError("epoch must be non-negative") + self._next_epoch = epoch + self._active_epoch = epoch + self._resume_offset = 0 + self._resume_batch_size = 1 + self._state_offset = 0 diff --git a/src/lerobot/datasets/streaming_sidecar.py b/src/lerobot/datasets/streaming_sidecar.py new file mode 100644 index 000000000..d597da374 --- /dev/null +++ b/src/lerobot/datasets/streaming_sidecar.py @@ -0,0 +1,141 @@ +# Copyright 2026 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 + +"""LeRobot metadata adapter for automatic MP4 sidecar resolution.""" + +from __future__ import annotations + +import logging +import shutil +from pathlib import Path + +import fsspec + +from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata +from lerobot.streaming.episode_video import EpisodeVideoManifest +from lerobot.streaming.sidecar import SidecarSpec, ensure_mp4_sidecar, sidecar_cache_path +from lerobot.utils.constants import HF_LEROBOT_HOME + +DEFAULT_SIDECAR_CACHE = HF_LEROBOT_HOME / "streaming-sidecars" + + +def range_backend_for_root(data_root: str) -> str: + """Use direct HTTP only for HF roots; all local and other fsspec protocols stay generic.""" + return "native-http" if data_root.startswith("hf://") else "fsspec" + + +def streaming_data_root( + meta: LeRobotDatasetMetadata, + *, + requested_root: str | Path | None, + configured_data_root: str | None, +) -> str: + if configured_data_root is not None: + return configured_data_root.rstrip("/") + if requested_root is not None: + return str(Path(requested_root).expanduser()) + return f"hf://datasets/{meta.repo_id}@{meta.revision}" + + +def make_sidecar_spec(meta: LeRobotDatasetMetadata, data_root: str) -> SidecarSpec: + relative_paths = sorted( + { + str(meta.get_video_file_path(episode_index, video_key)) + for episode_index in range(int(meta.total_episodes)) + for video_key in meta.video_keys + } + ) + root = Path(data_root).expanduser() + source_files: tuple[tuple[str, int | None], ...] + if root.is_dir(): + source_files = tuple((path, (root / path).stat().st_size) for path in relative_paths) + else: + source_files = tuple((path, None) for path in relative_paths) + return SidecarSpec( + repo_id=meta.repo_id, + revision=str(meta.revision), + data_root=data_root.rstrip("/"), + source_files=source_files, + ) + + +def build_mp4_sidecar( + destination: str | Path, + spec: SidecarSpec, + *, + workers: int = 8, + range_backend: str = "native-http", + max_probe_bytes: int = 64 * 1024 * 1024, +) -> None: + EpisodeVideoManifest.write_file_sidecar( + destination, + [path for path, _size in spec.source_files], + spec.data_root, + spec=spec, + range_backend=range_backend, + workers=workers, + max_probe_bytes=max_probe_bytes, + ) + + +def published_sidecar_url(spec: SidecarSpec, cache_root: str | Path = DEFAULT_SIDECAR_CACHE) -> str: + name = sidecar_cache_path(cache_root, spec).name + return f"{spec.data_root}/meta/mp4-sidecars/{name}" + + +def download_published_sidecar( + destination: Path, + spec: SidecarSpec, + *, + cache_root: str | Path = DEFAULT_SIDECAR_CACHE, +) -> bool: + if Path(spec.data_root).expanduser().is_dir(): + return False + filesystem, source = fsspec.core.url_to_fs(published_sidecar_url(spec, cache_root)) + if not filesystem.exists(source): + return False + with filesystem.open(source, "rb") as remote, destination.open("wb") as local: + shutil.copyfileobj(remote, local) + return True + + +def ensure_dataset_mp4_sidecar( + meta: LeRobotDatasetMetadata, + data_root: str, + *, + cache_root: str | Path = DEFAULT_SIDECAR_CACHE, + workers: int = 8, + range_backend: str = "native-http", + lock_timeout_s: float = 30 * 60, +) -> Path | None: + if not meta.video_keys: + return None + + spec = make_sidecar_spec(meta, data_root) + logging.info( + "Resolving training-time MP4 sidecar for %s@%s (%d files)", + spec.repo_id, + spec.revision, + len(spec.source_files), + ) + return ensure_mp4_sidecar( + spec, + cache_root, + build=lambda path, target_spec: build_mp4_sidecar( + path, + target_spec, + workers=workers, + range_backend=range_backend, + ), + download=lambda path, target_spec: download_published_sidecar( + path, + target_spec, + cache_root=cache_root, + ), + lock_timeout_s=lock_timeout_s, + ) diff --git a/src/lerobot/datasets/video_utils.py b/src/lerobot/datasets/video_utils.py index ef3005dd8..da5905831 100644 --- a/src/lerobot/datasets/video_utils.py +++ b/src/lerobot/datasets/video_utils.py @@ -28,7 +28,7 @@ from dataclasses import asdict, dataclass, field from fractions import Fraction from pathlib import Path from threading import Lock -from typing import Any, ClassVar +from typing import Any, BinaryIO, ClassVar import av import fsspec @@ -105,7 +105,7 @@ def decode_video_frames( def decode_video_frames_pyav( - video_path: Path | str, + video_path: Path | str | BinaryIO, timestamps: list[float], tolerance_s: float, log_loaded_timestamps: bool = False, @@ -124,7 +124,7 @@ def decode_video_frames_pyav( video can be adjusted at encoding time to trade off decoding speed against file size. Args: - video_path: Path to the video file. + video_path: Path to the video file or a seekable binary file object. timestamps: List of timestamps (in seconds) to extract frames for. tolerance_s: Allowed deviation in seconds between a queried timestamp and the closest decoded frame. @@ -137,7 +137,8 @@ def decode_video_frames_pyav( torch.Tensor of shape (len(timestamps), C, H, W). """ # TODO(rcadene): also load audio stream at the same time - video_path = str(video_path) + video_source = str(video_path) if isinstance(video_path, (Path, str)) else video_path + video_label = str(video_path) if isinstance(video_path, (Path, str)) else "" # set the first and last requested timestamps # Note: previous timestamps are usually loaded, since we need to access the previous key frame @@ -151,7 +152,7 @@ def decode_video_frames_pyav( # av.time_base units (microseconds). `backward=True` lands us on the nearest keyframe at or # before `first_ts`, so we can then decode forward until we cover `last_ts`. See: # https://pyav.basswood-io.com/docs/stable/api/container.html#av.container.InputContainer.seek - with av.open(video_path) as container: + with av.open(video_source) as container: stream = container.streams.video[0] # Seek to the nearest keyframe at or before `first_ts` with a 1 frame margin container.seek( @@ -180,7 +181,7 @@ def decode_video_frames_pyav( if not loaded_frames: raise FrameTimestampError( - f"No frames could be decoded from {video_path} in the timestamp range [{first_ts}, {last_ts}]." + f"No frames could be decoded from {video_label} in the timestamp range [{first_ts}, {last_ts}]." ) query_ts = torch.tensor(timestamps) @@ -199,7 +200,7 @@ def decode_video_frames_pyav( " To be safe, we advise to ignore this item during training." f"\nqueried timestamps: {query_ts}" f"\nloaded timestamps: {loaded_ts_t}" - f"\nvideo: {video_path}" + f"\nvideo: {video_label}" f"\nbackend: pyav" ) diff --git a/src/lerobot/scripts/lerobot_train.py b/src/lerobot/scripts/lerobot_train.py index ec5565cf4..0c96ac38f 100644 --- a/src/lerobot/scripts/lerobot_train.py +++ b/src/lerobot/scripts/lerobot_train.py @@ -41,6 +41,7 @@ from lerobot.common.train_utils import ( load_fsdp_optimizer_state, load_training_batch_size, load_training_num_processes, + load_training_num_workers, load_training_state, push_checkpoint_to_hub, save_checkpoint, @@ -201,7 +202,7 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None): require_package("accelerate", extra="training") from accelerate import Accelerator - from accelerate.utils import DistributedDataParallelKwargs, DistributedType + from accelerate.utils import DistributedDataParallelKwargs, DistributedType, send_to_device cfg.validate() @@ -459,6 +460,59 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None): else: shuffle = True sampler = None + train_num_workers = cfg.num_workers + if train_num_workers > 0: + max_nonempty_workers = dataset.num_episodes // accelerator.num_processes + if max_nonempty_workers == 0: + raise ValueError( + "At least one distributed rank owns no streaming episode. " + "Reduce the distributed world size." + ) + train_num_workers = min(train_num_workers, max_nonempty_workers) + if train_num_workers != cfg.num_workers and is_main_process: + logging.warning( + "Reducing streaming DataLoader workers from %d to %d so every rank/worker " + "owns at least one complete episode.", + cfg.num_workers, + train_num_workers, + ) + rank_frames = dataset.num_frames_for_rank( + accelerator.process_index, + accelerator.num_processes, + train_num_workers, + ) + if rank_frames == 0: + raise ValueError("This rank owns no streaming episodes. Reduce the distributed world size.") + if cfg.resume and step > 0: + saved_num_processes = load_training_num_processes(cfg.checkpoint_path) + saved_batch_size = load_training_batch_size(cfg.checkpoint_path) + saved_num_workers = load_training_num_workers(cfg.checkpoint_path) + if saved_num_processes not in (None, accelerator.num_processes): + raise ValueError( + "Sample-exact streaming resume requires the checkpoint world size " + f"({saved_num_processes}) to match the current world size " + f"({accelerator.num_processes})." + ) + if saved_batch_size not in (None, cfg.batch_size): + raise ValueError( + "Sample-exact streaming resume requires the checkpoint batch size " + f"({saved_batch_size}) to match the current batch size ({cfg.batch_size})." + ) + if saved_num_workers not in (None, train_num_workers): + raise ValueError( + "Sample-exact streaming resume requires the checkpoint DataLoader worker count " + f"({saved_num_workers}) to match the current count ({train_num_workers})." + ) + stream_offset = step * (saved_batch_size or cfg.batch_size) + dataset.load_state_dict( + { + "epoch": 0, + "offset": stream_offset, + "batch_size": cfg.batch_size, + } + ) + if is_main_process: + logging.info(f"Resuming streaming data order at local sample {stream_offset}") # Only swap in the language-aware collate when the dataset actually # declares language columns; otherwise stay on PyTorch's default @@ -466,15 +520,20 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None): collate_fn = lerobot_collate_fn if dataset.meta.has_language_columns else None dataloader = torch.utils.data.DataLoader( dataset, - num_workers=cfg.num_workers, + num_workers=train_num_workers if cfg.dataset.streaming else cfg.num_workers, batch_size=cfg.batch_size, shuffle=shuffle and not cfg.dataset.streaming, sampler=sampler, pin_memory=device.type == "cuda", drop_last=False, collate_fn=collate_fn, - prefetch_factor=cfg.prefetch_factor if cfg.num_workers > 0 else None, - persistent_workers=cfg.persistent_workers and cfg.num_workers > 0, + prefetch_factor=( + cfg.prefetch_factor + if (train_num_workers if cfg.dataset.streaming else cfg.num_workers) > 0 + else None + ), + persistent_workers=cfg.persistent_workers + and (train_num_workers if cfg.dataset.streaming else cfg.num_workers) > 0, ) # Build eval dataloader if a held-out split exists @@ -506,7 +565,16 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None): # Prepare everything with accelerator accelerator.wait_for_everyone() - if eval_dataloader is not None: + if cfg.dataset.streaming: + # StreamingLeRobotDataset already assigns complete episodes disjointly to ranks and workers. + # Preparing this loader would make Accelerate shard its batches a second time. + if eval_dataloader is not None: + policy, optimizer, lr_scheduler, eval_dataloader = accelerator.prepare( + policy, optimizer, lr_scheduler, eval_dataloader + ) + else: + policy, optimizer, lr_scheduler = accelerator.prepare(policy, optimizer, lr_scheduler) + elif eval_dataloader is not None: policy, optimizer, dataloader, lr_scheduler, eval_dataloader = accelerator.prepare( policy, optimizer, dataloader, lr_scheduler, eval_dataloader ) @@ -569,6 +637,8 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None): for _ in range(step, cfg.steps): start_time = time.perf_counter() batch = next(dl_iter) + if cfg.dataset.streaming: + batch = send_to_device(batch, device, non_blocking=device.type == "cuda") for cam_key in dataset.meta.camera_keys: if cam_key in batch and batch[cam_key].dtype == torch.uint8: batch[cam_key] = batch[cam_key].to(dtype=torch.float32) / 255.0 @@ -665,6 +735,7 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None): postprocessor=postprocessor, num_processes=accelerator.num_processes, batch_size=cfg.batch_size, + num_workers=train_num_workers if cfg.dataset.streaming else cfg.num_workers, model_state_dict=model_state_dict, optim_state_dict=optim_state_dict, ) diff --git a/src/lerobot/streaming/__init__.py b/src/lerobot/streaming/__init__.py new file mode 100644 index 000000000..5f70239b5 --- /dev/null +++ b/src/lerobot/streaming/__init__.py @@ -0,0 +1,9 @@ +# Copyright 2026 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 + +"""Low-level primitives for training-time dataset streaming.""" diff --git a/src/lerobot/datasets/episode_video_streaming.py b/src/lerobot/streaming/episode_video.py similarity index 87% rename from src/lerobot/datasets/episode_video_streaming.py rename to src/lerobot/streaming/episode_video.py index ebb3e7269..aed528447 100644 --- a/src/lerobot/datasets/episode_video_streaming.py +++ b/src/lerobot/streaming/episode_video.py @@ -11,17 +11,19 @@ from __future__ import annotations import contextlib import io import json +import logging import os +import posixpath import threading import time from collections import OrderedDict -from concurrent.futures import Future, ThreadPoolExecutor +from collections.abc import Sequence +from concurrent.futures import Future, ThreadPoolExecutor, as_completed from dataclasses import dataclass from datetime import UTC, datetime -from importlib import metadata from pathlib import Path from types import MethodType -from typing import Any +from typing import TYPE_CHECKING, Any from urllib.parse import quote, urljoin, urlparse from uuid import uuid4 @@ -31,8 +33,11 @@ import numpy as np from huggingface_hub import HfApi, HfFileSystem, constants from huggingface_hub.utils import get_session, hf_raise_for_status -from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata -from lerobot.datasets.mp4 import Mp4Index, Mp4SampleSlice, fetch_mp4_index, synthesize_mp4 +from lerobot.streaming.mp4 import Mp4Index, Mp4SampleSlice, fetch_mp4_index, synthesize_mp4 + +if TYPE_CHECKING: + from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata + from lerobot.streaming.sidecar import SidecarSpec _HTTP_FAILURE_LOG_LOCK = threading.Lock() @@ -134,11 +139,15 @@ class ThreadLocalRangeFetcher: def __init__(self, data_root: str | Path, *, block_size: int = 2**20, cache_type: str = "none"): self.data_root = str(data_root).rstrip("/") - protocol = "hf" if self.data_root.startswith("hf://") else "file" - self.fs = fsspec.filesystem(protocol) + self.fs, self._root_path = fsspec.core.url_to_fs(self.data_root) + self._is_local = self.fs.protocol in ("file", "local") or ( + isinstance(self.fs.protocol, tuple) and "file" in self.fs.protocol + ) self.block_size = block_size self.cache_type = cache_type self._local = threading.local() + self._handles_lock = threading.Lock() + self._all_handles: dict[int, Any] = {} self._timing_lock = threading.Lock() self._timing_totals = { "range_jobs": 0.0, @@ -149,9 +158,9 @@ class ThreadLocalRangeFetcher: } def _url(self, relative_path: str) -> str: - if self.data_root.startswith("hf://"): - return f"{self.data_root}/{relative_path}" - return str(Path(self.data_root) / relative_path) + if self._is_local: + return str(Path(self._root_path) / relative_path) + return posixpath.join(self._root_path.rstrip("/"), relative_path.lstrip("/")) def _handle(self, relative_path: str): handles = getattr(self._local, "handles", None) @@ -165,6 +174,8 @@ class ThreadLocalRangeFetcher: ) self._instrument_hf_handle(handle) handles[relative_path] = handle + with self._handles_lock: + self._all_handles[id(handle)] = handle return handle def info_size(self, relative_path: str) -> int: @@ -332,13 +343,15 @@ class ThreadLocalRangeFetcher: return dict(self._timing_totals) def close(self) -> None: - handles = getattr(self._local, "handles", None) - if handles is None: - return - for handle in handles.values(): + with self._handles_lock: + handles = list(self._all_handles.values()) + self._all_handles.clear() + for handle in handles: with contextlib.suppress(Exception): handle.close() - handles.clear() + local_handles = getattr(self._local, "handles", None) + if local_handles is not None: + local_handles.clear() class NativeHTTPRangeFetcher: @@ -751,7 +764,7 @@ def make_range_fetcher( class EpisodeVideoManifest: - _FILE_SIDECAR_CACHE: dict[str, dict[str, VideoFileRecord]] = {} + _FILE_SIDECAR_CACHE: dict[str, tuple[tuple[int, int], dict[str, VideoFileRecord]]] = {} _FILE_SIDECAR_CACHE_LOCK = threading.Lock() def __init__( @@ -873,7 +886,14 @@ class EpisodeVideoManifest: try: with ThreadPoolExecutor(max_workers=workers) as pool: - return list(pool.map(build_file, rel_paths)) + futures = {pool.submit(build_file, path): path for path in rel_paths} + records = [] + progress_interval = max(1, len(futures) // 20) + for completed, future in enumerate(as_completed(futures), start=1): + records.append(future.result()) + if completed == len(futures) or completed % progress_interval == 0: + logging.info("Indexed %d/%d MP4 files for streaming sidecar", completed, len(futures)) + return sorted(records, key=lambda record: record.file_path) finally: fetcher.close() @@ -884,6 +904,7 @@ class EpisodeVideoManifest: rel_paths: list[str], data_root: str | Path, *, + spec: SidecarSpec, range_backend: str = "native-http", workers: int = 8, header_probe_bytes: int = 4 * 1024 * 1024, @@ -897,14 +918,22 @@ class EpisodeVideoManifest: header_probe_bytes=header_probe_bytes, max_probe_bytes=max_probe_bytes, ) - cls.save_file_sidecar(sidecar_path, records) + cls.save_file_sidecar(sidecar_path, records, spec=spec) @staticmethod - def save_file_sidecar(sidecar_path: str | Path, records: list[VideoFileRecord]) -> None: + def save_file_sidecar( + sidecar_path: str | Path, + records: list[VideoFileRecord], + *, + spec: SidecarSpec, + ) -> None: sidecar_path = Path(sidecar_path) sidecar_path.parent.mkdir(parents=True, exist_ok=True) payload = { - "version": 1, + "version": 2, + "sidecar": spec.with_source_files( + tuple((record.file_path, record.file_size) for record in records) + ).to_dict(), "files": [ {"file_path": record.file_path, "file_size": record.file_size, "mp4": record.mp4.to_dict()} for record in records @@ -918,17 +947,49 @@ class EpisodeVideoManifest: arrays[f"{file_idx}/sample_offsets"] = record.mp4.sample_offsets arrays[f"{file_idx}/sync_samples"] = record.mp4.sync_samples np.savez_compressed(sidecar_path, manifest_json=json.dumps(payload).encode("utf-8"), **arrays) + cache_key = str(sidecar_path.expanduser()) + with EpisodeVideoManifest._FILE_SIDECAR_CACHE_LOCK: + EpisodeVideoManifest._FILE_SIDECAR_CACHE.pop(cache_key, None) + + @staticmethod + def load_file_sidecar_metadata(sidecar_path: str | Path) -> dict[str, Any]: + with np.load(sidecar_path, allow_pickle=False) as data: + payload = json.loads(bytes(data["manifest_json"]).decode("utf-8")) + if payload.get("version") != 2 or not isinstance(payload.get("sidecar"), dict): + raise ValueError(f"Unsupported MP4 sidecar schema in {sidecar_path}") + return payload["sidecar"] + + @staticmethod + def validate_file_sidecar(sidecar_path: str | Path, spec: SidecarSpec) -> bool: + try: + from lerobot.streaming.sidecar import SidecarSpec + + candidate = SidecarSpec.from_dict(EpisodeVideoManifest.load_file_sidecar_metadata(sidecar_path)) + if not spec.matches(candidate): + return False + records = EpisodeVideoManifest.load_file_sidecar(sidecar_path) + except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError): + return False + + expected = dict(candidate.source_files) + actual = {path: record.file_size for path, record in records.items()} + return actual == expected @staticmethod def load_file_sidecar(sidecar_path: str | Path) -> dict[str, VideoFileRecord]: - cache_key = str(Path(sidecar_path).expanduser()) + path = Path(sidecar_path).expanduser() + cache_key = str(path) + stat = path.stat() + signature = (stat.st_mtime_ns, stat.st_size) with EpisodeVideoManifest._FILE_SIDECAR_CACHE_LOCK: cached = EpisodeVideoManifest._FILE_SIDECAR_CACHE.get(cache_key) - if cached is not None: - return cached + if cached is not None and cached[0] == signature: + return cached[1] - with np.load(sidecar_path, allow_pickle=False) as data: + with np.load(path, allow_pickle=False) as data: payload = json.loads(bytes(data["manifest_json"]).decode("utf-8")) + if payload.get("version") != 2: + raise ValueError(f"Unsupported MP4 sidecar schema in {path}") records = {} for file_idx, item in enumerate(payload["files"]): arrays = { @@ -944,7 +1005,7 @@ class EpisodeVideoManifest: mp4 = Mp4Index.from_dict(item["mp4"], arrays) records[item["file_path"]] = VideoFileRecord(item["file_path"], int(item["file_size"]), mp4) with EpisodeVideoManifest._FILE_SIDECAR_CACHE_LOCK: - EpisodeVideoManifest._FILE_SIDECAR_CACHE[cache_key] = records + EpisodeVideoManifest._FILE_SIDECAR_CACHE[cache_key] = (signature, records) return records def camera_id(self, camera_key: str) -> int: @@ -1058,7 +1119,7 @@ class ExactCoveragePool: def resident(self) -> list[int]: return list(self._remaining) - def __iter__(self) -> "ExactCoveragePool": + def __iter__(self) -> ExactCoveragePool: return self def __next__(self) -> tuple[int, int]: @@ -1097,7 +1158,12 @@ class EpisodeByteCache: native_http_retries: int = 4, native_http_subranges: int = 1, open_decoders: bool = True, + max_open_decoders: int = 64, ): + if byte_budget <= 0: + raise ValueError("byte_budget must be positive") + if max_open_decoders <= 0: + raise ValueError("max_open_decoders must be positive") self.manifest = manifest self.fetcher = make_range_fetcher( data_root, @@ -1110,9 +1176,12 @@ class EpisodeByteCache: ) self.byte_budget = byte_budget self.open_decoders = open_decoders + self.max_open_decoders = max_open_decoders self._pool = ThreadPoolExecutor(max_workers=workers) self._cache: OrderedDict[tuple[int, str], dict[str, Any]] = OrderedDict() + self._decoders: OrderedDict[tuple[int, str], Any] = OrderedDict() self._futures: dict[tuple[int, str], Future[dict[str, Any]]] = {} + self._retained_episodes: dict[int, int] = {} self._bytes = 0 self._lock = threading.Lock() self._timing_totals = { @@ -1124,10 +1193,12 @@ class EpisodeByteCache: } def close(self) -> None: - self._pool.shutdown(wait=True) + self._pool.shutdown(wait=True, cancel_futures=True) with self._lock: self._cache.clear() + self._decoders.clear() self._futures.clear() + self._retained_episodes.clear() self._bytes = 0 self.fetcher.close() @@ -1141,9 +1212,34 @@ class EpisodeByteCache: for camera_key in self.manifest.video_keys: self._submit(episode_index, camera_key) + def retain_episode(self, episode_index: int) -> None: + with self._lock: + self._retained_episodes[episode_index] = self._retained_episodes.get(episode_index, 0) + 1 + + def release_episode(self, episode_index: int) -> None: + with self._lock: + count = self._retained_episodes.get(episode_index, 0) + if count <= 1: + self._retained_episodes.pop(episode_index, None) + else: + self._retained_episodes[episode_index] = count - 1 + self._evict_locked() + + @property + def resident_bytes(self) -> int: + with self._lock: + return self._bytes + + @property + def open_decoder_count(self) -> int: + with self._lock: + return len(self._decoders) + def ensure_ready(self, episode_index: int) -> None: for camera_key in self.manifest.video_keys: self.get_bytes(episode_index, camera_key) + if self.open_decoders: + self.get_decoder(episode_index, camera_key) def is_ready(self, episode_index: int) -> bool: """Non-blocking: True when every camera of the episode is fetched (cached or future done). @@ -1164,20 +1260,30 @@ class EpisodeByteCache: def get_bytes(self, episode_index: int, camera_key: str) -> bytes: return self._get_entry(episode_index, camera_key)["bytes"] - def get_decoder(self, episode_index: int, camera_key: str): + def get_decoder(self, episode_index: int, camera_key: str) -> Any: + key = (episode_index, camera_key) entry = self._get_entry(episode_index, camera_key) - decoder = entry.get("decoder") - if decoder is None: - decoder = open_video_decoder(io.BytesIO(entry["bytes"])) - entry["decoder"] = decoder + with self._lock: + decoder = self._decoders.get(key) + if decoder is not None: + self._decoders.move_to_end(key) + return decoder + + decoder = open_video_decoder(io.BytesIO(entry["bytes"])) + with self._lock: + existing = self._decoders.get(key) + if existing is not None: + self._decoders.move_to_end(key) + return existing + self._decoders[key] = decoder + while len(self._decoders) > self.max_open_decoders: + self._decoders.popitem(last=False) return decoder def get_frames(self, episode_index: int, camera_key: str, timestamps: list[float]): span = self.manifest.lookup(episode_index, camera_key) local_ts = [ts - span.source_start_pts for ts in timestamps] decoder = self.get_decoder(episode_index, camera_key) - if hasattr(decoder, "get_frames_played_at"): - return decoder.get_frames_played_at(local_ts).data metadata = decoder.metadata fps = getattr(metadata, "average_fps", None) if fps is None: @@ -1224,7 +1330,13 @@ class EpisodeByteCache: return existing self._cache[key] = entry self._bytes += len(entry["bytes"]) - self._evict_locked() + try: + self._evict_locked() + except MemoryError: + failed_entry = self._cache.pop(key, None) + if failed_entry is not None: + self._bytes -= len(failed_entry["bytes"]) + raise timings = entry.pop("_timings", None) if timings is not None: self._timing_totals["lookup_s"] += timings["lookup_s"] @@ -1235,9 +1347,18 @@ class EpisodeByteCache: return entry def _evict_locked(self) -> None: - while self._bytes > self.byte_budget and self._cache: - _key, entry = self._cache.popitem(last=False) + while self._bytes > self.byte_budget: + key = next( + (candidate for candidate in self._cache if candidate[0] not in self._retained_episodes), + None, + ) + if key is None: + raise MemoryError( + f"Retained episode bytes exceed byte budget ({self._bytes} > {self.byte_budget})" + ) + entry = self._cache.pop(key) self._bytes -= len(entry["bytes"]) + self._decoders.pop(key, None) def _fetch_and_synthesize(self, episode_index: int, camera_key: str) -> dict[str, Any]: lookup_start = time.perf_counter() @@ -1263,15 +1384,12 @@ class EpisodeByteCache: synthesize_s = time.perf_counter() - synthesize_start entry: dict[str, Any] = { "bytes": mp4_bytes, - "decoder": None, "_timings": { "lookup_s": lookup_s, "fetch_s": fetch_s, "synthesize_s": synthesize_s, }, } - if self.open_decoders: - entry["decoder"] = open_video_decoder(io.BytesIO(mp4_bytes)) return entry @@ -1283,32 +1401,6 @@ def open_video_decoder(file_like_or_bytesio, frame_mappings=None): return VideoDecoder(file_like_or_bytesio, seek_mode="approximate") -def assert_hf_hub_range_cache_branch() -> None: - """Fail unless huggingface_hub was installed from the required range-cache branch.""" - - try: - dist = metadata.distribution("huggingface_hub") - except metadata.PackageNotFoundError as exc: - raise AssertionError("huggingface_hub is not installed") from exc - - candidates = [] - direct_url = dist.read_text("direct_url.json") - if direct_url: - candidates.append(direct_url) - with contextlib.suppress(json.JSONDecodeError): - parsed = json.loads(direct_url) - candidates.append(str(parsed.get("url", ""))) - candidates.append(str(parsed.get("vcs_info", {}).get("requested_revision", ""))) - candidates.append(str(parsed.get("vcs_info", {}).get("commit_id", ""))) - - text = "\n".join(candidates) - if "feat/hffs-cache-cdn-range-reads" not in text: - raise AssertionError( - "huggingface_hub must be installed from " - "git+https://github.com/huggingface/huggingface_hub.git@feat/hffs-cache-cdn-range-reads" - ) - - @dataclass class StageTimer: fetch_ms: float = 0.0 diff --git a/src/lerobot/datasets/mp4.py b/src/lerobot/streaming/mp4.py similarity index 99% rename from src/lerobot/datasets/mp4.py rename to src/lerobot/streaming/mp4.py index 5d16908d1..4baf513c6 100644 --- a/src/lerobot/datasets/mp4.py +++ b/src/lerobot/streaming/mp4.py @@ -6,6 +6,8 @@ # # http://www.apache.org/licenses/LICENSE-2.0 +"""MP4 indexing and in-memory episode synthesis primitives.""" + from __future__ import annotations import struct diff --git a/src/lerobot/streaming/sidecar.py b/src/lerobot/streaming/sidecar.py new file mode 100644 index 000000000..312662ed1 --- /dev/null +++ b/src/lerobot/streaming/sidecar.py @@ -0,0 +1,176 @@ +# Copyright 2026 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 + +"""Revision-safe lifecycle for locally cached MP4 byte-index sidecars.""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import re +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from uuid import uuid4 + +from filelock import FileLock, Timeout + +from lerobot.streaming.episode_video import EpisodeVideoManifest + +SIDECAR_SCHEMA_VERSION = 2 + + +class SidecarLockTimeoutError(TimeoutError): + """Raised when another process does not finish a sidecar build in time.""" + + +@dataclass(frozen=True) +class SidecarSpec: + repo_id: str + revision: str + data_root: str + source_files: tuple[tuple[str, int | None], ...] + schema_version: int = SIDECAR_SCHEMA_VERSION + + def __post_init__(self) -> None: + if not self.repo_id: + raise ValueError("repo_id must not be empty") + if not self.revision: + raise ValueError("revision must not be empty") + normalized = tuple( + sorted((str(path), None if size is None else int(size)) for path, size in self.source_files) + ) + if any(not path or size is not None and size < 0 for path, size in normalized): + raise ValueError("source file paths must be non-empty and sizes must be non-negative") + object.__setattr__(self, "source_files", normalized) + + def to_dict(self) -> dict[str, object]: + return { + "schema_version": self.schema_version, + "repo_id": self.repo_id, + "revision": self.revision, + "data_root": self.data_root, + "source_files": [{"path": path, "size": size} for path, size in self.source_files], + } + + @classmethod + def from_dict(cls, data: dict[str, object]) -> SidecarSpec: + source_files = data.get("source_files") + if not isinstance(source_files, list): + raise ValueError("MP4 sidecar source_files must be a list") + parsed_files: list[tuple[str, int | None]] = [] + for item in source_files: + if not isinstance(item, dict) or not isinstance(item.get("path"), str): + raise ValueError("Invalid MP4 sidecar source file entry") + size = item.get("size") + parsed_files.append((item["path"], None if size is None else int(size))) + return cls( + schema_version=int(data["schema_version"]), + repo_id=str(data["repo_id"]), + revision=str(data["revision"]), + data_root=str(data["data_root"]), + source_files=tuple(parsed_files), + ) + + def with_source_files(self, source_files: tuple[tuple[str, int], ...]) -> SidecarSpec: + return SidecarSpec( + repo_id=self.repo_id, + revision=self.revision, + data_root=self.data_root, + source_files=source_files, + schema_version=self.schema_version, + ) + + def matches(self, candidate: SidecarSpec) -> bool: + if ( + self.schema_version != candidate.schema_version + or self.repo_id != candidate.repo_id + or self.revision != candidate.revision + or self.data_root != candidate.data_root + ): + return False + expected = dict(self.source_files) + actual = dict(candidate.source_files) + if expected.keys() != actual.keys(): + return False + return all(size is None or actual[path] == size for path, size in expected.items()) + + +SidecarBuilder = Callable[[Path, SidecarSpec], None] +SidecarDownloader = Callable[[Path, SidecarSpec], bool] + + +def sidecar_cache_path(cache_root: str | Path, spec: SidecarSpec) -> Path: + identity = json.dumps( + { + "schema_version": spec.schema_version, + "repo_id": spec.repo_id, + "revision": spec.revision, + "data_root": spec.data_root, + }, + sort_keys=True, + separators=(",", ":"), + ) + digest = hashlib.sha256(identity.encode()).hexdigest()[:16] + repo_slug = re.sub(r"[^A-Za-z0-9_.-]+", "--", spec.repo_id).strip("-") or "dataset" + revision_slug = re.sub(r"[^A-Za-z0-9_.-]+", "-", spec.revision).strip("-")[:32] or "revision" + return ( + Path(cache_root).expanduser() / repo_slug / f"mp4-v{spec.schema_version}-{revision_slug}-{digest}.npz" + ) + + +def ensure_mp4_sidecar( + spec: SidecarSpec, + cache_root: str | Path, + *, + build: SidecarBuilder, + download: SidecarDownloader | None = None, + lock_timeout_s: float = 30 * 60, +) -> Path: + """Return a valid local sidecar, downloading or building it exactly once. + + This function never uploads. ``download`` and ``build`` must write only to the temporary path + provided to them; a validated file becomes visible at the cache path through ``os.replace``. + """ + + destination = sidecar_cache_path(cache_root, spec) + if EpisodeVideoManifest.validate_file_sidecar(destination, spec): + return destination + + destination.parent.mkdir(parents=True, exist_ok=True) + lock_path = destination.with_suffix(f"{destination.suffix}.lock") + try: + with FileLock(lock_path, timeout=lock_timeout_s): + if EpisodeVideoManifest.validate_file_sidecar(destination, spec): + return destination + + temporary = destination.parent / f".{destination.name}.{uuid4().hex}.tmp.npz" + try: + if download is not None: + logging.info("Looking for published MP4 sidecar for %s@%s", spec.repo_id, spec.revision) + if download(temporary, spec) and EpisodeVideoManifest.validate_file_sidecar( + temporary, spec + ): + os.replace(temporary, destination) + return destination + temporary.unlink(missing_ok=True) + + logging.info("Building MP4 sidecar for %s@%s", spec.repo_id, spec.revision) + build(temporary, spec) + if not EpisodeVideoManifest.validate_file_sidecar(temporary, spec): + raise ValueError("Built MP4 sidecar failed revision and source validation") + os.replace(temporary, destination) + return destination + finally: + temporary.unlink(missing_ok=True) + except Timeout as exc: + raise SidecarLockTimeoutError( + f"Timed out waiting {lock_timeout_s:g}s for MP4 sidecar lock {lock_path}" + ) from exc diff --git a/tests/configs/test_default.py b/tests/configs/test_default.py index 238b8bacd..57945b75e 100644 --- a/tests/configs/test_default.py +++ b/tests/configs/test_default.py @@ -36,3 +36,16 @@ def test_dataset_config_none_episodes_ok(): def test_dataset_config_empty_episodes_ok(): DatasetConfig(repo_id="user/repo", episodes=[]) + + +@pytest.mark.parametrize( + ("field", "value", "message"), + [ + ("streaming_episode_pool_size", 0, "episode_pool_size"), + ("streaming_prefetch_episodes", -1, "prefetch_episodes"), + ("streaming_byte_budget_gb", 0, "byte_budget_gb"), + ], +) +def test_dataset_config_rejects_invalid_streaming_resource_limits(field, value, message): + with pytest.raises(ValueError, match=message): + DatasetConfig(repo_id="user/repo", **{field: value}) diff --git a/tests/datasets/test_depth.py b/tests/datasets/test_depth.py index 5391cc558..ac9b51d43 100644 --- a/tests/datasets/test_depth.py +++ b/tests/datasets/test_depth.py @@ -323,14 +323,13 @@ class TestDepthUnitMetadata: np.testing.assert_allclose(float(np.asarray(stats["mean"]).reshape(-1)[0]), expected, rtol=0.05) np.testing.assert_allclose(float(np.asarray(stats["count"]).reshape(-1)[0]), count) - if not use_videos: - depth = read_dataset[0][DEPTH_KEY] - assert torch.allclose(depth, torch.full_like(depth, expected)) + from lerobot.datasets.streaming_dataset import StreamingLeRobotDataset - from lerobot.datasets.streaming_dataset import StreamingLeRobotDataset - - stream_dataset = StreamingLeRobotDataset( - repo_id=DUMMY_REPO_ID, root=tmp_path / "ds", depth_output_unit=output_unit - ) - stream_depth = next(iter(stream_dataset))[DEPTH_KEY] - assert torch.allclose(stream_depth, torch.full_like(stream_depth, expected)) + stream_dataset = StreamingLeRobotDataset( + repo_id=DUMMY_REPO_ID, root=tmp_path / "ds", depth_output_unit=output_unit + ) + stream_item = next(iter(stream_dataset)) + stream_depth = stream_item[DEPTH_KEY] + reference_depth = read_dataset[int(stream_item["index"])][DEPTH_KEY] + assert torch.allclose(stream_depth, reference_depth) + assert torch.allclose(stream_depth, torch.full_like(stream_depth, expected), rtol=0.05) diff --git a/tests/datasets/test_episode_parquet_reader.py b/tests/datasets/test_episode_parquet_reader.py new file mode 100644 index 000000000..80ab1ffef --- /dev/null +++ b/tests/datasets/test_episode_parquet_reader.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python + +# Copyright 2026 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 + +from __future__ import annotations + +from pathlib import Path + +import fsspec +import pytest + +pytest.importorskip("pyarrow", reason="pyarrow is required (install lerobot[dataset])") + +import pyarrow as pa +import pyarrow.parquet as pq + +from lerobot.datasets.episode_parquet import EpisodeParquetReader + + +def _table(episodes: list[int]) -> pa.Table: + frame_counts: dict[int, int] = {} + frame_indices = [] + values = [] + ignored = [] + for episode in episodes: + frame_index = frame_counts.get(episode, 0) + frame_counts[episode] = frame_index + 1 + frame_indices.append(frame_index) + values.append(episode * 10 + frame_index) + ignored.append(f"ignored-{episode}-{frame_index}") + return pa.table( + { + "episode_index": episodes, + "frame_index": frame_indices, + "value": values, + "ignored": ignored, + } + ) + + +def _write_episode_row_groups(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + writer = pq.ParquetWriter(path, _table([0]).schema) + try: + writer.write_table(_table([0, 0])) + writer.write_table(_table([1, 1, 1])) + finally: + writer.close() + + +def test_reader_projects_columns_and_reads_matching_row_group(tmp_path: Path) -> None: + path = tmp_path / "data/chunk-000/file-000.parquet" + _write_episode_row_groups(path) + reader = EpisodeParquetReader(tmp_path, columns=("episode_index", "frame_index", "value")) + + table = reader.read_episode(path.relative_to(tmp_path), episode_index=1, expected_rows=3) + + assert table.column_names == ["episode_index", "frame_index", "value"] + assert table.column("value").to_pylist() == [10, 11, 12] + + +def test_reader_filters_legacy_mixed_row_group(tmp_path: Path) -> None: + path = tmp_path / "data/chunk-000/file-000.parquet" + path.parent.mkdir(parents=True) + pq.write_table(_table([0, 0, 1, 1, 1]), path) + reader = EpisodeParquetReader(tmp_path, columns=("episode_index", "frame_index", "value")) + + table = reader.read_episode(path.relative_to(tmp_path), episode_index=1, expected_rows=3) + + assert table.column("episode_index").to_pylist() == [1, 1, 1] + assert table.column("frame_index").to_pylist() == [0, 1, 2] + + +def test_reader_rejects_partial_episode(tmp_path: Path) -> None: + path = tmp_path / "data/chunk-000/file-000.parquet" + path.parent.mkdir(parents=True) + pq.write_table(_table([2, 2]), path) + reader = EpisodeParquetReader(tmp_path, columns=("episode_index", "frame_index")) + + with pytest.raises(ValueError, match="expected 3 rows, found 2"): + reader.read_episode(path.relative_to(tmp_path), episode_index=2, expected_rows=3) + + +def test_reader_rejects_missing_episode(tmp_path: Path) -> None: + path = tmp_path / "data/chunk-000/file-000.parquet" + path.parent.mkdir(parents=True) + pq.write_table(_table([0, 0]), path) + reader = EpisodeParquetReader(tmp_path, columns=("episode_index", "frame_index")) + + with pytest.raises(ValueError, match="episode 4"): + reader.read_episode(path.relative_to(tmp_path), episode_index=4, expected_rows=1) + + +def test_reader_supports_fsspec_remote_root() -> None: + filesystem = fsspec.filesystem("memory") + root = "memory://episode-reader" + path = "episode-reader/data/chunk-000/file-000.parquet" + with filesystem.open(path, "wb") as output: + pq.write_table(_table([0, 0, 0]), output) + reader = EpisodeParquetReader(root, columns=("episode_index", "value")) + + table = reader.read_episode("data/chunk-000/file-000.parquet", episode_index=0, expected_rows=3) + + assert table.column("value").to_pylist() == [0, 1, 2] diff --git a/tests/datasets/test_episode_video_streaming.py b/tests/datasets/test_episode_video_streaming.py index 226b04c9b..eedb1fdc6 100644 --- a/tests/datasets/test_episode_video_streaming.py +++ b/tests/datasets/test_episode_video_streaming.py @@ -10,12 +10,19 @@ import json import struct +import threading +from concurrent.futures import ThreadPoolExecutor import numpy as np import pytest -from lerobot.datasets.episode_video_streaming import assert_hf_hub_range_cache_branch -from lerobot.datasets.mp4 import ( +from lerobot.streaming.episode_video import ( + EpisodeByteCache, + EpisodeVideoManifest, + ThreadLocalRangeFetcher, + _log_http_failure, +) +from lerobot.streaming.mp4 import ( _box, _co64, _dinf, @@ -89,33 +96,115 @@ def test_parser_accepts_co64_chunk_offsets(): np.testing.assert_array_equal(mp4.sample_offsets, np.array([10_000, 10_050, 10_025])) -def test_hf_hub_branch_assertion_accepts_requested_revision(monkeypatch): - class FakeDist: - def read_text(self, name): - assert name == "direct_url.json" - return json.dumps( - { - "url": "https://github.com/huggingface/huggingface_hub.git", - "vcs_info": {"requested_revision": "feat/hffs-cache-cdn-range-reads"}, - } - ) - +def _fake_cache(monkeypatch, tmp_path, *, byte_budget=8, max_open_decoders=1): + manifest = EpisodeVideoManifest(video_keys=["camera"], files=[], spans={}) + cache = EpisodeByteCache( + manifest, + tmp_path, + byte_budget=byte_budget, + workers=1, + open_decoders=False, + max_open_decoders=max_open_decoders, + ) monkeypatch.setattr( - "lerobot.datasets.episode_video_streaming.metadata.distribution", lambda _: FakeDist() + cache, + "_fetch_and_synthesize", + lambda episode_index, _camera_key: {"bytes": bytes([episode_index]) * 5, "_timings": None}, + ) + return cache + + +def test_byte_cache_does_not_evict_retained_episode(monkeypatch, tmp_path): + with _fake_cache(monkeypatch, tmp_path, byte_budget=10) as cache: + cache.retain_episode(0) + cache.ensure_ready(0) + cache.ensure_ready(1) + cache.ensure_ready(2) + + assert (0, "camera") in cache._cache + assert (1, "camera") not in cache._cache + assert cache.resident_bytes <= cache.byte_budget + + +def test_byte_cache_rejects_retained_set_larger_than_budget(monkeypatch, tmp_path): + with _fake_cache(monkeypatch, tmp_path, byte_budget=4) as cache: + cache.retain_episode(0) + + with pytest.raises(MemoryError, match="byte budget"): + cache.ensure_ready(0) + + +def test_decoder_count_has_independent_limit(monkeypatch, tmp_path): + opened = [] + + class FakeDecoder: + pass + + def open_decoder(_data): + decoder = FakeDecoder() + opened.append(decoder) + return decoder + + monkeypatch.setattr("lerobot.streaming.episode_video.open_video_decoder", open_decoder) + with _fake_cache(monkeypatch, tmp_path, byte_budget=20, max_open_decoders=1) as cache: + first = cache.get_decoder(0, "camera") + second = cache.get_decoder(1, "camera") + + assert first is not second + assert cache.open_decoder_count == 1 + + +def test_releasing_episode_allows_immediate_eviction(monkeypatch, tmp_path): + with _fake_cache(monkeypatch, tmp_path, byte_budget=5) as cache: + cache.retain_episode(0) + cache.ensure_ready(0) + cache.release_episode(0) + cache.ensure_ready(1) + + assert (0, "camera") not in cache._cache + assert (1, "camera") in cache._cache + + +def test_range_fetcher_closes_handles_from_all_worker_threads(tmp_path): + (tmp_path / "video.mp4").write_bytes(b"0123456789") + fetcher = ThreadLocalRangeFetcher(tmp_path) + barrier = threading.Barrier(2) + + def read_from_worker(offset): + barrier.wait() + return fetcher.read_range("video.mp4", offset, 1) + + with ThreadPoolExecutor(max_workers=2) as pool: + futures = [pool.submit(read_from_worker, offset) for offset in range(2)] + assert [future.result() for future in futures] == [b"0", b"1"] + + handles = list(fetcher._all_handles.values()) + assert len(handles) == 2 + fetcher.close() + + assert not fetcher._all_handles + assert all(handle.closed for handle in handles) + + +def test_http_failure_log_does_not_write_credentials(tmp_path, monkeypatch): + log_path = tmp_path / "http-failures.jsonl" + monkeypatch.setenv("LEROBOT_HTTP_FAILURE_LOG", str(log_path)) + + _log_http_failure( + backend="native-http", + method="GET", + url="https://cdn.example/private/video.mp4?token=url-secret", + headers={ + "Authorization": "Bearer header-secret", + "Range": "bytes=0-10", + "X-Request-Id": "safe-request-id", + }, + elapsed_s=0.1, + status_code=403, ) - assert_hf_hub_range_cache_branch() - - -def test_hf_hub_branch_assertion_rejects_plain_install(monkeypatch): - class FakeDist: - def read_text(self, name): - assert name == "direct_url.json" - return json.dumps({"url": "https://github.com/huggingface/huggingface_hub.git"}) - - monkeypatch.setattr( - "lerobot.datasets.episode_video_streaming.metadata.distribution", lambda _: FakeDist() - ) - - with pytest.raises(AssertionError): - assert_hf_hub_range_cache_branch() + record = json.loads(log_path.read_text()) + assert record["host"] == "cdn.example" + assert record["path"] == "/private/video.mp4" + assert record["request_id"] == "safe-request-id" + assert "secret" not in log_path.read_text() diff --git a/tests/datasets/test_exact_coverage_pool.py b/tests/datasets/test_exact_coverage_pool.py index 476c17f76..e31a7354d 100644 --- a/tests/datasets/test_exact_coverage_pool.py +++ b/tests/datasets/test_exact_coverage_pool.py @@ -16,7 +16,7 @@ from collections import Counter -from lerobot.datasets.episode_video_streaming import ExactCoveragePool +from lerobot.streaming.episode_video import ExactCoveragePool EPISODES = [(0, 5), (1, 3), (2, 8), (3, 1), (4, 6), (5, 4), (6, 7), (7, 2)] TOTAL = sum(n for _, n in EPISODES) diff --git a/tests/datasets/test_language.py b/tests/datasets/test_language.py index 52c7b3708..e45050919 100644 --- a/tests/datasets/test_language.py +++ b/tests/datasets/test_language.py @@ -25,6 +25,7 @@ from lerobot.datasets.language import ( # noqa: E402 language_persistent_arrow_type, validate_camera_field, ) +from lerobot.datasets.streaming_dataset import StreamingLeRobotDataset # noqa: E402 from lerobot.datasets.utils import DEFAULT_DATA_PATH # noqa: E402 @@ -171,3 +172,16 @@ def test_lerobot_dataset_passes_language_columns_through(tmp_path, empty_lerobot assert first[LANGUAGE_EVENTS] == [event] assert second[LANGUAGE_PERSISTENT] == persistent assert second[LANGUAGE_EVENTS] == [] + + streamed = { + int(item["index"]): item + for item in StreamingLeRobotDataset( + repo_id=dataset.repo_id, + root=root, + shuffle=False, + ) + } + assert streamed[0][LANGUAGE_PERSISTENT] == persistent + assert streamed[0][LANGUAGE_EVENTS] == [event] + assert streamed[1][LANGUAGE_PERSISTENT] == persistent + assert streamed[1][LANGUAGE_EVENTS] == [] diff --git a/tests/datasets/test_streaming.py b/tests/datasets/test_streaming.py index db167f657..cd1e9acbe 100644 --- a/tests/datasets/test_streaming.py +++ b/tests/datasets/test_streaming.py @@ -13,64 +13,16 @@ # 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. -import numpy as np import pytest import torch pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])") from lerobot.datasets.streaming_dataset import StreamingLeRobotDataset -from lerobot.datasets.utils import safe_shard from lerobot.utils.constants import ACTION from tests.fixtures.constants import DUMMY_REPO_ID -def get_frames_expected_order(streaming_ds: StreamingLeRobotDataset) -> list[int]: - """Replicates the shuffling logic of StreamingLeRobotDataset to get the expected order of indices.""" - rng = np.random.default_rng(streaming_ds.seed) - buffer_size = streaming_ds.buffer_size - num_shards = streaming_ds.num_shards - - shards_indices = [] - for shard_idx in range(num_shards): - shard = streaming_ds.hf_dataset.shard(num_shards, index=shard_idx) - shard_indices = [item["index"] for item in shard] - shards_indices.append(shard_indices) - - shard_iterators = {i: iter(s) for i, s in enumerate(shards_indices)} - - buffer_indices_generator = streaming_ds._iter_random_indices(rng, buffer_size) - - frames_buffer = [] - expected_indices = [] - - while shard_iterators: # While there are still available shards - available_shard_keys = list(shard_iterators.keys()) - if not available_shard_keys: - break - - # Call _infinite_generator_over_elements with current available shards (key difference!) - shard_key = next(streaming_ds._infinite_generator_over_elements(rng, available_shard_keys)) - - try: - frame_index = next(shard_iterators[shard_key]) - - if len(frames_buffer) == buffer_size: - i = next(buffer_indices_generator) - expected_indices.append(frames_buffer[i]) - frames_buffer[i] = frame_index - else: - frames_buffer.append(frame_index) - - except StopIteration: - del shard_iterators[shard_key] # Remove exhausted shard - - rng.shuffle(frames_buffer) - expected_indices.extend(frames_buffer) - - return expected_indices - - def test_single_frame_consistency(tmp_path, lerobot_dataset_factory): """Test if are correctly accessed""" ds_num_frames = 400 @@ -92,7 +44,7 @@ def test_single_frame_consistency(tmp_path, lerobot_dataset_factory): key_checks = [] for _ in range(ds_num_frames): streaming_frame = next(streaming_ds) - frame_idx = streaming_frame["index"] + frame_idx = int(streaming_frame["index"]) target_frame = ds[frame_idx] for key in streaming_frame: @@ -141,22 +93,16 @@ def test_frames_order_over_epochs(tmp_path, lerobot_dataset_factory, shuffle): repo_id=repo_id, root=local_path, buffer_size=buffer_size, seed=seed, shuffle=shuffle ) - first_epoch_indices = [frame["index"] for frame in streaming_ds] - expected_indices = get_frames_expected_order(streaming_ds) - - assert first_epoch_indices == expected_indices, "First epoch indices do not match expected indices" - - expected_indices = get_frames_expected_order(streaming_ds) + first_epoch_indices = [int(frame["index"]) for frame in streaming_ds] + assert sorted(first_epoch_indices) == list(range(ds_num_frames)) for _ in range(n_epochs): - streaming_indices = [frame["index"] for frame in streaming_ds] - frames_match = all( - s_index == e_index for s_index, e_index in zip(streaming_indices, expected_indices, strict=True) - ) + streaming_indices = [int(frame["index"]) for frame in streaming_ds] + assert sorted(streaming_indices) == list(range(ds_num_frames)) if shuffle: - assert not frames_match + assert streaming_indices != first_epoch_indices else: - assert frames_match + assert streaming_indices == first_epoch_indices @pytest.mark.parametrize( @@ -196,22 +142,16 @@ def test_frames_order_with_shards(tmp_path, lerobot_dataset_factory, shuffle): max_num_shards=4, ) - first_epoch_indices = [frame["index"] for frame in streaming_ds] - expected_indices = get_frames_expected_order(streaming_ds) - - assert first_epoch_indices == expected_indices, "First epoch indices do not match expected indices" + first_epoch_indices = [int(frame["index"]) for frame in streaming_ds] + assert sorted(first_epoch_indices) == list(range(ds_num_frames)) for _ in range(n_epochs): - streaming_indices = [ - frame["index"] for frame in streaming_ds - ] # NOTE: this is the same as first_epoch_indices - frames_match = all( - s_index == e_index for s_index, e_index in zip(streaming_indices, expected_indices, strict=True) - ) + streaming_indices = [int(frame["index"]) for frame in streaming_ds] + assert sorted(streaming_indices) == list(range(ds_num_frames)) if shuffle: - assert not frames_match + assert streaming_indices != first_epoch_indices else: - assert frames_match + assert streaming_indices == first_epoch_indices @pytest.mark.parametrize( @@ -261,7 +201,7 @@ def test_frames_with_delta_consistency(tmp_path, lerobot_dataset_factory, state_ for i in range(ds_num_frames): streaming_frame = next(streaming_ds) - frame_idx = streaming_frame["index"] + frame_idx = int(streaming_frame["index"]) target_frame = ds[frame_idx] assert set(streaming_frame.keys()) == set(target_frame.keys()), ( @@ -344,20 +284,11 @@ def test_frames_with_delta_consistency_with_shards( max_num_shards=4, ) - iter(streaming_ds) - - num_shards = 4 - shards_indices = [] - for shard_idx in range(num_shards): - shard = safe_shard(streaming_ds.hf_dataset, shard_idx, num_shards) - shard_indices = [item["index"] for item in shard] - shards_indices.append(shard_indices) - streaming_ds = iter(streaming_ds) for i in range(ds_num_frames): streaming_frame = next(streaming_ds) - frame_idx = streaming_frame["index"] + frame_idx = int(streaming_frame["index"]) target_frame = ds[frame_idx] assert set(streaming_frame.keys()) == set(target_frame.keys()), ( diff --git a/tests/datasets/test_streaming_factory.py b/tests/datasets/test_streaming_factory.py new file mode 100644 index 000000000..3126473d2 --- /dev/null +++ b/tests/datasets/test_streaming_factory.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python + +# Copyright 2026 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 + +from types import SimpleNamespace + +import pytest + +pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])") + +from lerobot.configs.default import DatasetConfig +from lerobot.datasets import factory + + +def test_factory_wires_production_streaming_settings(monkeypatch): + captured = {} + + class DummyStreamingDataset: + def __init__(self, *args, **kwargs): + captured["args"] = args + captured["kwargs"] = kwargs + self.meta = SimpleNamespace(camera_keys=[], depth_keys=[], stats={}) + + monkeypatch.setattr(factory, "LeRobotDatasetMetadata", lambda *args, **kwargs: object()) + monkeypatch.setattr(factory, "resolve_delta_timestamps", lambda *args, **kwargs: {"action": [0.0]}) + monkeypatch.setattr(factory, "StreamingLeRobotDataset", DummyStreamingDataset) + dataset_config = DatasetConfig( + repo_id="owner/dataset", + streaming=True, + streaming_data_root="memory://payload", + streaming_episode_pool_size=7, + streaming_prefetch_episodes=3, + streaming_byte_budget_gb=2.5, + ) + cfg = SimpleNamespace( + dataset=dataset_config, + trainable_config=object(), + num_workers=0, + tolerance_s=1e-4, + ) + + dataset = factory.make_dataset(cfg) + + assert isinstance(dataset, DummyStreamingDataset) + assert captured["args"] == ("owner/dataset",) + assert captured["kwargs"]["data_root"] == "memory://payload" + assert captured["kwargs"]["episode_pool_size"] == 7 + assert captured["kwargs"]["prefetch_episodes"] == 3 + assert captured["kwargs"]["byte_budget_gb"] == 2.5 + assert captured["kwargs"]["max_num_shards"] == 1 + assert captured["kwargs"]["return_uint8"] is True + assert captured["kwargs"]["repeat"] is True diff --git a/tests/datasets/test_streaming_production.py b/tests/datasets/test_streaming_production.py new file mode 100644 index 000000000..1db2c6751 --- /dev/null +++ b/tests/datasets/test_streaming_production.py @@ -0,0 +1,519 @@ +#!/usr/bin/env python + +# Copyright 2026 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 + +from __future__ import annotations + +from itertools import islice +from pathlib import Path + +import fsspec +import pytest +import torch + +pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])") + +from lerobot.datasets.streaming_dataset import StreamingLeRobotDataset +from lerobot.utils.utils import cycle +from tests.fixtures.constants import DUMMY_REPO_ID + + +def _indices(dataset: StreamingLeRobotDataset) -> list[int]: + return [int(item["index"]) for item in dataset] + + +def _assert_item_equal(left: dict, right: dict) -> None: + assert left.keys() == right.keys() + for key in left: + if isinstance(left[key], torch.Tensor): + assert torch.equal(left[key], right[key]), key + else: + assert left[key] == right[key], key + + +def test_streaming_matches_map_style_with_exact_coverage(tmp_path: Path, lerobot_dataset_factory) -> None: + root = tmp_path / "dataset" + map_dataset = lerobot_dataset_factory( + root=root, + repo_id=DUMMY_REPO_ID, + total_episodes=4, + total_frames=40, + use_videos=False, + ) + streaming = StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + shuffle=False, + buffer_size=3, + ) + + samples = list(streaming) + + assert len(samples) == len(map_dataset) + assert sorted(int(sample["index"]) for sample in samples) == list(range(len(map_dataset))) + for sample in samples: + _assert_item_equal(sample, map_dataset[int(sample["index"])]) + + +def test_streaming_rgb_video_matches_map_style(tmp_path: Path, lerobot_dataset_factory) -> None: + root = tmp_path / "dataset" + map_dataset = lerobot_dataset_factory( + root=root, + repo_id=DUMMY_REPO_ID, + total_episodes=2, + total_frames=20, + ) + streaming = StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + shuffle=False, + buffer_size=2, + ) + + for sample in streaming: + reference = map_dataset[int(sample["index"])] + assert sample.keys() == reference.keys() + for camera_key in map_dataset.meta.camera_keys: + assert torch.equal(sample[camera_key], reference[camera_key]), ( + camera_key, + int(sample["index"]), + float((sample[camera_key] - reference[camera_key]).abs().max()), + ) + + +def test_streaming_applies_rgb_transforms_and_preserves_uint8( + tmp_path: Path, lerobot_dataset_factory +) -> None: + root = tmp_path / "dataset" + + def flip_width(image: torch.Tensor) -> torch.Tensor: + return image.flip(-1) + + map_dataset = lerobot_dataset_factory( + root=root, + repo_id=DUMMY_REPO_ID, + total_episodes=2, + total_frames=10, + image_transforms=flip_width, + return_uint8=True, + ) + streaming = StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + shuffle=False, + buffer_size=2, + image_transforms=flip_width, + return_uint8=True, + ) + + sample = next(iter(streaming)) + reference = map_dataset[int(sample["index"])] + for camera_key in map_dataset.meta.camera_keys: + assert sample[camera_key].dtype == torch.uint8 + assert torch.equal(sample[camera_key], reference[camera_key]) + + +def test_streaming_honors_episode_subset(tmp_path: Path, lerobot_dataset_factory) -> None: + root = tmp_path / "dataset" + map_dataset = lerobot_dataset_factory( + root=root, + repo_id=DUMMY_REPO_ID, + total_episodes=5, + total_frames=50, + use_videos=False, + ) + selected = [1, 3] + streaming = StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + episodes=selected, + shuffle=False, + buffer_size=2, + ) + + indices = _indices(streaming) + expected = [ + index + for episode in selected + for index in range( + map_dataset.meta.episodes[episode]["dataset_from_index"], + map_dataset.meta.episodes[episode]["dataset_to_index"], + ) + ] + + assert sorted(indices) == sorted(expected) + + +def test_streaming_reads_episode_parquet_from_configured_fsspec_root( + tmp_path: Path, lerobot_dataset_factory +) -> None: + root = tmp_path / "metadata" + map_dataset = lerobot_dataset_factory( + root=root, + repo_id=DUMMY_REPO_ID, + total_episodes=3, + total_frames=30, + use_videos=False, + ) + remote_root = "memory://streaming-production" + filesystem = fsspec.filesystem("memory") + for path in (root / "data").glob("*/*.parquet"): + relative = path.relative_to(root).as_posix() + filesystem.put(str(path), f"streaming-production/{relative}") + + streaming = StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + data_root=remote_root, + shuffle=False, + buffer_size=2, + ) + + assert sorted(_indices(streaming)) == list(range(len(map_dataset))) + + +def test_streaming_reads_video_bytes_from_configured_fsspec_root( + tmp_path: Path, lerobot_dataset_factory +) -> None: + root = tmp_path / "metadata" + map_dataset = lerobot_dataset_factory( + root=root, + repo_id=DUMMY_REPO_ID, + total_episodes=2, + total_frames=10, + ) + namespace = f"streaming-video-{tmp_path.name}" + remote_root = f"memory://{namespace}" + filesystem = fsspec.filesystem("memory") + for path in [*(root / "data").glob("*/*.parquet"), *(root / "videos").glob("*/*/*.mp4")]: + relative = path.relative_to(root).as_posix() + filesystem.put(str(path), f"{namespace}/{relative}") + + streaming = StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + data_root=remote_root, + shuffle=False, + buffer_size=2, + ) + + sample = next(iter(streaming)) + reference = map_dataset[int(sample["index"])] + for camera_key in map_dataset.meta.camera_keys: + assert torch.equal(sample[camera_key], reference[camera_key]) + + +def test_streaming_rank_shards_are_disjoint(tmp_path: Path, lerobot_dataset_factory, monkeypatch) -> None: + root = tmp_path / "dataset" + map_dataset = lerobot_dataset_factory( + root=root, + repo_id=DUMMY_REPO_ID, + total_episodes=8, + total_frames=80, + use_videos=False, + ) + per_rank = [] + for rank in range(2): + monkeypatch.setenv("RANK", str(rank)) + monkeypatch.setenv("WORLD_SIZE", "2") + per_rank.append( + set( + _indices( + StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + shuffle=False, + buffer_size=2, + ) + ) + ) + ) + + assert per_rank[0].isdisjoint(per_rank[1]) + assert per_rank[0] | per_rank[1] == set(range(len(map_dataset))) + + +def test_streaming_workers_do_not_duplicate_frames(tmp_path: Path, lerobot_dataset_factory) -> None: + root = tmp_path / "dataset" + map_dataset = lerobot_dataset_factory( + root=root, + repo_id=DUMMY_REPO_ID, + total_episodes=8, + total_frames=80, + use_videos=False, + ) + streaming = StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + shuffle=False, + buffer_size=2, + ) + loader = torch.utils.data.DataLoader(streaming, batch_size=None, num_workers=2) + + indices = [int(item["index"]) for item in loader] + + assert len(indices) == len(map_dataset) + assert set(indices) == set(range(len(map_dataset))) + + +def test_streaming_persistent_workers_advance_epochs(tmp_path: Path, lerobot_dataset_factory) -> None: + root = tmp_path / "dataset" + map_dataset = lerobot_dataset_factory( + root=root, + repo_id=DUMMY_REPO_ID, + total_episodes=8, + total_frames=80, + use_videos=False, + ) + streaming = StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + seed=23, + shuffle=True, + buffer_size=2, + ) + loader = torch.utils.data.DataLoader( + streaming, + batch_size=None, + num_workers=2, + persistent_workers=True, + ) + try: + first = [int(item["index"]) for item in loader] + second = [int(item["index"]) for item in loader] + finally: + if loader._iterator is not None: + loader._iterator._shutdown_workers() + + assert sorted(first) == list(range(len(map_dataset))) + assert sorted(second) == list(range(len(map_dataset))) + assert first != second + + +def test_streaming_worker_exception_propagates_and_workers_stop( + tmp_path: Path, lerobot_dataset_factory +) -> None: + root = tmp_path / "dataset" + lerobot_dataset_factory( + root=root, + repo_id=DUMMY_REPO_ID, + total_episodes=4, + total_frames=40, + use_videos=False, + ) + streaming = StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + shuffle=False, + buffer_size=2, + ) + next((root / "data").glob("*/*.parquet")).write_bytes(b"corrupt parquet") + loader = torch.utils.data.DataLoader( + streaming, + batch_size=None, + num_workers=2, + persistent_workers=True, + ) + try: + with pytest.raises(Exception, match="Parquet"): + list(loader) + finally: + if loader._iterator is not None: + loader._iterator._shutdown_workers() + assert not any(worker.is_alive() for worker in loader._iterator._workers) + + +def test_streaming_resume_reproduces_remaining_stream(tmp_path: Path, lerobot_dataset_factory) -> None: + root = tmp_path / "dataset" + lerobot_dataset_factory( + root=root, + repo_id=DUMMY_REPO_ID, + total_episodes=5, + total_frames=50, + use_videos=False, + ) + full = _indices( + StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + seed=7, + shuffle=True, + buffer_size=3, + ) + ) + resumed = StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + seed=7, + shuffle=True, + buffer_size=3, + ) + resumed.load_state_dict({"epoch": 0, "offset": 11}) + + assert _indices(resumed) == full[11:] + + +@pytest.mark.parametrize(("batch_size", "offset"), [(None, 17), (4, 20)]) +def test_streaming_worker_resume_reproduces_remaining_stream( + tmp_path: Path, + lerobot_dataset_factory, + batch_size: int | None, + offset: int, +) -> None: + root = tmp_path / "dataset" + lerobot_dataset_factory( + root=root, + repo_id=DUMMY_REPO_ID, + total_episodes=8, + total_frames=80, + use_videos=False, + ) + + def load(dataset: StreamingLeRobotDataset) -> list[int]: + loader = torch.utils.data.DataLoader(dataset, batch_size=batch_size, num_workers=2) + if batch_size is None: + return [int(item["index"]) for item in loader] + return [int(index) for batch in loader for index in batch["index"]] + + full = load( + StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + seed=31, + shuffle=True, + buffer_size=2, + ) + ) + resumed = StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + seed=31, + shuffle=True, + buffer_size=2, + ) + resumed.load_state_dict({"epoch": 0, "offset": offset, "batch_size": batch_size or 1}) + + assert load(resumed) == full[offset:] + + +def test_streaming_state_dict_round_trip_mid_epoch(tmp_path: Path, lerobot_dataset_factory) -> None: + root = tmp_path / "dataset" + lerobot_dataset_factory( + root=root, + repo_id=DUMMY_REPO_ID, + total_episodes=5, + total_frames=50, + use_videos=False, + ) + source = StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + seed=17, + shuffle=True, + buffer_size=3, + ) + iterator = iter(source) + consumed = [int(next(iterator)["index"]) for _ in range(13)] + state = source.state_dict() + remaining = [int(item["index"]) for item in iterator] + + restored = StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + seed=17, + shuffle=True, + buffer_size=3, + ) + restored.load_state_dict(state) + + assert len(consumed) == state["offset"] + assert _indices(restored) == remaining + + +def test_streaming_worker_resume_after_epoch_boundary(tmp_path: Path, lerobot_dataset_factory) -> None: + root = tmp_path / "dataset" + lerobot_dataset_factory( + root=root, + repo_id=DUMMY_REPO_ID, + total_episodes=4, + total_frames=24, + use_videos=False, + ) + + def infinite_indices(dataset: StreamingLeRobotDataset, count: int) -> list[int]: + loader = torch.utils.data.DataLoader( + dataset, + batch_size=4, + num_workers=2, + persistent_workers=True, + ) + try: + return [ + int(index) for batch in islice(cycle(loader), (count + 3) // 4) for index in batch["index"] + ][:count] + finally: + if loader._iterator is not None: + loader._iterator._shutdown_workers() + + full = infinite_indices( + StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + seed=47, + shuffle=True, + buffer_size=2, + repeat=True, + ), + 56, + ) + offset = 32 + resumed = StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + seed=47, + shuffle=True, + buffer_size=2, + repeat=True, + ) + resumed.load_state_dict({"epoch": 0, "offset": offset, "batch_size": 4}) + + assert infinite_indices(resumed, 24) == full[offset : offset + 24] + + +def test_streaming_local_training_step_smoke(tmp_path: Path, lerobot_dataset_factory) -> None: + root = tmp_path / "dataset" + lerobot_dataset_factory( + root=root, + repo_id=DUMMY_REPO_ID, + total_episodes=4, + total_frames=24, + use_videos=False, + ) + dataset = StreamingLeRobotDataset( + DUMMY_REPO_ID, + root=root, + seed=53, + buffer_size=2, + repeat=True, + ) + loader = torch.utils.data.DataLoader(dataset, batch_size=4, num_workers=2) + iterator = iter(loader) + try: + batch = next(iterator) + finally: + iterator._shutdown_workers() + model = torch.nn.Linear(batch["action"].shape[-1], batch["action"].shape[-1]) + optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) + + loss = torch.nn.functional.mse_loss(model(batch["action"]), batch["action"]) + loss.backward() + optimizer.step() + + assert torch.isfinite(loss) + assert batch["index"].shape == (4,) diff --git a/tests/datasets/test_streaming_sidecar_adapter.py b/tests/datasets/test_streaming_sidecar_adapter.py new file mode 100644 index 000000000..a429f5d54 --- /dev/null +++ b/tests/datasets/test_streaming_sidecar_adapter.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python + +# Copyright 2026 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 + +from pathlib import Path +from types import SimpleNamespace + +import pytest + +pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])") + +from lerobot.datasets.streaming_sidecar import range_backend_for_root, streaming_data_root + + +def test_hub_data_root_is_revision_qualified() -> None: + meta = SimpleNamespace(repo_id="owner/dataset", revision="commit-sha") + + root = streaming_data_root(meta, requested_root=None, configured_data_root=None) + + assert root == "hf://datasets/owner/dataset@commit-sha" + assert range_backend_for_root(root) == "native-http" + + +def test_explicit_bucket_root_is_preserved() -> None: + meta = SimpleNamespace(repo_id="owner/dataset", revision="commit-sha") + bucket = "hf://buckets/owner/dataset-bucket/prefix/" + + root = streaming_data_root(meta, requested_root=None, configured_data_root=bucket) + + assert root == bucket.rstrip("/") + assert range_backend_for_root(root) == "native-http" + + +def test_local_and_generic_remote_roots_use_fsspec(tmp_path: Path) -> None: + meta = SimpleNamespace(repo_id="owner/dataset", revision="commit-sha") + + local = streaming_data_root(meta, requested_root=tmp_path, configured_data_root=None) + + assert local == str(tmp_path) + assert range_backend_for_root(local) == "fsspec" + assert range_backend_for_root("memory://dataset") == "fsspec" diff --git a/tests/test_streaming_core_imports.py b/tests/test_streaming_core_imports.py new file mode 100644 index 000000000..e12827797 --- /dev/null +++ b/tests/test_streaming_core_imports.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python + +# Copyright 2026 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 + +from __future__ import annotations + +import subprocess +import sys + + +def test_streaming_core_imports_without_dataset_extra() -> None: + code = """ +import importlib.abc +import sys + +class BlockDatasets(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path, target=None): + if fullname == "datasets" or fullname.startswith("datasets."): + raise ModuleNotFoundError("blocked optional datasets dependency") + return None + +sys.meta_path.insert(0, BlockDatasets()) +from lerobot.streaming.episode_video import EpisodeByteCache, ExactCoveragePool +from lerobot.streaming.mp4 import Mp4Index +""" + result = subprocess.run( + [sys.executable, "-c", code], + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode == 0, result.stderr diff --git a/tests/test_streaming_sidecar.py b/tests/test_streaming_sidecar.py new file mode 100644 index 000000000..fdc0b63ba --- /dev/null +++ b/tests/test_streaming_sidecar.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python + +# Copyright 2026 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 + +from __future__ import annotations + +import shutil +import threading +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import numpy as np +import pytest +from filelock import FileLock + +from lerobot.streaming.episode_video import EpisodeVideoManifest, VideoFileRecord +from lerobot.streaming.mp4 import Mp4Index +from lerobot.streaming.sidecar import ( + SidecarLockTimeoutError, + SidecarSpec, + ensure_mp4_sidecar, + sidecar_cache_path, +) + + +def _record(path: str = "videos/camera/chunk-000/file-000.mp4", size: int = 128) -> VideoFileRecord: + arrays = np.array([0], dtype=np.int64) + index = Mp4Index( + file_path=path, + file_size=size, + ftyp=b"", + moov_offset=0, + mdat_offset=0, + mdat_payload_offset=0, + mdat_payload_size=size, + faststart=True, + codec="avc1", + timescale=1, + duration=1, + track_id=1, + width=1, + height=1, + stsd_body=b"", + sample_pts=np.array([0.0]), + sample_durations=arrays, + sample_sizes=arrays, + sample_offsets=arrays, + sync_samples=arrays, + ) + return VideoFileRecord(path, size, index) + + +def _spec(revision: str = "rev-a", size: int = 128) -> SidecarSpec: + return SidecarSpec( + repo_id="owner/dataset", + revision=revision, + data_root="hf://datasets/owner/dataset", + source_files=(("videos/camera/chunk-000/file-000.mp4", size),), + ) + + +def _write_valid(path: Path, spec: SidecarSpec) -> None: + EpisodeVideoManifest.save_file_sidecar(path, [_record(size=spec.source_files[0][1])], spec=spec) + + +def test_sidecar_cache_path_is_revision_keyed(tmp_path: Path) -> None: + first = sidecar_cache_path(tmp_path, _spec("rev-a")) + second = sidecar_cache_path(tmp_path, _spec("rev-b")) + + assert first != second + assert first.parent == second.parent + + +def test_ensure_reuses_valid_local_sidecar(tmp_path: Path) -> None: + spec = _spec() + path = sidecar_cache_path(tmp_path, spec) + _write_valid(path, spec) + build_calls = 0 + + def build(_path: Path, _spec: SidecarSpec) -> None: + nonlocal build_calls + build_calls += 1 + + resolved = ensure_mp4_sidecar(spec, tmp_path, build=build) + + assert resolved == path + assert build_calls == 0 + + +def test_ensure_downloads_valid_published_sidecar(tmp_path: Path) -> None: + spec = _spec() + published = tmp_path / "published.npz" + _write_valid(published, spec) + build_calls = 0 + + def download(path: Path, _spec: SidecarSpec) -> bool: + shutil.copyfile(published, path) + return True + + def build(_path: Path, _spec: SidecarSpec) -> None: + nonlocal build_calls + build_calls += 1 + + resolved = ensure_mp4_sidecar(spec, tmp_path / "cache", build=build, download=download) + + assert EpisodeVideoManifest.validate_file_sidecar(resolved, spec) + assert build_calls == 0 + + +@pytest.mark.parametrize("invalid_kind", ["corrupt", "stale"]) +def test_ensure_rebuilds_invalid_local_sidecar(tmp_path: Path, invalid_kind: str) -> None: + spec = _spec() + path = sidecar_cache_path(tmp_path, spec) + path.parent.mkdir(parents=True, exist_ok=True) + if invalid_kind == "corrupt": + path.write_bytes(b"not-an-npz") + else: + _write_valid(path, _spec(revision="other-revision")) + build_calls = 0 + + def build(target: Path, target_spec: SidecarSpec) -> None: + nonlocal build_calls + build_calls += 1 + _write_valid(target, target_spec) + + resolved = ensure_mp4_sidecar(spec, tmp_path, build=build) + + assert EpisodeVideoManifest.validate_file_sidecar(resolved, spec) + assert build_calls == 1 + + +def test_concurrent_ensure_builds_once(tmp_path: Path) -> None: + spec = _spec() + start = threading.Barrier(2) + build_calls = 0 + build_lock = threading.Lock() + + def build(path: Path, target_spec: SidecarSpec) -> None: + nonlocal build_calls + with build_lock: + build_calls += 1 + _write_valid(path, target_spec) + + def ensure() -> Path: + start.wait() + return ensure_mp4_sidecar(spec, tmp_path, build=build) + + with ThreadPoolExecutor(max_workers=2) as pool: + paths = list(pool.map(lambda _: ensure(), range(2))) + + assert paths[0] == paths[1] + assert build_calls == 1 + + +def test_failed_build_does_not_replace_existing_file(tmp_path: Path) -> None: + spec = _spec() + path = sidecar_cache_path(tmp_path, spec) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"old-corrupt-file") + + def build(target: Path, _spec: SidecarSpec) -> None: + target.write_bytes(b"partial") + raise RuntimeError("build failed") + + with pytest.raises(RuntimeError, match="build failed"): + ensure_mp4_sidecar(spec, tmp_path, build=build) + + assert path.read_bytes() == b"old-corrupt-file" + assert not list(path.parent.glob(f".{path.name}.*.tmp.npz")) + + +def test_lock_timeout_is_actionable(tmp_path: Path) -> None: + spec = _spec() + path = sidecar_cache_path(tmp_path, spec) + lock_path = path.with_suffix(f"{path.suffix}.lock") + lock_path.parent.mkdir(parents=True, exist_ok=True) + + with ( + FileLock(lock_path), + pytest.raises(SidecarLockTimeoutError, match="Timed out waiting"), + ): + ensure_mp4_sidecar(spec, tmp_path, build=_write_valid, lock_timeout_s=0.01) diff --git a/tests/utils/test_train_utils.py b/tests/utils/test_train_utils.py index ccd769bd0..be6e7f029 100644 --- a/tests/utils/test_train_utils.py +++ b/tests/utils/test_train_utils.py @@ -24,6 +24,7 @@ from lerobot.common.train_utils import ( get_step_identifier, load_training_batch_size, load_training_num_processes, + load_training_num_workers, load_training_state, load_training_step, push_checkpoint_to_hub, @@ -90,6 +91,16 @@ def test_load_training_batch_size_absent_returns_none(tmp_path, optimizer, sched assert load_training_batch_size(tmp_path) is None +def test_save_training_state_records_num_workers(tmp_path, optimizer, scheduler): + save_training_state(tmp_path, 10, optimizer, scheduler, num_workers=6) + assert load_training_num_workers(tmp_path) == 6 + + +def test_load_training_num_workers_absent_returns_none(tmp_path, optimizer, scheduler): + save_training_state(tmp_path, 10, optimizer, scheduler) + assert load_training_num_workers(tmp_path) is None + + def test_update_last_checkpoint(tmp_path): checkpoint = tmp_path / "0005" checkpoint.mkdir() diff --git a/uv.lock b/uv.lock index 7d873d8f5..5390e7a19 100644 --- a/uv.lock +++ b/uv.lock @@ -2829,7 +2829,10 @@ dependencies = [ { name = "cmake" }, { name = "draccus" }, { name = "einops" }, + { name = "filelock" }, + { name = "fsspec" }, { name = "gymnasium" }, + { name = "httpx" }, { name = "huggingface-hub" }, { name = "numpy" }, { name = "opencv-python-headless" }, @@ -3302,7 +3305,9 @@ requires-dist = [ { name = "faker", marker = "extra == 'sarm'", specifier = ">=33.0.0,<35.0.0" }, { name = "fastapi", marker = "extra == 'phone'", specifier = "<1.0" }, { name = "feetech-servo-sdk", marker = "extra == 'feetech'", specifier = ">=1.0.0,<2.0.0" }, + { name = "filelock", specifier = ">=3.12.0,<4.0.0" }, { name = "foxglove-sdk", marker = "extra == 'viz'", specifier = ">=0.25.1,<0.26.0" }, + { name = "fsspec", specifier = ">=2023.5.0,<2027.0.0" }, { name = "grpcio", marker = "extra == 'grpcio-dep'", specifier = ">=1.73.1,<2.0.0" }, { name = "grpcio", marker = "extra == 'reachy2'", specifier = "<=1.73.1" }, { name = "grpcio-tools", marker = "extra == 'dev'", specifier = ">=1.73.1,<2.0.0" }, @@ -3313,6 +3318,7 @@ requires-dist = [ { name = "hebi-py", marker = "extra == 'phone'", specifier = ">=2.8.0,<2.12.0" }, { name = "hf-libero", marker = "sys_platform == 'linux' and extra == 'libero'", specifier = ">=0.1.4,<0.2.0" }, { name = "hidapi", marker = "extra == 'gamepad'", specifier = ">=0.14.0,<0.15.0" }, + { name = "httpx", specifier = ">=0.27.0,<1.0.0" }, { name = "huggingface-hub", specifier = ">=1.0.0,<2.0.0" }, { name = "ipykernel", marker = "extra == 'notebook'", specifier = ">=6.0.0,<7.0.0" }, { name = "jsonlines", marker = "extra == 'dataset'", specifier = ">=4.0.0,<5.0.0" },