mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-08 18:41:54 +00:00
feat(streaming): defer video decode, episode-pool shuffle, and remote-IO retries
- streaming_dataset: defer torchcodec decode until a sample leaves the shuffle buffer (buffer now holds ~KB tabular rows, not MB of pixels) and add an opt-in episode-pool shuffle (episode_pool_size) with exact in-episode delta lookups; expose decode/fetch timing_stats. - video_utils: retry transient hf:///fsspec/httpx transport errors during streaming decode (LEROBOT_REMOTE_IO_MAX_RETRIES). - dataset_tools: write multiple ~32MB row groups with a page index to bound per-shard streaming memory. - benchmarks/slurm: streaming benchmark + matrix submitter updates. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -36,7 +36,9 @@ is whatever ``--repo_id``/``--root`` point at. See the README for bucket prewarm
|
|||||||
import argparse
|
import argparse
|
||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import statistics
|
import statistics
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -47,6 +49,60 @@ from lerobot.datasets import LeRobotDatasetMetadata, StreamingLeRobotDataset
|
|||||||
from lerobot.utils.constants import ACTION
|
from lerobot.utils.constants import ACTION
|
||||||
|
|
||||||
|
|
||||||
|
def _tree_rss_bytes() -> int:
|
||||||
|
"""Sum RSS of this process and all its descendants via /proc (Linux only; 0 elsewhere).
|
||||||
|
|
||||||
|
DataLoader workers are separate processes, so the parent's own RSS misses most of the pipeline's
|
||||||
|
memory. Walking the process tree captures the real footprint (parquet buffers + decoders + shuffle).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
children: dict[int, list[int]] = {}
|
||||||
|
for entry in os.listdir("/proc"):
|
||||||
|
if not entry.isdigit():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{entry}/stat") as f:
|
||||||
|
ppid = int(f.read().split(") ", 1)[1].split()[1])
|
||||||
|
children.setdefault(ppid, []).append(int(entry))
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
total, stack = 0, [os.getpid()]
|
||||||
|
while stack:
|
||||||
|
cur = stack.pop()
|
||||||
|
try:
|
||||||
|
with open(f"/proc/{cur}/statm") as f:
|
||||||
|
total += int(f.read().split()[1]) * os.sysconf("SC_PAGE_SIZE")
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
stack.extend(children.get(cur, []))
|
||||||
|
return total
|
||||||
|
except OSError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
class PeakRSSSampler:
|
||||||
|
"""Background thread tracking peak process-tree RSS for the duration of the `with` block."""
|
||||||
|
|
||||||
|
def __init__(self, interval_s: float = 0.5):
|
||||||
|
self.interval_s = interval_s
|
||||||
|
self.peak_bytes = 0
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
while not self._stop.is_set():
|
||||||
|
self.peak_bytes = max(self.peak_bytes, _tree_rss_bytes())
|
||||||
|
self._stop.wait(self.interval_s)
|
||||||
|
|
||||||
|
def __enter__(self) -> "PeakRSSSampler":
|
||||||
|
self._thread.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
self._thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--repo_id", type=str, required=True)
|
parser.add_argument("--repo_id", type=str, required=True)
|
||||||
@@ -62,8 +118,30 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--source", type=str, default="hub", help="Label only: hub | bucket | warmed_bucket.")
|
parser.add_argument("--source", type=str, default="hub", help="Label only: hub | bucket | warmed_bucket.")
|
||||||
parser.add_argument("--batch_size", type=int, default=64)
|
parser.add_argument("--batch_size", type=int, default=64)
|
||||||
parser.add_argument("--num_workers", type=int, default=8)
|
parser.add_argument("--num_workers", type=int, default=8)
|
||||||
|
parser.add_argument(
|
||||||
|
"--prefetch_factor",
|
||||||
|
type=int,
|
||||||
|
default=2,
|
||||||
|
help="DataLoader batches prefetched per worker. Higher hides IO/decode latency but raises RAM "
|
||||||
|
"(prefetch_factor x num_workers x batch_size decoded frames held in flight). Ignored if num_workers=0.",
|
||||||
|
)
|
||||||
parser.add_argument("--buffer_size", type=int, default=2000)
|
parser.add_argument("--buffer_size", type=int, default=2000)
|
||||||
|
parser.add_argument(
|
||||||
|
"--max_num_shards",
|
||||||
|
type=int,
|
||||||
|
default=16,
|
||||||
|
help="Cap on concurrently-open stream shards. Each open shard holds ~one parquet row group in "
|
||||||
|
"RAM; reading from an hf:// bucket buffers ~5x more per shard than hf:// datasets, so lower this "
|
||||||
|
"(e.g. to num_workers) for bucket sources to avoid OOM. All data is still covered via re-sharding.",
|
||||||
|
)
|
||||||
parser.add_argument("--video_decoder_cache_size", type=int, default=None)
|
parser.add_argument("--video_decoder_cache_size", type=int, default=None)
|
||||||
|
parser.add_argument(
|
||||||
|
"--episode_pool_size",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="A3 shuffle: keep this many full episodes live and sample frames uniformly across them "
|
||||||
|
"(mixing radius = this many episodes). Unset = default per-shard reservoir shuffle.",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--video_decode_device",
|
"--video_decode_device",
|
||||||
type=str,
|
type=str,
|
||||||
@@ -87,8 +165,10 @@ def build_dataset(args: argparse.Namespace, meta: LeRobotDatasetMetadata) -> Str
|
|||||||
data_files_root=args.data_files_root,
|
data_files_root=args.data_files_root,
|
||||||
delta_timestamps=delta_timestamps,
|
delta_timestamps=delta_timestamps,
|
||||||
buffer_size=args.buffer_size,
|
buffer_size=args.buffer_size,
|
||||||
|
max_num_shards=args.max_num_shards,
|
||||||
video_decoder_cache_size=args.video_decoder_cache_size,
|
video_decoder_cache_size=args.video_decoder_cache_size,
|
||||||
video_decode_device=args.video_decode_device,
|
video_decode_device=args.video_decode_device,
|
||||||
|
episode_pool_size=args.episode_pool_size,
|
||||||
tolerance_s=1e-3,
|
tolerance_s=1e-3,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,37 +196,43 @@ def main() -> None:
|
|||||||
# tensors errors). Pin only when decode is on CPU and we copy to a CUDA device.
|
# tensors errors). Pin only when decode is on CPU and we copy to a CUDA device.
|
||||||
pin_memory=device.type == "cuda" and not gpu_decode,
|
pin_memory=device.type == "cuda" and not gpu_decode,
|
||||||
drop_last=True,
|
drop_last=True,
|
||||||
prefetch_factor=2 if args.num_workers > 0 else None,
|
prefetch_factor=args.prefetch_factor if args.num_workers > 0 else None,
|
||||||
# CUDA cannot initialize in forked workers; NVDEC decode in workers needs the spawn start method.
|
# CUDA cannot initialize in forked workers; NVDEC decode in workers needs the spawn start method.
|
||||||
multiprocessing_context="spawn" if gpu_decode and args.num_workers > 0 else None,
|
multiprocessing_context="spawn" if gpu_decode and args.num_workers > 0 else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
sample_latencies_ms: list[float] = []
|
sample_latencies_ms: list[float] = []
|
||||||
|
episodes_per_batch: list[int] = [] # shuffle-randomness proxy: distinct episodes within a batch
|
||||||
frames = 0
|
frames = 0
|
||||||
first_batch_latency_s = None
|
first_batch_latency_s = None
|
||||||
steady_start = None # wall-clock start of the post-warmup measurement window
|
steady_start = None # wall-clock start of the post-warmup measurement window
|
||||||
|
|
||||||
t_start = time.perf_counter()
|
t_start = time.perf_counter()
|
||||||
t_prev = t_start
|
t_prev = t_start
|
||||||
for i, batch in enumerate(loader):
|
with PeakRSSSampler() as rss:
|
||||||
# Dummy consume: move tensors to the device, mimicking what a real trainer would do.
|
for i, batch in enumerate(loader):
|
||||||
for value in batch.values():
|
# Dummy consume: move tensors to the device, mimicking what a real trainer would do.
|
||||||
if torch.is_tensor(value):
|
for value in batch.values():
|
||||||
value.to(device, non_blocking=device.type == "cuda")
|
if torch.is_tensor(value):
|
||||||
now = time.perf_counter()
|
value.to(device, non_blocking=device.type == "cuda")
|
||||||
if first_batch_latency_s is None:
|
now = time.perf_counter()
|
||||||
first_batch_latency_s = now - t_start
|
if first_batch_latency_s is None:
|
||||||
|
first_batch_latency_s = now - t_start
|
||||||
|
|
||||||
if i == args.warmup_batches:
|
if i == args.warmup_batches:
|
||||||
# Start the steady window here; the slow first batch and the prefetch queue it filled are
|
# Start the steady window here; the slow first batch and the prefetch queue it filled are
|
||||||
# excluded so throughput reflects sustained production, not draining a pre-filled queue.
|
# excluded so throughput reflects sustained production, not draining a pre-filled queue.
|
||||||
steady_start = now
|
steady_start = now
|
||||||
elif i > args.warmup_batches:
|
elif i > args.warmup_batches:
|
||||||
sample_latencies_ms.append((now - t_prev) / args.batch_size * 1000.0)
|
sample_latencies_ms.append((now - t_prev) / args.batch_size * 1000.0)
|
||||||
frames += args.batch_size
|
frames += args.batch_size
|
||||||
t_prev = now
|
ep = batch.get("episode_index")
|
||||||
if i + 1 >= args.num_batches:
|
if torch.is_tensor(ep):
|
||||||
break
|
episodes_per_batch.append(int(torch.unique(ep).numel()))
|
||||||
|
t_prev = now
|
||||||
|
if i + 1 >= args.num_batches:
|
||||||
|
break
|
||||||
|
peak_rss_gb = round(rss.peak_bytes / 1e9, 2) if rss.peak_bytes else None
|
||||||
|
|
||||||
now = time.perf_counter()
|
now = time.perf_counter()
|
||||||
elapsed = now - t_start
|
elapsed = now - t_start
|
||||||
@@ -154,6 +240,16 @@ def main() -> None:
|
|||||||
# gaps collapse to ~0 (the consumer drains a pre-filled queue) and overstate throughput by ~100x.
|
# gaps collapse to ~0 (the consumer drains a pre-filled queue) and overstate throughput by ~100x.
|
||||||
steady_elapsed_s = (now - steady_start) if steady_start is not None else elapsed
|
steady_elapsed_s = (now - steady_start) if steady_start is not None else elapsed
|
||||||
cache_stats = dataset.video_decoder_cache_stats()
|
cache_stats = dataset.video_decoder_cache_stats()
|
||||||
|
timing = dataset.timing_stats() # cumulative decode/fetch seconds summed across workers
|
||||||
|
# Image (camera frame) resolution as decoded, e.g. [C, H, W]. Read from the dataset feature contract.
|
||||||
|
image_shape = (
|
||||||
|
list(meta.features[meta.video_keys[0]]["shape"]) if meta.video_keys else None
|
||||||
|
)
|
||||||
|
# Decode/fetch overlap in wall-clock (workers run in parallel), so normalize against the total worker
|
||||||
|
# budget (num_workers x wallclock) to express each stage as a fraction of available worker time.
|
||||||
|
worker_budget_s = max(args.num_workers, 1) * elapsed
|
||||||
|
decode_pct = round(100 * timing["decode_s_total"] / worker_budget_s, 1) if worker_budget_s else None
|
||||||
|
fetch_pct = round(100 * timing["fetch_s_total"] / worker_budget_s, 1) if worker_budget_s else None
|
||||||
|
|
||||||
# A 0-frame run is a failure, not a 0-throughput result: the pipeline produced no batches (decode
|
# A 0-frame run is a failure, not a 0-throughput result: the pipeline produced no batches (decode
|
||||||
# error swallowed in workers, all batches dropped by drop_last, etc.). Exit non-zero so the job is
|
# error swallowed in workers, all batches dropped by drop_last, etc.). Exit non-zero so the job is
|
||||||
@@ -172,11 +268,22 @@ def main() -> None:
|
|||||||
"mode": args.mode,
|
"mode": args.mode,
|
||||||
"batch_size": args.batch_size,
|
"batch_size": args.batch_size,
|
||||||
"num_workers": args.num_workers,
|
"num_workers": args.num_workers,
|
||||||
|
"prefetch_factor": args.prefetch_factor if args.num_workers > 0 else None,
|
||||||
"buffer_size": args.buffer_size,
|
"buffer_size": args.buffer_size,
|
||||||
|
"episode_pool_size": args.episode_pool_size,
|
||||||
|
"episodes_per_batch_mean": round(statistics.mean(episodes_per_batch), 1)
|
||||||
|
if episodes_per_batch
|
||||||
|
else None,
|
||||||
|
# Fraction of a batch that is distinct episodes; ~1.0 ≈ map-style uniform, low ≈ correlated.
|
||||||
|
"shuffle_randomness_frac": round(statistics.mean(episodes_per_batch) / args.batch_size, 3)
|
||||||
|
if episodes_per_batch
|
||||||
|
else None,
|
||||||
"num_cameras": len(meta.video_keys),
|
"num_cameras": len(meta.video_keys),
|
||||||
|
"image_shape": image_shape,
|
||||||
"fps": meta.fps,
|
"fps": meta.fps,
|
||||||
"device": str(device),
|
"device": str(device),
|
||||||
"video_decode_device": args.video_decode_device,
|
"video_decode_device": args.video_decode_device,
|
||||||
|
"peak_rss_gb": peak_rss_gb,
|
||||||
"frames_measured": frames,
|
"frames_measured": frames,
|
||||||
"first_batch_latency_s": round(first_batch_latency_s or float("nan"), 4),
|
"first_batch_latency_s": round(first_batch_latency_s or float("nan"), 4),
|
||||||
"frames_per_s_node": round(frames / steady_elapsed_s, 2) if steady_elapsed_s else 0.0,
|
"frames_per_s_node": round(frames / steady_elapsed_s, 2) if steady_elapsed_s else 0.0,
|
||||||
@@ -186,13 +293,23 @@ def main() -> None:
|
|||||||
else None,
|
else None,
|
||||||
"p95_sample_latency_ms": round(percentile(sample_latencies_ms, 95), 3),
|
"p95_sample_latency_ms": round(percentile(sample_latencies_ms, 95), 3),
|
||||||
"p99_sample_latency_ms": round(percentile(sample_latencies_ms, 99), 3),
|
"p99_sample_latency_ms": round(percentile(sample_latencies_ms, 99), 3),
|
||||||
|
"total_time_s": round(elapsed, 2),
|
||||||
|
"steady_time_s": round(steady_elapsed_s, 2),
|
||||||
"wallclock_s": round(elapsed, 2),
|
"wallclock_s": round(elapsed, 2),
|
||||||
|
"decode_s_total": timing["decode_s_total"],
|
||||||
|
"fetch_s_total": timing["fetch_s_total"],
|
||||||
|
"decode_pct_worker_time": decode_pct,
|
||||||
|
"fetch_pct_worker_time": fetch_pct,
|
||||||
"video_decoder_cache": cache_stats,
|
"video_decoder_cache": cache_stats,
|
||||||
}
|
}
|
||||||
|
|
||||||
out_dir = Path(args.out_dir)
|
out_dir = Path(args.out_dir)
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
tag = f"{args.source}_{args.mode}_bs{args.batch_size}_w{args.num_workers}_{args.video_decode_device}"
|
pool_tag = f"_ep{args.episode_pool_size}" if args.episode_pool_size else ""
|
||||||
|
tag = (
|
||||||
|
f"{args.source}_{args.mode}_bs{args.batch_size}_w{args.num_workers}"
|
||||||
|
f"_pf{args.prefetch_factor}{pool_tag}_{args.video_decode_device}"
|
||||||
|
)
|
||||||
(out_dir / f"{tag}.json").write_text(json.dumps(results, indent=2))
|
(out_dir / f"{tag}.json").write_text(json.dumps(results, indent=2))
|
||||||
flat = {k: (json.dumps(v) if isinstance(v, dict) else v) for k, v in results.items()}
|
flat = {k: (json.dumps(v) if isinstance(v, dict) else v) for k, v in results.items()}
|
||||||
with open(out_dir / f"{tag}.csv", "w", newline="") as f:
|
with open(out_dir / f"{tag}.csv", "w", newline="") as f:
|
||||||
|
|||||||
@@ -34,9 +34,14 @@ GPUS=${GPUS:-1}
|
|||||||
SERIAL=${SERIAL:-1} # 1 = run one job at a time (correct for bandwidth measurement)
|
SERIAL=${SERIAL:-1} # 1 = run one job at a time (correct for bandwidth measurement)
|
||||||
CPU_WORKERS=${CPU_WORKERS:-8}
|
CPU_WORKERS=${CPU_WORKERS:-8}
|
||||||
GPU_WORKERS=${GPU_WORKERS:-2} # low on purpose: each cuda worker holds a CUDA context + NVDEC session
|
GPU_WORKERS=${GPU_WORKERS:-2} # low on purpose: each cuda worker holds a CUDA context + NVDEC session
|
||||||
CPU_BUFFER=${CPU_BUFFER:-4000}
|
CPU_BUFFER=${CPU_BUFFER:-2000} # shuffle buffer dominates worker RAM (buffer_size x num_workers decoded frames)
|
||||||
GPU_BUFFER=${GPU_BUFFER:-1000} # smaller buffer bounds on-GPU frame memory
|
GPU_BUFFER=${GPU_BUFFER:-1000} # smaller buffer bounds on-GPU frame memory
|
||||||
|
# Cap concurrently-open stream shards. Each open shard holds ~one parquet row group in RAM, and reading
|
||||||
|
# from an hf:// bucket buffers ~5x more per shard than hf:// datasets (~1.2GB vs ~0.26GB). So for bucket
|
||||||
|
# sources default to num_workers (1 shard/worker); hub keeps 16. Override globally with MAX_SHARDS.
|
||||||
|
MAX_SHARDS=${MAX_SHARDS:-}
|
||||||
BATCH_SIZE=${BATCH_SIZE:-64}
|
BATCH_SIZE=${BATCH_SIZE:-64}
|
||||||
|
PREFETCH=${PREFETCH:-2} # DataLoader batches prefetched per worker (higher = more throughput + RAM)
|
||||||
RUN=${RUN:-python}
|
RUN=${RUN:-python}
|
||||||
# CONDA_ENV=<name> runs each job via `conda run -n <name>` (no activation needed inside the dash --wrap;
|
# CONDA_ENV=<name> runs each job via `conda run -n <name>` (no activation needed inside the dash --wrap;
|
||||||
# --no-capture-output streams logs live). Set this to a conda env that has a MODERN torchcodec (>=0.11)
|
# --no-capture-output streams logs live). Set this to a conda env that has a MODERN torchcodec (>=0.11)
|
||||||
@@ -69,6 +74,7 @@ for SOURCE in $SOURCES; do
|
|||||||
for MODE in $MODES; do
|
for MODE in $MODES; do
|
||||||
for DECODE in $DECODES; do
|
for DECODE in $DECODES; do
|
||||||
if [ "$DECODE" = cpu ]; then W=$CPU_WORKERS; B=$CPU_BUFFER; else W=$GPU_WORKERS; B=$GPU_BUFFER; fi
|
if [ "$DECODE" = cpu ]; then W=$CPU_WORKERS; B=$CPU_BUFFER; else W=$GPU_WORKERS; B=$GPU_BUFFER; fi
|
||||||
|
if [ -n "$MAX_SHARDS" ]; then S=$MAX_SHARDS; elif [ "$SOURCE" = hub ]; then S=16; else S=$W; fi
|
||||||
# Run strictly after the previous job so only one job touches the network at a time.
|
# Run strictly after the previous job so only one job touches the network at a time.
|
||||||
DEPFLAG=""
|
DEPFLAG=""
|
||||||
if [ "$SERIAL" = 1 ] && [ -n "$prev_jid" ]; then DEPFLAG="--dependency=afterany:$prev_jid"; fi
|
if [ "$SERIAL" = 1 ] && [ -n "$prev_jid" ]; then DEPFLAG="--dependency=afterany:$prev_jid"; fi
|
||||||
@@ -83,7 +89,8 @@ for SOURCE in $SOURCES; do
|
|||||||
$RUN benchmarks/streaming/benchmark_streaming.py \
|
$RUN benchmarks/streaming/benchmark_streaming.py \
|
||||||
--repo_id $REPO_ID $ROOTFLAG \
|
--repo_id $REPO_ID $ROOTFLAG \
|
||||||
--mode $MODE --source $SOURCE --video_decode_device $DECODE \
|
--mode $MODE --source $SOURCE --video_decode_device $DECODE \
|
||||||
--batch_size $BATCH_SIZE --num_workers $W --buffer_size $B \
|
--batch_size $BATCH_SIZE --num_workers $W --prefetch_factor $PREFETCH \
|
||||||
|
--buffer_size $B --max_num_shards $S \
|
||||||
--num_batches $NUM_BATCHES --out_dir $OUT_DIR")
|
--num_batches $NUM_BATCHES --out_dir $OUT_DIR")
|
||||||
jid=${jid%%;*} # strip ';cluster' suffix on federated setups
|
jid=${jid%%;*} # strip ';cluster' suffix on federated setups
|
||||||
echo "submitted job $jid bench_${SOURCE}_${MODE}_${DECODE}${DEPFLAG:+ (after $prev_jid)}"
|
echo "submitted job $jid bench_${SOURCE}_${MODE}_${DECODE}${DEPFLAG:+ (after $prev_jid)}"
|
||||||
@@ -96,5 +103,5 @@ done
|
|||||||
echo
|
echo
|
||||||
echo "Submitted $n jobs ($([ "$SERIAL" = 1 ] && echo 'serial chain — one runs at a time' || echo 'parallel'))."
|
echo "Submitted $n jobs ($([ "$SERIAL" = 1 ] && echo 'serial chain — one runs at a time' || echo 'parallel'))."
|
||||||
echo "Watch: squeue -u \$USER (later jobs show reason '(Dependency)' until their turn)"
|
echo "Watch: squeue -u \$USER (later jobs show reason '(Dependency)' until their turn)"
|
||||||
echo "Results: $OUT_DIR/<source>_<mode>_bs${BATCH_SIZE}_w<workers>_<decode>.{json,csv}"
|
echo "Results: $OUT_DIR/<source>_<mode>_bs${BATCH_SIZE}_w<workers>_pf<prefetch>_<decode>.{json,csv}"
|
||||||
echo "Summarize when done: $RUN benchmarks/streaming/summarize_results.py $OUT_DIR"
|
echo "Summarize when done: $RUN benchmarks/streaming/summarize_results.py $OUT_DIR"
|
||||||
|
|||||||
@@ -945,8 +945,17 @@ def _write_parquet(df: pd.DataFrame, path: Path, meta: LeRobotDatasetMetadata) -
|
|||||||
ep_dataset = embed_images(ep_dataset)
|
ep_dataset = embed_images(ep_dataset)
|
||||||
|
|
||||||
table = ep_dataset.with_format("arrow")[:]
|
table = ep_dataset.with_format("arrow")[:]
|
||||||
writer = pq.ParquetWriter(path, schema=table.schema, compression="snappy", use_dictionary=True)
|
# Emit several row groups with a page index instead of one giant row group. A single row group forces
|
||||||
writer.write_table(table)
|
# streaming readers to materialize the whole file's columns per open shard; with random-access streaming
|
||||||
|
# (shuffle + delta windows) across many workers x shards that dominates RAM. Targeting ~32MB-uncompressed
|
||||||
|
# groups bounds per-shard memory while keeping groups large enough to scan
|
||||||
|
# efficiently; the page index lets readers skip to the pages they need.
|
||||||
|
target_row_group_bytes = 32 * 1024 * 1024
|
||||||
|
row_group_size = max(1, min(table.num_rows, table.num_rows * target_row_group_bytes // max(table.nbytes, 1)))
|
||||||
|
writer = pq.ParquetWriter(
|
||||||
|
path, schema=table.schema, compression="snappy", use_dictionary=True, write_page_index=True
|
||||||
|
)
|
||||||
|
writer.write_table(table, row_group_size=row_group_size)
|
||||||
writer.close()
|
writer.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from collections.abc import Callable, Generator, Iterable, Iterator
|
from collections.abc import Callable, Generator, Iterable, Iterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -263,6 +264,7 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
|
|||||||
video_decoder_cache_size: int | None = None,
|
video_decoder_cache_size: int | None = None,
|
||||||
data_files_root: str | None = None,
|
data_files_root: str | None = None,
|
||||||
video_decode_device: str = "cpu",
|
video_decode_device: str = "cpu",
|
||||||
|
episode_pool_size: int | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize a StreamingLeRobotDataset.
|
"""Initialize a StreamingLeRobotDataset.
|
||||||
|
|
||||||
@@ -326,12 +328,18 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
|
|||||||
self.video_decoder_cache_size = video_decoder_cache_size
|
self.video_decoder_cache_size = video_decoder_cache_size
|
||||||
self.data_files_root = data_files_root.rstrip("/") if data_files_root else None
|
self.data_files_root = data_files_root.rstrip("/") if data_files_root else None
|
||||||
self.video_decode_device = video_decode_device
|
self.video_decode_device = video_decode_device
|
||||||
|
# A3 shuffle: when set, iterate by keeping this many full episodes live in memory and sampling
|
||||||
|
# frames uniformly across them (mixing radius = episode_pool_size episodes), instead of the
|
||||||
|
# default per-shard reservoir. Tabular deltas become exact in-episode index lookups (no
|
||||||
|
# Backtrackable). Trades video-decode locality for much stronger shuffle.
|
||||||
|
self.episode_pool_size = episode_pool_size
|
||||||
|
|
||||||
# We cache the video decoders to avoid re-initializing them at each frame (avoiding a ~10x slowdown)
|
# We cache the video decoders to avoid re-initializing them at each frame (avoiding a ~10x slowdown)
|
||||||
self.video_decoder_cache = None
|
self.video_decoder_cache = None
|
||||||
# Shared [hits, misses, evictions] tensor so DataLoader workers aggregate decoder-cache stats into
|
# Shared [hits, misses, evictions, decode_ns, fetch_ns] tensor so DataLoader workers aggregate
|
||||||
# one place the main process can read after iteration (see video_decoder_cache_stats()).
|
# decoder-cache stats and component timings into one place the main process can read after
|
||||||
self._cache_counters = torch.zeros(3, dtype=torch.int64).share_memory_()
|
# iteration (see video_decoder_cache_stats() / timing_stats()).
|
||||||
|
self._cache_counters = torch.zeros(5, dtype=torch.int64).share_memory_()
|
||||||
# Resume state captured by load_state_dict() and consumed at the next __iter__.
|
# Resume state captured by load_state_dict() and consumed at the next __iter__.
|
||||||
self._resume_state: dict | None = None
|
self._resume_state: dict | None = None
|
||||||
|
|
||||||
@@ -494,6 +502,14 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
|
|||||||
shard.load_state_dict(resume["shards"][str(idx)])
|
shard.load_state_dict(resume["shards"][str(idx)])
|
||||||
self._shards[idx] = shard
|
self._shards[idx] = shard
|
||||||
|
|
||||||
|
# A3 episode-pool shuffle (opt-in): sample frames uniformly across many fully-loaded episodes.
|
||||||
|
if self.episode_pool_size:
|
||||||
|
shard_iters = {
|
||||||
|
idx: iter(self._shards[idx]) for idx in shard_indices if idx not in self._exhausted
|
||||||
|
}
|
||||||
|
yield from self._iter_episode_pool(shard_iters, rng)
|
||||||
|
return
|
||||||
|
|
||||||
buffer_indices_generator = self._iter_random_indices(rng, self.buffer_size)
|
buffer_indices_generator = self._iter_random_indices(rng, self.buffer_size)
|
||||||
|
|
||||||
idx_to_backtrack_dataset = {
|
idx_to_backtrack_dataset = {
|
||||||
@@ -506,6 +522,8 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
|
|||||||
# the logic is to add 2 levels of randomness:
|
# the logic is to add 2 levels of randomness:
|
||||||
# (1) sample one shard at random from the ones available, and
|
# (1) sample one shard at random from the ones available, and
|
||||||
# (2) sample one frame from the shard sampled at (1)
|
# (2) sample one frame from the shard sampled at (1)
|
||||||
|
# Buffer entries are (partial, video_spec): undecoded tabular rows. Video is decoded by
|
||||||
|
# _attach_video only when a sample leaves the buffer, keeping peak memory ~prefetch-bounded.
|
||||||
frames_buffer = []
|
frames_buffer = []
|
||||||
while available_shards := list(idx_to_backtrack_dataset.keys()):
|
while available_shards := list(idx_to_backtrack_dataset.keys()):
|
||||||
shard_key = next(self._infinite_generator_over_elements(rng, available_shards))
|
shard_key = next(self._infinite_generator_over_elements(rng, available_shards))
|
||||||
@@ -515,7 +533,7 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
|
|||||||
for frame in self.make_frame(backtrack_dataset):
|
for frame in self.make_frame(backtrack_dataset):
|
||||||
if len(frames_buffer) == self.buffer_size:
|
if len(frames_buffer) == self.buffer_size:
|
||||||
i = next(buffer_indices_generator) # samples a element from the buffer
|
i = next(buffer_indices_generator) # samples a element from the buffer
|
||||||
yield frames_buffer[i]
|
yield self._attach_video(*frames_buffer[i]) # decode just-in-time on the way out
|
||||||
frames_buffer[i] = frame
|
frames_buffer[i] = frame
|
||||||
else:
|
else:
|
||||||
frames_buffer.append(frame)
|
frames_buffer.append(frame)
|
||||||
@@ -527,9 +545,10 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
|
|||||||
del idx_to_backtrack_dataset[shard_key] # Remove exhausted shard, onto another shard
|
del idx_to_backtrack_dataset[shard_key] # Remove exhausted shard, onto another shard
|
||||||
self._exhausted.add(shard_key)
|
self._exhausted.add(shard_key)
|
||||||
|
|
||||||
# Once shards are all exhausted, shuffle the buffer and yield the remaining frames
|
# Once shards are all exhausted, shuffle the buffer and yield the remaining frames (decoding each).
|
||||||
rng.shuffle(frames_buffer)
|
rng.shuffle(frames_buffer)
|
||||||
yield from frames_buffer
|
for partial, video_spec in frames_buffer:
|
||||||
|
yield self._attach_video(partial, video_spec)
|
||||||
|
|
||||||
def state_dict(self) -> dict:
|
def state_dict(self) -> dict:
|
||||||
"""Capture resume state: per-shard HF stream position, exhausted shards, and RNG state.
|
"""Capture resume state: per-shard HF stream position, exhausted shards, and RNG state.
|
||||||
@@ -557,7 +576,7 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
|
|||||||
hits/misses/evictions over every worker. Counts are lock-free across processes, so treat them as
|
hits/misses/evictions over every worker. Counts are lock-free across processes, so treat them as
|
||||||
approximate; the ``hit_rate`` ratio is preserved.
|
approximate; the ``hit_rate`` ratio is preserved.
|
||||||
"""
|
"""
|
||||||
hits, misses, evictions = (int(x) for x in self._cache_counters.tolist())
|
hits, misses, evictions = (int(x) for x in self._cache_counters[:3].tolist())
|
||||||
total = hits + misses
|
total = hits + misses
|
||||||
return {
|
return {
|
||||||
"hits": hits,
|
"hits": hits,
|
||||||
@@ -566,6 +585,14 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
|
|||||||
"hit_rate": round(hits / total, 4) if total else 0.0,
|
"hit_rate": round(hits / total, 4) if total else 0.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def timing_stats(self) -> dict[str, float]:
|
||||||
|
"""Cumulative seconds spent in video decode and parquet/sample fetch, summed across DataLoader
|
||||||
|
workers via the shared counter tensor. These overlap in wall-clock (workers run in parallel), so
|
||||||
|
compare them to ``num_workers x wallclock`` — not to wallclock directly — to get time fractions.
|
||||||
|
"""
|
||||||
|
decode_ns, fetch_ns = (int(x) for x in self._cache_counters[3:5].tolist())
|
||||||
|
return {"decode_s_total": round(decode_ns / 1e9, 2), "fetch_s_total": round(fetch_ns / 1e9, 2)}
|
||||||
|
|
||||||
def _get_window_steps(
|
def _get_window_steps(
|
||||||
self, delta_timestamps: dict[str, list[float]] | None = None, dynamic_bounds: bool = False
|
self, delta_timestamps: dict[str, list[float]] | None = None, dynamic_bounds: bool = False
|
||||||
) -> tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
@@ -640,8 +667,17 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
|
|||||||
return padding_mask
|
return padding_mask
|
||||||
|
|
||||||
def make_frame(self, dataset_iterator: Backtrackable) -> Generator:
|
def make_frame(self, dataset_iterator: Backtrackable) -> Generator:
|
||||||
"""Makes a frame starting from a dataset iterator"""
|
"""Build a frame's tabular content and defer the video decode.
|
||||||
|
|
||||||
|
Yields a ``(partial, video_spec)`` pair: ``partial`` holds all non-video fields (tabular
|
||||||
|
features, tabular delta windows + padding, task); ``video_spec`` carries what
|
||||||
|
:meth:`_attach_video` needs to decode the camera frames just-in-time at yield time. Deferring
|
||||||
|
the decode keeps the shuffle reservoir holding ~KB tabular rows instead of multi-MB decoded
|
||||||
|
images, which collapses peak memory.
|
||||||
|
"""
|
||||||
|
_t0 = time.perf_counter_ns()
|
||||||
item = next(dataset_iterator)
|
item = next(dataset_iterator)
|
||||||
|
self._cache_counters[4] += time.perf_counter_ns() - _t0 # parquet/sample fetch time
|
||||||
item = item_to_torch(item)
|
item = item_to_torch(item)
|
||||||
|
|
||||||
updates = [] # list of "updates" to apply to the item retrieved from hf_dataset (w/o camera features)
|
updates = [] # list of "updates" to apply to the item retrieved from hf_dataset (w/o camera features)
|
||||||
@@ -673,29 +709,16 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
|
|||||||
updates.append(query_result)
|
updates.append(query_result)
|
||||||
updates.append(padding)
|
updates.append(padding)
|
||||||
|
|
||||||
# Load video frames, when needed
|
# Defer the (memory-heavy) video decode: capture only what _attach_video needs to decode the
|
||||||
|
# camera frames at yield time, so the shuffle buffer holds ~KB tabular rows, not MB of pixels.
|
||||||
|
video_spec = None
|
||||||
if len(self.meta.video_keys) > 0:
|
if len(self.meta.video_keys) > 0:
|
||||||
original_timestamps = self._make_timestamps_from_indices(current_ts, self.delta_indices)
|
original_timestamps = self._make_timestamps_from_indices(current_ts, self.delta_indices)
|
||||||
|
# Some timestamps might not be available considering the episode's boundaries
|
||||||
# Some timestamps might not result available considering the episode's boundaries
|
|
||||||
query_timestamps = self._get_query_timestamps(
|
query_timestamps = self._get_query_timestamps(
|
||||||
current_ts, self.delta_indices, episode_boundaries_ts
|
current_ts, self.delta_indices, episode_boundaries_ts
|
||||||
)
|
)
|
||||||
video_frames = self._query_videos(query_timestamps, ep_idx)
|
video_spec = (query_timestamps, original_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
|
|
||||||
)
|
|
||||||
updates.append(padding_mask)
|
|
||||||
|
|
||||||
result = item.copy()
|
result = item.copy()
|
||||||
for update in updates:
|
for update in updates:
|
||||||
@@ -703,7 +726,151 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
|
|||||||
|
|
||||||
result["task"] = self.meta.tasks.iloc[item["task_index"]].name
|
result["task"] = self.meta.tasks.iloc[item["task_index"]].name
|
||||||
|
|
||||||
yield result
|
yield result, video_spec
|
||||||
|
|
||||||
|
def _attach_video(self, result: dict, video_spec: tuple | None) -> dict:
|
||||||
|
"""Decode the camera frames for a buffered sample and merge them in (counterpart to make_frame).
|
||||||
|
|
||||||
|
This is where torchcodec decode actually runs — on one sample at a time as it leaves the shuffle
|
||||||
|
buffer — so peak memory is bounded by the prefetch queue rather than ``buffer_size`` decoded frames.
|
||||||
|
"""
|
||||||
|
if video_spec is None:
|
||||||
|
return result
|
||||||
|
query_timestamps, original_timestamps, ep_idx = video_spec
|
||||||
|
video_frames = self._query_videos(query_timestamps, ep_idx)
|
||||||
|
if self.image_transforms is not None:
|
||||||
|
for cam in self.meta.camera_keys:
|
||||||
|
video_frames[cam] = self.image_transforms(video_frames[cam])
|
||||||
|
result.update(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
|
||||||
|
)
|
||||||
|
result.update(padding_mask)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _ep_id(raw_item: dict) -> int:
|
||||||
|
"""Episode index of a raw (pre-torch) HF stream row, coerced to a plain int."""
|
||||||
|
return int(np.asarray(raw_item["episode_index"]).reshape(-1)[0])
|
||||||
|
|
||||||
|
def _read_one_episode(self, sid: int, shard_iters: dict, carry: dict) -> list[dict] | None:
|
||||||
|
"""Read one full episode (contiguous rows) from a shard iterator, or None if exhausted.
|
||||||
|
|
||||||
|
Episodes are contiguous in the stream, so we read until ``episode_index`` changes and stash the
|
||||||
|
first row of the next episode in ``carry`` to start the following read.
|
||||||
|
"""
|
||||||
|
it = shard_iters[sid]
|
||||||
|
first = carry[sid]
|
||||||
|
carry[sid] = None
|
||||||
|
if first is None:
|
||||||
|
first = next(it, None)
|
||||||
|
if first is None:
|
||||||
|
return None
|
||||||
|
ep = self._ep_id(first)
|
||||||
|
rows = [first]
|
||||||
|
for row in it:
|
||||||
|
if self._ep_id(row) != ep:
|
||||||
|
carry[sid] = row # belongs to the next episode; start there next time
|
||||||
|
break
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
def _make_frame_from_episode(self, ep_rows: list[dict], p: int) -> tuple[dict, tuple | None]:
|
||||||
|
"""Build ``(partial, video_spec)`` for frame ``p`` of a fully-loaded episode (A3).
|
||||||
|
|
||||||
|
All temporal neighbors live in ``ep_rows``, so tabular delta windows are exact index lookups
|
||||||
|
with correct episode-boundary padding — no Backtrackable, no lookahead pre-read. Video is still
|
||||||
|
decoded just-in-time by :meth:`_attach_video`.
|
||||||
|
"""
|
||||||
|
item = ep_rows[p]
|
||||||
|
ep_idx = item["episode_index"]
|
||||||
|
current_ts = float(item["timestamp"])
|
||||||
|
length = len(ep_rows)
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
if self.delta_indices is not None:
|
||||||
|
query_result, padding = {}, {}
|
||||||
|
for key, deltas in self.delta_indices.items():
|
||||||
|
if key in self.meta.video_keys:
|
||||||
|
continue # visual frames are decoded separately
|
||||||
|
frames, is_pad = [], []
|
||||||
|
for d in deltas:
|
||||||
|
q = p + d
|
||||||
|
clamped = min(max(q, 0), length - 1) # out-of-episode neighbors pad to the boundary
|
||||||
|
frames.append(ep_rows[clamped][key])
|
||||||
|
is_pad.append(q != clamped)
|
||||||
|
query_result[key] = torch.stack(frames)
|
||||||
|
padding[f"{key}_is_pad"] = torch.BoolTensor(is_pad)
|
||||||
|
updates.append(query_result)
|
||||||
|
updates.append(padding)
|
||||||
|
|
||||||
|
video_spec = None
|
||||||
|
if len(self.meta.video_keys) > 0:
|
||||||
|
episode_boundaries_ts = {
|
||||||
|
key: (
|
||||||
|
0.0,
|
||||||
|
self.meta.episodes[ep_idx][f"videos/{key}/to_timestamp"]
|
||||||
|
- self.meta.episodes[ep_idx][f"videos/{key}/from_timestamp"],
|
||||||
|
)
|
||||||
|
for key in self.meta.video_keys
|
||||||
|
}
|
||||||
|
original_timestamps = self._make_timestamps_from_indices(current_ts, self.delta_indices)
|
||||||
|
query_timestamps = self._get_query_timestamps(
|
||||||
|
current_ts, self.delta_indices, episode_boundaries_ts
|
||||||
|
)
|
||||||
|
video_spec = (query_timestamps, original_timestamps, ep_idx)
|
||||||
|
|
||||||
|
result = item.copy()
|
||||||
|
for update in updates:
|
||||||
|
result.update(update)
|
||||||
|
result["task"] = self.meta.tasks.iloc[item["task_index"]].name
|
||||||
|
return result, video_spec
|
||||||
|
|
||||||
|
def _iter_episode_pool(self, shard_iters: dict, rng: np.random.Generator) -> Iterator[dict]:
|
||||||
|
"""A3 shuffle: keep ``episode_pool_size`` full episodes live and sample frames uniformly across
|
||||||
|
them. Each episode costs ~one sequential read (IO-cheap); the mixing radius is the pool size.
|
||||||
|
|
||||||
|
``tickets`` holds one (slot, frame_pos) entry per live, not-yet-emitted frame; swap-remove gives
|
||||||
|
O(1) uniform sampling without replacement. When an episode drains it is evicted and a fresh one
|
||||||
|
is read in, keeping the pool full.
|
||||||
|
"""
|
||||||
|
carry = {sid: None for sid in shard_iters}
|
||||||
|
live = set(shard_iters)
|
||||||
|
pool: dict[int, dict] = {} # slot -> {"rows": [...], "remaining": int}
|
||||||
|
tickets: list[tuple[int, int]] = []
|
||||||
|
next_slot = 0
|
||||||
|
|
||||||
|
def load_episode() -> bool:
|
||||||
|
nonlocal next_slot
|
||||||
|
while live:
|
||||||
|
sid = int(rng.choice(tuple(live)))
|
||||||
|
rows = self._read_one_episode(sid, shard_iters, carry)
|
||||||
|
if rows is None:
|
||||||
|
live.discard(sid)
|
||||||
|
continue
|
||||||
|
ep_rows = [item_to_torch(r) for r in rows]
|
||||||
|
pool[next_slot] = {"rows": ep_rows, "remaining": len(ep_rows)}
|
||||||
|
tickets.extend((next_slot, p) for p in range(len(ep_rows)))
|
||||||
|
next_slot += 1
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
while len(pool) < self.episode_pool_size and load_episode():
|
||||||
|
pass
|
||||||
|
|
||||||
|
while tickets:
|
||||||
|
i = int(rng.integers(len(tickets)))
|
||||||
|
slot, p = tickets[i]
|
||||||
|
tickets[i] = tickets[-1] # swap-remove: O(1) sampling without replacement
|
||||||
|
tickets.pop()
|
||||||
|
partial, video_spec = self._make_frame_from_episode(pool[slot]["rows"], p)
|
||||||
|
yield self._attach_video(partial, video_spec)
|
||||||
|
pool[slot]["remaining"] -= 1
|
||||||
|
if pool[slot]["remaining"] == 0:
|
||||||
|
del pool[slot] # free the episode's frames
|
||||||
|
load_episode() # refill to keep the pool (and mixing radius) full
|
||||||
|
|
||||||
def _get_query_timestamps(
|
def _get_query_timestamps(
|
||||||
self,
|
self,
|
||||||
@@ -745,6 +912,7 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
|
|||||||
else:
|
else:
|
||||||
root = self.root
|
root = self.root
|
||||||
video_path = f"{root}/{self.meta.get_video_file_path(ep_idx, video_key)}"
|
video_path = f"{root}/{self.meta.get_video_file_path(ep_idx, video_key)}"
|
||||||
|
_t0 = time.perf_counter_ns()
|
||||||
frames = decode_video_frames_torchcodec(
|
frames = decode_video_frames_torchcodec(
|
||||||
video_path,
|
video_path,
|
||||||
shifted_query_ts,
|
shifted_query_ts,
|
||||||
@@ -752,6 +920,7 @@ class StreamingLeRobotDataset(torch.utils.data.IterableDataset):
|
|||||||
decoder_cache=self.video_decoder_cache,
|
decoder_cache=self.video_decoder_cache,
|
||||||
return_uint8=self._return_uint8,
|
return_uint8=self._return_uint8,
|
||||||
)
|
)
|
||||||
|
self._cache_counters[3] += time.perf_counter_ns() - _t0 # video decode time
|
||||||
|
|
||||||
item[video_key] = frames.squeeze(0) if len(query_ts) == 1 else frames
|
item[video_key] = frames.squeeze(0) if len(query_ts) == 1 else frames
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import queue
|
|||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import warnings
|
import warnings
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
@@ -47,6 +48,92 @@ from lerobot.utils.import_utils import get_safe_default_video_backend
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_REMOTE_IO_MAX_RETRIES = 5
|
||||||
|
"""Retry budget for transient hf:// / fsspec / httpx transport errors during streaming video decode.
|
||||||
|
|
||||||
|
Streaming a dataset from an HF bucket/CDN issues many small range requests and occasionally hits a
|
||||||
|
transient transport failure (timeout, dropped connection, 408/5xx). The right response is to rebuild
|
||||||
|
the connection and retry rather than crash the DataLoader worker. Override via
|
||||||
|
``LEROBOT_REMOTE_IO_MAX_RETRIES``; set to ``0`` to disable retries (fail fast).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Transient transport failures from the hf:// -> fsspec -> httpx stack. We match on text because the
|
||||||
|
# concrete exception types live in optional deps (httpx, huggingface_hub) and vary across versions.
|
||||||
|
# "client has been closed" is the important one: once a shared httpx client is closed by a single
|
||||||
|
# failed read, every subsequent read in that worker fails until the fsspec instance cache is cleared.
|
||||||
|
_RETRYABLE_TRANSPORT_FRAGMENTS = (
|
||||||
|
"client has been closed",
|
||||||
|
"server disconnected",
|
||||||
|
"remoteprotocolerror",
|
||||||
|
"unexpected_eof",
|
||||||
|
"eof occurred in violation of protocol",
|
||||||
|
"connection reset",
|
||||||
|
"connection aborted",
|
||||||
|
"connection broken",
|
||||||
|
"incompleteread",
|
||||||
|
"read operation timed out",
|
||||||
|
"timed out",
|
||||||
|
"request time-out",
|
||||||
|
"408",
|
||||||
|
"502",
|
||||||
|
"503",
|
||||||
|
"504",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_io_max_retries() -> int:
|
||||||
|
raw = os.environ.get("LEROBOT_REMOTE_IO_MAX_RETRIES")
|
||||||
|
if raw is None:
|
||||||
|
return DEFAULT_REMOTE_IO_MAX_RETRIES
|
||||||
|
try:
|
||||||
|
return max(0, int(raw))
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"LEROBOT_REMOTE_IO_MAX_RETRIES must be an integer; got {raw!r}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _is_retryable_transport_error(exc: BaseException) -> bool:
|
||||||
|
"""True if ``exc`` looks like a transient remote-IO failure worth retrying (vs a real bug)."""
|
||||||
|
text = f"{type(exc).__name__}: {exc}".lower()
|
||||||
|
return any(fragment in text for fragment in _RETRYABLE_TRANSPORT_FRAGMENTS)
|
||||||
|
|
||||||
|
|
||||||
|
def _recover_remote_io(decoder_cache: "VideoDecoderCache", video_path: str) -> None:
|
||||||
|
"""Drop the dead decoder for ``video_path`` and force a fresh fsspec client before a retry.
|
||||||
|
|
||||||
|
fsspec caches one filesystem instance per (protocol, args), and that instance owns the httpx
|
||||||
|
client a failed read may have closed. Clearing the instance cache makes the next ``fsspec.open``
|
||||||
|
build a new client, which is what breaks the "client has been closed" cascade.
|
||||||
|
"""
|
||||||
|
decoder_cache.invalidate(video_path)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
fsspec.AbstractFileSystem.clear_instance_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_remote_io(operation, on_retry, max_retries: int, base_delay: float = 0.5, max_delay: float = 10.0):
|
||||||
|
"""Run ``operation()``, retrying transient transport errors after ``on_retry()`` + capped backoff.
|
||||||
|
|
||||||
|
Non-transport errors (decode / index / timestamp issues) propagate immediately so real bugs are
|
||||||
|
never masked by retries.
|
||||||
|
"""
|
||||||
|
attempt = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
return operation()
|
||||||
|
except Exception as e:
|
||||||
|
if attempt >= max_retries or not _is_retryable_transport_error(e):
|
||||||
|
raise
|
||||||
|
attempt += 1
|
||||||
|
logger.warning(
|
||||||
|
"Transient remote-IO error (%s: %s); rebuilding connection and retrying (%d/%d).",
|
||||||
|
type(e).__name__,
|
||||||
|
e,
|
||||||
|
attempt,
|
||||||
|
max_retries,
|
||||||
|
)
|
||||||
|
on_retry()
|
||||||
|
time.sleep(min(base_delay * 2 ** (attempt - 1), max_delay))
|
||||||
|
|
||||||
|
|
||||||
def decode_video_frames(
|
def decode_video_frames(
|
||||||
video_path: Path | str,
|
video_path: Path | str,
|
||||||
timestamps: list[float],
|
timestamps: list[float],
|
||||||
@@ -296,7 +383,11 @@ class VideoDecoderCache:
|
|||||||
self.misses += 1
|
self.misses += 1
|
||||||
if self._counters is not None:
|
if self._counters is not None:
|
||||||
self._counters[1] += 1
|
self._counters[1] += 1
|
||||||
file_handle = fsspec.open(video_path).__enter__()
|
# Bound per-handle buffering: with many decoders kept open at once (one per camera per active
|
||||||
|
# shard, across all workers), the default fsspec read cache balloons RAM on remote backends
|
||||||
|
# like hf:// buckets. A small readahead cache caps each handle's footprint without hurting the
|
||||||
|
# mostly-sequential reads torchcodec issues.
|
||||||
|
file_handle = fsspec.open(video_path, cache_type="readahead", block_size=2**20).__enter__()
|
||||||
try:
|
try:
|
||||||
decoder = VideoDecoder(file_handle, seek_mode="approximate", device=self.device)
|
decoder = VideoDecoder(file_handle, seek_mode="approximate", device=self.device)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -326,6 +417,18 @@ class VideoDecoderCache:
|
|||||||
file_handle.close()
|
file_handle.close()
|
||||||
self._cache.clear()
|
self._cache.clear()
|
||||||
|
|
||||||
|
def invalidate(self, video_path: str) -> None:
|
||||||
|
"""Drop and close the cached decoder for a path whose connection went bad.
|
||||||
|
|
||||||
|
After a transport error the cached ``fsspec`` handle (and the httpx client behind it) is dead;
|
||||||
|
removing the entry forces the next :meth:`get_decoder` to re-open a fresh handle.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
entry = self._cache.pop(str(video_path), None)
|
||||||
|
if entry is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
entry[1].close()
|
||||||
|
|
||||||
def size(self) -> int:
|
def size(self) -> int:
|
||||||
"""Return the number of cached decoders."""
|
"""Return the number of cached decoders."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -381,20 +484,24 @@ def decode_video_frames_torchcodec(
|
|||||||
if decoder_cache is None:
|
if decoder_cache is None:
|
||||||
decoder_cache = _default_decoder_cache
|
decoder_cache = _default_decoder_cache
|
||||||
|
|
||||||
# Use cached decoder instead of creating new one each time
|
def _decode_frames():
|
||||||
decoder = decoder_cache.get_decoder(str(video_path))
|
# Both opening the decoder and reading frames go over the network for hf:// paths, so wrap the
|
||||||
|
# whole unit: a transient transport error retries by dropping the dead handle and rebuilding
|
||||||
|
# the connection (see _retry_remote_io / _recover_remote_io) instead of killing the worker.
|
||||||
|
decoder = decoder_cache.get_decoder(str(video_path))
|
||||||
|
average_fps = decoder.metadata.average_fps
|
||||||
|
frame_indices = [round(ts * average_fps) for ts in timestamps]
|
||||||
|
return decoder.get_frames_at(indices=frame_indices)
|
||||||
|
|
||||||
|
frames_batch = _retry_remote_io(
|
||||||
|
_decode_frames,
|
||||||
|
on_retry=lambda: _recover_remote_io(decoder_cache, str(video_path)),
|
||||||
|
max_retries=_remote_io_max_retries(),
|
||||||
|
)
|
||||||
|
|
||||||
loaded_ts = []
|
loaded_ts = []
|
||||||
loaded_frames = []
|
loaded_frames = []
|
||||||
|
|
||||||
# get metadata for frame information
|
|
||||||
metadata = decoder.metadata
|
|
||||||
average_fps = metadata.average_fps
|
|
||||||
# convert timestamps to frame indices
|
|
||||||
frame_indices = [round(ts * average_fps) for ts in timestamps]
|
|
||||||
# retrieve frames based on indices
|
|
||||||
frames_batch = decoder.get_frames_at(indices=frame_indices)
|
|
||||||
|
|
||||||
for frame, pts in zip(frames_batch.data, frames_batch.pts_seconds, strict=True):
|
for frame, pts in zip(frames_batch.data, frames_batch.pts_seconds, strict=True):
|
||||||
loaded_frames.append(frame)
|
loaded_frames.append(frame)
|
||||||
loaded_ts.append(pts.item())
|
loaded_ts.append(pts.item())
|
||||||
|
|||||||
Reference in New Issue
Block a user