all_lessons/ray/09lesson 10 / 17

Part II - Applications and libraries

Ray Data: Blocks, Streaming Execution, and ML Dataflow

Lesson 08 made Ray Tune a control plane that runs many trials and kills the losers early — but every trial still needs data, and so does every training run in lesson 10. The map-shuffle-reduce shape you built by hand in lesson 05 is the right primitive, yet writing it raw means you decide block sizes, materialization, and GPU feeding yourself. Ray Data packages that shape into a dataset abstraction that streams data larger than cluster RAM through a chain of transforms and hands batches to a trainer without ever stalling the GPU — if you tune it right. This lesson is about what a dataset actually is underneath, and the two knobs (compute strategy, block size) that decide whether your expensive GPUs sit idle.

Book source
Chapter 6, 'Data Processing with Ray' - Ray Datasets, transformations, streaming-style execution, external integrations with pandas/Dask/Spark-shaped data, and ML dataflow. Calibrated to current Ray: ray.data.read_parquet, map_batches with compute=ActorPoolStrategy(...), and the streaming executor that pipelines blocks rather than bulk-materializing a dataset.
Linear position
Prerequisite: Lesson 05 (map-shuffle-reduce) — the fan-out/shuffle/reduce shape and why a driver-side reduce is a bottleneck; and lesson 04 (object store, spilling) — where blocks physically live.
New capability: you can reason about a dataset as a stream of blocks, choose a tasks-vs-actors compute strategy for a transform, and size blocks so a CPU preprocessing pipeline keeps a GPU trainer saturated instead of starving it.
The plan
Five moves. (1) Define a dataset as a sequence of blocks and say why blocks are the right unit. (2) Explain streaming execution — why a dataset bigger than cluster RAM still flows, versus "load it all then map." (3) map_batches and the compute-strategy choice: stateless tasks vs an ActorPoolStrategy that loads an expensive model once, with the amortization number. (4) The producer/consumer link into Ray Train — the GPU-starvation math and the fixes. (5) Block-size and CPU/GPU-balance trade-offs, the integration boundary, failure modes, and the hand-off to training.

1 · A dataset is a sequence of blocks

A Ray Dataset is not a single in-memory table. It is a logical sequence of blocks, where a block is a contiguous chunk of rows held as an Apache Arrow table (a columnar in-memory format) and stored as one immutable value in the object store (lesson 04). A 200 GB Parquet dataset read with default settings might become, say, 1,000 blocks of ~200 MB each, scattered across the object stores of every node in the cluster.

Why blocks, and not rows or one big table? Two reasons, and they are the whole design.

parallel unitA block is the granularity at which Ray Data schedules work. A transform over a 1,000-block dataset becomes up to 1,000 independent tasks (or actor calls) — the same fan-out you wrote by hand in lesson 05, now automatic. One row would be far too fine (scheduling overhead dominates, lesson 02); one giant table would be unparallelizable.
object-store residencyEach block is an object-store value addressed by an ObjectRef. That means a block can live on the node that produced it, be read zero-copy by a same-node consumer, be spilled to disk under memory pressure (lesson 04), and be reconstructed from lineage if its node dies — none of which is possible for a value sitting in your driver's heap.

So the block is doing the same job the partition does in Spark or the chunk does in Dask: it is the atom of parallelism and the atom of placement. Everything in this lesson is a consequence of choosing the right number and size of those atoms.

2 · Streaming execution — bigger than RAM still flows

The naive mental model of a data pipeline is "load the dataset into memory, then map over it, then map again." That model breaks the moment the dataset is larger than cluster RAM: you cannot hold 2 TB of features in 512 GB of object-store memory, so a bulk-materialize-then-transform design either spills catastrophically (lesson 04's spill cliff) or crashes.

Ray Data instead uses streaming execution (pipelined execution): it does not materialize the whole dataset between operators. Operators in the chain — read → map_batches → map_batches → consumer — run concurrently, and blocks flow through them one at a time. At any instant only a working set of blocks is resident; a block is read, transformed, consumed, and its memory freed while the next block is still being read. The dataset's total size can exceed cluster RAM because the cluster never holds all of it at once.

Worked number — why streaming fits and bulk-load does not
Suppose 2 TB of training features, a cluster with 512 GB of object-store memory across nodes, and a pipeline read → featurize → train. Bulk-load: reading all 2 TB into the object store needs 2,000 GB but only 512 GB exists — the executor must spill 1,488 GB to local SSD. At a spill read-back of, say, ~1 GB/s per node that is minutes of pure I/O stall on top of the compute. Streaming: with a working set of, say, 32 blocks × 200 MB ≈ 6.4 GB resident at a time, the pipeline holds well under 512 GB and never spills — the 2 TB flows through in bounded memory. The dataset is 4× the size of cluster RAM and the job still runs.

The mechanism that keeps a streaming pipeline from blowing up is backpressure: a control signal that throttles a fast upstream operator when a slow downstream operator cannot keep up. If the reader is producing blocks faster than featurize can consume them, the resident blocks would pile up in the object store until it spills. Backpressure pauses the reader until the buffer drains, capping the working set. The consequence to internalize: in a streaming pipeline, the slowest operator sets the throughput of the whole chain, exactly like a factory line — which is why GPU starvation (§4) is a backpressure problem in disguise.

3 · map_batches and the compute-strategy choice

The workhorse transform is map_batches(fn): it hands your function a batch — a slice of one block, as a dict of NumPy arrays or a pandas frame — and you return the transformed batch. Batching is what makes it fast: a vectorized NumPy or framework call over 1,024 rows is far cheaper than 1,024 per-row Python calls (lesson 02's granularity argument, applied to data). Prefer map_batches over a per-row map for anything that vectorizes.

The decision that actually matters is the compute strategy: should each batch run as a stateless task or on a pool of stateful actors?

Tasks (default)
Each batch runs as a fresh stateless task on any free worker. Perfect for cheap, stateless transforms — parse a column, normalize, tokenize. No setup to amortize, maximum scheduling flexibility. This is the default and the right choice most of the time.
ActorPoolStrategy
An ActorPoolStrategy is a compute strategy that runs the transform on a fixed pool of long-lived actor processes (lesson 03), each constructing your callable once and reusing it across many batches. The point is to pay an expensive one-time setup — loading a model onto a GPU — a handful of times instead of once per batch.

You select actors by passing a class (not a function) plus compute=ray.data.ActorPoolStrategy(...). Ray Data builds the actors, calls __init__ once each, then streams batches through their __call__. This is the batch-inference pattern: load the model once per actor, score many blocks.

Worked number — init amortization
Say loading a vision model onto a GPU and warming it takes 20 s, and scoring one batch of 1,024 images takes 200 ms. The dataset is 1,000 blocks, one batch each. If init ran per batch (the trap of a task that loads the model inside fn): 1,000 × (20 s + 0.2 s) = 20,200 s ≈ 5.6 hours, of which 20,000 s is pure model loading. With an ActorPoolStrategy of 4 actors: init is paid 4 times = 4 × 20 s = 80 s total, and the 1,000 batches of scoring spread across 4 GPUs = (1,000 × 0.2 s) / 4 = 50 s. Total ≈ 130 s. The init cost collapsed from 20,000 s to 80 s — a ~155× reduction on the whole job — purely by loading the model once per actor instead of once per batch. That single choice is the reason ActorPoolStrategy exists.
import ray
from ray.data import ActorPoolStrategy

ds = ray.data.read_parquet("s3://bucket/images/")   # → a Dataset of blocks

# cheap, stateless: run as tasks (the default)
def to_float(batch):                                 # batch = dict of np arrays
    batch["image"] = batch["image"].astype("float32") / 255.0
    return batch

# expensive init: load the model ONCE per actor, reuse across batches
class Classifier:
    def __init__(self):
        self.model = load_model().cuda()             # ~20 s — paid once per actor

    def __call__(self, batch):
        import torch
        with torch.no_grad():
            batch["pred"] = self.model(
                torch.as_tensor(batch["image"]).cuda()).argmax(1).cpu().numpy()
        return batch

scored = (
    ds.map_batches(to_float, batch_format="numpy")              # tasks
      .map_batches(Classifier,                                   # actors:
                   compute=ActorPoolStrategy(size=4),            # 4 long-lived workers
                   num_gpus=1,                                    # one GPU each
                   batch_size=1024)
)

4 · The producer/consumer link to Ray Train — don't starve the GPU

Ray Data exists largely to feed Ray Train (lesson 10). The relationship is producer/consumer: the data pipeline (CPU work — read, decode, augment) produces batches; the training loop (GPU work — forward/backward) consumes them. The hard rule of any such pipeline: the GPU can only run as fast as the slowest upstream operator can feed it. If preprocessing cannot deliver a batch in the time the GPU takes to consume one, the GPU idles — and an idle A100 is the most expensive idle resource in your system.

GPU starvation — the utilization math
Say one training step (forward + backward on a batch) takes the GPU 50 ms. To keep the GPU busy, preprocessing must supply a fresh batch every 50 ms — a producer throughput of 20 batches/s. Suppose your CPU preprocessing (decode + augment) takes 80 ms per batch on a single preprocessing worker. Now each step is gated by data, not compute: the GPU does 50 ms of work then waits 30 ms for the next batch. GPU utilization = 50 / 80 = 62.5% — you are paying for an A100 and using under two-thirds of it. Fix by adding producers: with 2 preprocessing workers, effective batch supply is 80 / 2 = 40 ms < 50 ms, so data arrives before the GPU needs it and utilization returns to ~100%. The rule of thumb: provision CPU preprocessing parallelism so producer time-per-batch ÷ workers ≤ GPU time-per-batch.

The fixes follow directly from the math, and all of them are knobs Ray Data gives you:

More CPU preprocessing parallelism. Raise the number of tasks/actors doing decode and augment so aggregate producer throughput exceeds the GPU's consume rate (the 2-worker fix above). This is why heterogeneous clusters pair many CPU nodes with a few GPU nodes (lesson 00's resource-heterogeneity theme).
Prefetch. Have the consumer pull the next few batches while the GPU is still chewing on the current one (iter_batches(prefetch_batches=N)). Prefetching hides producer latency behind compute as long as average producer throughput keeps up — it buys slack, it does not fix a chronically slow producer.
Bigger blocks / larger batches. Fewer, larger units amortize per-batch overhead and feed GPUs that want big tensors — but watch the block-size trade-off in §5.

The handoff itself: in training you call iter_batches (or iter_torch_batches) on the dataset, and Ray streams shards to each training worker. Lesson 10 shows the sharded side — train.get_dataset_shard gives each DDP rank its slice; here the point is only that streaming + prefetch + enough producers is what keeps that shard's GPU fed.

# consumer side — stream batches to the trainer, prefetching to hide latency
for batch in scored.iter_torch_batches(batch_size=256, prefetch_batches=4):
    loss = train_step(batch)          # 50 ms GPU; prefetch keeps the next batch ready

5 · Block size, the CPU/GPU balance, and the integration boundary

Block size is the single tuning knob with the most leverage, and it is a genuine trade-off with a wrong answer on both ends.

Block sizeWhat goes wrong
Too big (e.g. 8 GB blocks)Stragglers — one slow block holds up the whole stage because work is coarse; memory pressure — a few resident giant blocks blow past the object-store limit and trigger spilling (lesson 04's spill cliff), turning an in-memory pipeline into a disk-bound one.
Too small (e.g. 1 MB blocks)Per-block overhead dominates — every block is a scheduled task with metadata, ref-counting, and a function dispatch (lesson 02's granularity wall). A million 1 MB blocks spend more time in bookkeeping than in your transform.
Worked number — picking a block target
You have 200 GB of data and want blocks large enough that scheduling overhead is negligible but small enough to stay resident. Target ~128–256 MB per block. At 200 MB/block that is 1,000 blocks. With ~0.5 ms scheduling overhead per block (lesson 02), total overhead ≈ 1,000 × 0.5 ms = 0.5 s — trivial against minutes of real transform. Drop to 2 MB blocks and you have 100,000 blocks: 100,000 × 0.5 ms = 50 s of pure overhead, now a visible tax. Push to 8 GB blocks and you have 25 blocks: too few to spread across, say, 64 CPUs (most sit idle), and any one of them resident is a memory event. The 128–256 MB band is where overhead, parallelism, and residency all stay comfortable. Use override_num_blocks on read or repartition(n) to steer it.

The other balance is CPU-preprocess vs GPU-train: spend too little on preprocessing parallelism and you starve the GPU (§4); spend too much and CPU nodes sit idle while the GPU is the bottleneck. The target is to provision just enough producers that the GPU is the bottleneck — because the GPU is what you are paying the most for, you want it to be the constraint, not the data path.

Finally, the integration boundary. Ray Data reads and writes the formats your data already lives in — read_parquet, read_csv, from_pandas, from_spark, from_huggingface — and is the glue that moves data from those systems into Python ML transforms. It is explicitly not a Spark-SQL replacement: it has no mature query optimizer, no SQL engine, no decades of enterprise ETL hardening. The honest division of labor — do heavy SQL ETL in Spark/Snowflake, then hand the result to Ray Data for the last-mile ML preprocessing and GPU feeding — is the subject of lesson 15's ecosystem positioning. Treat each integration as a contract: schema, partitioning, and ordering must stay explicit across the boundary.

6 · Failure modes & checklist

Failure modes

  • Loading the model inside a task. Putting expensive init in a map_batches function (tasks) pays the 20 s load once per batch — the §3 trap. Use an actor class + ActorPoolStrategy so init is amortized.
  • Per-row map over millions of rows. A Python call per row drowns in interpreter and scheduling overhead. Vectorize with map_batches.
  • Bulk-materializing every intermediate. Calling materialize() between operators defeats streaming, forces the whole dataset into the object store, and triggers spilling. Stream unless you genuinely need to inspect or reuse a stage.
  • Starved GPU. Too few preprocessing producers leaves the GPU idle (the 62.5% case); the symptom is low GPU util with idle CPUs to spare. Add producers / prefetch.
  • Pathological block size. Defaults can produce blocks far from the 128–256 MB band on skewed or many-small-files data — stragglers or overhead follow. Set block count deliberately.
  • Treating Ray Data as a SQL warehouse. No query optimizer; heavy joins/aggregations belong in Spark (lesson 15).

Implementation checklist

  • Is this transform cheap+stateless (tasks) or does it have expensive one-time init (ActorPoolStrategy)?
  • If actors: how many, and how many GPUs each — does the pool size match available accelerators?
  • Is preprocessing parallelism enough that producer-time ÷ workers ≤ GPU-step time?
  • Are blocks in the ~128–256 MB band — neither straggler-prone nor overhead-bound?
  • Am I streaming end-to-end, or did a stray materialize() force the whole dataset resident?
  • Is prefetch set on the trainer's iter_batches so the next batch is ready before the GPU asks?
  • At each external boundary, are schema, partitioning, and ordering explicit?

Checkpoint exercise

Try it
You have a read_parquet → decode_and_augment → resnet_embed → train pipeline. The GPU step is 40 ms/batch; decode_and_augment is 100 ms/batch on one CPU worker; loading the ResNet embedder costs 15 s. (a) How many CPU preprocessing workers do you need so the GPU is not starved? (b) Should resnet_embed run as tasks or an ActorPoolStrategy, and with how many actors if you have 2 GPUs? (c) Now the GPU step drops to 10 ms/batch (smaller model) — does your producer count from (a) still hold, and which resource is the bottleneck now? The "right answer" flips between (a) and (c): when the GPU got faster, the data path became the constraint and you need more producers, not more GPUs.

Where this points next

We can now keep a GPU fed; lesson 10 puts something on the other end of that pipe. Ray Train takes the streamed shards and runs N worker actors — one DDP rank per GPU — each holding a model replica and exchanging gradients via allreduce (the collective shape from lesson 05, and from the system_ml track). The open question this lesson leaves: once data feeding is solved, what bounds the speedup of adding more training workers, and what happens when the model itself no longer fits on one GPU? That is where data-parallel training meets the memory wall.

Takeaway
A Ray Dataset is a sequence of blocks — Arrow tables living as object-store values — which are simultaneously the unit of parallelism and the unit of placement. Streaming execution pipelines blocks through the operator chain so a dataset larger than cluster RAM still flows in bounded memory, with backpressure capping the working set. map_batches vectorizes transforms; the compute strategy is the decision that matters — stateless tasks for cheap work, an ActorPoolStrategy for expensive one-time init (loading a model once per actor cut a worked job from ~5.6 h to ~130 s). The pipeline's job is to feed Ray Train without starving the GPU: provision producers so producer-time ÷ workers ≤ GPU-step time, prefetch to hide latency, and keep blocks in the 128–256 MB band to avoid both stragglers and overhead. Ray Data is the ML-dataflow glue, not a Spark-SQL replacement.

Interview prompts