Search Tech Journey

Find topics, journeys and posts

back to blog
mlbeginner 120m read

DL S016 · Datasets, DataLoaders, and Collate

Load MNIST / CIFAR without hand-writing batching. Part of the 'Deep Learning & LLMs From Scratch' 80-session self-study series.

🧠SoftwareM03 · PyTorch fluency· Session 016 of 130 120 min

🎯 Load MNIST / CIFAR without hand-writing batching.

Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 16 / 80 · Module M03 · ~2 hours

The story — the day Andrej saw his GPU at 4%

Andrej Karpathy gave a talk at Stanford in 2019 where he shared the single most-forwarded slide of his Tesla years: a screenshot of nvidia-smi showing eight V100 GPUs at 4% utilization during a Tesla Autopilot training run. Every one of those GPUs cost $10K. They were running at the speed of a Raspberry Pi. The bottleneck wasn't the model. It wasn't the network. It was that the data pipeline was decoding raw camera frames on a single Python process, and the GPU spent 96% of every batch cycle sitting there waiting. Two engineers spent a week rewriting the loader with proper multiprocessing, prefetching, and pinned memory. Training time dropped from 3 weeks to 3 days. The model architecture never changed.

That's the lesson: your beautiful new architecture is running on a $10,000 GPU that can crunch a batch in 3 milliseconds — and it's sitting idle 90% of the time because your data pipeline is decoding a JPEG on a single CPU thread. On a toy problem you'd never notice. On a real training run it turns a 3-hour job into a 30-hour job.

PyTorch's answer is the Dataset / DataLoader split. Dataset says "here is item i". DataLoader says "here are batch_size random items, prefetched using num_workers processes, moved to pinned memory, ready when the GPU is ready". This decoupling is what lets a research student and Meta both write essentially the same training loop.

Today we do all of it: MNIST out of the box, then a custom Dataset for your own data, then the collate_fn trick that lets you handle variable-length text (this is what unlocks NLP), then the war stories around workers and pinned memory that will save you the "why is my GPU only 20% utilized" week.

You will be able to
  • Load MNIST or CIFAR-10 with `torchvision.datasets` in three lines.
  • Write your own `Dataset` subclass with `__len__` and `__getitem__`.
  • Explain what `num_workers`, `pin_memory`, `shuffle`, and `drop_last` each do.
  • Write a custom `collate_fn` for variable-length sequences (padding to max in batch).
  • Diagnose a slow-training run and correctly attribute the bottleneck to data vs model.

Prerequisites



1 · The two-piece design

Warehouse and delivery truck
🌍 Real world
💻 Code world

Every PyTorch data pipeline is exactly two objects.

Dataset — knows nothing about batching. Just:

  • __len__(self) → total number of items
  • __getitem__(self, i) → the i-th item, usually as a (x, y) tuple

DataLoader — wraps a Dataset. Knows about batching, shuffling, parallelism.

from torch.utils.data import DataLoader
 
loader = DataLoader(dataset,
                    batch_size=128,
                    shuffle=True,
                    num_workers=4,
                    pin_memory=True)
 
for xb, yb in loader:
    # xb.shape == (128, ...)
    ...

That's it. Every model in this series will consume data this way.


2 · MNIST in three lines

from torchvision import datasets, transforms
from torch.utils.data import DataLoader
 
train_ds = datasets.MNIST("./data", train=True, download=True,
                          transform=transforms.ToTensor())
train_loader = DataLoader(train_ds, batch_size=128, shuffle=True, num_workers=2)
 
for xb, yb in train_loader:
    print(xb.shape, yb.shape)     # torch.Size([128, 1, 28, 28]) torch.Size([128])
    break

datasets.MNIST downloads on first use, caches to ./data, returns a Dataset whose items are (PIL_Image, int_label). transforms.ToTensor() converts PIL → (C, H, W) float tensor in [0, 1]. DataLoader handles the rest.

Same pattern for CIFAR-10, CIFAR-100, FashionMNIST, ImageNet, etc.


3 · Writing a custom Dataset

The moment you have your own data (a folder of your own images, a CSV of tabular data, a JSONL of text), you write a Dataset.

from torch.utils.data import Dataset
from PIL import Image
import os, torch
 
class ImageFolderDataset(Dataset):
    def __init__(self, root, transform=None):
        self.samples = []                        # list of (path, label)
        self.transform = transform
        for label, cls in enumerate(sorted(os.listdir(root))):
            cls_dir = os.path.join(root, cls)
            for fn in os.listdir(cls_dir):
                self.samples.append((os.path.join(cls_dir, fn), label))
 
    def __len__(self):
        return len(self.samples)
 
    def __getitem__(self, i):
        path, label = self.samples[i]
        img = Image.open(path).convert("RGB")
        if self.transform:
            img = self.transform(img)
        return img, label

A few design rules worth staring at.

Try itWrite a 15-line Dataset from raw data of your own

Pick any folder on your disk with 20+ files (photos, .txt documents, CSV rows — anything). Write a Dataset subclass that returns (x, label) where label is derived from the filename or path. Wrap it in a DataLoader(batch_size=4, shuffle=True). Iterate one batch and print the shapes. You'll notice: your entire pipeline is 15 lines of code and it now interoperates with every training loop in the PyTorch ecosystem. The two-method contract is doing a lot of work for you.

💡 Hint · Use `torch.utils.data.Dataset` — you only implement `__len__` and `__getitem__`.
Dataset design rules
  • `__init__` should be FAST. Store paths, not decoded images. Decoding is O(GB) — do it lazily in `__getitem__`.
  • `__getitem__` should be PURE — no shared state, no counters. Multiple worker processes call it in parallel; shared state = races.
  • Return tensors from `__getitem__` when possible. If you return PIL/np arrays, `default_collate` will try to stack them and often fail with an unhelpful error.
  • Never open a file in `__init__` and read from it in `__getitem__` — file handles don't survive process fork. Open per call.

4 · The DataLoader parameter tour

Every one of these will bite you at some point. Read them all now, once, so you recognize the symptoms later.

4.1 batch_size

Number of items per batch. Trade-off: bigger = smoother gradients + better GPU utilization; smaller = more updates per epoch + fits smaller GPUs. Doubling batch size ~roughly doubles memory. If it OOMs, halve it. Session 024 shows gradient accumulation for when 1 is too many.

4.2 shuffle=True/False

Randomize order each epoch. Almost always True for training, always False for validation (so metrics are comparable across epochs and reproducible). Uses a fresh random permutation each epoch.

4.3 num_workers=N

Number of subprocess workers to prefetch batches. 0 = load in the main process (blocks GPU). 2–8 is typical. Rule of thumb: start with num_workers = min(8, os.cpu_count()). Higher isn't always better — too many workers thrash disk and RAM.

4.4 pin_memory=True

Copies each batch into pinned (page-locked) RAM. This lets .to("cuda", non_blocking=True) do an async DMA transfer — the CPU issues the copy and moves on to loading the next batch while the previous one is still moving to GPU. Always True when training on GPU. No-op on CPU.

4.5 drop_last=True

Drops the last incomplete batch. Set True if your model architecture assumes a fixed batch size (rare) or if a partial batch would trigger batch norm weirdness with 1 sample. Otherwise False.

4.6 persistent_workers=True

Keeps worker processes alive across epochs instead of respawning. Big win if __init__ of the dataset is even slightly expensive. Default False for backward compat; you almost always want True.

4.7 prefetch_factor=2 (default)

Each worker prefetches this many batches ahead. Increase if disk is slow and workers keep waiting.


5 · collate_fn — the escape hatch that unlocks NLP

DataLoader calls collate_fn(list_of_items) to turn N items into one batch. The default (default_collate) does the sensible thing: it stacks tensors along a new dim-0. Works perfectly for images (all same shape). Fails immediately for text, where every sentence has a different length.

5.1 The problem

sentences = [
    torch.tensor([1, 2, 3]),           # len 3
    torch.tensor([4, 5, 6, 7, 8]),     # len 5
    torch.tensor([9]),                 # len 1
]
torch.stack(sentences)
# RuntimeError: stack expects each tensor to be equal size

5.2 The fix — pad to max length in batch

from torch.nn.utils.rnn import pad_sequence
 
def collate_text(batch):
    """batch is a list of (token_ids, label)."""
    tokens = [b[0] for b in batch]
    labels = torch.tensor([b[1] for b in batch])
    # pad_sequence pads with 0 on the right to max length in batch
    padded = pad_sequence(tokens, batch_first=True, padding_value=0)
    return padded, labels
 
loader = DataLoader(text_dataset, batch_size=32, collate_fn=collate_text)

pad_sequence returns (batch, max_len) with 0s where sentences were shorter. Every transformer training loop you'll ever look at has some version of this pattern. When we get to LLMs in M07, this line is what makes variable-length batching possible.

5.3 Bonus — dynamic batching by length

Advanced but worth mentioning: bucket sentences by length before batching, so you don't pad a 5-token sentence to the length of a 500-token one in the same batch. That's what torch.utils.data.BatchSampler + a custom sampler is for. We revisit this in M07.


6 · Debugging the "GPU is only 20% utilized" problem

Classic symptom, classic diagnosis.

Step 1: Time one batch, model-only. Put a dummy xb, yb on the GPU and loop through the model without a DataLoader. If this is fast, your model isn't the bottleneck.

Step 2: Time one epoch with num_workers=0. If it's 5× slower than num_workers=4, data is the bottleneck.

Step 3: Profile the dataset. Wrap __getitem__ and time it. Common culprits:

  • Decoding JPEGs from disk on every call (fix: use fast image formats like WebP, or cache resized versions)
  • Heavy augmentation on CPU (fix: move augmentations to GPU with kornia, or reduce num_ops)
  • Small __getitem__ items with high per-call overhead (fix: batch items server-side, or use a WebDataset)

Step 4: Verify pin_memory=True and non_blocking=True in .to().

xb = xb.to(device, non_blocking=True)

Both together let CPU→GPU transfer overlap with computation. Free perf.


7 · War stories

War story The dataset that opened a SQLite connection in __init__

I built a custom Dataset that opened a SQLite handle in __init__ and queried it in __getitem__. Worked with num_workers=0. Set num_workers=4 for speed — every batch, one of the workers crashed with SQLite objects created in a thread can only be used in that same thread.

Reason: num_workers > 0 uses multiprocessing (fork). The SQLite handle can't survive fork.

Fix: open the connection lazily in __getitem__ (or use worker_init_fn to open per-worker).

def __getitem__(self, i):
    if not hasattr(self, "_conn"):
        self._conn = sqlite3.connect(self.path)
    return self._conn.execute("SELECT ... WHERE id=?", (i,)).fetchone()

Same pattern for HDF5, LMDB, any file handle.

War story The 'shuffle=True' that wasn't

I set shuffle=True on my loader. Loss plateaued after 1 epoch. Turned out my dataset's __getitem__ was returning the same index every time because I'd hardcoded a debug value. shuffle=True was correctly shuffling the indices, but my dataset ignored them.

Fix: always add assert i == expected sanity checks when you write a custom dataset. Or just, you know, look at the code.

War story `num_workers=8` on a laptop with 8 GB RAM

Each worker forks the parent's memory. If your parent process holds a 4 GB in-memory dataset, 8 workers = 32 GB of virtual RAM (usually copy-on-write, but pathological on macOS and older Linux). System started swapping, training crawled to a halt.

Fix: either drop num_workers on laptops, or restructure so the dataset doesn't hold everything in memory (paths only, load on demand).


8 · Diagram — the pipeline

Every arrow between "Dataset" and "Model" is an opportunity for a bottleneck. num_workers widens the top; pin_memory + non_blocking widens the bottom.


8.5 · IterableDataset and streaming — when your data doesn't fit

Everything so far assumed a map-style dataset: you can ask for item i and get it. That works for MNIST (60K images) and even ImageNet (1.3M). It stops working for the 15-trillion-token corpus used to train Llama 3, or the multi-terabyte video dataset used for Sora-style models. You can't hold the index of every item in RAM, and you can't seek to item i in a compressed tar stream.

Enter torch.utils.data.IterableDataset. Instead of __getitem__, you implement __iter__ — the dataset is a stream. This is what powers modern large-scale training.

from torch.utils.data import IterableDataset
import tarfile, io, torch
 
class ShardedTarDataset(IterableDataset):
    def __init__(self, shard_paths):
        self.shard_paths = shard_paths
 
    def __iter__(self):
        info = torch.utils.data.get_worker_info()
        # partition shards across workers so each sees a disjoint set
        shards = self.shard_paths[info.id::info.num_workers] if info else self.shard_paths
        for path in shards:
            with tarfile.open(path) as tar:
                for member in tar:
                    yield decode(tar.extractfile(member).read())

Two new concerns appear:

  1. Worker sharding — with map-style datasets, the DataLoader partitions indices across workers automatically. With iterable, you must partition, or every worker will yield the same items. Use torch.utils.data.get_worker_info().
  2. No shuffle=True — DataLoader can't shuffle a stream. Do it inside __iter__ with a shuffle buffer (read N ahead, yield randomly from the buffer).

The de facto standard for large-scale training is WebDataset (pip install webdataset) — an IterableDataset that reads sharded .tar files, does shuffle buffers, handles worker sharding, and streams from cloud storage (S3, GCS) transparently. Meta uses it. Stability AI uses it. If your dataset is >100 GB, use WebDataset.


8.6 · StatefulDataLoader — resumable training (PyTorch 2.4+, torchdata 0.8)

One of the ugliest bugs in long-running training: your job crashes 3 days in. You restart from a checkpoint. Great — but your DataLoader starts over from batch 0. If shuffle is random, you re-see items you already trained on this epoch. If shuffle isn't random, you re-see the same first 1000 batches again and skew the loss.

StatefulDataLoader (from torchdata, integrated into PyTorch's data ecosystem as of 2024) fixes this. Its state_dict() captures where in the epoch you were; load_state_dict() resumes from exactly that point. This is now the standard for pretraining runs on Llama-scale models.

from torchdata.stateful_dataloader import StatefulDataLoader
 
loader = StatefulDataLoader(dataset, batch_size=128, num_workers=4)
# during training, alongside model+optimizer state:
checkpoint['loader'] = loader.state_dict()
# on resume:
loader.load_state_dict(checkpoint['loader'])

A note on TorchData's twisty history: the older DataPipe API (2022–2023) was deprecated in mid-2024 in favor of a simpler design centered on StatefulDataLoader. If you find IterDataPipe in tutorials, know it's legacy — use plain IterableDataset + StatefulDataLoader today. See the PyTorch 2.4 release notes for the migration.


8.7 · HuggingFace datasets — the pragmatic answer

For 90% of NLP/vision work in 2025, you won't write a Dataset from scratch. You'll do:

from datasets import load_dataset
ds = load_dataset("HuggingFaceFW/fineweb-edu", split="train", streaming=True)
for row in ds:
    ...

HuggingFace's datasets library gives you Arrow-backed memory-mapped access, streaming from the Hub, on-the-fly filtering/mapping, and a .with_format("torch") that turns rows into tensors. It's what almost every LLM paper's data code actually looks like. We'll use it heavily starting in M07.

The rule of thumb for choosing:

  • Your own image folder, <10 GB → custom Dataset (§3).
  • Your own text/tabular data → datasets.Dataset.from_dict or .from_pandas.
  • Public dataset → load_dataset("...") from the Hub.
  • 100 GB streaming → WebDataset or load_dataset(..., streaming=True).

Common misconception
✗ What most people think

"More num_workers is faster. The data loading is the bottleneck, so I'll set workers to my core count — or higher — and the GPU will stop waiting."

✓ What is actually true

Workers are separate processes, and each one pays a real cost: memory for its copy of the dataset object, CPU for its share of the transform work, and a serialisation round trip to hand every batch back to the main process. Past the point where the pipeline saturates either the CPU or the storage, extra workers add contention and memory pressure while adding no throughput — and on a machine with limited RAM they turn a slow pipeline into an OOM-killed one.

Why the myth is so sticky

Because at num_workers=0 the loader runs in the training process and genuinely blocks the GPU, so the first increase produces a large, immediately visible win. The relationship really is steeply improving in that range, and nothing signals where it flattens. The other half of the trap is that the true bottleneck is often not CPU at all — it can be disk, or a decode step, or the inter-process handoff itself — and adding workers to a storage-bound pipeline just adds processes queueing on the same device. The knob that worked once gets turned again, which is the most human debugging strategy there is.

Prove it to yourself

Measure it rather than assuming — the curve flattens and then reverses:

import time, torch
from torch.utils.data import Dataset, DataLoader

class Synthetic(Dataset):
    def __len__(self): return 20000
    def __getitem__(self, i):
        x = torch.randn(3, 64, 64)      # stand-in for decode + transform
        return x.mean(), i % 10

for w in (0, 1, 2, 4, 8, 16):
    dl = DataLoader(Synthetic(), batch_size=64, num_workers=w)
    t = time.perf_counter()
    for _ in dl: pass
    print(f"workers={w:3d}  {time.perf_counter() - t:.2f}s")
From first principles
Start with the question

Why does the Dataset interface return one item while collate_fn assembles the batch — instead of just letting Dataset return batches directly? The split looks like extra ceremony.

  1. 1
    Two things must happen per epoch: choose which examples go together, and turn a set of examples into a single stacked tensor.
    forced by · a model consumes batched tensors, but the sampling policy is a property of training, not of the data
  2. 2
    The choice of which examples group together must change between epochs (shuffling), across processes (distributed sharding), and by policy (class balancing, length bucketing) — all without touching the data itself.
    forced by · shuffling every epoch is required for unbiased gradient estimates, and sharding is required for data parallelism
  3. 3
    If Dataset returned batches, it would have to own the sampling policy, and every change of policy would mean rewriting the dataset — and every dataset would reimplement shuffling.
    forced by · batching and sampling would be fused into one object, so neither could vary independently
  4. 4
    Splitting them gives an indexable Dataset (a pure map from index to example), a Sampler that emits index sequences, and a collate_fn that stacks. Each varies independently and any combination composes.
    forced by · random access by index is exactly the interface a sampler needs, and nothing more
  5. 5
    It also makes parallelism trivial: workers can be handed disjoint index ranges with no coordination, because __getitem__ is stateless and order-independent.
    forced by · a pure map has no shared state, so N processes can evaluate it concurrently without locks
⇒ Therefore

Therefore the split is what makes shuffling, distributed sharding, weighted sampling, and multi-process loading all orthogonal features rather than N × M special cases.

And note the prediction: any data source that cannot support random access by index — a network stream, a compressed log being read sequentially, an infinite generator — must break this design. That is exactly why IterableDataset exists as a separate type, and why it comes with a known sharp edge: with multiple workers, each worker runs the full iterator unless you shard explicitly using get_worker_info(), so the naive version silently yields every example num_workers times. The derivation predicts the bug before you meet it — with no index, there is nothing for the loader to partition on your behalf.

Mental modelThree jobs: pick, fetch, stack

Every input pipeline does exactly three things, and they are deliberately separate objects. Pick: a sampler decides which indices form the next batch. Fetch: the dataset turns each index into one example, doing whatever decoding and transforming that requires. Stack: collate turns a list of examples into batched tensors.

Any pipeline problem you will ever have is one of the three. Wrong data order or duplicate examples is pick. Slow loading or a transform bug is fetch. A shape or dtype error at the model boundary is stack. Name the stage before you start debugging and you have already halved the search.

  • Dataset is a pure map from index to example: no shared mutable state, or multi-worker loading breaks in ways that are painful to reproduce.
  • Default collate stacks with torch.stack, which requires identical shapes. Variable-length data (text, audio, graphs) always needs a custom collate_fn to pad — this is the single most common reason to write one.
  • Workers are processes, not threads. Each gets a copy of the dataset object, so a dataset holding a large in-memory array multiplies that memory by the worker count.
  • Shuffle the training set every epoch; never shuffle validation. And in distributed training, the sampler's epoch must be set explicitly or every rank sees the same order.
🔔 Fires when you see

Fire this model the moment you see: low GPU utilisation · stack expects each tensor to be equal size · duplicate examples in an epoch · a dataloader worker killed for memory · a custom collate_fn · IterableDataset · non-reproducible results across runs with the same seed.

The tradeoff

Your dataset is large. Load it fully into memory, read from disk per item, or stream it with an IterableDataset?

Fully in memory
+ you gain fastest possible per-item access with no I/O in the training loop, perfect random access so shuffling is free, and the simplest code — __getitem__ is an array index
− you pay capped by RAM, and multiplied by worker count unless the storage is shared (memory-mapped or a shared-memory tensor); startup pays the full load time before the first step
pick when the dataset fits in RAM with room for the worker copies — for the small image and tabular datasets you learn on, this is essentially always the right answer
Memory-map / read per item
+ you gain scales far past RAM while keeping true random access, and the page cache means hot data behaves like the in-memory case; workers share the mapping rather than copying it, so memory does not multiply
− you pay random reads on slow storage will bottleneck the pipeline, and small-file-per-example layouts are pathological — the per-file overhead dominates, which is why sharded container formats exist
pick when the dataset exceeds RAM but sits on fast local storage, and it is packed into a small number of large files rather than millions of small ones
IterableDataset / streaming
+ you gain constant memory regardless of dataset size, works over network sources and infinite streams, and starts producing batches immediately with no load phase
− you pay no random access, so shuffling is limited to a bounded buffer and is genuinely weaker than a full permutation; __len__ may not exist, breaking progress bars and any schedule expressed in epochs; and you must shard across workers yourself or duplicate every example
pick when the data does not fit on the machine at all, or arrives over the network, or is generated on the fly
What a senior engineer actually does

Take the simplest option your memory budget allows, and only move down the list when forced. The complexity increases sharply at each step, and streaming in particular introduces failure modes — weak shuffling, worker duplication, no epoch boundary — that are much harder to debug than the memory problem they solve.

Before changing strategy at all, confirm the pipeline is actually the bottleneck. Watch accelerator utilisation during a step: if it is high, the loader is keeping up and every hour spent optimising it is wasted. And when it genuinely is the bottleneck, check the file layout before the code — many small files is a storage-layout problem, and no amount of worker tuning fixes it.



9 · 🧠 Retention scaffold

Quick recall · click to reveal
★ = stretch question

One-line summary (write it in your own words): _______________________________

Spaced review: re-read §6 (the four-step diagnosis) and §5.2 (pad_sequence) in 24 hours. Full session on day 7.

Next session (S017): the training loop you'd trust in production — seeds, checkpoint/resume (now including StatefulDataLoader!), TensorBoard/W&B logging, gradient monitoring, torch.compile gotchas, and the "log everything for future you" mindset.

Sticky note (keep on your desk): pin_memory=True + .to(dev, non_blocking=True) = free GPU perf. pad_sequence(..., batch_first=True) in collate_fn unlocks NLP. If GPU util <50%, blame data first, model second.


Previous: ← DL S015 · nn.Module · Next: DL S017 · The Training Loop →