DataLoader num_workers Bus Error — Docker shm Fix
Docker's 64MB shm triggers Bus error in PyTorch DataLoader (num_workers>0).
20+ years shipping production ML systems and the infrastructure behind them. Notes here come from systems that actually shipped.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Dataset defines how to access a single sample — implement __len__ and __getitem__ for lazy loading
- DataLoader wraps a Dataset to provide batching, shuffling, and multi-process parallel loading
- pin_memory=True speeds up CPU-to-GPU transfers by using page-locked host memory
- num_workers > 0 parallelizes data loading on CPU — the #1 fix for GPU starvation
- The biggest production mistake is num_workers=0, which serializes loading and slows training 50%+
- In Docker, --shm-size must be increased when num_workers > 0 or you get Bus error crashes
Think of PyTorch DataLoader and Datasets as the logistics layer of a large industrial kitchen. The Dataset is your pantry — it holds all the raw ingredients and knows exactly where each one lives. The DataLoader is your sous-chef — it pulls those ingredients, organises them into manageable trays (batches), shuffles the order so the kitchen never gets stuck cooking the same meal twice in a row, and hands each tray to the head chef (the GPU) at exactly the right moment so the stove never sits idle waiting. Without a well-organised sous-chef, the most powerful stove in the world spends most of its time waiting for ingredients that are not ready yet.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
PyTorch DataLoader and Datasets decouple data storage from batching logic, enabling scalable pipelines that keep GPUs fully utilised. The Dataset class abstracts how individual samples are accessed — one at a time, lazily, from disk or a database. The DataLoader handles batching, shuffling, and multi-process loading on top of whatever Dataset you hand it.
The core problem these tools solve: training on datasets that do not fit in memory while keeping the GPU fed continuously. If data loading is slower than GPU computation, the GPU sits idle between batches — this is called data starvation and it is one of the most common reasons a training run is 3x slower than it should be. The DataLoader solves this by pre-fetching batches in parallel worker processes while the GPU processes the current batch. That overlap is the entire point.
The architectural separation is deliberate and worth internalising early: Dataset knows how to access one sample. DataLoader knows how to batch, shuffle, and parallelise. This means you can swap your data source (disk, SQL, S3, Kafka) without touching the DataLoader, and you can tune the DataLoader's parallelism without touching the Dataset. Each side has one job.
The most common production failure I see in 2026 is the same one I saw in 2022: developers set num_workers=0 during prototyping because it is simpler, everything works, and then they deploy to a real dataset and discover training is 3–5x slower than it needs to be because data loading is serialised on the main thread. The fix is always num_workers >= 1 with pin_memory=True for GPU training — and documenting that requirement so it does not get reverted in a future PR.
What Is PyTorch DataLoader and Datasets and Why Does It Exist?
PyTorch DataLoader and Datasets exist to solve a single concrete problem: how do you train on data that is too large to fit in memory, while keeping a GPU that costs thousands of dollars per hour fully utilised?
The Dataset class — specifically the Map-style variant — requires implementing two methods: __len__ (how many samples exist) and __getitem__ (fetch one sample by index). That is the entire contract. The Dataset knows nothing about batching, shuffling, or parallelism. It just answers 'give me sample 4,217' as fast as it can.
The DataLoader wraps that Dataset and adds everything else: it selects a batch of indices (optionally shuffled), hands those indices to worker processes that call __getitem__ in parallel, collates the results into a batch tensor, and optionally pre-pins that tensor in page-locked memory for faster GPU transfer. The training loop then pulls pre-fetched batches from a queue without waiting.
The performance insight that changes how you think about this: with num_workers=4 and pin_memory=True, the DataLoader is pre-fetching batch N+1 and N+2 while the GPU is still processing batch N. That pipeline overlap is what keeps GPU utilisation above 90%. Without it — with num_workers=0 — every batch is loaded synchronously on the main thread after the GPU finishes the previous one. The GPU sits idle for however long loading takes. On a dataset of real images with augmentations, that idle time can represent 60–70% of wall-clock training time.
As of 2026, with models being trained on increasingly large datasets and GPUs being increasingly expensive, getting this right is not an optimisation — it is table stakes.
- Dataset defines how to access ONE sample — it knows nothing about batching or parallelism and should not
- DataLoader wraps the Dataset and adds batching, shuffling, and multi-process loading on top
- Worker processes (producers) load and transform data in parallel on CPU cores while the GPU works
- The training loop (consumer) pulls pre-fetched batches from a queue — ideally it never waits
- pin_memory=True pre-pins batches to page-locked memory so DMA transfers to GPU start without an extra copy step
Enterprise Integration: SQL-Backed Datasets
In real production environments, your training data rarely lives in a flat folder of files. It lives in a database — with labels, metadata, train/val/test splits, and versioning all managed in SQL. Implementing a Dataset that queries a SQL backend is one of the more underrated patterns in production ML engineering.
The approach: in __init__, run a single SQL query to fetch metadata only — sample IDs, file paths on disk or object storage, and labels. Store that metadata in memory as a list or DataFrame. In __getitem__, use the file path from metadata to load the actual binary data — the image, audio file, or feature array — from disk or S3. This keeps memory usage proportional to the number of samples (a few bytes per row of metadata), not the size of the data (potentially gigabytes).
The production benefit that makes this pattern worth the setup: when you add new training data, you insert a row into the SQL table and drop the corresponding file on disk. The next training run picks it up automatically via the __init__ query. There is no CSV file to regenerate, no manifest to sync, and no risk of the file list drifting from the actual filesystem state. I have seen teams spend days debugging training regressions that turned out to be a stale CSV pointing to deleted files — this pattern eliminates that entire class of issue.
One thing to watch: do not query SQL inside __getitem__. SQL connections are not thread-safe and cannot be pickled for multi-process workers. Fetch all metadata once in __init__ and do all disk or object-storage I/O in __getitem__.
Containerised Data Pipelines with Docker
Wrapping your training environment in Docker is the standard way to ensure the data pipeline behaves identically across a developer's laptop, a CI server, and a production GPU cluster. It also surfaces the most common PyTorch DataLoader configuration mistake before it costs you a four-hour training run.
The critical Docker configuration that almost everyone gets wrong the first time: when num_workers > 0, PyTorch uses shared memory at /dev/shm to transfer tensors between worker processes and the main process. Docker's default shared memory allocation is 64MB — a sensible default for containerised web services that never heard of PyTorch. For a training job with num_workers=4 and any real batch size, that 64MB fills up within a few epochs and the container dies with a Bus error and no Python traceback. The fix is one flag: --shm-size=2g.
The deployment checklist I use for every new training container: set --shm-size=2g or larger; mount the data directory as a Docker volume rather than copying it into the image (datasets are too large for image layers and change too frequently); pin the PyTorch version explicitly rather than using pytorch/pytorch:latest (latest changes under you in ways that are hard to reproduce); set num_workers based on the CPU cores allocated to the container, not the host machine's total CPU count; and add a pre-flight health check that verifies /dev/shm has enough free space before the training job starts.
The --shm-size flag also belongs in your docker-compose.yml, your Kubernetes pod spec under resources, and your CI job definition — anywhere the container is launched. If it lives in only one place, it will be dropped in a refactor and you will spend an afternoon diagnosing a Bus error that you already fixed six months ago.
Common Mistakes and How to Avoid Them
Most DataLoader bugs in production fall into a small set of patterns. Knowing them in advance means you spend time training models instead of debugging pipelines.
The performance mistakes: num_workers=0 is the biggest one — it serialises every sample load on the main thread and GPU sits idle while it happens. Loading data in __init__ instead of __getitem__ is the second — it turns a lazy-loading Dataset into a greedy RAM consumer that OOMs before training even starts.
The correctness mistakes: passing unpickleable objects (open file handles, database connections, lambda functions) to a Dataset when num_workers > 0. Python's multiprocessing pickles the Dataset to send it to each worker process. If any attribute cannot be pickled, the worker hangs silently or crashes without a useful traceback. The fix is to initialise those objects inside __getitem__ (called per sample in each worker) or use worker_init_fn to set them up once per worker process.
The subtle one that costs teams debugging time: forgetting drop_last=True on the training DataLoader. The last batch of an epoch almost always has fewer samples than the configured batch_size. For most loss functions this is harmless, but for BatchNorm it is not — BatchNorm uses batch statistics during training, and a batch of size 1 produces undefined variance. Setting drop_last=True discards the last incomplete batch and ensures consistent batch sizes throughout training. For validation DataLoaders, use drop_last=False — you want to evaluate on every sample, no exceptions.
Custom collate_fn: Handling Variable-Length Data
The default collate_fn expects every sample in a batch to have the same shape so it can stack them into a uniform tensor. This assumption breaks the moment you work with NLP sequences of different lengths, graphs with different numbers of nodes, or images that have not been resized to a fixed resolution.
A custom collate_fn lets you define exactly how a list of heterogeneous samples becomes a batch. The most common pattern — one you will write or encounter in almost every NLP project — is pad-and-mask: pad all sequences to the length of the longest sequence in the batch, and return a binary mask tensor that tells downstream layers which positions are real data and which are padding. Attention layers, loss functions, and pooling operations all need this mask to avoid treating padding as signal.
The production subtlety that trips people up: collate_fn runs on the main thread, not inside the worker processes. This means even with num_workers=4, a slow collate_fn becomes the bottleneck for the entire pipeline. Keep it to reshaping and padding only. If you find yourself sorting sequences, computing complex statistics, or doing any non-trivial transformation in collate_fn, move that work into __getitem__ where it can run in parallel across workers.
For NLP work in 2026, most teams use Hugging Face's DataCollatorWithPadding which implements this pattern with tokeniser-aware padding. But understanding the underlying collate_fn contract means you can customise it when the standard collators do not fit your data structure.
- Use pad_sequence from torch.nn.utils.rnn — it handles batch-first padding in one call and is well-tested
- Always return a mask alongside padded data — every downstream layer that touches sequences needs to know where padding starts
- collate_fn runs on the main thread — if profiling shows it as the bottleneck, move the heavy work into __getitem__ where workers can parallelise it
- Consider bucket sampling (grouping sequences by similar length before batching) to reduce padding waste — padding above 40% per batch is worth addressing
- For images of different sizes, resize in __getitem__ not in collate_fn — resizing is CPU-intensive and belongs in the parallel workers
IterableDataset: Streaming Data Without Random Access
Map-style datasets require __len__ and __getitem__ — random access to any sample by index. This breaks when data arrives as a stream (Kafka, network logs, real-time sensor feeds) or when the dataset is genuinely too large to index. IterableDataset solves this by yielding samples sequentially without needing to know the total size.
The use case that justifies reaching for IterableDataset: training on a live event stream where the concept of 'total dataset size' does not exist, or on a dataset so large that generating a complete index would take longer than training itself. The DataLoader iterates through the __iter__ method, batches samples as they arrive, and provides limited shuffling within a buffer of recent samples.
The production trade-off that you need to understand before choosing IterableDataset: it cannot shuffle globally because it never knows the full dataset. It can shuffle within a configurable buffer of recent samples, but the model always sees data in approximately the order it arrives in the stream. If the stream has any temporal structure — and real data almost always does — the model will see a biased distribution. For training data that can be indexed, Map-style datasets with global shuffling are strictly better. Use IterableDataset only when indexing is genuinely impossible.
One non-obvious operational issue with num_workers > 0 and IterableDataset: each worker receives its own copy of the __iter__ method and will iterate the entire stream independently. Without sharding the stream across workers, every sample gets loaded num_workers times. You need to detect the worker ID inside __iter__ using torch.utils.data.get_worker_info() and partition the stream so each worker handles a distinct subset.
get_worker_info() inside __iter__ — otherwise each sample is loaded num_workers times.get_worker_info() — this doubles or quadruples I/O load silently.get_worker_info() sharding inside __iter__ or every sample gets loaded num_workers times. Use Map-style datasets whenever the data can be indexed; reach for IterableDataset only when it genuinely cannot.Why Batch Size Breaks Your Model (And How to Pick It)
Most tutorials tell you batch size is a hyperparameter you tune for memory. That's half the story. The real reason batch size matters is gradient variance.
Small batches (32-64) give noisy gradients. That noise acts as regularization — it helps escape sharp minima and generalizes better. Large batches (512+) average out the noise, giving you cleaner gradients that converge faster but often to flatter minima with worse generalization. This isn't theory. It's why you see models train slower per epoch with small batches but reach better test accuracy.
Here's the trap: doubling batch size to speed up training doesn't halve wall time. DataLoader overhead, GPU memory bandwidth, and the cost of synchronizing gradients across devices grow non-linearly. A batch of 1024 isn't 32x faster than a batch of 32. It's maybe 2-3x faster per epoch, but you'll need more epochs to converge.
Rule of thumb: start at 32 for vision tasks, 16 for NLP. Double only if gradients look stable and validation loss isn't plateauing early. Monitor GPU utilization with nvidia-smi — if you're below 80%, increase batch size until you hit 90%.
Shuffling Isn't Optional — Here's Why Your Model Memorizes Without It
You've seen the shuffle=True argument in DataLoader. Ever wondered what happens when you set it to False? Your model learns the order of your data, not the patterns. This is called dataset memorization, and it's insidious.
Consider a dataset sorted by class: first 1000 cats, then 1000 dogs. Without shuffling, the first epoch shows the model only cats. The model adapts to predict 'cat' for everything. Then epoch 2 shows only dogs. The model flips to predict 'dog'. This creates oscillations in loss that never converge. Even if your data isn't explicitly sorted, inherent ordering from data collection (e.g., timestamps, sensor IDs) leaks bias.
Shuffling breaks temporal correlations between samples. It ensures each mini-batch is an i.i.d. sample from the data distribution. This stabilizes gradient updates and prevents the model from exploiting spurious order patterns.
But there's a catch: shuffling entire datasets is expensive. For datasets larger than RAM (think 100GB+), full shuffling is impossible. That's when you use Sampler objects — specifically RandomSampler with replacement or distributed-aware samplers for multi-GPU training. Never implement your own shuffle logic. PyTorch's DataLoader does it correctly with Fisher-Yates in C++. Your Python loop will be 100x slower.
shuffle=True AND drop_last=True for training. The last incomplete batch has biased class distribution. Dropping it ensures every batch is uniform size, keeping gradient variance consistent.When DataLoader Bottlenecks Your GPU: Num Workers and Prefetch
Your GPU sits at 30% utilization while training. You blame the model. I blame your DataLoader. The culprit is almost always insufficient num_workers or missing prefetch_factor. Here's the math.
By default, DataLoader uses num_workers=0 — meaning the main process loads data serially. That's a death sentence for GPU-bound workloads. Each time the GPU finishes a batch, it waits for the CPU to load the next one. This creates a bubble of idle GPU time.
Set num_workers to the number of CPU cores (not threads). For a 16-core machine, use 12-14 workers. Each worker prefetches batches independently. The prefetch_factor (default 2) controls how many batches each worker queues ahead. Increase it to 4 or 8 if your data transforms are heavy (e.g., image augmentations).
But there's a ceiling. Too many workers cause contention on disk I/O and memory bandwidth. Monitor with htop and nvidia-smi. If CPU usage hits 100% or disk reads saturate, back off workers. Also, Windows users: num_workers=0 is your only safe option due to multiprocessing quirks — use Linux for serious training.
One more thing: when using custom transforms, move heavy operations (resizing, normalization) into the Dataset's __getitem__ and let workers parallelize them. Don't apply them after batching — that's serial and slow.
Stop Guessing Transforms: Compose Like a Pro
You don't hand-roll random crops and flips. torchvision.transforms.Compose chains them in one pipeline. The WHY is simple: your model sees invariant features faster when it can't rely on pixel-location memorization. Compose lets you stack RandomHorizontalFlip, ColorJitter, and RandomResizedCrop into a single callable that fits into Dataset.__getitem__.
Here's how it works: you pass a list of transforms to Compose, it applies each sequentially at index time. No manual loop. No duplicated logic. For production, pin the transforms order: normalization must be last, after all random augmentations. If you flip after normalize, your color distribution breaks.
ToTensor() before Normalize. If you normalize raw PIL images, you'll crash on type mismatch.Don't Import ImageFolder Blind — Know When to Dump It
Waste of time: asking "what's the difference between DataLoader and ImageFolder." They're not even the same thing. ImageFolder is a dataset class that assumes your images sit in subfolders named by class. DataLoader is a load balancer — it wraps any dataset, batches samples, and spins up workers. ImageFolder gives you (PIL.Image, label) tuples. DataLoader gives you tensors.
Here's the real difference: use ImageFolder when your data is already sorted into class folders on disk. That's it. If your data is in a CSV, a SQL table, or a blob store, skip ImageFolder. Write a custom Dataset. ImageFolder is a convenience wrapper over datasets.DatasetFolder. It reads file paths, applies your transform, and returns samples. DataLoader then handles concurrency, batching, and shuffling. Know the boundary.
dataset.classes and dataset.class_to_idx before training. They map folder names → integer labels, saved in sorted order.Prefetching Is Free Speed — Use It or Waste GPU Cycles
Your GPU sits idle while DataLoader fetches the next batch. That's the problem. prefetch_factor in DataLoader tells how many batches to load ahead while the current one trains. Default is 2. Bump it to 4 or 8 on high-throughput pipelines. The WHY is simple: overlap I/O with compute. While your model runs forward and backward, workers load the next prefetch_factor * batch_size samples into a queue.
Combine this with num_workers. Rule of thumb: set workers to 4–8 per GPU, then prefetch to 4. Watch your RAM. If you hit swap, back off workers or lower prefetch. Production setup: log time_to_first_batch and iter_time — if iter_time < load_time, increase prefetch. Your GPU will thank you.
prefetch_factor with large images = OOM. Profile with nvidia-smi before settling on 8. Start at 4.Creating an Instance of the Dataset
Most engineers copy-paste dataset instantiation without understanding the memory cost. Every time you create a Dataset object, you are building a mapping from indices to data points. For map-style datasets, this means the entire list of file paths or SQL row pointers is loaded into memory. A common mistake is creating a separate Dataset instance per epoch instead of reusing one — this doubles memory overhead for zero benefit. Instead, instantiate the Dataset once and feed it into DataLoader repeatedly. When using transforms, the Dataset instance stores them as attributes; passing transforms during creation lets you swap augmentations without recreating the entire mapping. If your data is on remote storage, lazy-loading strategies inside the Dataset constructor can prevent OOM errors. Always profile memory usage with torch.cuda.memory_summary() after instantiation to catch leaks early.
Benefits of Using Mini-Batches
Training on mini-batches isn't just about fitting data into GPU memory — it fundamentally changes optimization dynamics. Single-sample (stochastic) updates have high variance because gradients from one sample don't represent the full data distribution. Full-batch gradients are stable but computationally prohibitive and generalize poorly due to sharp minima. Mini-batches hit the sweet spot: they average gradients over a small, representative subset, reducing variance while maintaining per-step efficiency. PyTorch's DataLoader handles batching automatically via the batch_size parameter, but the real win is parallelization — mini-batches let you exploit matrix operations on GPU hardware, achieving 10–100x throughput over sequential single-sample updates. The optimal batch size depends on your model's memory footprint and the dataset's intrinsic variance; start with powers of 2 between 32 and 512. Large batches require higher learning rates to maintain convergence speed.
Docker container crashes with Bus error when num_workers > 0
- Docker's default 64MB shared memory is too small for PyTorch multi-process DataLoader — this is not a corner case, it affects every real training job
- Always run Docker containers with --shm-size=2g or larger when num_workers > 0, and encode this in your CI job definition so it cannot be silently removed
- Bus error crashes with no Python traceback are the unmistakable signature of shared memory exhaustion — do not waste time on hardware diagnostics before checking /dev/shm
- Test training inside Docker before shipping to production — local and container environments differ in ways that only surface under sustained load
nvidia-smi -l 1 # watch GPU utilisation in real time — below 80% means starvationpython -c "import torch; p = torch.profiler.profile(activities=[torch.profiler.ProfilerActivity.CPU, torch.profiler.ProfilerActivity.CUDA]); print('profiler ready')"| File | Command / Code | Purpose |
|---|---|---|
| io | from torch.utils.data import Dataset, DataLoader | What Is PyTorch DataLoader and Datasets and Why Does It Exis |
| io | SELECT | Enterprise Integration |
| Dockerfile | FROM pytorch/pytorch:2.3.0-cuda12.1-cudnn8-runtime | Containerised Data Pipelines with Docker |
| io | from torch.utils.data import DataLoader | Common Mistakes and How to Avoid Them |
| io | from torch.utils.data import Dataset, DataLoader | Custom collate_fn |
| io | from torch.utils.data import IterableDataset, DataLoader, get_worker_info | IterableDataset |
| BatchSizeProfiler.py | from torch.utils.data import DataLoader, TensorDataset | Why Batch Size Breaks Your Model (And How to Pick It) |
| ShuffleOrNot.py | from torch.utils.data import DataLoader, TensorDataset | Shuffling Isn't Optional |
| WorkerBenchmark.py | from torch.utils.data import DataLoader, TensorDataset | When DataLoader Bottlenecks Your GPU |
| TransformPipeline.py | from torchvision import transforms | Stop Guessing Transforms |
| ImageFolderBare.py | from torchvision.datasets import ImageFolder | Don't Import ImageFolder Blind |
| PrefetchConfig.py | from torch.utils.data import DataLoader, TensorDataset | Prefetching Is Free Speed |
| dataset_instance.py | from torch.utils.data import Dataset, DataLoader | Creating an Instance of the Dataset |
| mini_batch_benefits.py | from torch.utils.data import DataLoader, TensorDataset | Benefits of Using Mini-Batches |
Key takeaways
get_worker_info() when num_workers > 0. Use Map-style datasets whenever indexing is possible.Interview Questions on This Topic
How does the DataLoader utilise Python's multi-processing to bypass the Global Interpreter Lock (GIL)?
Frequently Asked Questions
20+ years shipping production ML systems and the infrastructure behind them. Notes here come from systems that actually shipped.
That's PyTorch. Mark it forged?
12 min read · try the examples if you haven't