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

Sponsored

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.futures works 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:

  1. Initialize Ray with ray.init() — at the start of your script. This spins up the runtime.
  2. Define a remote function with @ray.remote. This can be your load-and-preprocess step.
  3. Call it asynchronously with func.remote(...). This returns an ObjectRef immediately.
  4. Collect results with ray.get(list_of_refs). This blocks until all tasks finish and returns the data.
  5. Batch the results into your training loop (or a tf.data / torch.utils.data dataset).

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 use ray.init(address='auto') if connecting to an existing cluster. Also ensure you're not using ray.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. Use ray.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 pandas operations 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 the ObjectRef to 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 use ray.shutdown() first.
  • Passing a huge object directly to .remote() on every call — use ray.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 collect ObjectRefs but never the actual data, causing silent memory buildup.

Variations

  1. Use ray.data.read_csv() for high-level parallel file reading instead of a custom remote function.
  2. Use @ray.remote with num_cpus=0.5 to oversubscribe cores for I/O-bound tasks.
  3. Combine Ray with concurrent.futures in 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.remote and .remote(), returning ObjectRef futures.
  • Use ray.put() for large objects to avoid serialization overhead when passing them to many tasks.
  • ray.data offers 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.

Sponsored

Sponsored

Discussion

Questions, corrections, and tips help everyone reading this page.

0 comments

Add a comment

Shown publicly with your comment.

Be constructive · max 4,000 characters

No comments yet — start the thread.

Related tutorials, quizzes, and articles for this topic.