3. Dataset and DataLoader engineering
In the previous chapter we established that a PyTorch training loop alternates between forward passes, backward passes, and optimizer steps. What often goes unnoticed is that the loop can only run as fast as its slowest component. On modern GPUs, a single forward-backward step on a ResNet-50 with batch size 256 may take 30–50 ms, while naively loading and decoding 256 JPEG images on the CPU can easily take 200 ms or more. The result is a GPU that sits idle most of the time, waiting for data. This chapter focuses on the engineering discipline of feeding the model fast enough: how Dataset and DataLoader interact, how transforms and samplers shape the data stream, and how prefetching, worker processes, and memory pinning keep the accelerator saturated.
The Dataset Contract
A PyTorch Dataset is any object that implements two methods: __len__, returning the number of samples, and __getitem__(index), returning one sample (or a tuple of tensors). The map-style interface is the most common, but PyTorch also supports IterableDataset for streaming sources such as log files or remote object stores where random access is impossible.
A minimal map-style dataset for image classification looks like this:
from torch.utils.data import Dataset
from PIL import Image
class ImageFolderDataset(Dataset):
def __init__(self, samples, transform=None):
# samples: list of (image_path, label)
self.samples = samples
self.transform = transform
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
path, label = self.samples[idx]
img = Image.open(path).convert("RGB")
if self.transform:
img = self.transform(img)
return img, label
The critical engineering point is that __getitem__ runs inside worker processes when num_workers > 0. Any state it touches must be picklable, and any file handle or database connection should be opened lazily inside the method rather than in __init__. A common bug is opening a file in __init__ and then forking: the child processes inherit a stale descriptor and read corrupted data.
Transforms: Where CPU Time Goes
Transforms are the dominant CPU cost in most vision pipelines. Decoding a JPEG, resizing, and normalizing is far more expensive than the tensor copy that follows. Two rules of thumb apply:
- Do the cheap, tensor-friendly operations (normalization, channel permutation) after conversion to tensor; do the expensive geometric operations (resize, crop, flip) on the PIL or NumPy representation, which is faster for those kernels.
- Use
torchvision.transforms.v2when possible. It operates on tensors directly and fuses several operations, reducing Python overhead.
For training-time augmentation, the transform pipeline is stochastic. For validation, it must be deterministic. A typical configuration:
from torchvision import transforms as T
train_tf = T.Compose([
T.RandomResizedCrop(224, scale=(0.6, 1.0)),
T.RandomHorizontalFlip(),
T.ToTensor(),
T.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]),
])
val_tf = T.Compose([
T.Resize(256),
T.CenterCrop(224),
T.ToTensor(),
T.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]),
])
Note that the normalization constants are ImageNet statistics. Using the wrong mean and std will not crash training, but it will slow convergence and can cost several points of accuracy. Always verify that the statistics match the pretrained weights you load.
Samplers: Controlling What the Model Sees
The DataLoader does not decide the order of samples; the sampler does. By default, DataLoader uses RandomSampler when shuffle=True and SequentialSampler otherwise. For imbalanced datasets, WeightedRandomSampler lets you oversample rare classes:
from torch.utils.data import WeightedRandomSampler
class_counts = [900, 80, 20] # e.g., three classes
weights = [1.0 / c for c in class_counts]
sample_weights = [weights[label] for _, label in samples]
sampler = WeightedRandomSampler(
weights=sample_weights,
num_samples=len(sample_weights),
replacement=True,
)
For distributed training, DistributedSampler shards the dataset across ranks so that each GPU sees a disjoint subset. It must be told the current epoch via sampler.set_epoch(epoch); otherwise every epoch shuffles identically and the shards never rotate. This is one of the most common silent bugs in multi-GPU training.
Prefetching, Workers, and Pinned Memory
The DataLoader overlaps data loading with GPU computation through three mechanisms:
- Worker processes (
num_workers): each worker runs__getitem__independently, so decoding is parallelized across CPU cores. - Prefetching (
prefetch_factor): each worker preparesprefetch_factorbatches ahead of time, so the main process rarely blocks. - Pinned memory (
pin_memory=True): the batch is copied into page-locked host memory, enabling asynchronous host-to-device transfers viatensor.to(device, non_blocking=True).
A practical starting configuration for a single-GPU workstation with 8 CPU cores:
from torch.utils.data import DataLoader
loader = DataLoader(
dataset,
batch_size=128,
shuffle=True,
num_workers=4,
prefetch_factor=2,
pin_memory=True,
persistent_workers=True,
drop_last=True,
)
persistent_workers=True keeps worker processes alive between epochs, avoiding the fork/join cost that can dominate when epochs are short. drop_last=True discards the final partial batch, which prevents BatchNorm from seeing a batch of size 1 and producing unstable statistics.
torch.cuda.synchronize() around the step. If GPU utilization (visible via nvidia-smi dmon or torch.profiler) stays below ~70% and CPU cores are saturated, increase num_workers or move transforms to the GPU. If GPU utilization is high but step time is still long, the bottleneck is the model, not the data.
Putting It Together: A Data Flow View
The diagram below traces a single batch from disk to GPU. Each stage has a distinct cost profile, and the engineering goal is to keep the GPU stage from ever waiting on the earlier ones.
Diagnosing and Fixing Common Pipeline Failures
Three failure modes account for most slow or broken input pipelines:
| Symptom | Likely Cause | Fix |
|---|---|---|
| GPU utilization < 50%, CPU cores pegged | Transforms too heavy or num_workers too low |
Increase workers; move resize to GPU with torchvision.transforms.v2; cache decoded images |
| Training hangs at epoch boundary | Worker processes re-forked every epoch | Set persistent_workers=True |
| Loss spikes or NaN on the last batch | BatchNorm sees batch size 1 | Set drop_last=True or use BatchNorm with track_running_stats carefully |
__getitem__ returns a CUDA tensor, worker processes will attempt to serialize GPU memory across process boundaries, which either fails or silently copies through host memory. Keep datasets CPU-only; move to device in the training loop.
Scaling Beyond a Single Machine
When the dataset no longer fits on local disk, the pipeline must be re-architected. Two patterns dominate:
- Sharded webdataset: pack samples into large tar shards (e.g., 1 GB each) and stream them with an
IterableDataset. This avoids the per-file open/close overhead that kills performance on object stores. - Pre-tokenized / pre-decoded caches: for NLP, tokenize once and store as memory-mapped arrays; for vision, store decoded tensors in a
WebDatasetorLMDB. The transform pipeline then only does augmentation, not decoding.
In both cases, the sampler must be replaced by a sharding strategy: each rank reads a disjoint subset of shards, and the IterableDataset yields samples until exhausted. The DistributedSampler does not apply to iterable datasets, so you must implement shard assignment manually, typically by passing rank and world_size to the dataset constructor.
Key Takeaways
- The
DataLoaderis a pipeline, not a convenience wrapper. Its throughput is determined by the slowest stage: I/O, decode, transform, collate, or transfer. - Set
num_workersto roughly the number of physical CPU cores,prefetch_factorto 2–4, andpin_memory=Truewithnon_blocking=Truefor GPU training. - Use
persistent_workers=Trueanddrop_last=Trueto avoid epoch-boundary stalls and BatchNorm instability. - Choose the sampler deliberately:
WeightedRandomSamplerfor class imbalance,DistributedSamplerfor multi-GPU, and manual sharding forIterableDataset. - Profile before optimizing. If GPU utilization is already above 85%, the data pipeline is not your bottleneck.