all_lessons/ray/01lesson 2 / 17

Part I - Core mental model

Why Ray Exists

Lesson 00 drew the map: ordinary Python tasks and actors, an object store between them, and a runtime that builds the dependency graph. It also named the gap Ray fills, but only in a sentence. This lesson is the rigorous version of that sentence. We start from why a single Python process runs out of room — the GIL, then the three independent scaling walls — and then put Ray on trial against the tools that already exist: multiprocessing, Dask, Spark, MPI. The point is not to declare Ray the winner; it is to see exactly which shape of problem each tool fits, where the seam between them is, and what generality costs you. By the end you should be able to look at a workload and say "this is a Spark job" or "this needs Ray" with a reason, not a vibe.

Book source
Book coverage: Chapter 1, 'An Overview of Ray' - motivation, design principles, Core/libraries/ecosystem, and the data-science workflow. Calibrated to current Ray: the libraries are named Ray Data, Train, Tune, Serve, and RLlib, and the design principles below match the modern Ray Core architecture (dynamic task graphs, actors, a shared object store).
Linear position
Prerequisite: Lesson 00 (Orientation) — the task/actor/object-store mental model and the two questions (unit of parallelism? where do the large bytes live?).
New capability: a principled decision — given a workload, name which scaling wall it hits and choose between multiprocessing, Dask, Spark, MPI, and Ray with the trade-off stated, rather than reaching for whatever you used last.
The plan
Five moves. (1) Pin down the actual constraint — one Python process, one GIL — and a worked number for what that costs. (2) Split "I need more machines" into three independent walls: data, compute, model. (3) Try each existing tool and find where it stops: multiprocessing, Dask, Spark, MPI. (4) State the gap Ray fills and the five design principles that fill it, each motivated by a wall above. (5) Name the residual trade-off — generality forfeits the whole-program optimization a narrow engine can do — so you adopt Ray with eyes open.

1 · The constraint: one process, one GIL

A CPython process executes Python bytecode under a single Global Interpreter Lock (GIL) — a mutex that lets exactly one thread interpret bytecode at any instant. Threads still help when the work is I/O-bound (a thread holding the GIL releases it while it waits on a socket or disk), but for CPU-bound Python — a scoring loop, a feature transform, a simulation step — adding threads buys nothing: they take turns under the lock. The only way to actually run Python bytecode on more than one core is to run more than one process. That single fact is the root of everything that follows; distribution in Python is fundamentally about coordinating processes, not threads.

Worked number — what one core actually costs you
Say you must score 1,000,000 records and each call to your model takes 50 ms of pure Python/NumPy CPU work. Serial on one core: 1,000,000 × 50 ms = 50,000 s ≈ 13.9 hours. Add four threads expecting a 4× speedup — and get essentially nothing, because the GIL serializes the bytecode; you measure ~13.9 hours plus thread-switching overhead. Switch to four processes on a 4-core laptop and you get close to 13.9 / 4 ≈ 3.5 hours. But the laptop has only 4 cores; the job is still hours long. To get to minutes you need cores you do not own — a cluster — and now the question is which tool manages those processes for you.

So "scale Python" decomposes into two sub-problems: run many processes, and move data between them (because separate processes do not share memory — values must be serialized, shipped, and deserialized). Every tool below is some answer to those two sub-problems. They differ in how much they automate, and in what they assume about the shape of your computation.

2 · Three walls, not one

"I ran out of one machine" is actually three different complaints, and the right tool depends on which one you are making. Lesson 00 introduced these; here they are the axes we will score every tool against.

data wallThe dataset will not fit in one machine's RAM, or reading it serially dominates the job. A 2 TB feature table cannot load into a 64 GB box; it must be partitioned across nodes and streamed.
compute wallThe work is parallelizable but serial Python is too slow — the 13.9-hour scoring job above. You need many worker processes, ideally across many machines, each doing an independent slice.
model wallThe model itself exceeds one accelerator's memory. A 70B-parameter model in fp16 is ~140 GB of weights — larger than an 80 GB GPU — so a single forward pass must be split across devices, with collective communication between them.

These are independent. A batch-scoring job hits the compute wall but not the model wall. Training a 70B model hits the model wall and usually the data wall too. A real ML platform hits all three at different stages — which is the deep reason a single specialized engine rarely covers an end-to-end pipeline, and the first hint at what Ray is trying to be.

3 · The tools that already exist — and where each stops

Before adopting anything new, walk the existing options. Each is excellent inside its assumed shape and awkward outside it. The seam is always the same: how rigid is the computation graph it expects, and does it model state.

multiprocessing — the standard-library floor

Python's multiprocessing.Pool forks worker processes on one machine and maps a function over an iterable. It directly solves the GIL problem for embarrassingly parallel CPU work on a single box. Two hard limits: it does not cross machine boundaries (a 4-core laptop stays 4-core), and it makes you hand-marshal data — every argument is pickled to the worker and every result pickled back, with no shared store, so passing a 500 MB array to 100 tasks copies it 100 times. It has no notion of a task that depends on another task's output except through your own orchestration, and no first-class long-lived stateful worker. It is the right tool when the job fits one machine and the data per task is small.

Dask — multiprocessing's distributed, Python-native cousin

Dask scales Python — particularly NumPy/pandas-shaped work — across a cluster, with a distributed scheduler and a real task graph. For "my pandas dataframe got too big" or "parallelize this array computation," Dask is often the lightest answer and stays idiomatically Python. Its sweet spot is collection-style parallelism (Dask DataFrame / Array) and its scheduler does handle arbitrary task graphs. Where it gets thinner than Ray: stateful long-lived services (actor support exists but is not the center of gravity), heterogeneous GPU+CPU pipelines, and the dense ML-serving / RL / distributed-training stack. Dask and Ray overlap most on data; they diverge as the workload becomes stateful, online, or accelerator-heavy.

Spark — the bulk-synchronous dataflow engine

Spark is the mature answer to the data wall for ETL and SQL. Its model is bulk-synchronous partitioned dataflow: data lives as partitions, you express a DAG of transformations (map, filter, join, groupBy), and Spark executes it in synchronized stages separated by shuffles. Crucially the graph is declared up front and largely static — which is exactly what lets the Catalyst optimizer rewrite it (push down filters, reorder joins, pick join strategies) before a single byte moves. That whole-program optimization is Spark's superpower and its constraint: the model assumes coarse, stage-structured batch transforms over data, and is awkward for fine-grained dynamic graphs, mutable stateful workers, tight iterative loops with per-step control flow, or GPU-bound model serving. You would not build an online inference service or an RL training loop in Spark.

MPI — the HPC substrate

MPI (Message Passing Interface) is the HPC standard for the model wall and tightly-coupled numerical computing. It is SPMD — single program, multiple data: every one of N ranks runs the same program, and you, the programmer, explicitly orchestrate communication (send, recv, allreduce) between them. It is the fastest path to a gradient allreduce across GPUs because nothing is hidden. Its cost is also that nothing is hidden: the world size is fixed (no rank joins or leaves mid-run), there is no built-in fault tolerance (one dead rank typically kills the job), no dynamic task spawning, and no scheduler — you hand-manage placement and communication. MPI is superb when the computation is a fixed, regular, tightly-synchronized numerical kernel and brutal when it is dynamic or heterogeneous.

ToolParallel granularityState modelFault toleranceBest-fit workload
multiprocessingFunction over an iterable, one machineNone (stateless workers, manual marshalling)None (you handle it)Embarrassingly parallel CPU work that fits one box
DaskTask graph + collections (Array/DataFrame), clusterMostly stateless; actors secondaryTask retry / reschedulingScaling NumPy/pandas-shaped Python across nodes
SparkCoarse stages over partitions, static DAGStateless transforms; state in the engineLineage-based recompute of partitionsBatch ETL / SQL over data larger than RAM
MPIFixed N ranks, SPMD, manual messagesPer-rank, programmer-managedNone (a dead rank kills the run)Tightly-coupled numerical kernels, gradient allreduce
RayFine-grained dynamic tasks and stateful actors, clusterBoth: stateless tasks + stateful actors as first-classTask retry + lineage reconstruction; actor restartsMixed/dynamic/heterogeneous ML: RL, serving, tuning, glue

4 · The gap Ray fills, and the principles that fill it

Read the table down the "shape" axis and a hole appears. Spark assumes a static, coarse, stateless dataflow. MPI assumes a static, regular, hand-communicated SPMD kernel. multiprocessing and Dask are Python-native but thin on first-class state and (for multiprocessing) on cluster scale. Nothing in the list is a general-purpose way to distribute arbitrary Python that is simultaneously: stateless and stateful, dynamic (a task can decide at runtime to spawn more tasks), heterogeneous (CPU and GPU work in the same graph), and fault-tolerant — without making you a distributed-systems engineer first.

That hole is Ray's reason to exist. Its design is five principles, and each one is the direct answer to a wall or a limitation above — this is the part to actually understand, not memorize.

Simple API: functions → tasks, classes → actors
A plain function becomes a stateless distributed task with @ray.remote; a plain class becomes a stateful actor. You keep writing Python — the answer to "don't make me a distributed-systems engineer." Lesson 02 (tasks) and 03 (actors).
Dynamic task graphs
Calling f.remote() returns immediately and a task may itself call g.remote() — the graph is discovered at runtime, not declared up front. This is precisely what Spark's static DAG and MPI's fixed SPMD cannot do, and what RL loops and recursive algorithms need.
State via actors
An actor is a worker process that owns a Python object and serves method calls — a loaded model, a replay buffer, a simulator, a parameter server. First-class mutable state is what multiprocessing and the dataflow engines lack. Lesson 03.
Heterogeneous resources
@ray.remote(num_gpus=1) vs @ray.remote(num_cpus=2) in the same graph; the scheduler places each unit on a node that has the resource. Cheap CPU rollout workers and a few GPU learners coexist — the model wall and compute wall in one program. Lesson 04.
Shared object store
A per-node in-memory store of immutable values, addressed by ObjectRef. ray.put a big array once and pass the ref; same-node workers read it zero-copy. This kills the "copy the 500 MB array 100 times" problem multiprocessing has. Lesson 04.

The smallest possible demonstration: the local loop and its Ray form are nearly the same code, but the Ray form returns futures immediately and lets a runtime fan the work across a cluster of processes that the GIL would otherwise have prevented.

import ray
ray.init()                                    # start or connect to a cluster

# --- local: one process, GIL-bound, 13.9 hours for 1M @ 50 ms ---
results = [score(r) for r in records]

# --- Ray: stateless tasks fanned across the cluster ---
@ray.remote                                   # a function becomes a task
def score(record):
    return expensive_model(record)            # ~50 ms of CPU work

refs = [score.remote(r) for r in records]     # returns instantly: N ObjectRefs (futures)
results = ray.get(refs)                        # block once, collect all results

# --- state when you need it: a class becomes an actor (lesson 03) ---
@ray.remote
class Model:                                   # loaded ONCE, reused across calls
    def __init__(self, path): self.net = load(path)
    def score(self, r): return self.net(r)
m = Model.remote("ckpt.pt")
out = ray.get(m.score.remote(record))          # method call dispatched to the actor
Worked number — the dynamic graph earns its keep
Consider an RL training loop: 32 rollout-worker actors each generate experience on CPU, and 1 learner actor consumes batches and updates weights on a GPU. In Spark you would force this into synchronized batch stages; in MPI you would fix the 33 ranks and hand-code every message and have no recovery if a worker dies. In Ray it is 32 @ray.remote(num_cpus=1) actors plus 1 @ray.remote(num_gpus=1) actor, the learner pulls finished rollouts with ray.wait as they complete (not in lockstep), and if a rollout worker dies it is restarted and its lost work re-scheduled. Heterogeneous resources + dynamic graph + actor state + fault tolerance — all four principles in one program that none of the alternatives expresses cleanly. Lesson 06 builds exactly this by hand; lesson 07 hands it to RLlib.

5 · The residual trade-off: generality forfeits whole-program optimization

Generality is never free, and it is intellectually dishonest to pretend Ray is strictly better than the engines it competes with. The price is precise: because Ray discovers the task graph dynamically and runs arbitrary Python, it cannot perform the whole-program optimization a narrow engine can. Spark's Catalyst optimizer sees your entire query as a static relational plan and rewrites it — pushing filters before joins, choosing a broadcast vs shuffle join, pruning unread columns — before execution. A bespoke serving engine can fuse a fixed model graph and pre-compile kernels. Ray sees a stream of opaque Python tasks arriving at runtime; it schedules them well, but it will not fuse two of your maps into one pass, will not reorder your computation, and will not auto-pick a join strategy. You own those decisions.

The real contract
Ray optimizes scheduling and data movement (placement, locality, zero-copy reads, retries), not your algorithm. If your workload is a static, relational, batch transform over big data, a narrow engine that can plan the whole thing (Spark for SQL/ETL) will likely beat Ray on that one job — and that is fine, because Ray's win is the workloads no single narrow engine covers: dynamic, stateful, heterogeneous, end-to-end ML. Reach for Ray when generality is the requirement; reach past it when a specialized engine already owns the shape.
Worked number — when the narrow engine wins
Suppose a nightly job filters a 2 TB Parquet table to 3% of rows, then joins to a 50 GB dimension table. Catalyst pushes the filter into the Parquet scan (reads ~60 GB, not 2 TB) and broadcasts the 50 GB table to avoid a giant shuffle. Hand-write the naive version in Ray tasks and you might read all 2 TB and shuffle both sides: reading an extra ~1.94 TB at, say, 1 GB/s aggregate is ~32 extra minutes of pure I/O the optimizer would have erased. The lesson is not "Ray is slow" — it is "Ray will not rewrite your plan, so for plannable batch SQL, use the engine that does." Ray Data narrows this gap for ML preprocessing, but it is not a Spark-SQL replacement (lesson 15).

6 · Failure modes & checklist

Failure modes

  • Reaching for Ray when the shape is plannable batch SQL. A static relational transform over big data is Spark's home turf; Ray will not give you Catalyst, so you forfeit the optimization for no gain.
  • Reaching for MPI/Spark when the graph is dynamic or stateful. An RL loop, a model-serving service, or a recursive task that spawns tasks fits Ray and fights the static-DAG / fixed-SPMD engines.
  • Threads for CPU-bound Python. The GIL serializes bytecode; threads help only I/O-bound work. You need processes, which is what every tool here actually manages.
  • Assuming generality is free. Expecting Ray to fuse your maps or plan your joins. It optimizes scheduling and data movement, not your algorithm — that is your job.
  • Big objects through many small tasks. Without ray.put + ObjectRef, you re-serialize the big array per task — the same trap multiprocessing has, just at cluster scale.

Implementation checklist

  • Which wall am I hitting — data, compute, or model? (More than one is common.)
  • Is the computation graph static and relational, or dynamic with runtime control flow?
  • Does the workload need long-lived mutable state (a model, a buffer, a service), or is it purely stateless maps?
  • Is it heterogeneous — does CPU and GPU work need to coexist in one graph?
  • Could a mature narrow engine (Spark SQL, a serving runtime) already own this exact shape better?
  • If I pick Ray: what is the unit of parallelism, and where do the large bytes live? (Lesson 00's two questions.)

Checkpoint exercise

Try it
Take a job you would naturally write in Spark: read a 1 TB event log, filter to one customer segment, aggregate daily counts, write a small summary table. Now flip one assumption: the aggregation step must call a 7B-parameter LLM on each surviving row to classify intent on a GPU. Re-decide the tool. Pure Spark now has a GPU-model step it handles badly; pure Ray now has a big plannable filter Spark would optimize. The "right answer" splits: filter/aggregate in the engine that plans it, hand the surviving rows to Ray for the heterogeneous GPU step — and the seam between them is exactly the platform question lesson 13 takes up. Write down where you would place the boundary and why.

Where this points next

We have argued why Ray exists and named its five principles, but we have not yet written a real program against the smallest one. The whole edifice rests on a single mechanism: f.remote() returns an ObjectRef — a future — immediately, and passing one ref as another task's argument is what builds the dynamic graph. Lesson 02 makes that concrete: how futures defer work, why ray.get in a loop quietly re-serializes your program back into a serial bottleneck, how ray.put shares a big value once, and the granularity math that decides whether a task is worth its scheduling overhead.

Takeaway
Distributing Python is fundamentally about running many processes (the GIL forbids true CPU parallelism in one) and moving data between them, and "I ran out of one machine" is really three independent walls — data, compute, model. The existing tools each own a shape: multiprocessing is single-box stateless maps, Dask scales Python collections, Spark plans static batch dataflow (and optimizes it), MPI runs fixed tightly-coupled SPMD kernels. The gap none fills is general-purpose distributed Python that is stateless and stateful, dynamic, and heterogeneous — which is exactly what Ray's five principles deliver (functions→tasks, classes→actors, dynamic graphs, heterogeneous resources, a shared object store). The honest cost: that generality forfeits the whole-program optimization a narrow engine performs, so Ray optimizes scheduling and data movement, not your algorithm — adopt it when generality is the requirement, and keep the specialized engine where it already owns the shape.

Interview prompts