Parallelize Data Loading with Ray
Learn to parallelize data loading with Ray in this hands-on tutorial. Master core concepts, step-by-step implementation, and troubleshooting—then move to the next lesson in the track.
Focus: parallelize data loading with ray
Your training pipeline is fast, your model is hungry, but your data loader is the bottleneck — every epoch stalls on a single-threaded read_csv or an API call that takes 300ms per sample. You've tried concurrent.futures, but managing a pool of workers, keeping track of progress, and handling failures feels like reinventing the wheel. The pain is real: data loading is often the slowest part of any AI workflow, and scaling it naively can introduce bugs, race conditions, and wasted GPU cycles. Parallelize data loading with Ray — and turn your data pipeline from a choke point into a throughput engine.
The problem this lesson solves
Data loading is rarely the interesting part of an AI project, but it's almost always the limiting one. Think about it: your GPU can process thousands of samples per second, but if your DataLoader feeds it at a few hundred, you're paying for hardware you're not using. Worse, doing this naively — with raw threading or a custom process pool — quickly becomes a maintenance nightmare. You end up with:
- I/O-bound stalls: Reading from disk, network, or a database is slow, and the CPU sits idle.
- CPU-bound preprocessing: Resizing images, tokenizing text, or augmenting data on a single core is a bottleneck.
- Scalability limits:
concurrent.futuresworks for small tasks, but managing thousands of tasks, retries, and dynamic resource allocation gets messy fast.
The result? Your training loop spends more time waiting than learning. Ray solves this by giving you a distributed, Python-native framework that scales from your laptop to a cluster — with almost no change to your code.
Core concept / mental model
Think of Ray as a task & actor factory for Python. Instead of writing your own worker pool, you decorate a function with @ray.remote and call it with .remote(). Ray handles the scheduling, the data passing, and the fault tolerance under the hood.
Analogy: Imagine you're running a busy kitchen. Your old approach (serial loading) is one chef doing every dish from start to finish. With concurrent.futures, you have a few chefs but you have to manage the queue and handle when one burns a pan. Ray is like a head chef with a team of sous-chefs — you say „cook this table's order", and the kitchen manages who does what, when, and what to do if a pan burns. You just wait for the plate.
Key terms:
- Task: A function decorated with
@ray.remote, executed asynchronously on a worker. - ObjectRef: A future-like handle to the result of a remote task. You get it immediately, but the actual data arrives later.
- Actor: A long-lived, stateful remote object — useful for things like a counter or a model in memory.
- Worker: A separate Python process Ray spawns on your machine or cluster.
The magic is that Ray object store (shared memory) lets you pass large numpy arrays or DataFrames between workers without serializing/deserializing the whole thing repeatedly — a huge win for data loading.
How it works step by step
Here's the mental flow you'll implement every time:
- Initialize Ray with
ray.init()— at the start of your script. This spins up the runtime. - Define a remote function with
@ray.remote. This can be your load-and-preprocess step. - Call it asynchronously with
func.remote(...). This returns anObjectRefimmediately. - Collect results with
ray.get(list_of_refs). This blocks until all tasks finish and returns the data. - Batch the results into your training loop (or a
tf.data/torch.utils.datadataset).
Why does this work? Because each func.remote() call is scheduled independently — Ray spreads them across your CPU cores. If you have 8 cores, you can load 8 images at once, and Ray even pipelines the loading of the next batch while you're processing the current one.
For data loading specifically, you'll often use ray.data, a higher-level API that provides from_pandas or read_csv with parallel execution built in. But the core skill is understanding @ray.remote, because it gives you full control over your custom pipeline.
Hands-on walkthrough
Let's build a minimal but complete example. We'll simulate loading a list of CSV files, doing a slow transform (e.g., a time.sleep to mimic I/O), and collecting the results.
Setup
First, install Ray (if not already):
pip install "ray[data]"
Now, a basic remote function:
import ray
import time
# Start Ray with 4 CPU workers (adjust based on your machine)
ray.init(num_cpus=4)
@ray.remote
def load_and_process(file_name: str) -> dict:
# Simulate reading a file + a slow preprocessing step
time.sleep(1) # pretend it takes 1 second
return {"file": file_name, "processed": True}
# List of 8 files
files = [f"data_{i}.csv" for i in range(8)]
# Launch all tasks asynchronously
refs = [load_and_process.remote(f) for f in files]
# Block and collect results (this takes ~2s with 4 workers, not 8s)
results = ray.get(refs)
print(results)
Expected output (order may vary):
[{'file': 'data_0.csv', 'processed': True}, ...] # 8 dicts
Notice: with num_cpus=4, the 8 tasks run in 4 parallel slots, so the total time is about 2 seconds instead of 8.
A more realistic example: using ray.data
Now let's use ray.data to parallelize reading multiple CSV files. This is closer to what you'd use in production.
import ray
ray.init()
# Create a dummy dataset in memory (or read from disk)
import pandas as pd
dfs = [pd.DataFrame({"x": range(100), "y": range(100)}) for _ in range(4)]
# Convert to a Ray Dataset (this parallelizes the read)
ds = ray.data.from_pandas(dfs)
# Apply a transformation with `map_batches`
ds = ds.map_batches(lambda batch: batch + 1) # example transform
# Convert to a pandas DataFrame to see the results
result_df = ds.to_pandas()
print(result_df.head())
Expected output: Each value in x and y is incremented by 1. The key point is Ray handles batching and parallelism internally.
Building a custom parallel data loader for training
Here's a pattern you can adapt to your training loop. We'll create a remote function that returns a batch of data, and a generator that yields batches as they become ready.
import ray
import numpy as np
import time
ray.init(num_cpus=4)
@ray.remote
def load_batch(batch_id: int) -> np.ndarray:
# Simulate I/O: read from disk, preprocess, etc.
time.sleep(0.5)
return np.random.rand(32, 128) # (batch_size, features)
# Prefetch 2 batches ahead
def prefetch_batches(num_batches: int, prefetch_num: int):
refs = []
for b in range(prefetch_num):
refs.append(load_batch.remote(b))
for b in range(prefetch_num, num_batches):
# Wait for the oldest ref, yield it, and add a new one
ready, _ = ray.wait(refs, num_returns=1)
text = ray.get(ready)
yield text[0] # extract the array
refs = [r for r in refs if r not in ready]
refs.append(load_batch.remote(b))
while refs:
ready, _ = ray.wait(refs, num_returns=1)
text = ray.get(ready)
yield text[0]
refs = [r for r in refs if r not in ready]
# Use it
for i, batch in enumerate(prefetch_batches(10, prefetch_num=4)):
print(f"Batch {i}: shape {batch.shape}")
Expected output: You'll see batches printed roughly every 0.5–1.5 seconds, showing overlap of loading and consumption.
Compare options / when to choose what
| Approach | Best for | Key strength | Key weakness |
|---|---|---|---|
Serial (e.g., pd.read_csv in a loop) |
Small datasets, quick scripts | Simple, no dependencies | Slow for large data |
concurrent.futures.ThreadPoolExecutor |
I/O-bound tasks, low complexity | Familiar, low overhead | No auto-scaling, no fault tolerance |
concurrent.futures.ProcessPoolExecutor |
CPU-bound tasks, moderate size | Uses multiple cores | Awkward for large data passing |
Ray (@ray.remote) |
Distributed workflows, large data, dynamic scaling | Auto-scaling, fault tolerance, shared object store | Requires extra dependency; learning curve |
ray.data |
CSV/Parquet loading, basic transforms | High-level API, parallel I/O | Less flexible for custom per-sample logic |
When to choose what:
- If you're prototyping on a laptop with <1GB data, serial may be fine.
- If you have a single script and don't want a new dependency, concurrent.futures is okay.
- For production AI workloads — large datasets, repeated preprocessing, multi-machine scale — Ray is the clear winner.
Troubleshooting & edge cases
Problem: ray.init() fails or hangs.
- Fix: Check for port conflicts. Set
ray.init(_temp_dir='/tmp/ray')or useray.init(address='auto')if connecting to an existing cluster. Also ensure you're not usingray.init()inside a Jupyter notebook multiple times; restart the kernel.
Problem: Tasks return ObjectRef but ray.get(ref) raises a RayTaskError or times out.
- Fix: Add
ray.init(logging_level=logging.ERROR)to see the full traceback. Common causes: the remote function crashes on a specific input, or resources are exhausted. Useray.get(refs, timeout=60)to catch timeouts gracefully.
Problem: Performance doesn't improve, or gets worse, with num_cpus > 1.
- Fix: Check if your task is truly parallelizable. If it's
pandasoperations that are already using multiple threads, you may cause contention. Also, measure the overhead of data serialization: small tasks (microseconds) may be slower to distribute than to run serially. Batch your work into bigger chunks (e.g., load 100 rows per task).
Problem: Large data (e.g., 10GB numpy array) causes OutOfMemoryError when passing.
- Fix: Use
ray.put(large_object)once to place it in the shared object store, then pass theObjectRefto tasks. That avoids serialization every call.
large_data = ray.put(big_numpy_array)
refs = [process.remote(large_data, i) for i in range(10)]
What you learned & what's next
You've mastered the core of parallelize data loading with Ray: you can explain the mental model (tasks, object refs, actors), apply @ray.remote to speed up your data pipeline, and diagnose common pitfalls like serialization overhead. You also know when to reach for ray.data instead of a custom loop.
Next lesson in the track is likely about distributed training with Ray Train — where you'll use this data-loading foundation to feed multiple GPU workers in parallel. You'll take these skills to the next level by orchestrating not just data, but model training itself.
So go ahead: apply Ray to your next data loader, measure the speedup, and be ready to scale to the cluster when the time comes.
Practice recap
Try writing a small script that loads 10 CSV files using @ray.remote and compare the wall time with a serial loop. Then increase the file sizes and see how Ray's object store handles the data. Finally, experiment with ray.data.read_csv on a folder of files and observe how map_batches scales.
Common mistakes
- Calling
ray.init()multiple times in a notebook — you must restart the kernel or useray.shutdown()first. - Passing a huge object directly to
.remote()on every call — useray.put()to store it once in the shared object store. - Expecting speedups with tiny tasks — the serialization overhead can dominate; batch work into larger chunks.
- Forgetting to call
ray.get()— you'll collectObjectRefs but never the actual data, causing silent memory buildup.
Variations
- Use
ray.data.read_csv()for high-level parallel file reading instead of a custom remote function. - Use
@ray.remotewithnum_cpus=0.5to oversubscribe cores for I/O-bound tasks. - Combine Ray with
concurrent.futuresin a hybrid approach for specific bottlenecks.
Real-world use cases
- Amplifying a real-time feature engineering pipeline that must transform raw JSON logs into embeddings before feeding an LLM chatbot.
- Parallelizing loading of thousands of patient imaging files for a medical deep learning model, cutting preprocessing time from hours to minutes.
- Prefetching and augmenting batches of text and image data for a recommendation system model, keeping the GPU busy during training.
Key takeaways
- Ray lets you parallelize any Python function with
@ray.remoteand.remote(), returningObjectReffutures. - Use
ray.put()for large objects to avoid serialization overhead when passing them to many tasks. ray.dataoffers a high-level API for parallel CSV/Parquet loading and transformations.- Profile before optimizing: Ray won't help if your bottleneck is unavoidable serial work.
ray.wait()enables prefetching batches, keeping your training loop fed without blocking.- Always call
ray.get()to collect results and manage memory; otherwise ObjectRefs pile up.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.