all_lessons/ray/15lesson 16 / 17

Part III - Cluster and platform layer

Ecosystem, Integrations, and Alternatives

Lesson 14 closed the runtime story: you now understand Ray's failure model, observability, and the at-least-once semantics that make a Ray cluster operable. But no Ray cluster runs in a vacuum — it lands in an organization that already has a data warehouse, a Spark ETL pipeline, a Kubernetes platform, and maybe an HPC allocation. The hardest decisions left are not about Ray's API; they are about its boundary. This lesson makes the honest positioning argument: Ray is strongest when you know precisely what it should replace, what it should integrate with, and what it should leave alone. The through-line is the generality trade-off from lesson 01 — a general substrate buys flexibility by giving up the whole-program optimization a narrow engine performs.

Book source
Chapter 11, "Ray's Ecosystem and Beyond" — data processing, model training, model serving, custom integrations, and comparisons with other systems. Calibrated to current Ray: Ray Data / Train / Tune / Serve / RLlib as the libraries, and KubeRay (RayCluster/RayJob/RayService CRDs) as the deployment path.
Linear position
Prerequisite: Lessons 09–14 — you know what Ray Data, Train, Serve, and KubeRay each are, and how the cluster fails and recovers. You are standing on a working mental model of Ray's runtime.
New capability: Given an existing stack, you can defend where Ray belongs on a whiteboard — which incumbent it replaces, which it reads from, which it leaves running — instead of reflexively rewriting everything in Ray.
The plan
Five moves. (1) Make the generality trade-off concrete: why a general substrate cannot match a specialized engine on that engine's home turf, with a worked number. (2) The three verbs — replace / integrate / leave alone — as the only decision you are actually making. (3) An honest, per-system trade table across Spark, Dask, Kubernetes-native ML, Slurm/MPI, workflow engines, and dedicated LLM serving — for each: what it wins, where Ray wins. (4) A worked positioning example: a team with Spark ETL that needs GPU training. (5) A decision checklist and the failure modes of getting the boundary wrong.

1 · The generality trade-off, made concrete

Lesson 01 asserted that Ray's generality has a price: a runtime that runs arbitrary Python — stateless tasks and stateful actors, with a dependency graph discovered at runtime — cannot perform the whole-program optimization a narrow engine performs over a restricted, declarative program. This lesson cashes that claim out, because it is the single fact that decides every boundary question below.

Consider a SQL aggregation: SELECT region, SUM(revenue) FROM sales WHERE year = 2025 GROUP BY region. Spark's Catalyst optimizer (its query planner) sees the entire query as a declarative plan before a single byte moves. It can push the year = 2025 filter down into the Parquet reader so most rows are never read, prune every column except region and revenue, and pick a hash-aggregate strategy — all because the program is small, closed, and relational.

Now write the same job in Ray as a graph of Python tasks: a task per file that filters and sums. Ray's scheduler sees opaque Python closures. It cannot know that year = 2025 means it should skip Parquet row-groups, nor that only two columns matter. It will faithfully run exactly the tasks you wrote.

Worked number — the optimizer gap
A 2 TB sales table, columnar Parquet, 50 columns, one year of 5 selected. Spark + Catalyst: predicate pushdown reads only the 2025 row-groups (≈ 1/5 of the data) and column pruning reads only 2 of 50 columns. Bytes actually read ≈ 2 TB × (1/5) × (2/50) = 16 GB. Naive Ray tasks reading whole files then filtering in Python: read all 2 TB, deserialize 50 columns, throw away 99.6% of it. That is a 2000 / 16 = 125× difference in I/O — not because Ray is slow, but because nobody optimized the plan. The lesson is not "Ray loses"; it is "do not ask the general substrate to do the narrow engine's job." (Ray Data's own optimizer does projection/predicate pushdown for supported formats — but for heavy relational ETL it is still chasing what Catalyst has done for a decade.)

Flip the workload and the trade-off flips with it. The moment your job is "filter a bit of data, then run a Python UDF that loads a PyTorch model and scores each row on a GPU, then feed the survivors into distributed training," the declarative engine is the one out of its element: it has no first-class notion of a long-lived GPU actor, heterogeneous CPU+GPU placement, or a dynamic graph where tasks spawn tasks. That is exactly where Ray's generality pays for itself. The rule that falls out: match the engine to the shape of the program, not to a feature checklist.

2 · Three verbs: replace, integrate, leave alone

Every ecosystem decision reduces to assigning one of three verbs to each incumbent system. There is no fourth option, and "rewrite the whole platform in Ray" is the mistake of choosing replace for systems that should be integrate or leave alone.

Replace
Ray owns this control loop end-to-end. Justified when the incumbent is glue you wrote (a homegrown multiprocessing scheduler, a hand-rolled parameter server, a bespoke batch-inference cron) or when the workload's shape — dynamic, stateful, heterogeneous, GPU — is squarely Ray's home turf and the incumbent fights it.
Integrate
Ray is one client among several. It reads from / writes to the incumbent over a boring interface (Parquet, Delta, S3, a SQL connection, HTTP). The incumbent keeps owning what it is good at; Ray owns the last-mile ML compute. This is the most common and most under-chosen verb.
Leave alone
Ray does not touch this. The data warehouse, the lakehouse governance layer, the durable workflow engine, the company auth gateway — replacing these buys you nothing and inherits years of correctness work you would have to re-earn.

The cost of the verb is asymmetric. Choosing integrate when you could have replaced leaves a little glue around; choosing replace when you should have integrated means re-implementing (and re-debugging) someone's mature SQL planner, governance layer, or scheduler — a multi-quarter project that usually ends back at integrate. When unsure, default to integrate and earn the right to replace later.

3 · The alternatives, honestly compared

Each row defines the system in one line, then states what it genuinely wins and where Ray wins. The verb column is the default recommendation — the boundary to draw on the whiteboard.

System (one-line definition)What it winsWhere Ray winsDefault verb
Spark — JVM bulk-synchronous dataflow engine with SQL and the Catalyst optimizer. Huge-scale relational ETL, SQL, joins/shuffles, mature lakehouse governance (Delta/Iceberg). Decade of query optimization (§1). Last-mile ML preprocessing feeding GPU training; Python UDFs on GPUs; mixed task/actor compute; dynamic graphs. Integrate
Dask — Python-native parallel arrays/dataframes mirroring the NumPy/pandas API. Familiar pandas/NumPy ergonomics for scientists; out-of-core dataframes with near-zero new API to learn. Stateful actors and services, GPU-heavy ML, RL, and the Train/Tune/Serve stack. (Dask-on-Ray exists — same scheduler underneath.) Integrate / Replace
Kubernetes-native ML — KServe, Kubeflow, Argo Workflows, Seldon: declarative CRD-driven pipelines and serving. If you are all-in on k8s: GitOps, declarative CRDs, per-step pods, org-standard ops tooling. Fine-grained dynamic Python parallelism inside one job and tight train↔serve coupling via a shared object store. Ray runs on k8s via KubeRay. Integrate
Slurm / MPI — HPC batch scheduler + SPMD message-passing with raw collectives. Tightly-coupled bulk-synchronous jobs at extreme scale on supercomputers; lowest-overhead collectives; established HPC allocations. Dynamic graphs, heterogeneous tasks, Pythonic ergonomics, elastic scaling. Ray can even run inside a Slurm allocation. Integrate / Leave alone
Temporal / Airflow — durable workflow / DAG engines for orchestrating coarse, long-lived steps. Durable orchestration: retries, timers, human approvals, multi-day workflows that survive process death — a different layer. Fine-grained in-job parallelism. Ray is a step inside these; or run a durable workflow engine on top of Ray when you need both. Leave alone (compose)
vLLM / dedicated LLM engines — purpose-built token-serving runtimes (paged KV cache, continuous batching). Pure LLM token-serving throughput/latency: they out-serve a generic framework on a single model family by a wide margin. Composition and orchestration: Ray Serve fronts/scales/routes across vLLM replicas, ensembles, and pre/post-processing. Integrate

Two rows deserve emphasis because they are the most commonly mis-decided.

Spark is the canonical "integrate, do not replace." Ray Data is not a Spark-SQL replacement. It has no Catalyst, no decade of join optimization, no lakehouse governance. What Ray Data is good at is the last mile: streaming blocks through a Python/GPU transform that feeds Ray Train without materializing the whole dataset (lesson 09). So the pattern is: Spark does the heavy relational ETL and writes Parquet/Delta; Ray reads those files and does the GPU-bound preprocessing + training. You replace neither — you hand off at the file boundary.

vLLM is the canonical "specialized engine inside a general one." A dedicated LLM serving engine like vLLM (see the vllm track) wins on raw token throughput because it does whole-program optimization for one shape: a paged KV cache and continuous batching that a general @serve.batch deployment cannot match. The right move is not to compete with it — it is to let Ray Serve compose it: Serve owns autoscaling, request routing, multi-model ensembles, and the pre/post-processing graph, while each replica wraps a vLLM engine. Same generality trade-off, resolved by nesting the narrow engine inside the general substrate.

# Ray Serve as the orchestrator; vLLM as the specialized inner engine.
from ray import serve
from vllm import LLM, SamplingParams

@serve.deployment(num_replicas=4, ray_actor_options={"num_gpus": 1})
class LLMReplica:
    def __init__(self):
        # the narrow engine does the hard part: paged KV cache, continuous batching
        self.engine = LLM(model="meta-llama/Llama-3.1-8B-Instruct")

    async def __call__(self, request):
        prompts = (await request.json())["prompts"]
        outs = self.engine.generate(prompts, SamplingParams(max_tokens=256))
        return [o.outputs[0].text for o in outs]

# Serve owns what it is good at: HTTP ingress, autoscaling 4 replicas,
# routing, and (next line) composing this with a retriever / re-ranker.
app = LLMReplica.bind()        # serve.run(app) — Ray supplies the *platform* around vLLM

4 · A worked positioning example

Make it concrete. A team has a mature Spark pipeline producing a 5 TB feature table in Delta on S3, refreshed nightly. They now need to train a recommendation model on GPUs and serve it online. The reflex is "migrate everything to Ray." The right answer is one replace, one integrate, one leave alone.

leave aloneThe Spark ETL. It is correct, governed, and Catalyst-optimized over 5 TB of relational joins — exactly §1's home turf for the declarative engine. Re-implementing it in Ray buys nothing and risks correctness. It keeps writing Delta to S3.
integrateThe data handoff. Ray Data reads the Delta/Parquet output directly and streams blocks through the GPU-bound preprocessing (image decode, tokenize, embed) into Ray Train — no full materialization, no GPU starvation (lesson 09 → 10).
replaceThe homegrown training + batch-inference glue (a tangle of multiprocessing and cron). Ray Train owns distributed GPU training; Ray Serve owns online inference. This was bespoke glue — the legitimate case for replace.
Worked number — why not "all Ray"
Suppose the nightly ETL is 4 hours of relational shuffles on Spark over 5 TB. Porting it to naive Ray tasks risks the §1 optimizer gap: even a modest I/O blow-up from lost predicate/column pushdown turns a 4-hour job into ~20 hours, blowing the nightly window. Meanwhile the actual win — GPU training that Spark cannot do well — is captured by reading Spark's output and training in Ray. Net: keep 4-hour Spark ETL, add Ray for GPU train/serve, and you ship in days. The "rewrite in Ray" plan spends a quarter re-deriving Catalyst and is slower at the end.

The boundary that makes this work is a boring interface: Parquet/Delta files on S3. Each side owns its strength; neither reaches into the other's internals. That is the shape of almost every good Ray integration — files, checkpoints, metrics, HTTP/gRPC, explicit configs — never a hidden in-process dependency.

5 · When Ray Serve fronts a workflow engine vs is fronted by one

Workflow engines (Temporal, Airflow) sit at a different layer than Ray, and conflating the two is a common boundary error. A durable workflow engine guarantees that a multi-step, possibly multi-day process survives crashes: it persists every step's state, replays on failure, fires timers, and waits on human approval. Ray's own task graph is a lighter, in-cluster dependency graph (lesson 02) — fast and fine-grained, but its durability is lineage-based reconstruction within a live cluster, not a persisted multi-day journal.

Ray as a step inside the workflow. Airflow/Temporal orchestrates the coarse pipeline (ingest → train → evaluate → promote, with retries and approvals); one step submits a RayJob. The workflow engine owns durability; Ray owns the heavy parallel compute inside that step.
A durable workflow on top of Ray. When you want both fine-grained Ray parallelism and durable orchestration in one system, run a workflow layer over Ray. Ray supplies the compute substrate; the workflow layer supplies the persistence and replay.

The decision is "which guarantee do I need at this layer?" Need durable, day-spanning, human-in-the-loop orchestration? That is the workflow engine's job — leave it alone and make Ray a step. Need fast in-job fan-out/fan-in? That is Ray's task graph — do not pull in a workflow engine for it.

6 · Failure modes & checklist

Failure modes

  • The big-bang migration. Trying to replace the warehouse, orchestrator, registry, and serving platform in one project. You inherit every correctness guarantee at once and ship nothing. Pick one workflow; integrate at file boundaries.
  • Replacing the declarative engine. Porting heavy SQL/relational ETL to hand-written Ray tasks loses Catalyst's pushdown and pruning (§1) — a silent 10–100× I/O regression that only shows up at scale.
  • Competing with the specialized serving engine. Reimplementing paged-KV-cache token serving in a generic Serve deployment instead of wrapping vLLM. Compose the narrow engine; do not re-derive it.
  • Hidden resources. A custom integration that spawns threads/processes/GPU contexts the Ray scheduler cannot see, so it over-subscribes nodes. Wrap external units of work as tasks/actors with explicit num_cpus/num_gpus.
  • Layer confusion. Using Ray's task graph for durable multi-day orchestration (no persisted journal), or pulling in Temporal for a 200 ms fan-out. Match the guarantee to the layer (§5).
  • Ignoring org ownership. The platform team owns Spark and k8s; a technically elegant Ray rewrite that crosses those ownership lines dies in review regardless of merit.

Decision checklist

  • What shape is the workload — declarative/relational (favor Spark) or dynamic/stateful/GPU (favor Ray)?
  • For each incumbent, which verb: replace, integrate, or leave alone? Default to integrate when unsure.
  • Is the handoff a boring interface (Parquet/Delta/S3/checkpoint/HTTP), not a hidden in-process dependency?
  • Does any external library hide resources from the Ray scheduler? Are num_cpus/num_gpus explicit?
  • For LLM serving, am I composing a dedicated engine (vLLM) rather than re-implementing it?
  • Do I need durable orchestration (workflow engine) or fast in-job parallelism (Ray task graph)?
  • Have I written down what Ray does not own, so platform boundaries stay legible?
  • Does the proposed boundary respect who owns each incumbent system organizationally?

Checkpoint exercise

Try it
Take a platform you know and assign each system a verb. A team runs: Snowflake (warehouse), Airflow (orchestration), a Spark job (feature engineering), MLflow (registry), and a homegrown Flask + multiprocessing batch-scoring service. Where does Ray go? Now flip one assumption: the Spark job is only a Python UDF doing GPU embedding with almost no SQL. Does Spark's verb change from "leave alone" to "replace"? It should — once the workload shape stops being relational (§1), the declarative engine loses its advantage and the verb flips. Articulate the I/O number that justifies each choice.

Where this points next

You can now defend Ray's boundary against every major alternative and assign each incumbent a verb. The final lesson stops arguing boundaries and builds the platform: lesson 16 is the capstone — one end-to-end pipeline (ingest with Ray Data → tune+train with Tune+Train → checkpoint → serve with Serve, on KubeRay) with the cross-cutting concerns (failure model from lesson 14, autoscaling from lesson 12, cost, and train/serve skew from lesson 13) made explicit. Everything you decided here — what Ray owns and what it hands off — becomes a design constraint there.

Takeaway
Ray's value is decided at its boundary, not its API. The root cause is the generality trade-off (lesson 01): a runtime that runs arbitrary dynamic Python cannot match a narrow declarative engine's whole-program optimization on that engine's home turf — Catalyst reads 16 GB where naive Ray tasks read 2 TB — but it wins decisively on dynamic, stateful, heterogeneous GPU compute the narrow engine cannot express. So every decision is one of three verbs: replace bespoke glue and dynamic/GPU workloads, integrate with Spark (read its Parquet/Delta), Dask, k8s (KubeRay), and dedicated serving engines (Ray Serve composing vLLM), and leave alone the warehouse, durable workflow engines, and HPC allocations. Default to integrate at a boring file/checkpoint/HTTP boundary; earn the right to replace later.

Interview prompts