Before writing a single line of optimization code, you need to understand the producer-consumer relationship in deep learning training:
[Data Pipeline] → produces batches → [GPU] → consumes batches → trains model
The GPU is your most expensive resource. It should never wait. When it does, you have a producer bottleneck — your data pipeline cannot produce batches fast enough.
| Metric | Before | After |
|---|---|---|
| GPU Utilization | 10–15% | 60%+ |
| Training Time | 22 hours | 3 hours |
| Compute Cost | Baseline | ~80% reduction |
Key Insight: The GPU was idle ~85–90% of the time. This means the hardware was paid for but not used. Optimization here is more impactful than improving the model architecture itself.
Understanding what existed before helps you understand why it was slow.
HDFS (remote storage)
↓ [Network I/O — slow]
Worker Pool reads raw Parquet row groups
↓ [CPU transformation — slow, in main thread]
PyArrow → NumPy conversion
↓
Batch Dataloader batches the data
↓
Async Dataloader feeds GPU
↓
GPU trains
| Bottleneck | Type | Description |
|---|---|---|
| Network I/O | Latency | Reading from HDFS over the network every epoch |
| CPU Transformation | Compute | Converting PyArrow → NumPy in the main thread, blocking batch delivery |
Critical Point: Both bottlenecks existed simultaneously. Fixing only one would not solve the problem — as Uber discovered when disk caching alone failed.
Uber did not guess. They used systematic elimination. This is the most transferable skill in this article.
Experiment 1: Is the model too small for the GPU?
Larger model → more compute per batch → GPU stays busy longer → masks data delay
Experiment 2: Is the network the bottleneck?
Experiment 3: Is disk caching enough?
RAM Cache experiment:
Read once → Transform once → Store as NumPy → Reuse instantly ✓
Disk Cache (naive) experiment:
Read from disk (fast) → Transform EVERY TIME in main thread (slow) ✗
The RAM cache had accidentally hidden the CPU bottleneck. The disk cache exposed it.
Rule: When you fix one bottleneck, the next one becomes visible. Always profile again after each fix.
Before:
Worker Thread → reads raw row group → puts raw data in queue
Main Thread → pulls raw data → converts PyArrow→NumPy → sends to GPU
After:
Worker Thread → reads raw row group → converts PyArrow→NumPy → puts NumPy array in queue
Main Thread → pulls ready NumPy array → sends directly to GPU
Why this works:
The tradeoff:
The insight: The same row groups are read from HDFS every single epoch. You pay the network cost repeatedly for identical data.
The solution: Cache the pre-transformed data on local disk after the first read.
Worker Thread receives task: "read row group #42"
↓
Check local FanoutCache
↓
[Cache HIT] [Cache MISS]
↓ ↓
Load NumPy array Fetch from HDFS
from local disk Apply transformation
(very fast) Write to cache
↓ ↓
└──────────┬──────────────┘
↓
Return ready NumPy array
Why "pre-transformed" matters:
Problem: Datasets are tens of terabytes. Local disk on a worker node cannot hold everything.
Naive solution (wrong): Use LRU eviction — discard least recently used data when disk is full.
Why LRU fails here:
Training epoch: every row group must be read exactly once
LRU evicts row group #5 to make room for row group #100
Next epoch: row group #5 must be read again → cache miss → back to HDFS
LRU creates a cycle of eviction and re-fetching for training workloads.
Actual solution: Quota management
Problem: If you cache pre-batched data, you replay the same sequence every epoch. Models need random data order to generalize.
Solution: Cache row groups, not batches.
Row group = smaller unit of data (not a full batch)
Multiple worker threads fetch row groups in parallel
→ Arrival order naturally varies (thread scheduling, I/O timing)
→ Final shuffle step applied to each batch in memory before GPU
This gives you:
Why switch from Process Pool to Thread Pool?
| Pool Type | Data Sharing | Overhead |
|---|---|---|
| Process Pool | Requires serialization (pickling) between processes | High CPU overhead |
| Thread Pool | Shares memory directly | Low overhead |
New problem introduced: Python threads blocked on I/O (slow HDFS reads) cannot be easily killed. This caused "zombie threads" that prevented clean job shutdown.
Solutions:
If two identical training runs produce different results, you cannot answer:
Petastorm already controlled some randomness:
But runs were still non-deterministic. Why?
The answer: Race conditions in shared queues
Original Architecture:
┌─ Worker 1 ─┐
Ventilator Queue ──┤─ Worker 2 ─├──→ Shared Results Queue → GPU
(shared) └─ Worker 3 ─┘ (shared)
Secondary issue: Legacy np.random.RandomState API behaves inconsistently when reseeded across distributed components.
Change 1: Replace the random API
# Before (legacy, inconsistent)
rng = np.random.RandomState(seed)
# After (modern, consistent)
rng = np.random.default_rng(seed)
Change 2: Dedicated queues with round-robin scheduling
New Architecture:
┌─ Worker 1 ─→ Queue 1 ─┐
Ventilator ────────┤─ Worker 2 ─→ Queue 2 ─├──→ Round-Robin Merge → GPU
(round-robin) └─ Worker 3 ─→ Queue 3 ─┘
Result: Data order is now fully determined by the seed, not by which thread finishes first.
Important: Dedicated queues do not reduce throughput. Workers still run in parallel. Only the collection order is made deterministic.
PROBLEM ROOT CAUSE SOLUTION
─────────────────────────────────────────────────────────────────
Low GPU utilization → Network I/O latency → Local disk cache
(10-15%) + CPU transformation + Push-down transformation
in main thread to worker pool
Non-deterministic → Race conditions in → Dedicated per-worker queues
training results shared queues + Round-robin scheduling
+ Legacy random API + np.random.default_rng
Never assume where the bottleneck is. Use controlled experiments to isolate variables one at a time.
Fixing one bottleneck reveals the next. Uber fixed network I/O and immediately hit the CPU transformation wall.
Caching raw data and caching transformed data have very different performance implications. Cache the output of your most expensive operation.
More workers = more throughput = more race conditions. Design for determinism explicitly, not as an afterthought.
Every optimization has a cost. The goal is to choose trade-offs that align with your constraints.
| Concept | What It Is | Why It Matters |
|---|---|---|
| GPU Starvation | GPU idles waiting for data | Wastes expensive compute resources |
| Push-Down Transformation | Move CPU work into worker threads | Parallelizes transformation, unblocks main thread |
| FanoutCache | Local disk cache for pre-transformed data | Eliminates repeated network I/O and CPU work |
| Quota Management | Cache until disk full, then fall back | Handles datasets larger than local storage |
| Race Condition | Non-deterministic thread execution order | Causes run-to-run variance in training |
| Round-Robin Scheduling | Fixed assignment and collection order | Eliminates race conditions without reducing throughput |
np.random.default_rng | Modern NumPy random API | Consistent behavior under fixed seeds |