all_lessons/ray/10lesson 11 / 17

Part II - Applications and libraries

Ray Train: Distributed Training

Lesson 09 built a streaming data pipeline whose whole job was to keep a GPU fed — and it ended on an open question: once the pipeline is fast enough, what consumes the batches? This lesson answers it. Ray Train takes the one distributed-training shape that recurs across PyTorch, the orientation table, and lesson 05's tree-reduce — N processes, each holding a full model replica, each chewing a different data shard, all agreeing on one gradient every step — and packages it as a job harness. The work here is to understand that shape precisely enough to predict when adding a GPU stops helping, and to see exactly where Ray Data hands its shards across to Train.

Book source
Chapter 7, "Distributed Training with Ray Train" - training basics, example pipeline, trainers, migration, scaling, preprocessing, Tune integration, and callbacks. Calibrated to current Ray: ray.train.torch.TorchTrainer, ScalingConfig, ray.train.report(...) with a Checkpoint, and ray.train.get_dataset_shard().
Linear position
Prerequisite: Lesson 09 (Ray Data) — a dataset is a stream of blocks, pipelined so it can outrun cluster RAM, and the failure to keep up is GPU starvation. Also lesson 05's tree/all-to-all reduce, and actors (lesson 03) as long-lived stateful workers.
New capability: launch a multi-GPU data-parallel training job, reason quantitatively about its scaling efficiency from interconnect bandwidth and model size, and know precisely when data parallelism stops working and tensor/pipeline parallelism must take over.
The plan
Five moves. (1) Name the recurring data-parallel shape — replica per GPU, shard per replica, allreduce per step — and define every term in it. (2) Show how Ray Train wraps that shape: TorchTrainer + ScalingConfig, prepare_model, report, and the Ray Data → Train shard handoff. (3) The central trade-off, with worked numbers: scaling efficiency as compute/(compute+comm), and why interconnect bandwidth sets the knee. (4) The memory wall — what data parallelism cannot do, and the forward reference to tensor/pipeline parallelism. (5) Checkpoint frequency vs overhead as its own worked cost balance, then failure modes and a checklist.

1 · The shape: one replica per GPU, one shard per replica, one gradient per step

Distributed training is not "run the script on more GPUs." It is a specific, tightly synchronized dance, and the most common form of it — the one Ray Train defaults to — is data parallelism. Hold this picture: you have N worker processes, each pinned to its own GPU. Every worker holds a complete copy of the model — same architecture, same weights, identical at the start of every step. We call each such process a rank (rank 0, rank 1, … rank N−1 — the integer identity PyTorch's distributed layer assigns each worker). PyTorch's DistributedDataParallel (DDP) is the machinery that keeps those replicas identical; a Ray Train worker is, mechanically, one DDP rank running inside a Ray actor.

Within one training step, each rank does the following, in lockstep with all the others:

1Different data. Each rank pulls its own shard of the batch — rank 0 sees examples the others never touch. The effective batch is N times the per-GPU batch.
2Same forward + backward. Each rank runs forward and backward on its shard and computes a local gradient. Because the shards differ, the N local gradients differ.
3Allreduce the gradients. The ranks exchange gradients so that every rank ends the step holding the same summed-then-averaged gradient — as if one machine had seen the whole big batch.
4Identical update. Each rank applies that shared averaged gradient with its optimizer. Because the gradient was identical, the weights stay identical. Replicas never drift.

Allreduce is the load-bearing word, so define it exactly. An allreduce is a collective operation in which every participant contributes a vector (here, its local gradient) and every participant ends up with the element-wise reduction (here, the sum, which is then divided by N to average) of all contributions. The naive way — send everything to rank 0, sum, broadcast back — makes rank 0 a bottleneck that moves N×model_bytes. The standard implementation is ring-allreduce: arrange the N ranks in a ring, split each gradient into N chunks, and pass chunks around the ring in two passes (a reduce-scatter then an all-gather). Its bandwidth cost per rank is

comm_bytes ≈ 2 × (N − 1) / N × model_bytes

which approaches 2 × model_bytes as N grows — crucially, independent of N for large N. That bandwidth-optimality is exactly why ring-allreduce, not the naive star reduce of lesson 05, is what production training uses. The deep mechanics of collectives and the wires they run over are the subject of the system_ml track — see collectives and interconnect, and DDP for the full data-parallel treatment. Ray Train is the orchestration layer that launches those ranks and hands them their data; it does not reinvent the collective.

2 · How Ray Train wraps the shape

Ray Train's contract is small on purpose. You write a function that trains on one worker — exactly the loop you already know — and Ray Train runs N copies of it as actors, sets up the DDP process group, and feeds each its shard. You hand it a TorchTrainer with a ScalingConfig(num_workers=N, use_gpu=True) describing the physical shape, then call .fit().

import ray.train.torch
from ray.train import ScalingConfig, Checkpoint, RunConfig
from ray.train.torch import TorchTrainer
import tempfile, os, torch

def train_loop_per_worker(config):
    # Ray Train has already set up the DDP process group and assigned this rank a GPU.
    model = build_model(config)
    model = ray.train.torch.prepare_model(model)   # wraps in DDP + moves to this rank's GPU
    opt = torch.optim.Adam(model.parameters(), lr=config["lr"])

    # Ray Data → Train handoff: this rank gets ITS shard, no one else's (see lesson 09).
    shard = ray.train.get_dataset_shard("train")

    for epoch in range(config["epochs"]):
        for batch in shard.iter_torch_batches(batch_size=config["bs"]):
            loss = step(model, opt, batch)         # forward/backward; DDP allreduces the grad here
        # report metrics AND a checkpoint at the epoch barrier (all ranks must call this)
        with tempfile.TemporaryDirectory() as d:
            if ray.train.get_context().get_world_rank() == 0:   # rank 0 writes weights once
                torch.save(model.module.state_dict(), os.path.join(d, "model.pt"))
            ray.train.report({"loss": loss.item(), "epoch": epoch},
                             checkpoint=Checkpoint.from_directory(d))

trainer = TorchTrainer(
    train_loop_per_worker,
    train_loop_config={"lr": 1e-3, "epochs": 10, "bs": 256},
    scaling_config=ScalingConfig(num_workers=4, use_gpu=True),   # 4 GPUs, 4 DDP ranks
    datasets={"train": ray_dataset},                             # from lesson 09
    run_config=RunConfig(storage_path="s3://my-bucket/runs"),    # durable, shared checkpoint store
)
result = trainer.fit()
best = result.checkpoint                                          # handoff to Tune/Serve

Three pieces do all the work, and each maps onto something from earlier lessons:

prepare_model
Moves the model to this rank's GPU and wraps it in DDP, which registers the backward hook that fires the allreduce. After this line, loss.backward() silently synchronizes gradients across all ranks. This is the only line that turns a single-GPU loop into a data-parallel one.
get_dataset_shard
The Ray Data → Train handoff. The dataset from lesson 09 is split so each rank streams a disjoint shard. This is where lesson 09's starvation point lands: if the pipeline can't fill a rank's shard fast enough, that rank's GPU idles, and because of the step-4 barrier, every rank waits for it.
report + Checkpoint
ray.train.report({...}, checkpoint=...) publishes metrics for monitoring/Tune and persists a checkpoint to the shared storage_path. It is a synchronization point: all ranks must call it the same number of times, or the ones that skipped it deadlock the barrier.

Fault tolerance falls out of the checkpoint. A checkpoint is a serialized snapshot of everything needed to resume — model weights, optimizer state, scheduler state, epoch counter — written to durable shared storage (S3, NFS), never worker-local scratch (a dead worker takes its scratch with it). If a worker actor dies mid-run, Ray Train tears down the process group, requests a fresh worker from the cluster, and restarts every rank from the last reported checkpoint. The blast radius of a single GPU failure is therefore "redo the work since the last checkpoint," not "lose the whole run" — which is precisely why checkpoint frequency becomes a tunable cost in §5.

3 · The central trade-off: scaling efficiency and the interconnect knee

The promise of data parallelism is linear speedup: 4 GPUs train 4× faster. It is never quite true, because step 3 — the allreduce — is pure overhead that grows with the cluster. The right mental model is that each step costs compute + comm, where compute is the forward/backward on one shard and comm is the allreduce. Define scaling efficiency as the fraction of wall-clock spent doing useful compute:

efficiency = compute / (compute + comm)

Work it with numbers. Take a model with 1 GB of gradients (a ~250M-parameter model in fp32, or a ~500M-param model in fp16). Per-rank allreduce volume is ≈ 2 × model_bytes = 2 GB moved across the interconnect each step. Suppose the per-GPU forward+backward takes compute = 200 ms.

Worked number — the interconnect decides the knee
The allreduce time is 2 GB ÷ bandwidth. Two fabrics, same job:

NVLink / NVSwitch (≈ 300 GB/s effective): comm = 2 GB ÷ 300 GB/s ≈ 6.7 ms. Efficiency = 200 / (200 + 6.7) ≈ 97%. Eight GPUs give you ≈ 7.7× — nearly linear.

10 GbE Ethernet (≈ 1.25 GB/s effective): comm = 2 GB ÷ 1.25 GB/s ≈ 1600 ms. Efficiency = 200 / (200 + 1600) ≈ 11%. Eight GPUs give you ≈ 0.9× — you have spent seven GPUs to go nowhere, because every step is 89% waiting on the wire.

Same model, same compute, same N — only the fabric changed, and it moved efficiency from 97% to 11%. This is the single most important fact about data-parallel scaling: the interconnect, not the GPU, sets the knee.

Note that ring-allreduce's comm volume is roughly constant in N (it stays ≈ 2× model_bytes per rank), so on a fast fabric efficiency degrades gently. The real damage comes from crossing a slow boundary: 8 GPUs inside one NVLink-connected node stay at 97%, but spreading those same 8 ranks across two nodes joined by 10 GbE forces the slow link into the ring and collapses efficiency. This is why placement matters and why Ray Train's ScalingConfig should keep a job's workers on well-connected nodes — a lesson that connects directly to placement groups (lesson 04) and to cluster topology (lesson 12). The fix when the wire is the wall is either a faster fabric, or larger per-GPU batches so compute grows relative to the fixed comm (raise compute to 800 ms on 10 GbE and efficiency climbs from 11% to 33% — still poor, but it shows the lever).

LeverEffect on efficiency = compute/(compute+comm)Cost it brings
Faster interconnect (NVLink > IB > Ethernet)Shrinks comm directly; the dominant lever. 10 GbE → NVLink took our example 11% → 97%.Hardware/placement constraint; can't always get it.
Larger per-GPU batchRaises compute while comm stays fixed, so the ratio improves.Memory pressure; very large effective batch can hurt convergence (needs LR warmup).
Gradient compression / fp16 gradsHalves or more the model_bytes moved, so comm drops.Numerical care; not free accuracy-wise.
More workers (N)On a fast fabric, near-linear; on a slow one, near-zero return.Only worth it once efficiency is high — otherwise pure waste.

4 · The memory wall: where data parallelism simply cannot help

Everything above assumes one thing: the model fits on one GPU. Data parallelism replicates the entire model — weights, gradients, and optimizer state — on every rank. Adding workers adds throughput, never capacity. So when the model itself is too big for a single accelerator, data parallelism does nothing at all; you cannot even start.

The wall, with numbers
Take a 70B-parameter model trained with Adam. Per parameter you need, roughly: 2 bytes weights (fp16) + 2 bytes gradient + 4 + 4 + 4 bytes of fp32 optimizer state (master copy, momentum, variance) ≈ 16 bytes/param. That is 70e9 × 16 ≈ 1.1 TB — versus a single 80 GB H100. The model is ~14× too large for one GPU before a single activation is stored. No number of data-parallel replicas changes this: each replica still needs the whole 1.1 TB on its own device. Data parallelism scales compute, not capacity.

This is the boundary where you must switch strategies. Tensor parallelism splits each layer's matrices across GPUs so one layer lives on several devices; pipeline parallelism splits the model by depth so different GPUs hold different layers and micro-batches flow through like an assembly line. These are not Ray Train concepts — they are model-partitioning strategies you bring (via DeepSpeed, FSDP, Megatron), and Ray Train then orchestrates the resulting workers. The full treatment is in system_ml: tensor parallelism and pipeline parallelism. The practical decision rule: data parallel until the model stops fitting, then partition the model, and usually combine both (data-parallel groups of model-parallel shards). Knowing which wall you are on — the throughput wall (use more data-parallel ranks) or the memory wall (partition the model) — is the whole judgment of this lesson.

5 · Checkpoint frequency: a cost balance, not a habit

Checkpointing trades a known, recurring cost (the time to write a snapshot, during which all ranks barrier) against an unknown, occasional cost (the work you redo when a worker dies and you restart from the last checkpoint). Checkpoint too rarely and a failure late in an epoch throws away hours; too often and you spend the run writing to S3 instead of training.

Worked number — the optimal interval
Suppose one checkpoint costs C = 30 s to write (serialize + push ~10 GB to shared storage), a step takes 1 s, and the expected failure rate is one worker failure every MTBF = 4 hours = 14,400 s. If you checkpoint every T seconds, then over an interval you pay, on average:
checkpoint overhead = C / T fraction of time (30 s every T s), and
expected recompute on failure ≈ on average half an interval lost per failure = (T / 2) / MTBF fraction.

Total overhead ≈ C/T + T/(2 × MTBF). Minimizing over T gives T* = √(2 × C × MTBF) = √(2 × 30 × 14,400) ≈ 930 s ≈ 15.5 min. At that interval each cost is ≈ 30/930 ≈ 3.2%, for ≈ 6.4% total overhead. Checkpoint every 60 s instead and overhead balloons to 30/60 = 50% — you spend half the run checkpointing. Checkpoint once an hour (3600 s) and a failure costs an expected 1800 s ≈ 30 min of redo. The square-root rule is the lesson: checkpoint interval should scale with √(checkpoint_cost × MTBF), not "every epoch" by reflex.

Two refinements. Cheaper checkpoints (async writes, sharded/incremental snapshots) lower C and so let you checkpoint more often for the same overhead. And MTBF gets worse as you add workers — more GPUs means more things to fail — so a 512-GPU run on commodity hardware genuinely needs frequent checkpoints, while an 8-GPU run on reliable hardware barely needs them. The deeper failure-tolerance machinery (lineage, reconstruction, idempotency) is lesson 14 and system_ml's checkpointing & fault tolerance.

6 · Failure modes & checklist

Failure modes

  • Scaling into a starved pipeline. Adding GPUs while Ray Data can't fill them: every rank waits at the step barrier for the slowest-fed one, so 8 GPUs run at the speed of the data loader. Measure loader throughput (lesson 09) before adding workers.
  • The interconnect wall, unnoticed. A run that's "only 1.6× faster on 4 GPUs" is usually allreduce-bound on a slow fabric — efficiency is 40%, not the 95% you assumed. Compute compute/(compute+comm) before blaming the code.
  • Barrier mismatch. One rank calls report/checkpoint a different number of times than the others (e.g. a per-rank early-stop), so the others hang forever at a barrier no one will reach.
  • Partial checkpoints. Saving weights but not optimizer/scheduler/epoch state — resume restarts the optimizer cold, and the loss curve visibly jumps.
  • Worker-local checkpoints. Writing to /tmp on the worker; the one resource you lose when a worker dies is exactly its local disk.
  • Reaching for model parallelism too early. Splitting a model that fits fine on one GPU adds communication for no capacity gain. Partition only when you've hit the memory wall.

Implementation checklist

  • Which wall am I on — throughput (more data-parallel ranks) or memory (the model doesn't fit, so partition it)?
  • What is the per-step compute, and the allreduce time at my model size and interconnect — i.e. what is my scaling efficiency, really?
  • Are all my workers on a fast, well-connected set of nodes, or is a slow link in the ring?
  • Does every rank call report the same number of times?
  • Does the checkpoint capture weights + optimizer + scheduler + epoch, on durable shared storage?
  • Is my checkpoint interval near √(2 × C × MTBF), given my failure rate and snapshot cost?
  • Did I make the single-worker version correct and deterministic before scaling to N?

Checkpoint exercise

Try it
You have a 1.3B-parameter model (fp16: ~2.6 GB weights, ~2.6 GB gradients) and 8 GPUs. Per-GPU forward+backward is 150 ms. (a) Compute scaling efficiency on NVLink (300 GB/s) and on 10 GbE (1.25 GB/s) — note the allreduce volume is ≈ 2 × gradient_bytes. (b) Now the model grows to 40B parameters: does it still fit on one 80 GB GPU with Adam optimizer state? Recompute the ~16 bytes/param budget — and watch the right answer flip from "add more data-parallel workers" to "partition the model." (c) Failures now hit once every 30 minutes (you moved to spot instances); with a 20 s checkpoint cost, what interval minimizes overhead, and how does it differ from the 4-hour-MTBF case?

Where this points next

Training produces a checkpoint; the checkpoint's whole reason to exist is to be served. Lesson 11 picks up exactly there: the trained weights now have to answer live requests under a latency SLO, which is a completely different shape from the synchronized lockstep of training. Where Train fans out replicas to agree on one gradient, Serve fans out replicas to handle independent requests, and the central knob flips from "allreduce overhead" to "how long do I wait to batch requests before the GPU is full." The handoff object is the same Checkpoint you just produced.

Takeaway
Data-parallel training is one tight shape: N ranks, each a full model replica on its own GPU, each on a different data shard, synchronizing gradients every step via ring-allreduce (cost ≈ 2 × model_bytes per rank, near-constant in N). Ray Train wraps it — TorchTrainer + ScalingConfig launch the ranks, prepare_model wires DDP, get_dataset_shard is the Ray Data → Train handoff, and report(..., checkpoint=...) gives metrics plus the snapshot that makes worker failure recoverable. The governing trade-off is scaling efficiency = compute/(compute+comm), and the interconnect sets the knee: the same job runs at 97% on NVLink and 11% on 10 GbE. Data parallelism scales throughput, never capacity — when the model itself doesn't fit (a 70B model needs ~1.1 TB), you hit the memory wall and must partition with tensor/pipeline parallelism. And checkpoint frequency is a real cost balance, optimal near √(2 × cost × MTBF), not "every epoch" by habit.

Interview prompts