Source code for omniloader.sampling.sampler
"""A ``Sampler`` that turns a mixing strategy into a per-epoch index stream.
:class:`OmniSampler` is the bridge between a
:class:`~omniloader.sampling.strategies.MixingStrategy` and PyTorch's ``DataLoader``.
Each epoch it asks the strategy for the global indices to visit; call
:meth:`OmniSampler.set_epoch` before every epoch to advance the reproducible draw.
It is **distributed-aware**: when ``num_replicas``/``rank`` are given (or inferred
from an initialized ``torch.distributed`` process group) it shards the epoch's
indices across replicas so each rank sees a disjoint slice, mirroring
``torch.utils.data.DistributedSampler``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import torch.distributed as dist
from torch.utils.data import Sampler
if TYPE_CHECKING:
from collections.abc import Iterator
from omniloader.sampling.strategies import MixingStrategy
def _resolve_dist(num_replicas: int | None, rank: int | None) -> tuple[int, int]:
"""Resolve ``(num_replicas, rank)``, inferring from ``torch.distributed``.
Args:
num_replicas: Explicit replica count, or ``None`` to infer.
rank: Explicit rank, or ``None`` to infer.
Returns:
The resolved ``(num_replicas, rank)``; ``(1, 0)`` when not distributed.
Raises:
ValueError: If ``rank`` is outside ``[0, num_replicas)``.
"""
if num_replicas is None:
num_replicas = dist.get_world_size() if dist.is_available() and dist.is_initialized() else 1
if rank is None:
rank = dist.get_rank() if dist.is_available() and dist.is_initialized() else 0
if not 0 <= rank < num_replicas:
raise ValueError(f"rank {rank} out of range for {num_replicas} replicas")
return num_replicas, rank
[docs]
class OmniSampler(Sampler[int]):
"""Yield global indices for one epoch according to a mixing strategy.
Args:
strategy: The mixing strategy that computes the epoch's index list.
epoch: Initial epoch number.
num_replicas: Number of distributed replicas. ``None`` infers from
``torch.distributed`` (or ``1`` when not distributed).
rank: This process's replica index. ``None`` infers from
``torch.distributed`` (or ``0``).
drop_last: When sharding, drop the tail so every rank gets exactly the
same number of samples. When ``False`` (default), the index list is
padded by wrapping so no sample is skipped.
"""
def __init__(
self,
strategy: MixingStrategy,
epoch: int = 0,
num_replicas: int | None = None,
rank: int | None = None,
drop_last: bool = False,
) -> None:
self.strategy = strategy
self.epoch = epoch
self.num_replicas, self.rank = _resolve_dist(num_replicas, rank)
self.drop_last = drop_last
[docs]
def set_epoch(self, epoch: int) -> None:
"""Set the epoch used to seed the next iteration.
Args:
epoch: The upcoming epoch number.
"""
self.epoch = epoch
def _shard(self, indices: list[int]) -> list[int]:
"""Return this rank's slice of ``indices`` (identity when single-process)."""
if self.num_replicas == 1:
return indices
if self.drop_last:
usable = (len(indices) // self.num_replicas) * self.num_replicas
indices = indices[:usable]
elif len(indices) % self.num_replicas:
# Pad by wrapping from the front so the split is even.
pad = self.num_replicas - (len(indices) % self.num_replicas)
indices = indices + indices[:pad]
return indices[self.rank :: self.num_replicas]
[docs]
def __iter__(self) -> Iterator[int]:
"""Iterate this rank's global indices for the current epoch."""
return iter(self._shard(self.strategy.epoch_indices(self.epoch)))
[docs]
def __len__(self) -> int:
"""Number of indices this rank produces per epoch."""
total = len(self.strategy)
if self.num_replicas == 1:
return total
if self.drop_last:
return total // self.num_replicas
return -(-total // self.num_replicas) # ceil