Skip to content

Data Loading

About

A DataProvider is the factory that you supply to the train or inference loop, like ModelProvider or OptimizerProvider. Given the run context, it builds a MicrobatchPackStream. This is a Stateful iterable that yields microbatch packs and reports its length through the total_steps property.

Concepts

  • A pack holds the data of one step: a sequence of microbatches. len(pack) is the number of microbatches in that step (the gradient accumulation factor). It can vary from step to step. The loop copies packs to the device ahead of their steps (see Data Prefetching).
  • total_steps is the number of steps the stream yields. It is None when the length is not known ahead of time, e.g. for streaming or data-dependent batching. JobSchedule takes the job duration from JobScheduleConfig.total_steps if it is set. It must not exceed the stream length. Otherwise, JobSchedule uses the total_steps of the stream. The loop runs exactly that many steps. It cuts a longer stream short and raises an error if the stream ends earlier.
  • The stream is the only checkpoint boundary for the data. It saves and restores its own position per data-parallel rank, so the job resumes exactly. Prefetching iterates the stream on a background thread and can call state_dict() after every pack. So keep state_dict() cheap, and do not return objects that later iteration changes.

You can use the shipped AutoDataProvider for the common case, or write your own provider for full control.

Using AutoDataProvider

AutoDataProvider builds the default data stack for you. You supply the two parts that cannot be serialized: a dataset_factory and a collator. An AutoDataConfig holds the serializable settings: global_batch_size, microbatch_size, shard_indexing_mode, drop_last and loader settings. The loader settings include shuffle, num_workers, pin_memory and prefetch_factor. A PinMemoryMicrobatchPackStream does the pinning. Unlike the DataLoader option, it also pins tensors nested in dataclasses.

AutoDataProvider shards the dataset across data-parallel ranks for you. It also builds the loader, derives the gradient accumulation factor and returns the stream. So your factory returns the unsharded dataset.

  • The dataset_factory receives the DistributedContext, so it can guard data preparation. For example, dist_context.main_process_first() lets rank 0 fill the cache before the other ranks read it.
  • The collator collates a list of samples into one microbatch.
import dataclasses

import datasets
import torch

from d9d.loop.auto import AutoDataConfig, AutoDataProvider
from d9d.loop.run import TrainingConfigurator


def build_dataset(dist_context):
    # Rank 0 downloads and processes the data first. The other ranks load from its cache.
    # Return the dataset unsharded: AutoDataProvider shards it across data-parallel ranks.
    with dist_context.main_process_first():
        return datasets.load_dataset("my/dataset", split="train")


@dataclasses.dataclass
class Batch:
    input_ids: torch.Tensor
    labels: torch.Tensor


# The rows of a Hugging Face dataset are dicts.
def collate(samples: list[dict]) -> Batch:
    return Batch(
        input_ids=torch.stack([s["input_ids"] for s in samples]),
        labels=torch.stack([s["labels"] for s in samples]),
    )


provider = AutoDataProvider(
    dataset_factory=build_dataset,
    collator=collate,
    config=AutoDataConfig(
        global_batch_size=256,  # Effective batch across all DP replicas and accumulation
        microbatch_size=4,  # Samples per microbatch on a single rank
        num_workers=4,
        shuffle=True,
    ),
)

# Hand it to the loop alongside the other providers.
trainer = TrainingConfigurator(
    mesh=...,
    parameters=...,
    model_provider=...,
    task_provider=...,
    data_provider=provider,
    optimizer_provider=...,
    lr_scheduler_provider=...,
).configure()

The batch sizes are known, so the stream reports its total_steps. JobSchedule then derives the job duration without JobScheduleConfig.total_steps.

Writing a Custom DataProvider

A custom provider builds the same default stack by hand. It has two layers:

  1. A loader: any DataLoaderProtocol (from d9d.core.protocol). It is a Stateful, Sized iterable of single collated microbatches. torchdata's StatefulDataLoader satisfies it directly.
  2. A packer: FixedCountMicrobatchPacker(microbatches_per_step=k) groups k microbatches into each pack, which gives gradient accumulation. drop_last controls whether a short trailing pack is dropped. Training drops it, evaluation keeps it. The helper num_microbatches_for_global_batch derives k.

Wrap the packer in a PinMemoryMicrobatchPackStream to pin the packs, so the loop copies them to the device asynchronously. The packer and the pinning stream live in d9d.dataset.batch_iterator.

Unlike with AutoDataProvider, you own the data-parallel sharding. Shard the dataset yourself before you build the loader, e.g. with shard_dataset_data_parallel. See the Dataset Utilities documentation.

import dataclasses
from collections.abc import Sequence
from typing import Any

import torch
import datasets
from pydantic import BaseModel
from tokenizers import Tokenizer
from torch.utils.data import Dataset
from torchdata.stateful_dataloader import StatefulDataLoader

from d9d.core.protocol import MicrobatchPackStream
from d9d.core.types import TensorTree
from d9d.dataset import (
    BufferSortedDataset,
    DatasetImplementingSortKeyProtocol,
    FixedCountMicrobatchPacker,
    PinMemoryMicrobatchPackStream,
    num_microbatches_for_global_batch,
    shard_dataset_data_parallel,
)
from d9d.loop.control import DataProvider, InitializeDataProviderContext


@dataclasses.dataclass
class ProjectBatch:
    input_ids: torch.Tensor
    labels: torch.Tensor


class ProjectDataset(Dataset, DatasetImplementingSortKeyProtocol):
    def __init__(self, dataset: datasets.Dataset, tokenizer: Tokenizer):
        self._dataset = dataset
        self._tokenizer = tokenizer

    def sort_key(self, index: int) -> Any:
        # Used by BufferSortedDataset to group examples of similar length together.
        return self._dataset[index]["token_counts"]

    def __getitem__(self, index: int) -> TensorTree:
        return {...}

    @classmethod
    def collate(cls, batch: Sequence[TensorTree]) -> ProjectBatch:
        return ProjectBatch(...)

    def __len__(self) -> int:
        return len(self._dataset)


class DataConfig(BaseModel):
    dataset: str
    split: str
    tokenizer: str
    presort_buffer_size: int
    global_batch_size: int
    microbatch_size: int
    num_workers: int


class ProjectDataProvider(DataProvider):
    def __init__(self, config: DataConfig):
        self._config = config

    def __call__(self, context: InitializeDataProviderContext) -> MicrobatchPackStream:
        tokenizer = Tokenizer.from_file(self._config.tokenizer)

        # Rank 0 builds the cache first. The other ranks then load from it.
        with context.dist_context.main_process_first():
            data = datasets.load_dataset(self._config.dataset, split=self._config.split)

        dataset = ProjectDataset(data, tokenizer)

        # Length-bucketing buffer, which minimizes padding.
        dataset_buf = BufferSortedDataset(
            dataset,
            buffer_size=self._config.presort_buffer_size,
            pack_size=self._config.global_batch_size,
        )

        # Shard across data-parallel ranks (a custom DataProvider owns sharding).
        dataset_shard = shard_dataset_data_parallel(dataset_buf, context.dist_context)

        # Loader (layer 1): yields one collated microbatch at a time.
        loader = StatefulDataLoader(
            dataset_shard,
            batch_size=self._config.microbatch_size,
            collate_fn=ProjectDataset.collate,
            num_workers=self._config.num_workers,
            drop_last=True,
        )

        # Packer (layer 2): groups microbatches into one pack per step.
        microbatches_per_step = num_microbatches_for_global_batch(
            context.dist_context,
            global_batch_size=self._config.global_batch_size,
            microbatch_size=self._config.microbatch_size,
        )
        packer = FixedCountMicrobatchPacker(loader, microbatches_per_step=microbatches_per_step, drop_last=True)

        # Pin the packs, so the loop copies them to the device asynchronously.
        return PinMemoryMicrobatchPackStream(packer)

API Reference

d9d.loop.control.data_provider

Interface for providing training and inference data.

DataProvider

Bases: Protocol

Protocol for defining how the data pipeline is built.

A DataProvider is the factory the user supplies to the train/eval loop, exactly like ModelProvider or OptimizerProvider. Given the run context, it composes and returns a MicrobatchPackStream.

The user is responsible for sharding the dataset across data-parallel ranks (e.g. with d9d.dataset.shard_dataset_data_parallel) inside the provider.

__call__(context)

Builds the microbatch pack stream for the job.

Parameters:

Name Type Description Default
context InitializeDataProviderContext

Context for this operation.

required

Returns:

Type Description
MicrobatchPackStream

The microbatch pack stream the loop will drive.

InitializeDataProviderContext dataclass

Context data required to initialize a data provider.

Attributes:

Name Type Description
dist_context DistributedContext

The distributed context containing rank and world size information.

d9d.loop.auto.auto_data

Default data provider built from a configuration.

DatasetFactory = Callable[[DistributedContext], Dataset] module-attribute

A callable that builds the (unsharded) dataset, given the distributed context.

It receives the context so it can guard data preparation (e.g. with dist_context.main_process_first()).

AutoDataConfig

Bases: BaseModel

Configuration for the default data pipeline.

Attributes:

Name Type Description
global_batch_size int

The total effective batch size across all replicas and accumulation.

microbatch_size int

The number of samples in a single microbatch on a single rank.

shard_indexing_mode ShardIndexingMode

The dataset sharding strategy.

shuffle bool

Whether to reshuffle the data every epoch.

drop_last bool

Whether to drop the trailing incomplete microbatch and pack. Set it to False for evaluation.

num_workers int

The number of subprocesses to use for data loading.

pin_memory bool

Whether to copy the packs into pinned host memory before returning them.

persistent_workers bool

Whether to keep worker processes alive between epochs.

prefetch_factor int | None

The number of batches each worker prefetches ahead.

timeout float

The timeout in seconds for collecting a batch from workers.

AutoDataProvider

Bases: DataProvider

Data provider for the default data stack, which shards the dataset, loads microbatches and packs them per step.

The dataset is sharded across data-parallel ranks and loaded by a stateful loader at the microbatch size. The number of microbatches per step follows from the global batch size. A FixedCountMicrobatchPacker groups the microbatches, and a PinMemoryMicrobatchPackStream pins the packs if enabled. For a different stack, write your own DataProvider.

__init__(dataset_factory, collator, config)

Constructs the AutoDataProvider object.

Parameters:

Name Type Description Default
dataset_factory DatasetFactory

Builds the unsharded dataset given the distributed context.

required
collator CollateFn

Collates individual samples into a microbatch.

required
config AutoDataConfig

The serializable settings for the default stack.

required

d9d.dataset.batch_iterator

Composable building blocks for the batch iterator stack.

FixedCountMicrobatchPacker

Bases: MicrobatchPackStream

Microbatch pack stream that groups the microbatches of a loader into packs of microbatches_per_step.

It is the default MicrobatchPackStream. A short trailing pack (drop_last=False) is consistent across ranks only when every rank sees the same number of microbatches, e.g. when the dataset was sharded with pad_to_equal_size_across_shards.

Its total_steps is derived from the loader's length, and it delegates its state to the loader (the checkpoint boundary).

total_steps property

The number of packs (steps) this packer yields.

It includes a trailing short pack unless drop_last is set.

__init__(loader, microbatches_per_step, drop_last=True)

Constructs the FixedCountMicrobatchPacker object.

Parameters:

Name Type Description Default
loader DataLoaderProtocol

The microbatch stream to group.

required
microbatches_per_step int

The number of microbatches in each pack.

required
drop_last bool

Whether to drop the trailing incomplete pack instead of yielding it short.

True

Raises:

Type Description
ValueError

If microbatches_per_step is not positive.

__iter__()

Iterates the loader, grouping microbatches into packs.

Yields:

Type Description
MicrobatchPack

A full pack, or a shorter trailing pack when drop_last is unset.

PinMemoryMicrobatchPackStream

Bases: MicrobatchPackStream

Microbatch pack stream wrapper that copies every tensor of each pack into pinned host memory.

Pinned memory lets the loop copy packs to the device asynchronously. Unlike the pin_memory option of torch.utils.data.DataLoader, the traversal uses d9d.core.pytree, so tensors nested in dataclasses are pinned as well. Pinning runs in the iterating thread.

Its total_steps and state are those of the wrapped stream.

total_steps property

The number of packs (steps) the wrapped stream yields, or None if it is unknown.

__init__(inner)

Constructs the PinMemoryMicrobatchPackStream object.

Parameters:

Name Type Description Default
inner MicrobatchPackStream

The wrapped stream that owns the data and its position.

required
__iter__()

Iterates the wrapped stream, pinning each pack.

Yields:

Type Description
MicrobatchPack

The next pack with all of its tensors in pinned host memory.

num_microbatches_for_global_batch(dist_context, global_batch_size, microbatch_size)

Computes the number of microbatches per step required to reach a target global batch size.

The global batch is spread across the data-parallel ranks, and each rank processes microbatch_size samples per microbatch. So each rank processes global_batch_size / (dp_size * microbatch_size) microbatches per optimizer step.

Parameters:

Name Type Description Default
dist_context DistributedContext

The distributed context.

required
global_batch_size int

The total effective batch size across all replicas and accumulation.

required
microbatch_size int

The number of samples in a single microbatch on a single rank.

required

Returns:

Type Description
int

The number of microbatches per step (the gradient-accumulation factor).

Raises:

Type Description
ValueError

If global_batch_size is not divisible by dp_size * microbatch_size.