Data Pipelines: Dataset & DataLoader
Decoupling your data loading logic from your model training loops is one of PyTorch's cleanest design paradigms. By combining torch.utils.data.Dataset and torch.utils.data.DataLoader, you can stream datasets larger than RAM, parallelize preprocessing across multi-core CPU workers, and maximize GPU saturation.
1. The Two-Stage PyTorch Data Contract
In production deep learning, your training pipeline alternates between CPU-bound I/O (disk read, image decode, tokenization) and GPU-bound tensor math (matrix multiplication, backpropagation). PyTorch splits this responsibility:
Data Storage & Sample Access
Represents your dataset as a collection. Subclassing requires only two methods: __len__() (returns total samples) and __getitem__(idx) (loads, transforms, and returns sample index idx).
Batching & Concurrency Engine
Wraps a Dataset to provide mini-batching, shuffling, multi-process sample prefetching via num_workers, and fast page-locked memory transfer to CUDA via pin_memory=True.
2. Building a Custom Map-Style Dataset
Here is a production-ready custom dataset that loads numerical tabular features and target labels from an in-memory dictionary or CSV, applying on-the-fly normalization:
import torch
from torch.utils.data import Dataset, DataLoader
import numpy as np
class CustomTabularDataset(Dataset):
"""
Map-style Dataset subclass for tabular feature matrices.
"""
def __init__(self, features: np.ndarray, labels: np.ndarray, normalize: bool = True):
# Convert NumPy inputs to PyTorch tensors
self.features = torch.tensor(features, dtype=torch.float32)
self.labels = torch.tensor(labels, dtype=torch.long)
# Optional feature normalization: (x - mean) / std
if normalize:
mean = self.features.mean(dim=0, keepdim=True)
std = self.features.std(dim=0, keepdim=True) + 1e-7
self.features = (self.features - mean) / std
def __len__(self) -> int:
"""Returns the total number of samples."""
return len(self.features)
def __getitem__(self, idx: int):
"""Fetches and returns the (x, y) pair at sample index idx."""
return self.features[idx], self.labels[idx]
# Mock data initialization (10,000 samples, 16 features, binary label)
mock_x = np.random.randn(10000, 16)
mock_y = np.random.randint(0, 2, size=(10000,))
dataset = CustomTabularDataset(mock_x, mock_y)
print(f"Total dataset length: {len(dataset)}")
sample_x, sample_y = dataset[42]
print(f"Sample 42 feature shape: {sample_x.shape}, label: {sample_y.item()}")3. High-Throughput DataLoader Configuration
An improperly tuned DataLoader can starve your GPU, leaving expensive compute cores idle while waiting for disk reads. Below is the gold-standard production configuration:
# Production DataLoader Setup
train_loader = DataLoader(
dataset=dataset,
batch_size=64,
shuffle=True, # Reshuffle data at every epoch
num_workers=4, # Number of CPU worker processes fetching batches in background
pin_memory=True, # Allocates page-locked host memory for instant CUDA DMA transfer
drop_last=True, # Discard the final partial batch to preserve uniform batch size
persistent_workers=True # Keeps background worker processes alive between epochs
)
# Iterating over the mini-batches
for batch_idx, (batch_features, batch_targets) in enumerate(train_loader):
# Fast non-blocking transfer to GPU
batch_features = batch_features.cuda(non_blocking=True)
batch_targets = batch_targets.cuda(non_blocking=True)
if batch_idx == 0:
print(f"Batch shape: {batch_features.shape}, Targets: {batch_targets.shape}")
breakWhat is pin_memory=True?
When training on an NVIDIA GPU, host CPU RAM is usually pageable (can be swapped to disk). Setting pin_memory=True instructs the OS to allocate "page-locked" pinned physical memory. The GPU can copy pinned memory directly across the PCIe bus via Direct Memory Access (DMA)without CPU intervention, yielding 2x-3x faster host-to-device transfers.
4. Custom collate_fn for Variable-Length NLP Sequences
By default, DataLoader uses default_collate which expects all samples in a batch to have the exact same tensor dimensions. In Natural Language Processing (NLP) or Audio processing, sentences have varying word lengths. A custom collate_fn allows dynamic zero-padding:
import torch
from torch.nn.utils.rnn import pad_sequence
# Mock dataset returning variable-length integer token lists
class VariableTextDataset(Dataset):
def __init__(self):
self.samples = [
torch.tensor([101, 2054, 2003, 1037, 102]), # 5 tokens
torch.tensor([101, 7592, 102]), # 3 tokens
torch.tensor([101, 2023, 2003, 1037, 2742, 102]), # 6 tokens
torch.tensor([101, 2129, 102]) # 3 tokens
]
self.labels = torch.tensor([1, 0, 1, 0])
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
return self.samples[idx], self.labels[idx]
def pad_collate_fn(batch):
"""
Custom collator: receives a list of tuples [(x1, y1), (x2, y2), ...]
and zero-pads features to match the longest sequence in this mini-batch.
"""
sequences = [item[0] for item in batch]
labels = torch.tensor([item[1] for item in batch], dtype=torch.long)
# Pad sequences with token ID 0 (batch_first=True -> [B, Max_Seq_Len])
padded_sequences = pad_sequence(sequences, batch_first=True, padding_value=0)
# Compute attention mask (1 for real token, 0 for pad)
attention_mask = (padded_sequences != 0).long()
return padded_sequences, attention_mask, labels
nlp_dataset = VariableTextDataset()
nlp_loader = DataLoader(nlp_dataset, batch_size=2, shuffle=False, collate_fn=pad_collate_fn)
for batch_seq, batch_mask, batch_labels in nlp_loader:
print(f"Padded Batch Shape: {batch_seq.shape}")
print(f"Padded Sequences:\n{batch_seq}")
print(f"Attention Mask:\n{batch_mask}\n")
break5. Common Traps & Multi-Worker Gotchas
| Pitfall | Root Cause | Correct Pattern |
|---|---|---|
| Worker Seed Duplication | Using standard random.random() inside Dataset with num_workers > 1 clones the RNG state across all worker processes. | Use worker_init_fn=seed_worker or PyTorch's built-in torch.randn with a torch.Generator(). |
| CUDA Initialization in Worker | Calling .cuda() inside __getitem__ crashes multiprocessing due to CUDA context inheritance issues. | Keep Dataset strictly on the CPU. Only transfer tensors to CUDA in the main training loop. |
Windows num_workers Error | On Windows, Python uses spawn instead of fork, requiring scripts to be protected by if __name__ == '__main__':. | Always guard your DataLoader execution inside if __name__ == '__main__': when running on Windows OS. |
Training Loop & Optimizers
Continue to the next hands-on deep learning guide in this PyTorch series.
Explore Related Tools
Hand-picked utilities and calculators related to this tool.
