ml-models / Training systems / Data loading and batching intro

Data loading and batching

Before a model sees a single gradient, something has to read examples off disk, turn them into tensors of equal shape, group them into batches and get those batches onto the accelerator fast enough that it never waits. This page follows PyTorch's DataLoader through samplers, worker processes, collation and pinned memory, measures how much compute padding wastes and how bucketing and packing win it back, and shows what batch size does to gradient noise.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. How the DataLoader is assembled
  5. Collation
  6. Workers, prefetching and pinned memory
  7. Sharding across processes
  8. Bucketing and packing
  9. Batch size, noise and the learning rate
  10. Worked example
  11. Padding waste
  12. Counting batches and shards
  13. How many workers?
  14. Gradient noise against batch size
  15. Implementation
  16. Tradeoffs
  17. Connections
  18. Read first
  19. Read next
  20. References

Problem

A training loop wants one thing per step: a batch, a tensor of shape [B, ...] sitting in device memory. What it actually has is a dataset of individual examples: image files of different sizes, sentences of different lengths, rows in a table. Getting from one to the other raises four separate problems:

  • Order. SGD assumes each batch is a fair random draw from the data. Reading files in their stored order (all cats, then all dogs) breaks that.
  • Shape. A batch is one rectangular tensor, but examples differ in length. Something has to pad, crop or pack them.
  • Speed. Decoding a JPEG or tokenizing a document runs on the CPU in Python. If it takes longer than the forward and backward pass, the GPU sits idle, and a faster GPU makes no difference.
  • Batch size. Bigger batches are more efficient per example and give less noisy gradients, but they change how far each step should go and take more memory.

Take 12 token sequences with lengths 5, 23, 9, 31, 14, 7, 18, 4, 27, 11, 16, 8 and batch them in fours in the order they arrive. Each batch is padded to its longest sequence, so the model processes 304 token positions of which only 173 are real: 56.9% useful work. Nearly half the compute goes to padding.

Intuition

PyTorch splits the job into small pieces that each do one thing, so any one can be replaced:

  • A Dataset answers "give me example i" (__getitem__) and "how many are there" (__len__). An IterableDataset is a stream instead: it only answers "give me the next one".
  • A Sampler decides the order: it yields indices. RandomSampler is a fresh random permutation each epoch; SequentialSampler is 0, 1, 2, ….
  • A BatchSampler groups those indices into lists of batch_size.
  • A collate function turns a list of examples into one batch, usually by stacking tensors along a new first dimension.
  • Workers are separate processes that run the fetch-and-collate step in parallel, so batch t+1, t+2, … are being prepared while the model trains on batch t.

The DataLoader wires these together. The main process owns the sampler, hands lists of indices to workers, and receives finished batches back. A worker only ever sees indices; it never decides what to load next. That is what keeps the order reproducible even when many processes load at once.

How a batch moves through a multi-worker DataLoader In the main process, the sampler yields indices and the batch sampler groups them into lists. Each list goes onto one worker's index queue, round robin. Each worker process calls the dataset's getitem for every index, collates the examples into a batch, and puts it on a shared result queue. A pin-memory thread in the main process copies the batch into page-locked memory, the iterator puts batches back in order, and the training loop copies each batch to the GPU without blocking. main process Sampler 7, 2, 9, 0, 5, … BatchSampler [7, 2, 9], [0, 5, …] pin_memory thread page-locked copy reorder → loop .to(cuda, non_blocking) index queue 0 worker 0 dataset[i] for i in idx collate_fn(samples) index queue 1 worker 1 dataset[i] for i in idx collate_fn(samples) index queue 2 worker 2 dataset[i] for i in idx collate_fn(samples) worker result queue (idx, batch), any order at most prefetch_factor × num_workers = 2 × 3 = 6 batches in flight

Padding is the second idea. A batch has to be rectangular, so short sequences get filler tokens that the model computes on and then masks out. How much filler depends entirely on which sequences end up together. Group similar lengths (bucketing) and each batch pads only a little. Drop the batch-of-rows idea entirely and concatenate sequences end to end into fixed-length rows (packing) and almost nothing is padding.

The same twelve sequences batched three ways Left: batches of four in arrival order, each padded to its longest sequence, 56.9% of slots real. Middle: sorted into length buckets first, 78.6% real. Right: packed end to end into rows of 32 tokens, 6 rows, 90.1% real. Solid outlined bars are real tokens; the faint area behind them is padding. arrival order 173 / 304 = 56.9% bucketed by length 173 / 220 = 78.6% packed into 32-token rows documents alternate colour 173 / 192 = 90.1%

Mechanics

How the DataLoader is assembled

DataLoader.__init__ mostly validates arguments and fills in defaults. If you pass neither sampler nor batch_sampler, it builds a RandomSampler when shuffle=True and a SequentialSampler otherwise, then wraps it as BatchSampler(sampler, batch_size, drop_last). Passing your own sampler together with shuffle raises, and a custom batch_sampler excludes batch_size, shuffle, sampler and drop_last, because it already decides all four. With batching on, the default collate_fn is default_collate.

RandomSampler draws a torch.randperm of the dataset each epoch, so every example is seen exactly once per epoch (sampling without replacement). BatchSampler then cuts that stream into lists of batch_size; its __len__ is ⌈N / B⌉, or ⌊N / B⌋ with drop_last=True, which throws away the final short batch.

For each list of indices, a fetcher calls the dataset. _MapDatasetFetcher.fetch uses the dataset's __getitems__ if it has one (one call for the whole batch, useful when a database or file format can read many rows at once), otherwise [dataset[i] for i in indices], and passes the list to collate_fn.

Collation

default_collate calls a recursive collate that mirrors the structure of one example: a list of dicts becomes a dict of batched values, a list of tuples a list of batched fields (a namedtuple stays a namedtuple), a list of tensors one stacked tensor (Python and NumPy numbers become tensors too, strings stay a list). Stacking needs equal shapes; a list field of uneven length fails with "each element in list of batch should be of equal size" (line 208). Inside a worker, collate_tensor_fn stacks straight into a shared-memory tensor, so handing the batch back to the main process needs no extra copy.

For variable-length sequences you write your own collate_fn, usually around pad_sequence, which pads a list of [L_i, ...] tensors to the longest L with padding_value on the right or left (padding_side), and returns the lengths or an attention mask alongside so the model can ignore the filler.

Workers, prefetching and pinned memory

With num_workers=0, _get_iterator returns a single-process iterator: each next() fetches and collates in the training process itself, so loading and compute strictly take turns. With workers it returns _MultiProcessingDataLoaderIter, whose source comment (lines 798-815) draws the same queues as the diagram above. When the iterator starts, _reset primes the pipeline by sending prefetch_factor × num_workers index lists (prefetch_factor defaults to 2 once there are workers). _try_put_index hands lists to workers round robin and never lets more than that many be outstanding; each batch the loop consumes triggers one more.

Workers finish out of order, so _next_data holds early arrivals until the batch the sampler asked for next shows up. Results are always in sampler order unless you pass in_order=False, which trades reproducibility for not stalling on one slow worker.

Each worker loop sets torch.set_num_threads(1) so N workers don't oversubscribe the CPU, then seeds Python's random and torch with base_seed + worker_id, and NumPy from a hash of the same two numbers (lines 274-282). The base seed is drawn when the iterator is created from the loader's generator, so random augmentations differ between workers and between epochs, yet a fixed generator reproduces them. Use get_worker_info() inside an IterableDataset to give each worker its own slice of the stream; without it every worker yields the full stream and you get each example num_workers times.

With pin_memory=True a background thread copies each batch into page-locked host memory. The GPU's copy engine can read pinned memory directly, so batch.to("cuda", non_blocking=True) becomes an asynchronous copy that overlaps with compute already queued on the GPU (see GPU execution). From ordinary pageable memory the driver has to stage the copy first. persistent_workers=True keeps worker processes alive between epochs (__iter__ resets the old iterator instead of building a new one), which saves start-up time when each worker holds something expensive, like an open file or a tokenizer.

Sharding across processes

In data-parallel training each of R processes must see a different part of the data and all must take the same number of steps, or the gradient all-reduce deadlocks. DistributedSampler shuffles with the same seed on every rank (seed + epoch), then pads the index list by repeating its head until the length divides by R (or with drop_last=True cuts the tail), and gives rank r every R-th index starting at r (line 134). Because the shuffle seed includes the epoch, you must call set_epoch(e) before each epoch; forget it and every epoch replays the same order.

Bucketing and packing

Bucketing sorts (or roughly groups) examples by length, cuts batches from neighbours, and then shuffles the order of the batches so training still sees lengths in random order. It is a custom batch_sampler: the dataset and model don't change. The cost is a little randomness: examples of similar length always share batches.

Packing concatenates documents into rows of exactly the context length, which is how language-model pretraining is usually batched. Two details make it correct rather than merely fast. Without an attention mask, tokens of one document attend to the previous document in the same row; a block-diagonal causal mask (or a variable-length attention kernel given the document boundaries) keeps them apart. And position ids should restart at 0 for each document, or the second document is encoded as if it began mid-sentence. Some pipelines skip both and accept the cross-talk, treating the separator token as enough; that is a deliberate tradeoff, not the default.

ComponentShape/ParamsMeaningSource
Dataset / IterableDataset__getitem__(i), __len__ / __iter__Where examples come from: random access or a streamdataset.py
RandomSamplerN indices per epochA fresh permutation each epoch (shuffle=True)sampler.py
BatchSamplerbatch_size, drop_lastGroups indices; ⌈N/B⌉ or ⌊N/B⌋ batchessampler.py
_MapDatasetFetcherlist of B indices → list of B examplesCalls __getitems__ or __getitem__, then collatefetch.py
default_collateB × [...] → [B, ...]Recursive stack over dicts, tuples and tensorscollate.py
pad_sequenceB × [L_i, ...] → [B, max L, ...]Pads to the longest sequence in the batchrnn.py
num_workers, prefetch_factorW processes, ≤ prefetch × W batches in flightParallel loading that runs ahead of the loopdataloader.py
pin_memoryone threadPage-locked copies for async host-to-device transferpin_memory.py
DistributedSamplerR replicas, ⌈N/R⌉ indices eachEqual-sized disjoint shards (up to padding), reshuffled by set_epochdistributed.py

Batch size, noise and the learning rate

The mini-batch gradient is an average of per-example gradients, so it is an unbiased estimate of the full-data gradient whose variance shrinks as the batch grows. Sampling B of N examples without replacement, with per-example gradient variance σ²:

Var[ĝ_B] = σ² / B · (N − B) / (N − 1)

Doubling the batch halves the variance (for B much smaller than N), which is the case for a bigger step. The linear scaling rule (Goyal et al.) says: when you multiply the batch size by k, multiply the learning rate by k, and warm the learning rate up over the first few epochs because the rule fails while the weights are changing fastest (see learning-rate schedules). The rule only holds up to a point: past a critical batch size that depends on the task and grows as training proceeds (McCandlish et al.), extra examples per step stop reducing the number of steps needed, and you are paying for compute that buys nothing. In practice the learning rate is re-tuned whenever the batch size changes by a large factor.

Worked example

Padding waste

The twelve lengths from the problem section, batch size 4. In arrival order:

BatchLengthsPadded toSlots (4 × max)Real tokens
15, 23, 9, 313112468
214, 7, 18, 4187243
327, 11, 16, 82710862
Total304173 (56.9%)

Sorted by length first, then batched:

BatchLengthsPadded toSlotsReal tokens
14, 5, 7, 883224
29, 11, 14, 16166450
318, 23, 27, 313112499
Total220173 (78.6%)

Packing into rows of 32 with first-fit decreasing (take the longest remaining sequence, put it in the first row with room, open a new row if none has room) gives 6 rows: [31], [27 + 5], [23 + 9], [18 + 14], [16 + 11 + 4], [8 + 7]. That is 6 × 32 = 192 slots for 173 real tokens, 90.1%. Same data, same model: the packed version does 1.58× less work than the arrival-order one.

Counting batches and shards

10 examples with batch size 3: len(loader) is ⌈10/3⌉ = 4 (the last batch holds one example) or, with drop_last=True, ⌊10/3⌋ = 3.

Now shard those 10 examples (unshuffled, for readability) across 4 ranks with DistributedSampler:

RankDefault (pad)drop_last=True
00, 4, 80, 4
11, 5, 91, 5
22, 6, 02, 6
33, 7, 13, 7

By default each rank gets ⌈10/4⌉ = 3 indices: the list is padded to 12 by repeating its first two entries, so ranks 2 and 3 see examples 0 and 1 a second time this epoch. With drop_last=True every rank gets 2 and examples 8 and 9 are skipped this epoch (with shuffling on, which two depends on the epoch). For evaluation, where duplicates bias the metric, this matters; for training it rarely does.

How many workers?

Suppose loading one batch takes a worker 40 ms and a training step takes 15 ms, and the epoch has 12 batches. In an idealized model (fixed costs, no start-up time, prefetch factor 2), the epoch takes:

num_workersEpoch (ms)Per batch (ms)GPU busy
066055.0027.3%
149541.2536.4%
227022.5066.7%
322018.3381.8%
422018.3381.8%

With no workers the loop alternates: 12 × (40 + 15) = 660 ms. One worker overlaps loading with compute, but it can only produce a batch every 40 ms, so the GPU still waits. Loading keeps up once W workers produce a batch at least every 15 ms, that is W ≥ 40/15, so W = 3. Then the epoch is the first load plus 12 back-to-back steps: 40 + 12 × 15 = 220 ms, and a fourth worker changes nothing. The remaining idle time is waiting for the first batch.

Timelines for zero, one and three workers Time runs left to right, one pixel per millisecond; loads are green and training steps amber. With zero workers, one lane alternates 40 millisecond loads and 15 millisecond steps, ending at 660 milliseconds. With one worker, the worker loads back to back and the GPU lane shows short steps separated by gaps, ending at 495. With three workers, the three worker lanes load in staggered fashion and the GPU lane is busy continuously after the first load, ending at 220. 0 workers worker 0 GPU (1w) worker 0 worker 1 worker 2 GPU (3w) green: a worker loading one batch · amber: one training step 0 ms 220 ms 495 ms 660 ms

Gradient noise against batch size

Take a tiny "dataset" of 8 per-example gradients for one weight: 0.9, -0.3, 1.6, 0.2, -1.1, 0.7, 2.0, 0.4. The full gradient is their mean, 0.55. A mini-batch of size B drawn without replacement estimates it with variance σ²/B · (N − B)/(N − 1):

BVar of batch meanStd
10.86750.931
20.37180.610
40.12390.352
80.00000.000

Going from 1 to 2 to 4 cuts the standard deviation by a bit more than √2 each time (the finite-population factor helps); at B = N the estimate is exact. The test suite checks these numbers against brute-force enumeration of every possible batch.

Implementation

A bucketing batch sampler and a padding collate function for a token dataset. The sampler shuffles within coarse length buckets and then shuffles the batch order, so lengths in a batch are similar but the training order is still random:

import random
import torch
from torch.nn.utils.rnn import pad_sequence
from torch.utils.data import DataLoader, Dataset, Sampler

class Tokens(Dataset):
    def __init__(self, seqs):
        self.seqs = seqs                          # list of 1-D LongTensors
    def __len__(self):
        return len(self.seqs)
    def __getitem__(self, i):
        return self.seqs[i]

class BucketBatches(Sampler):
    def __init__(self, lengths, batch_size, bucket=100, seed=0):
        self.lengths, self.batch_size, self.bucket = lengths, batch_size, bucket
        self.seed, self.epoch = seed, 0
    def set_epoch(self, epoch):
        self.epoch = epoch
    def __iter__(self):
        rng = random.Random(self.seed + self.epoch)
        idx = list(range(len(self.lengths)))
        rng.shuffle(idx)
        chunk = self.bucket * self.batch_size
        batches = []
        for start in range(0, len(idx), chunk):
            block = sorted(idx[start:start + chunk], key=self.lengths.__getitem__)
            batches += [block[i:i + self.batch_size] for i in range(0, len(block), self.batch_size)]
        rng.shuffle(batches)
        return iter(batches)
    def __len__(self):
        return sum(-(-min(self.bucket * self.batch_size, len(self.lengths) - s) // self.batch_size)
                   for s in range(0, len(self.lengths), self.bucket * self.batch_size))

def pad_collate(seqs, pad_id=0):
    ids = pad_sequence(seqs, batch_first=True, padding_value=pad_id)
    lengths = torch.tensor([len(s) for s in seqs])
    mask = torch.arange(ids.shape[1])[None, :] < lengths[:, None]
    return ids, mask

data = Tokens(seqs)
sampler = BucketBatches([len(s) for s in seqs], batch_size=32)
loader = DataLoader(data, batch_sampler=sampler, collate_fn=pad_collate,
                    num_workers=4, pin_memory=True, persistent_workers=True)

for epoch in range(epochs):
    sampler.set_epoch(epoch)
    for ids, mask in loader:
        ids = ids.to("cuda", non_blocking=True)
        mask = mask.to("cuda", non_blocking=True)
        ...

A few things to check when a loader is slow or wrong:

  • Is the GPU waiting? Time next(iter) separately from the step. If fetching a batch takes longer than a step, add workers (until CPU cores run out), move work like decoding out of Python, or store data preprocessed.
  • Are workers duplicating data? An IterableDataset must shard itself with get_worker_info() and, under distributed training, by rank too.
  • Is the order reproducible? Pass a seeded generator to the DataLoader and call set_epoch on distributed or custom samplers.
  • Is memory growing? Each worker is a separate process holding its own copy of whatever the dataset object references; a large Python list of strings in the dataset is touched (and so copied, under fork) by every worker. Arrays or memory-mapped files share better.

Tradeoffs

  • Map-style vs iterable. Map-style datasets get exact shuffling, sharding and len() for free but need random access. Streams (web-scale shards, databases) need an IterableDataset, which only approximates shuffling with a buffer and must shard itself.
  • Padding vs bucketing vs packing. Padding is simplest and exact. Bucketing keeps examples separate and cuts most waste, at the cost of length-correlated batches. Packing wastes almost nothing but needs a mask and position resets to be exact, and splits or drops documents longer than the context.
  • More workers. They hide loading time until loading is no longer the bottleneck; past that they only cost memory and CPU, and each one adds start-up time unless workers persist.
  • Pinned memory. Faster, asynchronous copies, but page-locked memory can't be swapped out; pinning too much starves the rest of the system.
  • drop_last. Keeps every batch the same shape (helpful for compiled graphs and for batch-statistics layers) at the cost of skipping a few examples each epoch.
  • Large batches. Better hardware use and fewer steps, but each example does less work past the critical batch size, and very large batches need learning-rate warmup and retuning. When memory is the limit, lower precision or gradient accumulation gives the same effective batch.
Training loss plateaus and the GPU shows 30% utilization. What do you check first?

Whether the loop is waiting for data: time how long each next() on the loader takes compared with the step. If loading dominates, raise num_workers until loading per batch divided by workers is below the step time, and turn on pin_memory with non_blocking=True copies.

Every epoch of a distributed run shows the same loss curve shape, batch for batch. Why?

DistributedSampler shuffles with seed + epoch, and set_epoch was never called, so epoch stays 0 and every epoch replays the same permutation.

An IterableDataset with num_workers=4 makes each epoch four times longer than expected. What happened?

Every worker iterated the full stream, so each example arrived four times. The dataset's __iter__ has to use get_worker_info() to read only its worker's share.

You pack documents into 2048-token rows with no attention mask. What does the model learn that it shouldn't?

To predict tokens of one document from an unrelated preceding document in the same row. Masking attention to each document's own block (and restarting position ids) removes the cross-talk.

Connections

Read first

Read next

No page builds on this one yet.

References

  • Goyal et al. Accurate, Large Minibatch SGD: Training ImageNet in 1 Hour. 2017. (The linear scaling rule and warmup.)
  • McCandlish, Kaplan, Amodei and the OpenAI Dota Team. An Empirical Model of Large-Batch Training. 2018. (The critical batch size.)
  • Krell, Kosec, Perez and Fitzgibbon. Efficient Sequence Packing without Cross-contamination. 2021. (Packing with masks and position resets.)
  • PyTorch v2.15.0: DataLoader, _MultiProcessingDataLoaderIter, sampler.py, collate.py, _worker_loop, DistributedSampler, pad_sequence.