Spaces:
Running on Zero
Running on Zero
| # sharded_frame_dataset.py | |
| import json | |
| import os | |
| import bisect | |
| import random | |
| from collections import OrderedDict | |
| from pathlib import Path | |
| from typing import Sequence, List, Dict, Union, Mapping, Optional | |
| import torch | |
| import torch.distributed as dist | |
| from torch.utils.data import Dataset | |
| class ShardedFrameDataset(Dataset): | |
| """ | |
| Samples contiguous sequences from preprocessed shards across multiple roots: | |
| root/<task>/<task>_index.json with {"shard_name": num_frames, ...} | |
| root/<task>/*.pt with {"frames": (N, 3, H, W) uint8} | |
| Returns: (T, 3, H, W) float32 in [0,1], where T = seq_len. | |
| If iid_sampling=True, ignores idx and samples a random sequence from this | |
| rank's slice of shards. Each worker holds a "current" shard for | |
| samples_per_shard draws before picking a new one — this amortizes the cost | |
| of loading a shard from disk over many sequences and is the main lever for | |
| avoiding I/O-bound training on large datasets. | |
| cache_size controls how many shards are kept in memory (LRU eviction) per | |
| worker process. With samples_per_shard > 1, a small cache (4-8) suffices | |
| since most accesses go to the current shard. | |
| ddp_partition controls whether iid_sampling shards are partitioned across | |
| DDP ranks. Set to False for rank-0-only validation loaders so rank 0 can | |
| still see the full validation set. | |
| """ | |
| def __init__( | |
| self, | |
| outdirs: Union[str, Sequence[str]], | |
| tasks: Sequence[str] = (), | |
| seq_len: int = 16, | |
| iid_sampling: bool = True, | |
| cache_size: int = 8, | |
| samples_per_shard: int = 1, | |
| ddp_partition: bool = True, | |
| task_weights: Optional[Mapping[str, float]] = None, | |
| verbose: bool = True, | |
| return_task_idx: bool = False, | |
| ): | |
| super().__init__() | |
| assert outdirs is not None, "outdirs must be specified" | |
| if isinstance(outdirs, (str, Path)): | |
| self.outdirs = [str(outdirs)] | |
| else: | |
| self.outdirs = [str(p) for p in outdirs] | |
| self.tasks = list(tasks) | |
| self.seq_len = int(seq_len) | |
| self.iid_sampling = bool(iid_sampling) | |
| self._cache_size = max(1, int(cache_size)) | |
| self.samples_per_shard = max(1, int(samples_per_shard)) | |
| self.verbose = bool(verbose) | |
| self.return_task_idx = bool(return_task_idx) | |
| # Resolve optional per-task sampling weights. Mirrors WMDataset: a dict | |
| # {task_name: weight} sets relative task draw probabilities; tasks not | |
| # present get weight 0 (excluded from sampling). If None, fall back to | |
| # the legacy behavior where shard weight = num_starts, which makes | |
| # P(task) ∝ total valid_starts (short-trajectory domains get starved). | |
| if task_weights is not None: | |
| tw_list: List[float] = [] | |
| missing: List[str] = [] | |
| for t in self.tasks: | |
| if t in task_weights: | |
| w = float(task_weights[t]) | |
| if w < 0: | |
| raise ValueError(f"task_weights[{t!r}] is negative: {w}") | |
| tw_list.append(w) | |
| else: | |
| missing.append(t) | |
| tw_list.append(0.0) | |
| if missing and self.verbose: | |
| print(f"[ShardedFrameDataset] Warning: {len(missing)} tasks have no " | |
| f"task_weights entry and will be excluded from sampling " | |
| f"(e.g. {missing[:5]})") | |
| self.task_weights: Optional[List[float]] = tw_list | |
| else: | |
| self.task_weights = None | |
| self.shards: List[Dict] = [] | |
| self.cum_starts: List[int] = [] | |
| total_starts = 0 | |
| for root in self.outdirs: | |
| root = Path(root) | |
| for ti, task in enumerate(self.tasks): | |
| task_dir = root / task | |
| if not task_dir.exists(): | |
| continue | |
| index_path = task_dir / f"{task}_index.json" | |
| if index_path.exists(): | |
| # Fast path: read frame counts from the metadata index written by | |
| # preprocess_dataset.py — no tensor data is loaded at init time. | |
| with open(index_path) as f: | |
| index = json.load(f) | |
| for shard_name, num_frames in sorted(index.items()): | |
| path = task_dir / shard_name | |
| if not path.exists(): | |
| print(f"[ShardedFrameDataset] Shard {path} listed in index but missing, skipping") | |
| continue | |
| N = int(num_frames) | |
| if N < self.seq_len: | |
| print(f"[ShardedFrameDataset] Skipping {path} (N={N} < seq_len={self.seq_len})") | |
| continue | |
| num_starts = N - self.seq_len + 1 | |
| self.shards.append({"path": str(path), "num_frames": N, "num_starts": num_starts, "task_idx": ti}) | |
| total_starts += num_starts | |
| self.cum_starts.append(total_starts) | |
| else: | |
| # Slow fallback: load every shard to inspect its shape. | |
| # Run preprocess_dataset.py to generate index files and avoid this. | |
| print(f"[ShardedFrameDataset] No index for task={task} in {root}, scanning shards (slow)") | |
| for fname in sorted(os.listdir(task_dir)): | |
| if not fname.endswith(".pt"): | |
| continue | |
| path = task_dir / fname | |
| try: | |
| td = torch.load(path, map_location="cpu", weights_only=True) | |
| except Exception as e: | |
| print(f"[ShardedFrameDataset] Skipping shard {path} (load error): {e}") | |
| continue | |
| frames = td.get("frames", None) | |
| if not isinstance(frames, torch.Tensor): | |
| print(f"[ShardedFrameDataset] Skipping shard {path} (no 'frames' tensor)") | |
| continue | |
| if frames.ndim != 4 or frames.shape[1] != 3: | |
| print(f"[ShardedFrameDataset] Skipping shard {path} (unexpected shape {frames.shape})") | |
| continue | |
| N = int(frames.shape[0]) | |
| if N < self.seq_len: | |
| print(f"[ShardedFrameDataset] Skipping shard {path} (N={N} < seq_len={self.seq_len})") | |
| continue | |
| num_starts = N - self.seq_len + 1 | |
| self.shards.append({"path": str(path), "num_frames": N, "num_starts": num_starts, "task_idx": ti}) | |
| total_starts += num_starts | |
| self.cum_starts.append(total_starts) | |
| self.total_starts = total_starts | |
| # ---- DDP-aware sharding for iid_sampling ---- | |
| # When running under DDP with iid_sampling=True, partition shards across | |
| # ranks so each rank only samples from its own slice. This (a) makes the | |
| # "epoch" concept meaningful by removing cross-rank overlap, and (b) | |
| # shrinks each worker's shard pool by world_size, dramatically improving | |
| # per-worker LRU cache effectiveness. | |
| rank, world_size = 0, 1 | |
| if ( | |
| ddp_partition | |
| and self.iid_sampling | |
| and dist.is_available() | |
| and dist.is_initialized() | |
| ): | |
| rank = dist.get_rank() | |
| world_size = dist.get_world_size() | |
| if world_size > 1 and len(self.shards) >= world_size: | |
| # Strided (round-robin) partitioning rather than contiguous: shards | |
| # are appended in task order, so a contiguous slice would lock each | |
| # rank into a small set of tasks. Striding interleaves tasks across | |
| # ranks, so every rank (and therefore the rank-0 viz) sees a | |
| # task-diverse sample. | |
| self._iid_shard_indices = list(range(rank, len(self.shards), world_size)) | |
| else: | |
| self._iid_shard_indices = list(range(len(self.shards))) | |
| # Precompute selection weights. Default: proportional to num_starts so | |
| # the marginal distribution over sequences stays uniform across shards | |
| # of varying length — but this makes P(task) ∝ total valid_starts. | |
| # When task_weights is set, keep the intra-task distribution ∝ num_starts | |
| # (for cache locality) but rescale per-task totals to match task_weights. | |
| # Shards of tasks with weight 0 (or tasks absent from the dict) are | |
| # dropped from the sampling pool entirely. | |
| if self._iid_shard_indices: | |
| if self.task_weights is None: | |
| weights = [float(self.shards[i]["num_starts"]) for i in self._iid_shard_indices] | |
| else: | |
| # Group this rank's shards by task, compute per-task scale. | |
| task_to_total_ns: Dict[int, float] = {} | |
| for i in self._iid_shard_indices: | |
| ti = self.shards[i]["task_idx"] | |
| task_to_total_ns[ti] = task_to_total_ns.get(ti, 0.0) + float(self.shards[i]["num_starts"]) | |
| task_scale: Dict[int, float] = {} | |
| for ti, total_ns in task_to_total_ns.items(): | |
| w_task = float(self.task_weights[ti]) | |
| if w_task <= 0 or total_ns <= 0: | |
| task_scale[ti] = 0.0 | |
| else: | |
| task_scale[ti] = w_task / total_ns | |
| kept_indices: List[int] = [] | |
| weights = [] | |
| for i in self._iid_shard_indices: | |
| ti = self.shards[i]["task_idx"] | |
| s = task_scale.get(ti, 0.0) | |
| if s <= 0: | |
| continue | |
| kept_indices.append(i) | |
| weights.append(s * float(self.shards[i]["num_starts"])) | |
| self._iid_shard_indices = kept_indices | |
| total_w = float(sum(weights)) | |
| if total_w > 0: | |
| self._iid_shard_weights = [w / total_w for w in weights] | |
| else: | |
| self._iid_shard_weights = [] | |
| else: | |
| self._iid_shard_weights = [] | |
| if self.total_starts == 0: | |
| print("[ShardedFrameDataset] WARNING: no usable sequences found in outdirs") | |
| else: | |
| extra = "" | |
| if world_size > 1 and self.iid_sampling and ddp_partition: | |
| extra = ( | |
| f", ddp_rank={rank}/{world_size}, " | |
| f"local_shards={len(self._iid_shard_indices):,}" | |
| ) | |
| if self.task_weights is not None: | |
| nz = sum(1 for w in self.task_weights if w > 0) | |
| extra += f", task_weights active ({nz}/{len(self.tasks)} tasks with w>0)" | |
| print( | |
| f"[ShardedFrameDataset] roots={len(self.outdirs)}, " | |
| f"shards={len(self.shards):,}, seq_starts={self.total_starts:,}, " | |
| f"samples_per_shard={self.samples_per_shard}{extra}" | |
| ) | |
| # LRU shard cache: most-recently-used shards stay resident in memory. | |
| # With samples_per_shard > 1, the bulk of accesses hit the current | |
| # shard, so a small cache_size suffices. | |
| self._cache: OrderedDict[str, torch.Tensor] = OrderedDict() | |
| # Per-worker state for samples_per_shard. These attributes are inherited | |
| # by forked workers but mutated independently in each worker process, | |
| # so each worker maintains its own "currently active" shard. | |
| self._cur_shard_idx: Union[int, None] = None | |
| self._cur_shard_draws: int = 0 | |
| def __len__(self) -> int: | |
| return self.total_starts | |
| def _load_shard(self, path: str) -> torch.Tensor: | |
| if path in self._cache: | |
| self._cache.move_to_end(path) # mark as most-recently-used | |
| return self._cache[path] | |
| td = torch.load(path, map_location="cpu", weights_only=True) | |
| frames = td["frames"] | |
| if len(self._cache) >= self._cache_size: | |
| self._cache.popitem(last=False) # evict least-recently-used | |
| self._cache[path] = frames | |
| return frames | |
| def _map_global_start_to_shard(self, global_start: int) -> tuple[int, int]: | |
| # global_start in [0, total_starts) | |
| shard_idx = bisect.bisect_right(self.cum_starts, global_start) | |
| prev_cum = 0 if shard_idx == 0 else self.cum_starts[shard_idx - 1] | |
| start_idx_in_shard = global_start - prev_cum | |
| return shard_idx, start_idx_in_shard | |
| def __getitem__(self, idx: int) -> torch.Tensor: | |
| if self.total_starts == 0: | |
| raise IndexError("Empty dataset") | |
| if self.iid_sampling: | |
| # Reuse the current shard for samples_per_shard draws before picking | |
| # a new one. This is the key locality optimization that lets large | |
| # datasets train without saturating disk bandwidth. | |
| if ( | |
| self._cur_shard_idx is None | |
| or self._cur_shard_draws >= self.samples_per_shard | |
| ): | |
| self._cur_shard_idx = random.choices( | |
| self._iid_shard_indices, | |
| weights=self._iid_shard_weights, | |
| k=1, | |
| )[0] | |
| self._cur_shard_draws = 0 | |
| shard_idx = self._cur_shard_idx | |
| self._cur_shard_draws += 1 | |
| meta = self.shards[shard_idx] | |
| start = random.randrange(meta["num_starts"]) | |
| else: | |
| if idx < 0 or idx >= self.total_starts: | |
| raise IndexError(idx) | |
| shard_idx, start = self._map_global_start_to_shard(int(idx)) | |
| meta = self.shards[shard_idx] | |
| frames = self._load_shard(meta["path"]) # (N, 3, H, W) | |
| end = start + self.seq_len | |
| seq_u8 = frames[start:end] # (T, 3, H, W), guaranteed valid by construction | |
| seq = seq_u8.to(torch.float32) / 255.0 | |
| if self.return_task_idx: | |
| return {"frames": seq, "task_idx": int(meta["task_idx"])} | |
| return seq | |