Part III - Cluster and platform layer
Capstone: Design a Ray ML Platform
Lesson 15 drew the boundary of where Ray belongs in a stack; the fifteen lessons before it built the pieces — tasks and actors, the object store, Data, Tune, Train, Serve, KubeRay, the failure model. This lesson spends none of its budget on new API. Instead it takes one concrete product requirement and designs the whole platform end to end, walking every decision with the trade-off it leaves on the table and the lesson it draws from. The deliverable is a senior-engineer design-interview answer: requirements, an architecture you can defend, a decisions table, a worked cost-and-latency budget, and what changes at 10× scale.
RayCluster/RayService CRDs, AIR treated as the platform pattern of lesson 13 rather than a separate API).New capability: the ability to take a workload requirement and produce a defensible Ray platform design — naming, for each stage, its unit of parallelism, where the large bytes live, who owns retries, and what it costs per month.
1 · The product, and the SLOs that decide the rest
Concrete brief: a session-ranking service for a mid-size e-commerce site. Every page view sends ~40 candidate items; the model scores them and returns a ranked list. Three workloads share one model lineage:
The SLOs are not decoration — they pick the architecture. The 80 ms p99 forces GPU-backed Serve replicas with bounded dynamic batching (lesson 11). The 6-hour train window sets the worker count for data-parallel Train (lesson 10). The "2 TB already in S3 from Spark" line is the single most important constraint in the brief: it tells us, before any design, that we integrate with an existing Spark/Parquet lake rather than rebuild ingestion in Ray (lesson 15). Write the SLOs down first; a platform tuned for a workload whose SLO you never stated is tuned for nothing.
2 · The architecture: four stages on one KubeRay cluster
The pipeline is the same fan-out / object-store / fan-in graph the whole course has been about (lesson 00), specialized four times. Each stage names its unit of parallelism and where its large bytes live.
TorchTrainer — N worker actors, one per GPU, exchanging gradients by allreduce. Unit of parallelism: the trial (Tune) and the DDP rank (Train). Large bytes: model replicas in GPU memory, gradients on the wire.All four run on one KubeRay cluster (lesson 12) with worker groups split by accelerator, because the stages do not scale alike — the whole point of Ray's heterogeneous scheduling (lesson 00):
| Worker group | Resource / role | Scaling signal |
|---|---|---|
| head | 1 pod: GCS, dashboard, the Ray autoscaler. No workload. | fixed (the SPOF — §4) |
| cpu-ingest | CPU pods for Ray Data blocks + Preprocessor. | pending Data tasks |
| gpu-train | A100/H100 pods, used only nightly/weekly. | pending Train/Tune demand → scale to 0 when idle |
| gpu-serve | L4/A10 pods, always warm, behind the SLO. | Serve replica autoscaling (request load) |
Splitting gpu-train from gpu-serve is deliberate. Training wants a few big expensive GPUs for hours; serving wants many cheaper GPUs always on. One pooled group would either starve serving during a training run or pay A100 prices to serve 40-item rankings. The Ray autoscaler scales each group on pending logical resource demand, not CPU utilization (lesson 12) — when no Train job has requested GPUs, gpu-train drains to zero and the k8s cluster-autoscaler reclaims the nodes.
3 · The decisions table
The core of any design doc. Each row is a real fork in the road with what it costs and where it was argued.
| Decision | Choice | Trade-off you accept | Lesson |
|---|---|---|---|
| Ingest engine | Read existing S3 Parquet with Ray Data; do not rebuild the Spark ETL | Two systems to operate (Spark + Ray), but you avoid re-implementing mature SQL ETL in a tool that is not built for it | 15, 09 |
| HPO strategy | ASHA early-stopping over 64 configs | Aggressive halving can kill a late-blooming config; you trade a small accuracy risk for ~3-4× less GPU-hours than training all 64 fully | 08 |
| Train parallelism | Data-parallel TorchTrainer, 4 GPUs | Works only because the ranker fits on one GPU; a larger model would need tensor/pipeline parallel (system_ml) | 10 |
| Train→serve handoff | One Checkpoint carrying weights + Preprocessor | Couples the two stages to a serialization format; in exchange, train/serve skew is structurally impossible (§4) | 13 |
| Serve batching | Dynamic batching, max_batch_size=64, batch_wait_timeout_s=0.005 | Up to 5 ms added latency under light load, in exchange for filling the GPU and hitting throughput | 11 |
| Cluster topology | One KubeRay cluster, worker groups per accelerator | Shared object store and simple handoffs vs a single blast radius (§4) | 12, 13 |
| LLM-style models | If a future feature needs an LLM, serve it via a dedicated engine (vLLM) behind a Serve deployment | One more dependency, but you do not reimplement a paged-attention server in Ray | 15 |
4 · Cross-cutting concerns — each a real decision, not a checkbox
The failure model across stages (lesson 14)
Ray's defaults differ by stage, and the design must match retry semantics to idempotency. Ingest: Ray Data tasks are deterministic and side-effect-free, so a lost block is reconstructed from lineage by re-reading its Parquet input — cheap, automatic, idempotent. Train: a worker dying mid-epoch is not idempotent — the model state is in GPU memory and would be lost. So Train checkpoints to S3 every epoch; on failure it resumes from the last checkpoint. With a 6-hour run over, say, 12 epochs (30 min each), a worker failure costs at most one epoch of redo — ~30 min of expected lost work, not 6 hours. Serve: a replica crash is handled by the deployment respawning it; in-flight requests on that replica fail and the client retries (idempotent — scoring is a pure read). The head node is the exception: it owns the GCS, so its loss takes the control plane down. Mitigate with GCS fault tolerance (external Redis) so a head restart does not drop the running cluster (lesson 12).
Autoscaling and cost (lesson 12)
The cost lever is the gpu-train group scaling to zero when idle, against a warm pool for serve. Back-of-envelope monthly bill for the GPU groups, at ~$2/hr per serve GPU (L4-class) and ~$3.5/hr per train GPU (A100-class):
Train: nightly run uses 4 A100 for ~4 hr = 16 GPU-hr/night × 30 = 480 GPU-hr. Weekly tune (after ASHA) ≈ 200 GPU-hr/run × 4 = 800 GPU-hr/mo. Total ≈ 1,280 GPU-hr × $3.5 = ≈ $4,480/mo — but only because the group sits at zero the other ~95% of the time. If it stayed pinned at 4 A100 all month it would be 4 × $3.5 × 730 = $10,220/mo, so scale-to-zero saves ~$5,700/mo. Combined ≈ $13,240/mo for GPUs.
The residual trade-off: scale-to-zero on train means the first nightly job each day eats cold-start latency (node provision + image + weights ≈ 3-5 min). For a 4-hour batch job that is noise; for serving it would be fatal — which is exactly why the two groups have opposite policies.
Train/serve skew prevention (lesson 13)
The classic production bug: the model trained on features normalized one way, but the serving path normalizes differently, so live accuracy quietly degrades. The design makes this impossible by construction: the Preprocessor is fitted once during ingest, then travels inside the Checkpoint. Serve loads the same object, so the exact same normalization runs in both places. There is no second code path to drift. This is the whole reason the handoff in §2 carries the Preprocessor and not just the weights.
Observability and SLOs (lesson 14)
Two SLO families, watched separately (lesson 14's "jobs vs serving" split). For serving: p99 latency (the 80 ms budget), replica queue depth, and object-store pressure — if the object store fills, Ray spills to disk and the spill latency cliff (lesson 04) shows up as p99 spikes; alert on object-store memory before it hits the spill threshold. For jobs: train completion time against the 6-hour window, and GPU utilization (if Train workers sit < 80% busy, the Ray Data pipeline is starving them — rebalance CPU ingest workers, lesson 09). The dashboard, timeline, and ray memory are the instruments (lesson 14).
Build on Ray vs integrate vs replace (lesson 15)
The boundary is drawn three times in this design. Integrate: read from the existing Spark/Parquet lake — Ray Data is not a Spark replacement for big SQL ETL, so we consume its output instead. Build on Ray: the train→tune→serve loop, where Ray's dynamic graph and shared object store are exactly the fit. Replace nothing rashly: if an LLM feature appears, serve it through a dedicated engine (vLLM) behind a Serve deployment — Serve does the autoscaling and composition it is good at, the engine does the paged-attention decoding Ray should not reimplement.
The generality trade-off and blast radius (lessons 01, 13)
One unified cluster is the choice that buys the most and risks the most. The win (lesson 13): one runtime, one object store, trivial handoffs — a Checkpoint written by Train is one ref away from Serve. The cost (lesson 01's "generality isn't free"): a single blast radius. A bad model rollout, an OOM in a Train job, or a head-node fault can touch the serving path that owns the 80 ms SLO. The mitigation is the worker-group split (training cannot evict serving pods) plus GCS fault tolerance — but the honest design note is that a fully separate serve cluster would isolate blast radius at the cost of a harder handoff. We accept shared-cluster risk here because the handoff simplicity and utilization are worth more at this scale; §6 revisits that at 10×.
5 · The worked end-to-end budget
Three numbers a reviewer will ask for: does serving meet 80 ms, does training fit 6 hours, does tuning fit its budget.
max_batch_size=64; at 3,000 req/s spread over 6 replicas that is 500 req/s/replica, so batches fill in well under the 5 ms batch_wait_timeout. Budget: network in/out ≈ 8 ms, Preprocessor ≈ 3 ms, queue wait ≤ 5 ms, GPU forward ≈ 6 ms, ingress fan-out/serialize ≈ 10 ms → p50 ≈ 32 ms. p99 adds queueing + a GC pause + tail batch wait ≈ +35 ms → p99 ≈ 67 ms, inside the 80 ms SLO with ~13 ms of headroom. If load doubles, the batch-wait knob and replica autoscaler absorb it before the budget breaks.6 · What changes at 10× scale
Push the brief to 30,000 req/s and 20 TB nightly. The design does not break uniformly — specific decisions flip:
gpu-serve to its own KubeRay cluster (or KServe), keep the Checkpoint as the handoff over S3. You trade handoff simplicity for isolation — the opposite call from §4, made for a reason (lesson 13).7 · Design review — failure modes & checklist
Failure modes
- Reaching for Ray before the SLO is stated. Without the 80 ms / 6-hour numbers, every later choice (batch size, worker count, warm pool) is arbitrary. SLOs first (§1).
- Preprocessor left behind at the handoff. Shipping only weights and re-implementing normalization in the serving code is the canonical train/serve skew bug. The Preprocessor must travel in the Checkpoint (§4).
- Cost as a dashboard afterthought. If
gpu-trainnever scales to zero you pay $10k/mo for ~5% utilization. Cost is a scheduling/topology decision, not a post-hoc chart (§4). - Scaling serve to zero. A cold start blows the latency SLO on the first request. Floor stays warm; only headroom autoscales (§4).
- Assuming retries are free. At-least-once execution double-counts any non-idempotent side effect. The serving path writes nothing on purpose (§4).
- One cluster forever. The shared-cluster blast radius is fine at 1× and a liability at 10×; revisit the boundary as scale grows (§6).
Implementation checklist
- Are the serving and job SLOs written down as numbers before any component is chosen?
- For each stage: what is the unit of parallelism, and where do the large bytes live?
- Does the Checkpoint carry the Preprocessor, so train and serve share one feature path?
- Which stages are idempotent, and what is the expected redo work after a failure of each?
- Does each worker group scale on the right signal, and which group must stay warm?
- Is the head node's GCS made fault-tolerant, given it is the shared blast radius?
- What is integrated (Spark lake), built on Ray (train→serve loop), and left to a dedicated engine (LLM)?
- Have you run a failure drill: kill a Train worker, crash a Serve replica, restart the head?
Checkpoint exercise
Where this points next
This is the last lesson, so "next" is the rest of the stack. The Ray track gave you one substrate; the boundary lesson (15) named the systems on either side of it. From here the natural continuations are the ones this design kept gesturing at: the model-parallelism that takes over when data-parallel hits the memory wall, the Kubernetes-native serving platforms that compete with a Ray-only serve cluster, and the broader ML-systems design that treats Ray as one layer among many. Follow those cross-track links, or return to the index to revisit any stage you want to redesign.
gpu-train to zero but keep gpu-serve warm (cold start would blow the SLO), and carry the Preprocessor inside the Checkpoint so train/serve skew is structurally impossible. The single unified cluster buys handoff simplicity and utilization at the price of one blast radius — a bet that is correct at this scale and the first thing you reverse at 10×. The whole skill is naming, for every stage, its unit of parallelism, where the bytes live, who owns retries, and what it costs.Interview prompts
- You're asked to design a Ray platform. What do you nail down before drawing any architecture? (§1 — the SLOs as numbers: serving p99 and availability, job completion windows. They decide GPU choice, batch size, worker count, and warm-pool policy; a design without stated SLOs is tuned for nothing.)
- Why split
gpu-trainandgpu-serveinto separate worker groups? (§2, §4 — opposite needs: training wants a few expensive GPUs for hours and can scale to zero; serving wants many cheaper GPUs always warm. The Ray autoscaler scales each on pending logical demand, so a pooled group would starve serving or overpay.) - How does this design prevent train/serve skew? (§4, lesson 13 — the Preprocessor is fitted once at ingest and travels inside the Checkpoint, so serve runs the identical feature path; there is no second normalization code path to drift.)
- A Train worker dies 3 hours into the nightly run. What's the expected lost work, and why? (§4 — at most one epoch (~16-30 min), because Train checkpoints to S3 each epoch and resumes; GPU model state is not idempotent so it must be checkpointed, unlike ingest blocks which reconstruct from lineage for free.)
- Why not scale the serving group to zero off-peak to save money? (§4 — a cold start (pod schedule + image pull + ~4 GB weight load ≈ 90 s) would violate the 80 ms p99 on the first request. The floor stays warm; only headroom autoscales. Training, a batch job, can absorb the cold start, so it scales to zero.)
- Walk the 80 ms p99 latency budget. (§5 — network ≈ 8 ms, Preprocessor ≈ 3 ms, queue/batch wait ≤ 5 ms, GPU forward over 40 candidates ≈ 6 ms, ingress fan-out ≈ 10 ms → p50 ≈ 32 ms; tail effects push p99 ≈ 67 ms, inside the SLO with headroom.)
- What's the cost of the single unified cluster, and when do you reverse it? (§4, §6 — one blast radius: a training OOM or head-node fault can touch the serving SLO. Mitigated by worker-group isolation + GCS fault tolerance at 1×; at 10× you split serve onto its own cluster, trading handoff simplicity for isolation.)
- Where do you integrate rather than build on Ray, and why? (§3, §4, lesson 15 — read the existing Spark/Parquet lake instead of rebuilding ETL, and serve any LLM via a dedicated engine behind a Serve deployment. Ray is a general substrate, not a replacement for mature SQL ETL or a paged-attention server.)