PyTorch DataLoader Performance: num_workers, pin_memory, Prefetching and Why Your GPU Is Waiting

Page content

PyTorch: Zero to Hero — Step 05

A fast model with a slow input pipeline is still a slow training system.

One of the most common PyTorch performance failures looks like this:

GPU utilization: 20% → 95% → 10% → 90% → 15%

The model is not necessarily slow.

The GPU may simply be waiting for the next batch.

This article is about finding out where the wait is happening.

We are going to build and instrument a DataLoader, then attack the usual bottlenecks:

num_workers
pin_memory
non_blocking transfers
prefetch_factor
persistent_workers
batch_size
slow transforms
Python overhead
shared-memory pressure
multiprocessing mistakes

The goal is not to memorize a magic configuration such as:

DataLoader(..., num_workers=8, pin_memory=True)

That configuration may be excellent on one machine and terrible on another.

The goal is to measure the pipeline and tune the actual bottleneck.


The pipeline you are really training

Most training diagrams show this:

batch → model → loss → backward → optimizer

But the real system is closer to:

    flowchart TD
    A[disk / network / RAM] --> B[Dataset.__getitem__]
    B --> C[decode / parse / augment]
    C --> D[collate batch]
    D --> E[worker → main process]
    E --> F[CPU tensor]
    F --> G[CPU → GPU transfer]
    G --> H[forward]
    H --> I[backward]
    I --> J[optimizer]
    style G fill:#ffcc80,stroke:#333
    style H fill:#81c784,stroke:#333
    style I fill:#81c784,stroke:#333
    style J fill:#81c784,stroke:#333
  

If any stage takes longer than the GPU computation, the accelerator waits.

That means an expensive GPU can spend a surprising amount of time doing this:

nothing

Start with a deliberately slow dataset

We need something that makes the problem visible.

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


class SlowDataset(Dataset):
    def __init__(self, size=10_000, feature_dim=1024, delay=0.002):
        self.size = size
        self.feature_dim = feature_dim
        self.delay = delay

    def __len__(self):
        return self.size

    def __getitem__(self, index):
        # Simulate decoding, parsing, augmentation, disk work, etc.
        time.sleep(self.delay)

        x = torch.randn(self.feature_dim)
        y = torch.randint(0, 10, ()).long()
        return x, y

The sleep() is artificial, but the scheduling problem is real.

In production the delay may come from:

JPEG decoding
JSON parsing
PIL transforms
CSV parsing
network storage
compressed archives
audio decoding
random augmentation
Python tokenization
small-file filesystem overhead

Build a benchmark before tuning anything

Do not optimize a DataLoader by changing knobs and watching nvidia-smi casually.

Measure batch arrival time.

from time import perf_counter


def benchmark_loader(loader, max_batches=100):
    start = perf_counter()
    batches = 0
    examples = 0

    for x, y in loader:
        batches += 1
        examples += x.shape[0]

        if batches >= max_batches:
            break

    elapsed = perf_counter() - start

    return {
        "batches": batches,
        "examples": examples,
        "seconds": elapsed,
        "batches_per_second": batches / elapsed,
        "examples_per_second": examples / elapsed,
    }

Baseline:

dataset = SlowDataset()

loader = DataLoader(
    dataset,
    batch_size=64,
    shuffle=True,
    num_workers=0,
)

print(benchmark_loader(loader))

num_workers=0 means the main process performs the loading synchronously.

If __getitem__() is expensive, training cannot overlap that work with model execution.


num_workers=0 is not wrong

It is the safest debugging configuration.

It is also often correct when:

the dataset is already in RAM
__getitem__ is extremely cheap
the model itself is CPU-bound
multiprocessing overhead exceeds the work
running inside constrained containers
working on Windows while debugging worker failures

So do not start from:

More workers are always faster.

Start from:

Does parallel loading hide enough CPU work to justify multiprocessing?


Benchmark num_workers

Do this experimentally.

def make_loader(num_workers):
    return DataLoader(
        dataset,
        batch_size=64,
        shuffle=True,
        num_workers=num_workers,
    )


for workers in [0, 1, 2, 4, 8]:
    loader = make_loader(workers)
    result = benchmark_loader(loader)

    print(
        f"workers={workers:<2} "
        f"batches/s={result['batches_per_second']:.2f} "
        f"examples/s={result['examples_per_second']:.0f}"
    )

Your curve may look like:

0 workers  → slow
1 worker   → faster
2 workers  → faster
4 workers  → best
8 workers  → slightly worse
16 workers → much worse

Why can more workers make things slower?

Because workers compete for:

CPU cores
RAM
memory bandwidth
disk bandwidth
filesystem metadata
shared memory
Python process startup
cache locality

If eight workers all hit the same slow disk, you have not created eight disks.

We can plot this sweep to see the optimum at a glance:

import matplotlib.pyplot as plt

worker_counts = [0, 1, 2, 4, 8]
results = []

for w in worker_counts:
    loader = DataLoader(dataset, batch_size=64, shuffle=True, num_workers=w)
    r = benchmark_loader(loader)
    results.append(r['examples_per_second'])

plt.plot(worker_counts, results, marker='o')
plt.xlabel('Number of workers')
plt.ylabel('Examples per second')
plt.title('DataLoader throughput vs num_workers')
plt.grid(True)
plt.show()

The “knee” in the curve is your practical limit.


CPU oversubscription can destroy throughput

A very common failure is:

DataLoader workers × library threads >> available CPU cores

For example, each worker may invoke code backed by OpenMP, MKL, OpenCV or another threaded library.

Then:

8 workers × 8 CPU threads = 64 runnable threads

on an 8-core machine.

That can be dramatically slower than four workers using one or two threads each.

Inspect PyTorch CPU threads:

print(torch.get_num_threads())

Inside workers, you can deliberately constrain thread usage:

def worker_init_fn(worker_id):
    torch.set_num_threads(1)

Then:

loader = DataLoader(
    dataset,
    batch_size=64,
    num_workers=4,
    worker_init_fn=worker_init_fn,
)

This is not automatically the best value, but it is an important diagnostic when worker count makes performance unexpectedly worse.


Measure batch wait time separately from GPU compute

This is one of the most useful training-loop diagnostics you can add.

from time import perf_counter


def train_one_epoch(model, loader, optimizer, loss_fn, device):
    model.train()

    iterator = iter(loader)

    total_data_time = 0.0
    total_compute_time = 0.0

    for step in range(len(loader)):
        t0 = perf_counter()
        x, y = next(iterator)
        t1 = perf_counter()

        x = x.to(device)
        y = y.to(device)

        optimizer.zero_grad(set_to_none=True)
        logits = model(x)
        loss = loss_fn(logits, y)
        loss.backward()
        optimizer.step()

        if device.type == "cuda":
            torch.cuda.synchronize()

        t2 = perf_counter()

        total_data_time += t1 - t0
        total_compute_time += t2 - t1

    return {
        "data_seconds": total_data_time,
        "compute_seconds": total_compute_time,
    }

If you get:

data_seconds    = 30
compute_seconds = 10

then optimizing the model kernel is probably not your first problem.

Your accelerator is being starved.


A simple wait-ratio metric

def wait_ratio(data_seconds, compute_seconds):
    total = data_seconds + compute_seconds
    return data_seconds / total if total else 0.0

Interpretation:

0.05 → input pipeline is probably healthy
0.20 → worth investigating
0.50 → training spends half its time waiting for data
0.80 → model optimization is almost irrelevant until loading is fixed

The exact thresholds depend on your workload, but the metric forces the right question.


Why pin_memory=True exists

When training on CUDA, batches usually begin in normal CPU memory and must be copied to GPU memory.

Pinned host memory can support more efficient host-to-device transfers and can participate in asynchronous transfer patterns.

The normal pattern is:

loader = DataLoader(
    dataset,
    batch_size=64,
    num_workers=4,
    pin_memory=True,
)

Then:

for x, y in loader:
    x = x.to("cuda", non_blocking=True)
    y = y.to("cuda", non_blocking=True)

Do not assume pin_memory=True will transform every workload.

If your real bottleneck is JPEG decoding or a network filesystem, shaving transfer overhead may barely move total epoch time.

Again: benchmark the whole pipeline.


Do not manually pin every batch without measuring

This is tempting:

x = x.pin_memory()
x = x.to("cuda", non_blocking=True)

But explicit pinning itself has cost.

The more conventional approach is to let DataLoader manage pinned batches:

loader = DataLoader(..., pin_memory=True)

and then use:

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

for CUDA transfers.


non_blocking=True is not a magic async switch

This line:

x = x.to("cuda", non_blocking=True)

does not guarantee that your application now perfectly overlaps copies and GPU computation.

Whether useful overlap occurs depends on things such as:

source memory properties
CUDA stream behavior
dependency ordering
when synchronization occurs
whether subsequent kernels depend immediately on the copied tensor

But it is the standard building block for avoiding unnecessary host-side blocking during device copies.

The ideal overlap pattern can be seen as:

    sequenceDiagram
    participant CPU
    participant GPU
    CPU->>CPU: next batch (prefetched)
    CPU->>GPU: async copy (non_blocking)
    Note over CPU,GPU: CPU continues other work
    GPU->>GPU: compute on previous batch
    Note over GPU: copy completes in background
    GPU->>GPU: compute on new batch
  

Benchmark transfer strategies

def copy_batches(loader, device, max_batches=100, non_blocking=False):
    start = perf_counter()

    for i, (x, y) in enumerate(loader):
        x = x.to(device, non_blocking=non_blocking)
        y = y.to(device, non_blocking=non_blocking)

        if i + 1 >= max_batches:
            break

    if device.type == "cuda":
        torch.cuda.synchronize()

    return perf_counter() - start

Compare:

plain_loader = DataLoader(
    dataset,
    batch_size=64,
    num_workers=4,
    pin_memory=False,
)

pinned_loader = DataLoader(
    dataset,
    batch_size=64,
    num_workers=4,
    pin_memory=True,
)

print(copy_batches(plain_loader, device, non_blocking=False))
print(copy_batches(pinned_loader, device, non_blocking=True))

Do not publish one benchmark number from one laptop as a universal rule.

The useful output is the measurement method.


prefetch_factor: how far workers prepare ahead

With multiprocessing enabled, workers can prepare future batches while the current batch is being consumed.

Example:

loader = DataLoader(
    dataset,
    batch_size=64,
    num_workers=4,
    prefetch_factor=2,
)

The idea is simple:

GPU computes batch N
workers prepare batches N+1, N+2, ...

If loading latency has spikes, extra prefetched work can help keep the consumer fed.

But prefetching consumes memory.

A rough mental model is:

memory pressure ≈ workers × prefetch_factor × batch memory

plus worker process state, dataset state and Python overhead.

If your batches are large, increasing prefetch_factor aggressively can create RAM or shared-memory problems long before it improves throughput.


Benchmark prefetch_factor

Only set it when num_workers > 0.

for prefetch in [1, 2, 4, 8]:
    loader = DataLoader(
        dataset,
        batch_size=64,
        num_workers=4,
        prefetch_factor=prefetch,
    )

    result = benchmark_loader(loader)
    print(prefetch, result["examples_per_second"])

If:

1 → 5000 examples/s
2 → 7100
4 → 7200
8 → 6900

then 8 is not more optimized simply because it is larger.


persistent_workers=True

By default, worker processes may be created again when a new iterator is constructed for another epoch.

For many-epoch training, worker startup can be meaningful overhead.

Use:

loader = DataLoader(
    dataset,
    batch_size=64,
    num_workers=4,
    persistent_workers=True,
)

This is especially useful when:

worker startup is expensive
dataset initialization in each process is expensive
epochs are short
training runs for many epochs

Benchmark multiple epochs, not just the first iterator.

def benchmark_epochs(loader, epochs=5):
    start = perf_counter()

    for _ in range(epochs):
        for _batch in loader:
            pass

    return perf_counter() - start

Compare:

for persistent in [False, True]:
    loader = DataLoader(
        dataset,
        batch_size=64,
        num_workers=4,
        persistent_workers=persistent,
    )

    print(persistent, benchmark_epochs(loader))

The first epoch can lie to you

A first-epoch benchmark may include:

process startup
imports
filesystem cache misses
dataset initialization
kernel compilation
CUDA context initialization
page cache warm-up

If you are comparing settings, run warm-up iterations.

def warmup(loader, batches=20):
    for i, _ in enumerate(loader):
        if i + 1 >= batches:
            break

Then benchmark.


Batch size changes DataLoader economics

Suppose each __getitem__() costs meaningful Python overhead.

A tiny batch size means more batches, more collation events and more loop overhead.

Compare:

for batch_size in [8, 16, 32, 64, 128, 256]:
    loader = DataLoader(
        dataset,
        batch_size=batch_size,
        num_workers=4,
    )

    result = benchmark_loader(loader)
    print(batch_size, result["examples_per_second"])

But batch size also affects:

GPU memory
optimization dynamics
number of optimizer updates
kernel efficiency
activation memory

So DataLoader throughput is only one constraint.


Your transform may be the bottleneck

This is common in vision code:

class ImageDataset(Dataset):
    def __getitem__(self, index):
        image = load_image(self.paths[index])
        image = expensive_python_transform(image)
        return image

Measure inside __getitem__().

class TimedDataset(Dataset):
    def __init__(self, wrapped):
        self.wrapped = wrapped
        self.total_time = 0.0

    def __len__(self):
        return len(self.wrapped)

    def __getitem__(self, index):
        start = perf_counter()
        item = self.wrapped[index]
        elapsed = perf_counter() - start

        # Fine for debugging with num_workers=0.
        # With multiprocessing, each worker owns its own dataset copy/state.
        self.total_time += elapsed
        return item

For worker-safe profiling, emit timing metadata with the sample:

class ProfiledDataset(Dataset):
    def __getitem__(self, index):
        t0 = perf_counter()
        raw = self.read(index)
        t1 = perf_counter()

        transformed = self.transform(raw)
        t2 = perf_counter()

        return {
            "x": transformed,
            "read_time": t1 - t0,
            "transform_time": t2 - t1,
        }

Then inspect aggregated timings in the main process.


Python-heavy tokenization can starve training too

DataLoader problems are not only image problems.

Consider NLP preprocessing:

class TextDataset(Dataset):
    def __getitem__(self, index):
        text = self.rows[index]
        tokens = slow_python_tokenizer(text)
        return torch.tensor(tokens)

If tokenization is deterministic, ask whether it belongs in the hot training path at all.

Pre-tokenizing once may beat repeatedly doing expensive text work every epoch.

The same question applies to:

spectrogram generation
feature extraction
JSON parsing
resizing
normalization statistics
archive decompression

Move deterministic expensive work out of the epoch loop when practical.


Map-style vs iterable datasets matter

A map-style dataset implements operations such as:

__len__
__getitem__

An IterableDataset yields a stream.

from torch.utils.data import IterableDataset


class NumberStream(IterableDataset):
    def __iter__(self):
        for i in range(1_000_000):
            yield torch.tensor(i)

With multiple workers, naive iterable datasets can duplicate data because each worker gets its own dataset instance.

You need worker-aware sharding.


Correctly shard an IterableDataset

from torch.utils.data import get_worker_info


class ShardedRange(IterableDataset):
    def __init__(self, start, end):
        self.start = start
        self.end = end

    def __iter__(self):
        worker = get_worker_info()

        if worker is None:
            start = self.start
            end = self.end
        else:
            total = self.end - self.start
            per_worker = (total + worker.num_workers - 1) // worker.num_workers

            start = self.start + worker.id * per_worker
            end = min(start + per_worker, self.end)

        yield from range(start, end)

Test it:

loader = DataLoader(
    ShardedRange(0, 100),
    num_workers=4,
    batch_size=10,
)

values = []
for batch in loader:
    values.extend(batch.tolist())

print(len(values))
print(len(set(values)))

For a correct single-pass stream you want those counts to agree.


Debug worker crashes with num_workers=0

A classic error is:

DataLoader worker exited unexpectedly

The first debugging move should usually be:

DataLoader(..., num_workers=0)

Why?

Because the real exception happens in the main process and you get the full traceback directly.

A worker wrapper can make errors feel mysterious when the underlying bug is simply:

bad file
invalid index
broken transform
shape mismatch
missing dependency
unexpected None

Windows: protect multiprocessing entry points

On spawn-based multiprocessing systems, top-level code can be re-imported in child processes.

Bad:

loader = DataLoader(dataset, num_workers=4)

for batch in loader:
    train(batch)

Safer script structure:

def main():
    dataset = MyDataset()

    loader = DataLoader(
        dataset,
        batch_size=64,
        num_workers=4,
    )

    for batch in loader:
        pass


if __name__ == "__main__":
    main()

If multiprocessing works on Linux but recursively explodes or hangs on Windows, check the process entry point before blaming PyTorch.


Do not return CUDA tensors from DataLoader workers

Keep dataset workers producing CPU-side data.

Bad pattern:

class DatasetReturningCuda(Dataset):
    def __getitem__(self, index):
        x = load(index)
        return x.cuda()

Better:

class CpuDataset(Dataset):
    def __getitem__(self, index):
        return load(index)

Then in the training process:

for x, y in loader:
    x = x.to(device, non_blocking=True)
    y = y.to(device, non_blocking=True)

This keeps device ownership and process boundaries much easier to reason about.


Shared-memory failures

Multi-worker loading moves data between processes.

On Linux/container setups you may encounter errors such as:

Unexpected bus error encountered in worker

or shared-memory allocation failures.

Before reaching for exotic fixes, reduce pressure:

loader = DataLoader(
    dataset,
    batch_size=32,
    num_workers=2,
    prefetch_factor=1,
)

You just reduced:

worker count
prefetched batches
batch memory

which are exactly the parameters driving much of the queue/shared-memory pressure.

In Docker, also inspect /dev/shm sizing.


Estimate batch memory

For a tensor:

def tensor_bytes(tensor):
    return tensor.numel() * tensor.element_size()

For nested batch structures:

def object_bytes(obj):
    if torch.is_tensor(obj):
        return obj.numel() * obj.element_size()

    if isinstance(obj, dict):
        return sum(object_bytes(v) for v in obj.values())

    if isinstance(obj, (tuple, list)):
        return sum(object_bytes(v) for v in obj)

    return 0

Inspect:

batch = next(iter(loader))
print(object_bytes(batch) / 1024**2, "MiB")

Then mentally multiply by queued/prefetched batches and worker count.

This is not a perfect process-memory estimate, but it is far better than tuning prefetch depth blindly.


Custom collate_fn can secretly dominate runtime

Suppose samples have variable-length sequences.

def collate_fn(samples):
    # Maybe sorting, padding, Python loops, dictionaries, etc.
    ...

The collate function executes as part of batch construction.

Profile it independently.

samples = [dataset[i] for i in range(64)]

start = perf_counter()
batch = collate_fn(samples)
print("collate seconds:", perf_counter() - start)

If collate_fn spends 30 ms per batch and model compute spends 10 ms, your GPU will never be continuously busy without enough overlap.


A collate function should avoid needless Python loops

Bad pattern:

def slow_collate(samples):
    output = []
    for sample in samples:
        output.append(sample["x"])

    return torch.stack(output)

That particular example is simple, but real code often contains nested Python transforms, conversions and copying.

Whenever possible, use vectorized tensor operations after stacking.


Dataset object size matters with multiple workers

Worker processes may each hold dataset-related Python state.

This can hurt badly when the dataset object contains huge Python structures such as:

millions of file path strings
large dictionaries
metadata trees
huge nested lists

Be careful with designs like:

class HugeDataset(Dataset):
    def __init__(self):
        self.metadata = load_massive_python_dictionary()

If every worker materializes expensive independent state, increasing num_workers may explode memory usage.

Consider compact representations, memory mapping, databases, Arrow-like columnar formats, or loading worker-local state deliberately.


Do not reopen expensive resources for every sample

Bad:

def __getitem__(self, index):
    connection = open_database()
    row = connection.read(index)
    connection.close()
    return row

Better patterns depend on the resource, but often you want worker-local lazy initialization.

class DatabaseDataset(Dataset):
    def __init__(self):
        self.connection = None

    def _get_connection(self):
        if self.connection is None:
            self.connection = open_database()
        return self.connection

    def __getitem__(self, index):
        connection = self._get_connection()
        return connection.read(index)

With worker processes, each worker can establish its own safe connection when first needed.

Whether this is valid depends on the database/client library, so understand its multiprocessing guarantees.


worker_init_fn and reproducible randomness

When using random transforms, worker-local random state matters.

import random
import numpy as np


def seed_worker(worker_id):
    worker_seed = torch.initial_seed() % 2**32
    random.seed(worker_seed)
    np.random.seed(worker_seed)

Then:

generator = torch.Generator()
generator.manual_seed(1234)

loader = DataLoader(
    dataset,
    batch_size=64,
    num_workers=4,
    worker_init_fn=seed_worker,
    generator=generator,
)

This matters for performance experiments too.

If every benchmark run receives materially different augmentation behavior, comparing timings becomes noisier.


shuffle=True is usually not your performance problem

For ordinary map-style datasets, shuffling indices is cheap relative to real decode/transform work.

If turning off shuffle appears to create a huge speedup, investigate what the changed access order did to:

disk locality
network reads
cache behavior
compressed storage
remote object access

The sampler may have exposed a storage locality problem rather than being expensive itself.


Remote storage changes the tuning problem

For S3, network filesystems, blob storage or remote databases, worker tuning interacts with network concurrency.

Too few workers:

latency dominates

Too many:

connection contention
rate limits
bandwidth saturation
server throttling

The same benchmark loop still works.

Only the bottleneck changes.


Cache expensive deterministic work

Suppose preprocessing turns a raw sample into an expensive tensor.

raw -> decode -> resize -> feature transform -> tensor

If those operations are deterministic, consider caching the output.

For example:

from pathlib import Path


class CachedDataset(Dataset):
    def __init__(self, paths, cache_dir):
        self.paths = paths
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(parents=True, exist_ok=True)

    def __getitem__(self, index):
        cache_file = self.cache_dir / f"{index}.pt"

        if cache_file.exists():
            return torch.load(cache_file, weights_only=True)

        x = expensive_preprocess(self.paths[index])
        torch.save(x, cache_file)
        return x

Production caching requires invalidation/versioning, but conceptually this can move expensive deterministic work out of repeated epochs.


Separate throughput from latency variance

Average batch time can hide stalls.

Record individual waits.

def batch_wait_times(loader, count=100):
    iterator = iter(loader)
    waits = []

    for _ in range(count):
        start = perf_counter()
        try:
            next(iterator)
        except StopIteration:
            break
        waits.append(perf_counter() - start)

    return waits

Then:

waits = batch_wait_times(loader)
waits_sorted = sorted(waits)

print("mean:", sum(waits) / len(waits))
print("max:", max(waits))
print("p95:", waits_sorted[int(len(waits_sorted) * 0.95)])

A pipeline with:

mean = 5 ms
p95  = 60 ms

may cause visible GPU utilization sawtoothing even though the average looks respectable.


Instrument GPU utilization properly

nvidia-smi is useful, but sampling can miss short idle periods.

Inside PyTorch, time operations carefully with CUDA events.

def time_cuda_step(fn):
    start = torch.cuda.Event(enable_timing=True)
    end = torch.cuda.Event(enable_timing=True)

    start.record()
    fn()
    end.record()

    torch.cuda.synchronize()
    return start.elapsed_time(end)  # milliseconds

Example:

x, y = next(iter(loader))
x = x.cuda(non_blocking=True)
y = y.cuda(non_blocking=True)

ms = time_cuda_step(lambda: model(x))
print(ms)

Use wall-clock timing for the end-to-end data pipeline and CUDA events for actual GPU work.

Do not confuse the two.


PyTorch Profiler can expose input stalls

A minimal profile:

from torch.profiler import profile, ProfilerActivity

activities = [ProfilerActivity.CPU]
if torch.cuda.is_available():
    activities.append(ProfilerActivity.CUDA)

with profile(activities=activities, record_shapes=True) as prof:
    for step, (x, y) in enumerate(loader):
        x = x.to(device, non_blocking=True)
        y = y.to(device, non_blocking=True)

        optimizer.zero_grad(set_to_none=True)
        logits = model(x)
        loss = loss_fn(logits, y)
        loss.backward()
        optimizer.step()

        if step == 20:
            break

print(
    prof.key_averages().table(
        sort_by="self_cpu_time_total",
        row_limit=25,
    )
)

The profiler will not magically tell you your architecture, but it can reveal whether time is dominated by:

Python/data loading
CPU transforms
copies
model kernels
synchronization

Add explicit profiler ranges

This makes traces much easier to read.

from torch.profiler import record_function

for x, y in loader:
    with record_function("host_to_device"):
        x = x.to(device, non_blocking=True)
        y = y.to(device, non_blocking=True)

    with record_function("forward"):
        logits = model(x)

    with record_function("loss"):
        loss = loss_fn(logits, y)

    with record_function("backward"):
        loss.backward()

    with record_function("optimizer"):
        optimizer.step()
        optimizer.zero_grad(set_to_none=True)

Now the timeline has labels that match your mental model.


Build a reusable DataLoader benchmark matrix

Instead of manually changing one variable repeatedly:

from itertools import product


def benchmark_matrix(dataset):
    results = []

    for workers, batch_size in product(
        [0, 1, 2, 4, 8],
        [32, 64, 128],
    ):
        kwargs = {
            "dataset": dataset,
            "batch_size": batch_size,
            "shuffle": True,
            "num_workers": workers,
        }

        if workers > 0:
            kwargs["prefetch_factor"] = 2
            kwargs["persistent_workers"] = True

        loader = DataLoader(**kwargs)
        result = benchmark_loader(loader)

        results.append({
            "workers": workers,
            "batch_size": batch_size,
            **result,
        })

    return results

Print sorted results:

results = benchmark_matrix(dataset)

for row in sorted(
    results,
    key=lambda r: r["examples_per_second"],
    reverse=True,
):
    print(row)

This is already far more useful than copying someone else’s num_workers=8 from Stack Overflow.


Add pinning and prefetching to the matrix

def benchmark_configs(dataset):
    configs = []

    for workers in [0, 2, 4, 8]:
        for batch_size in [32, 64, 128]:
            if workers == 0:
                configs.append({
                    "batch_size": batch_size,
                    "num_workers": 0,
                    "pin_memory": False,
                })
                continue

            for prefetch in [1, 2, 4]:
                for persistent in [False, True]:
                    configs.append({
                        "batch_size": batch_size,
                        "num_workers": workers,
                        "prefetch_factor": prefetch,
                        "persistent_workers": persistent,
                        "pin_memory": torch.cuda.is_available(),
                    })

    results = []

    for config in configs:
        loader = DataLoader(dataset, shuffle=True, **config)
        result = benchmark_loader(loader)
        results.append({**config, **result})

    return results

Do not run enormous parameter grids on production datasets casually, but this is a powerful local tuning pattern.


A practical training configuration function

Once measured, centralize the chosen settings.

def build_loader(
    dataset,
    *,
    batch_size,
    training,
    num_workers,
    device,
):
    kwargs = {
        "dataset": dataset,
        "batch_size": batch_size,
        "shuffle": training,
        "num_workers": num_workers,
        "pin_memory": device.type == "cuda",
    }

    if num_workers > 0:
        kwargs.update({
            "prefetch_factor": 2,
            "persistent_workers": True,
        })

    return DataLoader(**kwargs)

This prevents configuration differences from leaking across training, validation and experiments.


Why is my GPU waiting? A debugging decision tree

Start here:

    flowchart TD
    A[GPU utilization low or sawtoothing] --> B[measure batch wait vs compute]
    B --> C{Is batch wait large?}
    C -- yes --> D[profile loader]
    D --> E{try num_workers > 0}
    E --> F{throughput improves?}
    F -- yes --> G[tune workers]
    F -- no --> H[profile __getitem__]
    H --> I[storage/transform/collate?]
    G --> J[CUDA training?]
    I --> J
    J --> K[pin_memory + non_blocking transfer]
    K --> L[benchmark again]
    C -- no --> M[profile model/GPU]
    M --> N[kernel / architecture bottleneck]
    style A fill:#ffccbc
    style L fill:#c8e6c9
    style M fill:#bbdefb
  

At every arrow:

measure again

Common failure: increasing num_workers forever

Symptoms:

CPU pinned at 100%
training slower
fans screaming
high context switching
random stalls

Fix:

for workers in [0, 1, 2, 4, 8]:
    benchmark(...)

Find the knee in the curve.

Do not treat CPU cores as a target worker count.


Common failure: GPU starvation from synchronous loading

Symptoms:

GPU utilization pulses
large gaps between steps
num_workers=0
expensive decoding or augmentation

Test:

loader = DataLoader(
    dataset,
    batch_size=64,
    num_workers=4,
)

Then compare end-to-end throughput.


Common failure: pin_memory=True does nothing

That can be completely normal.

If:

read + transform = 50 ms
CPU→GPU copy    = 2 ms

then optimizing copy time cannot produce a dramatic training speedup.

This is why we separate stages instead of assuming a feature is broken.


Common failure: persistent_workers=True appears slower

Did you benchmark only one epoch?

Keeping workers alive mainly avoids repeated startup between iterators/epochs.

For one short benchmark, it may provide no advantage.

Compare:

benchmark_epochs(loader, epochs=10)

not only:

next(iter(loader))

Common failure: huge RAM growth with workers

Inspect:

number of workers
batch size
prefetch factor
sample size
dataset Python state
collated batch size

Then reduce pressure methodically.

DataLoader(
    dataset,
    batch_size=32,
    num_workers=2,
    prefetch_factor=1,
)

If RAM suddenly stabilizes, you have evidence about the bottleneck.


Common failure: workers hang indefinitely

Possible causes include:

library not fork-safe
thread locks
network client state
unsafe database connection reuse
worker deadlock
bad process entry point
blocking I/O with no timeout

First isolate:

num_workers=0

Then:

num_workers=1

Then increase gradually.

The smallest failing worker count tells you a great deal.


Common failure: validation is unnecessarily slow

Validation often does not need random augmentation.

Bad:

train_dataset = Dataset(transform=expensive_random_transform)
val_dataset = Dataset(transform=expensive_random_transform)

Better:

train_dataset = Dataset(transform=train_transform)
val_dataset = Dataset(transform=val_transform)

Also disable autograd during validation:

model.eval()

with torch.no_grad():
    for x, y in val_loader:
        ...

That is not a DataLoader optimization, but end-to-end validation performance is what matters.


Common failure: timing without CUDA synchronization

Bad benchmark:

start = perf_counter()
output = model(x.cuda())
print(perf_counter() - start)

CUDA work is asynchronous with respect to the host in many cases.

For wall-clock GPU timing:

torch.cuda.synchronize()
start = perf_counter()

output = model(x)

torch.cuda.synchronize()
elapsed = perf_counter() - start

Without synchronization you can accidentally measure kernel launch overhead rather than actual compute time.


Build a batch inspector

def inspect_batch(batch, prefix="batch"):
    if torch.is_tensor(batch):
        print(
            prefix,
            "shape=", tuple(batch.shape),
            "dtype=", batch.dtype,
            "device=", batch.device,
            "pinned=", batch.is_pinned() if batch.device.type == "cpu" else False,
        )
        return

    if isinstance(batch, dict):
        for key, value in batch.items():
            inspect_batch(value, f"{prefix}.{key}")
        return

    if isinstance(batch, (tuple, list)):
        for i, value in enumerate(batch):
            inspect_batch(value, f"{prefix}[{i}]")
        return

    print(prefix, type(batch).__name__)

Use it:

batch = next(iter(loader))
inspect_batch(batch)

This catches surprising dtype/device/pinning behavior quickly.


Build a DataLoader configuration reporter

def describe_loader(loader):
    attrs = [
        "batch_size",
        "num_workers",
        "pin_memory",
        "prefetch_factor",
        "persistent_workers",
        "drop_last",
    ]

    for name in attrs:
        print(f"{name:20} {getattr(loader, name, None)}")

Experiment logs should include loader configuration.

Otherwise six weeks later you may know:

experiment B was 18% faster

without knowing that experiment B quietly used four workers instead of zero.


Save performance metadata with experiments

import json


def save_loader_benchmark(path, config, result):
    payload = {
        "config": config,
        "result": result,
        "torch_version": torch.__version__,
        "cuda_available": torch.cuda.is_available(),
    }

    with open(path, "w", encoding="utf-8") as f:
        json.dump(payload, f, indent=2)

Performance without environment metadata is hard to reproduce.

Consider recording:

CPU model
core count
RAM
GPU
storage type
operating system
PyTorch version
CUDA version
worker count
batch size

A realistic optimized training skeleton

import torch
from torch.utils.data import DataLoader


def build_training_loader(dataset, batch_size, device):
    workers = 4  # benchmark this on your machine

    return DataLoader(
        dataset,
        batch_size=batch_size,
        shuffle=True,
        num_workers=workers,
        pin_memory=device.type == "cuda",
        prefetch_factor=2,
        persistent_workers=True,
    )


def move_batch(batch, device):
    x, y = batch

    x = x.to(
        device,
        non_blocking=device.type == "cuda",
    )
    y = y.to(
        device,
        non_blocking=device.type == "cuda",
    )

    return x, y


def train_epoch(model, loader, optimizer, loss_fn, device):
    model.train()

    total_loss = 0.0

    for batch in loader:
        x, y = move_batch(batch, device)

        optimizer.zero_grad(set_to_none=True)

        logits = model(x)
        loss = loss_fn(logits, y)

        loss.backward()
        optimizer.step()

        total_loss += loss.item()

    return total_loss / len(loader)

The important comment is:

workers = 4  # benchmark this on your machine

not the number 4.


A benchmark-driven loader tuner

Here is a reusable starting point.

def tune_num_workers(
    dataset,
    batch_size,
    worker_options=(0, 1, 2, 4, 8),
    batches=100,
):
    results = []

    for workers in worker_options:
        kwargs = {
            "dataset": dataset,
            "batch_size": batch_size,
            "num_workers": workers,
            "shuffle": False,
        }

        if workers > 0:
            kwargs["persistent_workers"] = True
            kwargs["prefetch_factor"] = 2

        loader = DataLoader(**kwargs)

        start = perf_counter()
        examples = 0
        count = 0

        for batch in loader:
            x = batch[0] if isinstance(batch, (tuple, list)) else batch

            try:
                examples += len(x)
            except TypeError:
                pass

            count += 1
            if count >= batches:
                break

        elapsed = perf_counter() - start

        results.append({
            "num_workers": workers,
            "seconds": elapsed,
            "batches_per_second": count / elapsed,
            "examples_per_second": examples / elapsed if examples else None,
        })

    return results

Usage:

for result in tune_num_workers(dataset, batch_size=64):
    print(result)

Now worker count is an empirical result.


The performance rule that matters most

Do not ask:

What is the best num_workers value?

Ask:

What stage is preventing the accelerator from continuously receiving useful work?

That question scales beyond DataLoader.

It applies to:

model compilation
distributed training
network input
checkpoint writes
logging
CPU preprocessing
GPU transfers
kernel launch overhead

Performance engineering is bottleneck engineering.


Practical checklist: PyTorch DataLoader is slow

Run this in order.

1. Establish the baseline

num_workers=0

Measure examples/second.

2. Measure batch waiting separately

data wait vs model compute

3. Sweep worker count

0, 1, 2, 4, 8

4. Profile __getitem__

Separate:

read
decode
transform

5. Profile collate_fn

Especially for variable-length or nested data.

6. For CUDA, test

pin_memory=True

plus:

.to(device, non_blocking=True)

7. Tune prefetch depth

prefetch_factor=1, 2, 4

8. Benchmark persistent workers over multiple epochs

persistent_workers=True

9. Watch memory

Especially:

workers × prefetch × batch size

10. Investigate storage

If CPU parallelism does not help, your disk/network may already be saturated.

11. Check process/thread oversubscription

Workers spawning heavily threaded libraries can destroy performance.

12. Profile the entire step again

Because fixing one bottleneck often exposes the next.


Challenge: make the GPU stop waiting

Take a real dataset from one of your projects.

Create a baseline:

DataLoader(
    dataset,
    batch_size=YOUR_BATCH_SIZE,
    num_workers=0,
    pin_memory=False,
)

Record:

examples/second
batch wait time
GPU utilization
RAM usage

Then sweep:

num_workers
batch_size
prefetch_factor
persistent_workers
pin_memory

Do not change all parameters simultaneously at first.

Your final result should look something like:

baseline:              820 examples/s
4 workers:            2180 examples/s
+ persistent workers: 2390 examples/s
+ pinned memory:       2510 examples/s

Or perhaps:

baseline:             820 examples/s
4 workers:            790 examples/s

That second result is just as useful.

It tells you multiprocessing was not the bottleneck solution.


What we learned

The DataLoader is not just a convenience wrapper around a Dataset.

It is a concurrency and buffering boundary between data production and model consumption.

The important controls are:

batch_size
num_workers
pin_memory
prefetch_factor
persistent_workers
collate_fn
worker initialization

But the controls are not the lesson.

The lesson is:

measure producer speed
measure consumer speed
find the slower side
remove that bottleneck
measure again

That is how you stop an expensive GPU from waiting on Python.


Where the series goes next

Step 00 — What Are We Actually Doing?
Step 01 — Tensor Shapes and Broadcasting Bugs
Step 02 — Autograd Debugging
Step 03 — Build a Neural Network Without nn.Module
Step 04 — nn.Module, Parameters, Buffers and state_dict
Step 05 — DataLoader Performance and GPU Starvation
Step 06 — CNNs: Teaching PyTorch to See
Step 07 — Attention and Transformers From Scratch
Step 08 — Train Something Real
Step 09 — Performance, Compilation and Scale
Step 10 — Build a Small Language Model From Scratch

The next post moves back into model architecture, but we will keep the same programmer-first rule.

Instead of only explaining convolutions, we will attack the problems that show up when building them:

Conv2d input shape errors
channels-first vs channels-last confusion
calculating convolution output sizes
flatten-size mismatches
pooling mistakes

Then we will build a CNN that you can actually debug rather than merely copy.