Source code for nvalchemi.data.datapipes.dataloader

# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# 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
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# 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.
"""AtomicData-native DataLoader with amortized prefetching.

The ``DataLoader`` class is designed to be a drop-in replacement
for ``torch.data.DataLoader``, specializing for ``nvalchemi``
and atomistic systems by emitting ``Batch`` data.

Additionally, the ``DataLoader`` can fuse several emitted batches into one
backend read. ``prefetch_factor`` controls that read window, while optional
CUDA streams can overlap device transfers when available. An optional
``batch_transforms`` hook applies user-supplied callables to each collated
:class:`Batch` on the consumer thread.
"""

from __future__ import annotations

from collections.abc import Iterator, Sequence
from math import ceil

import torch
from torch.utils.data import RandomSampler, Sampler, SequentialSampler

from nvalchemi._typing import BatchTransform
from nvalchemi.data.batch import Batch
from nvalchemi.data.datapipes.dataset import BatchDatasetProtocol
from nvalchemi.data.transforms import Compose


[docs] class DataLoader: """Batch-iterating data loader that yields :class:`~nvalchemi.data.batch.Batch`. ``DataLoader`` is the consumer end of the pipeline (``Reader -> Dataset (-> MultiDataset) -> DataLoader``). It wraps a batch-loadable dataset -- a :class:`Dataset`, a :class:`~nvalchemi.data.datapipes.multidataset.MultiDataset`, or any object implementing :class:`BatchDatasetProtocol` -- and yields graph-collated :class:`~nvalchemi.data.batch.Batch` objects built via :meth:`~nvalchemi.data.batch.Batch.from_data_list`. Sampling follows PyTorch's conventions. With no sampler, ``shuffle`` selects a :class:`~torch.utils.data.RandomSampler` or :class:`~torch.utils.data.SequentialSampler`; a custom ``sampler`` (yielding sample indices) overrides ``shuffle``; and a ``batch_sampler`` (yielding whole lists of indices) sets the batch composition itself and is mutually exclusive with ``sampler``, ``shuffle``, and ``batch_size``. To mix several datasets, pair a :class:`~nvalchemi.data.datapipes.multidataset.MultiDataset` with :class:`~nvalchemi.data.datapipes.samplers.MultiDatasetSampler` (as ``sampler=``, per-sample rates) or :class:`~nvalchemi.data.datapipes.samplers.MultiDatasetBatchSampler` (as ``batch_sampler=``, a fixed per-batch mixture); under distributed training :class:`~nvalchemi.training.hooks.DDPHook` swaps in the rank-sharded variants automatically. Compared with :class:`torch.utils.data.DataLoader`, this loader yields ``Batch`` graphs rather than default-collated tensors, with graph-aware collation. Instead of forking worker processes it prefetches on background threads and overlaps device transfers on CUDA streams, and it *fuses* reads: ``prefetch_factor`` emitted batches are pulled from the backend in one windowed read (effective window ``batch_size * prefetch_factor``), which amortizes I/O far better than one ``__getitem__`` per sample. Set ``prefetch_factor=0`` to read a single emitted batch at a time, and ``pin_memory=True`` to request page-locked tensors from readers that support it. Parameters ---------- dataset : BatchDatasetProtocol AtomicData-native dataset to load from. batch_size : int, default=1 Number of samples per batch. shuffle : bool, default=False Randomize sample order each epoch. drop_last : bool, default=False Drop the last incomplete batch. sampler : torch.utils.data.Sampler | None, default=None Custom sampler (overrides ``shuffle``). batch_sampler : torch.utils.data.Sampler | None, default=None Custom sampler that yields batches of sample indices. prefetch_factor : int, default=2 Number of emitted batches to fuse into each backend read. The effective read window is ``batch_size * prefetch_factor``. Set to 0 to disable fused prefetching and read one emitted batch at a time. num_streams : int, default=4 Number of CUDA streams for prefetching. use_streams : bool, default=True Enable CUDA-stream prefetching. pin_memory : bool, default=False If True, request page-locked CPU tensors from readers that support pinned-memory reads. batch_transforms : Sequence[BatchTransform] | None, default=None Optional per-batch transforms applied to each yielded :class:`~nvalchemi.data.batch.Batch` after collation. ``None`` or an empty sequence disables the hook (zero runtime overhead on the hot path). See the Notes section for thread placement and CUDA-stream semantics. For per-sample transforms applied before collation, see :class:`Dataset` (``transforms`` parameter). Attributes ---------- dataset : BatchDatasetProtocol The underlying dataset. batch_size : int Number of samples per batch. sampler : torch.utils.data.Sampler Resolved sampler (``RandomSampler`` if ``shuffle=True``, else :class:`~torch.utils.data.SequentialSampler`; user-supplied ``sampler`` overrides both). drop_last : bool Whether the trailing partial batch is dropped. prefetch_factor : int Configured prefetch depth (see :meth:`__iter__`). num_streams : int Configured CUDA-stream pool size for prefetching. use_streams : bool Whether stream-based prefetching is actually enabled. Stored as ``use_streams and torch.cuda.is_available()``; reflects runtime availability, not the raw argument. pin_memory : bool Whether page-locked CPU tensors are requested from compatible readers. Raises ------ ValueError Raised at construction if ``batch_size < 1`` or ``prefetch_factor < 0``. TypeError Raised at construction if ``batch_transforms`` is not a :class:`~collections.abc.Sequence` (e.g. a single callable or a generator was passed). RuntimeError Raised during iteration (not construction) when any batch transform fails; the original exception is chained via ``__cause__``. Notes ----- Batch transforms run on the consumer (main) thread after collation, not on the prefetch workers; the fully assembled ``Batch`` does not exist until the main thread constructs it. Transforms are applied in order via :class:`~nvalchemi.data.transforms.Compose` and execute on the current CUDA stream at yield time; wrap iteration in your own ``torch.cuda.stream(...)`` context to control placement. Examples -------- >>> from nvalchemi.data.datapipes import AtomicDataZarrReader, Dataset, DataLoader >>> reader = AtomicDataZarrReader("dataset.zarr") # doctest: +SKIP >>> ds = Dataset(reader, device="cpu") # doctest: +SKIP >>> def center_positions(batch): # doctest: +SKIP ... batch.positions = batch.positions - batch.positions.mean(0) ... return batch >>> loader = DataLoader(ds, batch_size=4, batch_transforms=[center_positions]) # doctest: +SKIP >>> for batch in loader: # doctest: +SKIP ... print(batch.positions.shape) """ def __init__( self, dataset: BatchDatasetProtocol, *, batch_size: int = 1, shuffle: bool = False, drop_last: bool = False, sampler: Sampler | None = None, batch_sampler: Sampler[Sequence[int]] | None = None, prefetch_factor: int = 2, num_streams: int = 4, use_streams: bool = True, pin_memory: bool = False, batch_transforms: Sequence[BatchTransform] | None = None, ) -> None: """Initialize the AtomicData-native DataLoader.""" if batch_size < 1: raise ValueError(f"batch_size must be >= 1, got {batch_size}") if prefetch_factor < 0: raise ValueError(f"prefetch_factor must be >= 0, got {prefetch_factor}") if batch_sampler is not None and (sampler is not None or shuffle): raise ValueError( "batch_sampler is mutually exclusive with sampler and shuffle" ) if batch_transforms is not None and not isinstance(batch_transforms, Sequence): raise TypeError( "batch_transforms must be a Sequence of callables, not a " "single callable or generator. Pass [fn] instead of fn." ) self.dataset = dataset self.batch_size = batch_size self.shuffle = shuffle self.drop_last = drop_last self.prefetch_factor = prefetch_factor self.num_streams = num_streams self.use_streams = use_streams and torch.cuda.is_available() self.batch_sampler = batch_sampler self.pin_memory = pin_memory self._epoch_step_start = 0 if pin_memory: self._set_pin_memory(self.dataset, True) self._batch_transform: Compose | None = ( Compose(batch_transforms) if batch_transforms else None ) # Handle sampler if self.batch_sampler is None: if sampler is not None: self.sampler = sampler elif shuffle: self.sampler = RandomSampler(dataset) else: self.sampler = SequentialSampler(dataset) else: self.sampler = None self._streams: list[torch.cuda.Stream] = ( [torch.cuda.Stream() for _ in range(num_streams)] if self.use_streams else [] ) @staticmethod def _set_pin_memory(dataset: object, enabled: bool) -> None: """Request pinned-memory reads from a single dataset when supported.""" if hasattr(dataset, "pin_memory"): setattr(dataset, "pin_memory", enabled) @property def effective_read_window(self) -> int: """Return the maximum sample count in one fused backend read.""" return self.batch_size * max(self.prefetch_factor, 1) def __len__(self) -> int: """Return the number of batches. Returns ------- int Number of batches in the dataloader. """ if self.batch_sampler is not None: return len(self.batch_sampler) # type: ignore[arg-type] n_samples = len(self.sampler) if self.sampler is not None else len(self.dataset) if self.drop_last: return n_samples // self.batch_size return ceil(n_samples / self.batch_size) def __iter__(self) -> Iterator[Batch]: """Iterate over batches. Uses fused prefetching when ``prefetch_factor`` is positive, with CUDA streams added when enabled and available. Yields ------ Batch Batched AtomicData as a disjoint graph. """ if self.prefetch_factor > 0: yield from self._iter_prefetch() else: yield from self._iter_simple() def _generate_batches(self) -> Iterator[list[int]]: """Generate batches of indices. Yields ------ list[int] List of sample indices for each batch. """ start_batch = self._consume_epoch_step_start() emitted = 0 if self.batch_sampler is not None: for batch_indices in self.batch_sampler: if emitted < start_batch: emitted += 1 continue emitted += 1 yield list(batch_indices) return batch: list[int] = [] if self.sampler is None: return for idx in self.sampler: batch.append(idx) if len(batch) == self.batch_size: if emitted >= start_batch: yield batch emitted += 1 batch = [] if batch and not self.drop_last and emitted >= start_batch: yield batch def _consume_epoch_step_start(self) -> int: """Return and clear the pending intra-epoch batch start offset.""" start = self._epoch_step_start self._epoch_step_start = 0 return start
[docs] def set_epoch_step(self, step: int) -> None: """Seek the next iterator to an intra-epoch batch offset. Parameters ---------- step : int Number of complete batches to skip in sampler order before the next iterator starts yielding. The skip advances only the sampler/index stream; it does not load or collate skipped batches. Raises ------ ValueError If ``step`` is negative. """ if step < 0: raise ValueError(f"step must be >= 0, got {step}") self._epoch_step_start = step
def _iter_simple(self) -> Iterator[Batch]: """Simple synchronous iteration without prefetching. Yields ------ Batch Collated batch of AtomicData. """ transform = self._batch_transform for batch_indices in self._generate_batches(): batch = self.dataset.load_batches([batch_indices])[0] if transform is not None: batch = transform(batch) yield batch def _iter_prefetch(self) -> Iterator[Batch]: """Iteration with fused prefetching. Fuses ``prefetch_factor`` consecutive batches into a single ``read_many`` call so that Zarr reader optimisations can coalesce scattered indices into fewer large reads. Strategy (true double-buffered): 1. Collect and submit two chunks upfront so that one Zarr read is always in flight while the other is being consumed. 2. Consume the oldest completed chunk, submit a fresh chunk into the now-free queue slot, then yield the consumed batches. The next Zarr read runs in the background while the caller processes each yielded batch. 3. Drain the remaining queued chunk after the sampler is exhausted. 4. Cleanup runs in a ``finally`` block so that ``cancel_prefetch()`` fires on normal exhaustion, early break, and exceptions. Yields ------ Batch Collated batch of AtomicData. """ stream_idx = 0 batch_iter = self._generate_batches() transform = self._batch_transform def _collect_chunk() -> list[list[int]]: """Collect up to prefetch_factor batch-index lists.""" chunk: list[list[int]] = [] for _ in range(self.prefetch_factor): batch_indices = next(batch_iter, None) if batch_indices is None: break chunk.append(batch_indices) return chunk def _submit_chunk(chunk: list[list[int]]) -> None: nonlocal stream_idx stream = ( self._streams[stream_idx % self.num_streams] if self._streams else None ) self.dataset.prefetch_fused_batches(chunk, stream=stream) stream_idx += 1 try: # Prime: fill both queue slots so one read is always in # flight while the other is consumed. chunk_a = _collect_chunk() if not chunk_a: return _submit_chunk(chunk_a) chunk_b = _collect_chunk() if chunk_b: _submit_chunk(chunk_b) while True: # Consume oldest completed read. completed_batches = list(self.dataset.get_fused_batches()) # Refill: collect and submit next chunk into the freed # queue slot so the background thread starts reading # immediately -- *before* we yield any batches. next_chunk = _collect_chunk() if next_chunk: _submit_chunk(next_chunk) for batch in completed_batches: if transform is not None: batch = transform(batch) yield batch # Stop when both the sampler is exhausted and the # queue has been drained. if not next_chunk and not self.dataset.has_pending_fused_batches(): break finally: self.dataset.cancel_prefetch()
[docs] def set_epoch(self, epoch: int) -> None: """Set the epoch for the sampler (used in distributed training). Parameters ---------- epoch : int Current epoch number. """ candidates = ( self.batch_sampler, getattr(self.batch_sampler, "sampler", None), self.sampler, ) seen: set[int] = set() for sampler in candidates: if sampler is None or id(sampler) in seen: continue seen.add(id(sampler)) if hasattr(sampler, "set_epoch"): sampler.set_epoch(epoch) return