Source code for pimm.datasets.stateful

"""Checkpointable dataloader/sampler state helpers.

The sampler mirrors PyTorch distributed sampler padding semantics while tracking
the current position. This lets long jobs resume mid-epoch without reshuffling
or replaying already-consumed samples.
"""

from __future__ import annotations

import math
import pickle
from typing import Iterator, Sized

import torch
from torch.utils.data import DataLoader, Sampler

DATALOADER_STATE_FORMAT = "pimm.torchdata_state.v1"
UNSUPPORTED_DATALOADER_STATE_FORMAT = "pimm.dataloader_state.unsupported.v1"


[docs] class StatefulRandomSampler(Sampler[int]): """Rank-aware map-style sampler with checkpointable epoch and position.""" def __init__( self, data_source: Sized, *, shuffle: bool = True, seed: int = 0, epoch: int = 0, num_replicas: int = 1, rank: int = 0, drop_last: bool = False, ) -> None: """Create a deterministic per-rank sample order for one epoch.""" self.data_source = data_source self.shuffle = bool(shuffle) self.seed = int(seed) self.epoch = int(epoch) self.num_replicas = int(num_replicas) self.rank = int(rank) self.drop_last = bool(drop_last) self.position = 0 self.num_samples = self._compute_num_samples() self.total_size = self.num_samples * self.num_replicas self._order = self._build_order() def _compute_num_samples(self) -> int: """Compute local sample count after distributed padding or dropping.""" length = len(self.data_source) if length == 0: return 0 if self.drop_last and length % self.num_replicas != 0: return math.ceil((length - self.num_replicas) / self.num_replicas) return math.ceil(length / self.num_replicas) def _build_order(self) -> list[int]: """Build this rank's padded/dropped index order for the current epoch.""" length = len(self.data_source) if length == 0: return [] if not self.shuffle: indices = list(range(length)) else: generator = torch.Generator() generator.manual_seed(self.seed + self.epoch) indices = torch.randperm(length, generator=generator).tolist() if not self.drop_last: padding_size = self.total_size - len(indices) if padding_size <= len(indices): indices += indices[:padding_size] else: indices += (indices * math.ceil(padding_size / len(indices)))[:padding_size] else: indices = indices[: self.total_size] return indices[self.rank : self.total_size : self.num_replicas] def __iter__(self) -> Iterator[int]: """Yield remaining indices and advance the stored position.""" while self.position < len(self._order): index = self._order[self.position] self.position += 1 yield index def __len__(self) -> int: """Return this rank's sample count for the current epoch.""" return self.num_samples @property def remaining(self) -> int: """Number of indices left before this rank exhausts the epoch.""" return max(0, len(self._order) - self.position)
[docs] def set_epoch(self, epoch: int, *, reset_position: bool = True) -> None: """Change epoch seed and optionally rewind to the start of that order.""" self.epoch = int(epoch) self.num_samples = self._compute_num_samples() self.total_size = self.num_samples * self.num_replicas self._order = self._build_order() if reset_position: self.position = 0
[docs] def set_position(self, position: int) -> None: """Clamp and set the next index position within the current order.""" self.position = max(0, min(int(position), len(self._order)))
[docs] def state_dict(self) -> dict[str, object]: """Serialize enough state to resume this sampler mid-epoch.""" return { "type": self.__class__.__name__, "seed": self.seed, "epoch": self.epoch, "shuffle": self.shuffle, "position": self.position, "length": len(self.data_source), "num_replicas": self.num_replicas, "rank": self.rank, "drop_last": self.drop_last, "num_samples": self.num_samples, "total_size": self.total_size, }
[docs] def load_state_dict(self, state_dict: dict[str, object], *, strict: bool = True) -> None: """Restore sampler state, validating dataset length and replica count.""" length = int(state_dict["length"]) if length != len(self.data_source): raise ValueError( f"Sampler state length {length} does not match dataset length {len(self.data_source)}" ) saved_replicas = int(state_dict.get("num_replicas", self.num_replicas)) if strict and saved_replicas != self.num_replicas: raise ValueError( f"Sampler state was saved with num_replicas={saved_replicas}, " f"but current num_replicas={self.num_replicas}." ) self.seed = int(state_dict["seed"]) self.epoch = int(state_dict["epoch"]) self.shuffle = bool(state_dict["shuffle"]) self.drop_last = bool(state_dict.get("drop_last", self.drop_last)) self.num_samples = self._compute_num_samples() self.total_size = self.num_samples * self.num_replicas self._order = self._build_order() self.set_position(int(state_dict["position"]))
def dataloader_state_dict(loader: DataLoader) -> dict[str, object]: """Return a stable wrapper around torchdata's private loader state.""" if hasattr(loader, "state_dict") and hasattr(loader, "load_state_dict"): return { "format": DATALOADER_STATE_FORMAT, "state": pickle.dumps(loader.state_dict()), } return { "format": UNSUPPORTED_DATALOADER_STATE_FORMAT, "loader_type": loader.__class__.__name__, } def is_unsupported_dataloader_state(state_dict: dict[str, object] | None) -> bool: """Return whether a captured loader state cannot support exact resume.""" return ( isinstance(state_dict, dict) and state_dict.get("format") == UNSUPPORTED_DATALOADER_STATE_FORMAT ) def load_dataloader_state_dict( loader: DataLoader, state_dict: dict[str, object] | None, *, strict: bool = True, ) -> None: """Load the stable pimm wrapper and delegate raw state to torchdata.""" if not state_dict: return state_format = state_dict.get("format") if state_format == UNSUPPORTED_DATALOADER_STATE_FORMAT: raise RuntimeError( "Cannot restore exact dataloader position because the checkpoint " f"was written for unsupported train loader type " f"{state_dict.get('loader_type', 'unknown')!r}." ) if state_format != DATALOADER_STATE_FORMAT: raise ValueError( f"Unsupported dataloader state format {state_format!r}; expected " f"{DATALOADER_STATE_FORMAT!r}." ) if not hasattr(loader, "load_state_dict"): raise RuntimeError( f"Train loader type {loader.__class__.__name__!r} cannot restore " "exact dataloader state because it has no load_state_dict()." ) raw_state = pickle.loads(state_dict["state"]) loader.load_state_dict(raw_state) setattr(loader, "_pimm_loaded_state", True) def assert_exact_dataloader_state_available( state_dict: dict[str, object] | None, *, loader: DataLoader, iter_in_epoch: int, ) -> None: """Fail early when a mid-epoch checkpoint cannot restore loader position.""" if int(iter_in_epoch) <= 0 or not is_unsupported_dataloader_state(state_dict): return raise RuntimeError( "Cannot create an exact mid-epoch checkpoint for train loader type " f"{loader.__class__.__name__!r} because it does not implement " "state_dict()/load_state_dict(). Use torchdata StatefulDataLoader or " "save only at epoch boundaries." ) def set_dataloader_epoch(loader: DataLoader, epoch: int, *, reset_position: bool = True) -> None: """Set epoch on a loader's sampler while respecting a just-loaded state.""" if getattr(loader, "_pimm_loaded_state", False) and not reset_position: return if reset_position and getattr(loader, "_pimm_loaded_state", False): loader.load_state_dict({}) setattr(loader, "_pimm_loaded_state", False) sampler = getattr(loader, "sampler", None) if hasattr(sampler, "set_epoch"): try: sampler.set_epoch(epoch, reset_position=reset_position) except TypeError: sampler.set_epoch(epoch) return # Iterable datasets have no sampler; reshuffle via the dataset itself so any # IterableDataset exposing set_epoch (HF-backed or otherwise) reseeds. dataset = getattr(loader, "dataset", None) if hasattr(dataset, "set_epoch"): dataset.set_epoch(epoch)