all_lessons/ray/04lesson 5 / 17

Part I - Core mental model

Runtime, Scheduling, and the Object Store

Lessons 02 and 03 gave you the two primitives — stateless tasks returning ObjectRef futures, and stateful actors — and told you the object store "carries values between them." That was a promise made on faith. This lesson opens the box: the processes that actually run on each node, the scheduler that decides where a task lands, and the shared-memory object store that lets a 4 GB array sit in one place while ten workers read it. Once you can name where work is placed, where its bytes live, and what is actually scarce, almost every Ray performance question becomes a calculation rather than a mystery.

Book source
Chapter 2, 'Understanding Ray System Components' — scheduling and executing work on a node, the head node, and distributed scheduling. Calibrated to current Ray, where the per-node agent is the raylet, cluster metadata lives in the GCS on the head node, and the object store is the Plasma shared-memory store with distributed reference counting and the ownership model.
Linear position
Prerequisite: Lesson 02 (tasks, ObjectRef, ray.put) and Lesson 03 (actors as long-lived stateful workers) — you can already submit work and share values, but you treat the runtime as a black box.
New capability: reason quantitatively about placement (move the task to the data or the data to the task?), object-store sizing and spill, and the failure consequence of the ownership model — and reserve co-located resources with placement groups.
The plan
Five moves. (1) Name the four runtime processes — driver, workers, raylet, GCS — and what each owns. (2) Walk the distributed scheduler: resources as a contract, locality-aware placement, and spillback when a node is full. (3) Open the Plasma object store: zero-copy shared-memory reads, immutability, reference counting, and the ownership model that decides what can be rebuilt. (4) Make spilling and object-store sizing quantitative — the latency cliff when intermediate results exceed the budget. (5) Placement groups for gang-scheduled bundles, then failure modes, a checklist, and the hand-off to map-shuffle-reduce.

1 · Four processes, four responsibilities

Stop picturing a cluster as one big laptop. It is a set of nodes — separate machines (or pods) with their own CPU, GPU, RAM, disk, and a network between them. On every node Ray runs a small set of processes, and knowing which one owns what is the difference between guessing and debugging.

Driver
The process running your main() / the script that called ray.init(). It submits tasks, creates actors, and calls ray.get to pull results back. There is one driver per program; it owns top-level control flow and is the first thing you accidentally turn into a serial bottleneck (lesson 00).
Worker
An ordinary Python process that Ray manages on a node and uses to run a task or host an actor. A node has a pool of workers (one per CPU slot by default). A stateless task runs on any free worker; an actor pins one worker for its whole lifetime.
Raylet (per node)
The C++ agent on each node — the local control plane. It is two things fused: a local scheduler that decides which of this node's workers runs a queued task, and an object manager that owns this node's slice of the shared object store and fetches remote objects over the network when a local task needs them.
GCS (head node)
The Global Control Store — a key-value service on the head node holding cluster-wide metadata: which nodes exist and their resources, the actor registry (where each actor lives), placement-group state, and ObjectRef ownership records. It is the cluster's source of truth, and historically its single point of failure (lesson 12 covers GCS fault tolerance).

The split matters because it tells you who to suspect. A task that won't schedule is a raylet/GCS resource question — no node advertises the resources you asked for. A program that mysteriously stalls is usually the driver blocking on ray.get. An "actor not found" is the GCS actor registry. And bytes that won't move are the object manager half of a raylet. Most of this lesson lives inside the raylet.

2 · How the scheduler decides where work lands

When you call f.remote(x), the task does not run immediately — it is queued, and the scheduler must choose a worker on some node. Three ideas drive that choice.

Resources are a contract, not enforcement. A task declares what it needs: @ray.remote(num_cpus=2, num_gpus=1). Ray treats num_cpus, num_gpus, and any resources={"...": n} as logical resources — a bookkeeping budget the scheduler will not overcommit. It is crucial to internalize that these are a promise about scarcity, not a sandbox. Declaring num_cpus=2 does not pin your task to two cores or stop it spawning thirty threads; it only means the scheduler will subtract 2 from that node's CPU budget and refuse to co-place more tasks than the node advertises. Under-declare and you oversubscribe the hardware (your "2-CPU" tasks each secretly use 8 and the node thrashes); over-declare and the node sits half-idle because the budget says "full" while the cores are not. Resources are how you tell Ray what is scarce.

Locality-aware placement. Among nodes that satisfy the resource contract, the scheduler prefers a node that already holds the bytes of the task's arguments in its local object store — because then the task reads them with no network transfer. If preprocess.remote(block_ref) can run on the node where block_ref's value already lives, that is strictly cheaper than copying the block across the network first.

Spillback. When the preferred (local, or resource-rich) node is full — no free worker matching the resource request — the local scheduler does not queue indefinitely. It spills back: hands the task to the GCS / another raylet to place on a different node that has capacity. Spillback is what keeps the cluster busy instead of letting one hot node become a queue, at the cost of giving up locality for that task.

The real contract: locality is a preference, not a guarantee
The scheduler optimizes locality when it is cheap to honor. The moment honoring it would mean idling a node while a busy one drains, spillback wins and your task runs away from its data. So you cannot assume a task ran data-local; you can only make it likely (by where you put the inputs) and measure it. This is exactly the locality-vs-utilization trade-off, and the next box gives it a number.
Worked number — move the task to the data, or the data to the task?
Take a task whose input is a 4 GB block, on a cluster wired with 10 GbE (≈ 1.25 GB/s usable). Moving the block to a non-local worker costs roughly 4 GB ÷ 1.25 GB/s = 3.2 s of transfer.

Case A — short compute (5 s). Run data-local: total ≈ 5 s. Run remote: 3.2 + 5 = 8.2 s — a 64% slowdown. Here locality clearly matters; you want the task on the node holding the block.

Case B — long compute (10 min = 600 s). The same 3.2 s transfer is 3.2 / 600 ≈ 0.5% of the job. Now insisting on locality is silly — if the data-local node is busy, the 3.2 s of network is noise and you should let spillback place the task anywhere a worker is free, to keep utilization high.

The break-even is where transfer time is a meaningful fraction of compute: roughly compute < ~10× transfer → favor locality; compute ≫ transfer → favor utilization. The residual trade-off never disappears — you are trading saved network against idle workers, and the right answer flips with the compute-to-bytes ratio.

3 · The Plasma object store: shared memory, immutability, ownership

Every task result and every ray.put(value) lands in the node's object store — Ray's implementation is called Plasma, an in-memory store backed by shared memory. Three properties make it the load-bearing component of the whole runtime.

Zero-copy reads on the same node. "Zero-copy" means exactly what it says: a large object is written once into a shared-memory segment, and every worker process on that node maps the same physical pages (via mmap) into its own address space rather than receiving a private copy. Ten workers reading a 4 GB array consume 4 GB of RAM total, not 40 GB, and the "read" is a pointer remap that takes microseconds — no deserialization, no memcpy of the payload. This is precisely why you ray.put a big shared value once and pass its ref instead of passing the value into each task (the serialization trap from lesson 00).

Objects are immutable. Once written, an object's bytes never change. Immutability is what makes zero-copy safe — many readers can share the same pages because no one can mutate them out from under another — and it is what makes a value cacheable and reconstructible. To "change" a value you produce a new object with a new ref.

Distributed reference counting and the ownership model. Ray tracks how many references exist to each object across the whole cluster; when the count hits zero, the object is evicted. But counting needs an authority, and that authority is the owner: the worker that created the ref — by submitting the task that returns it, or by calling ray.put — owns that object's metadata and its lineage (the task and arguments that produced it). The owner, not the GCS, holds the reference count and the recipe.

The sharp edge of ownership: if the owner dies, the object is gone
Because lineage and the reference count live with the owner, an object is only as durable as the worker that created it. If that worker (often your driver, for ray.put values) dies, the object cannot be reconstructed and any task still waiting on it fails with an owner died error — even if the bytes are physically still sitting in some node's Plasma store. Practical consequence: do not ray.put a shared table from the driver and then expect long-lived actors to depend on it across a driver restart; give long-lived shared state an owner that lives as long as its readers (lesson 14 returns to this under failure semantics).

Lineage-based reconstruction. Here is what ownership buys you. Because the owner remembers the task and inputs that produced an object, a lost object (its node crashed, evicting the bytes) can be rebuilt by simply re-executing the task that produced it — recursively, if its inputs were lost too. You did not have to checkpoint the intermediate value; the task graph is the recovery plan. This is automatic for task results whose inputs are still reproducible, and it is the foundation of Ray's task fault tolerance — the full failure model, including where reconstruction cannot help (actor state, side effects, ray.put objects whose owner died), is lesson 14.

import ray
ray.init()

# A large, read-only lookup table the whole job shares.
# ray.put writes it into Plasma ONCE; we pass the ref, not the bytes.
lookup_ref = ray.put(load_lookup_table())   # e.g. a 4 GB embedding table

@ray.remote(num_cpus=2, num_gpus=1)          # the resource CONTRACT
def score(block, table):
    # Ray auto-dereferences a top-level ObjectRef arg before the task runs,
    # so `table` is the actual object — read zero-copy on this node.
    return model_score(block, table)

# Pass the SAME ref to every task. On a node holding `lookup_ref`'s bytes,
# all workers mmap the one copy (4 GB total), not one copy per task.
refs = [score.remote(b, lookup_ref) for b in blocks]
results = ray.get(refs)                       # block once, here

4 · Spilling, and why object-store memory is its own budget

The object store has a fixed size — by default Ray reserves about 30% of a node's RAM for Plasma, separate from the Python heap your task code allocates. This separation is the first thing to internalize: object-store memory ≠ process heap. A node with 64 GB RAM gives Plasma ≈ 19 GB; the rest is for your workers' own allocations. You can blow the heap (a worker OOMs) while the object store is empty, or fill Plasma while the heap is fine — they are tracked and exhausted independently.

Spilling is what happens when the object store fills: rather than failing, Ray writes least-recently-used objects out to disk (or remote storage) to make room — and reads them back when a task needs them. This keeps the job correct, but it introduces a latency cliff, because disk is orders of magnitude slower than the shared-memory read it replaced.

Worked number — the spill cliff
Node: 64 GB RAM → Plasma budget ≈ 0.30 × 64 = 19.2 GB. A map stage emits intermediate blocks of 0.5 GB each; you have 50 in flight before the reduce consumes them → 50 × 0.5 = 25 GB of live objects.

That is 25 GB chasing a 19.2 GB store, so ≈ 5.8 GB must spill. Reading a 0.5 GB block back from a local NVMe SSD at ~1 GB/s costs 0.5 s per block; from a network/cloud disk at ~200 MB/s it is ~2.5 s per block. Versus a shared-memory read measured in microseconds, that is a 10,000–100,000× jump for every spilled block touched — a true cliff, not a gentle slope. The fix is rarely "buy more RAM"; it is to reduce live objects: smaller blocks, stream the reduce so producers and consumers overlap (lesson 05), or stop fanning out faster than you fan back in.
Trade-offHow to reason about it
Locality vs utilizationPrefer the node holding an arg's bytes when transfer is a real fraction of compute (Case A); let spillback keep workers busy when compute dwarfs transfer (Case B). The break-even is the compute-to-bytes ratio, not a fixed rule.
Reservation vs flexibilityAccurate num_cpus/num_gpus declarations prevent oversubscription and thrash; over-declaring leaves capacity idle. The contract is only as good as your honesty about real usage.
Object store vs external storagePlasma is ideal for short-lived intermediate results (fast, reconstructible). Durable datasets and checkpoints belong in external storage (S3, a filesystem) with explicit lifecycle — they outlive any owner and must survive a full cluster restart.

5 · Placement groups: gang-scheduling a bundle of resources

Sometimes independent placement is wrong. If you need four GPU workers that must be co-located — say tensor-parallel ranks of one model that exchange activations over the fast intra-node link — you cannot let the scheduler scatter them across nodes and hope. A placement group reserves a set of resource bundles atomically (gang scheduling: either all bundles are placed or none are) and lets you control their spread.

from ray.util.placement_group import placement_group
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy

# Four bundles, each 1 GPU + 2 CPUs, all PACKed onto as few nodes as
# possible (STRICT_PACK would force a single node) — co-locate TP workers
# so their activations cross the intra-node link, not the slow network.
pg = placement_group([{"GPU": 1, "CPU": 2}] * 4, strategy="PACK")
ray.get(pg.ready())                              # gang: all 4 or none

@ray.remote(num_gpus=1, num_cpus=2)
class TPWorker:
    def step(self, shard): ...

workers = [
    TPWorker.options(
        scheduling_strategy=PlacementGroupSchedulingStrategy(
            placement_group=pg, placement_group_bundle_index=i)
    ).remote()
    for i in range(4)
]

The strategies trace the locality-vs-utilization tension again: PACK (and STRICT_PACK) maximize co-location for fast communication but can fail to schedule if no node is empty enough; SPREAD distributes bundles for fault isolation and balanced load at the cost of inter-node communication. Placement groups are how Ray Train and RLlib reserve co-located GPU workers under the hood (lessons 07, 10).

6 · Failure modes & checklist

Failure modes

  • Optimizing compute while transfer is the bottleneck. You profile and tune the function body while the real cost is a 4 GB arg copied to a non-local worker. Check object-store traffic and locality before micro-optimizing code.
  • Treating Plasma as infinite shared RAM. The object store is ~30% of node RAM, separate from the heap. Fan out faster than you reduce and you spill — a sudden latency cliff that looks like the job "randomly slowing down."
  • Owner death. A ray.put from the driver, depended on by long-lived actors, becomes unreconstructible the moment the driver exits — even though the bytes may still be in Plasma. Give shared state an owner that outlives its readers.
  • Under-declaring resources. Tasks that secretly use 8 cores declared as num_cpus=1 let the scheduler pack 8× too many onto a node, which then thrashes. The contract only protects you if it is honest.
  • Letting the head node run heavy work. Application tasks landing on the head compete with the GCS for CPU/RAM and can destabilize the cluster's control plane.

Implementation checklist

  • For each task: where do its large input bytes live, and is the task likely to run there?
  • Did you ray.put shared read-only data once and pass the ref, instead of passing the value per task?
  • Are num_cpus/num_gpus/custom resources declared to match real usage, not wishfully?
  • What is the object-store budget per node, and how many live objects will the heaviest stage hold at once — will it spill?
  • Who owns each long-lived shared object, and does that owner outlive every task that depends on it?
  • Do any tasks/actors need co-location? If so, are they in a placement group with the right PACK/SPREAD strategy?
  • Is durable state (datasets, checkpoints) in external storage rather than relying on the object store?

Checkpoint exercise

Try it
A task reads a 4 GB input over 10 GbE and then computes for 5 seconds. Decide: move the task to the data, or the data to the task? Now change the compute to 10 minutes and redo the arithmetic — the right answer should flip. Then size it: on a 64 GB node (≈19 GB Plasma), how many 4 GB inputs can be live before you spill, and what does the first spilled read cost off NVMe vs cloud disk? Finally, state which of these tasks you would put in a STRICT_PACK placement group and why.

Where this points next

You now know how one task is placed, how its bytes are shared, and what happens when the store fills. But real workloads are not one task — they are thousands, arranged in a shape: fan out a map, exchange data in a shuffle, and fold results back in a reduce. That shape stresses every mechanism in this lesson at once — locality during the map, the object store during the shuffle, and the spill cliff if the reduce can't keep up. Lesson 05 builds map-shuffle-reduce on top of this runtime, shows why naively ray.get-ing everything onto the driver is an anti-pattern, and gives the tree-reduce that shrinks the serial fraction Amdahl punished you for in lesson 00.

Takeaway
A Ray cluster is four kinds of process: a driver (your control flow), workers (where tasks/actors run), a per-node raylet (local scheduler + object manager), and the head-node GCS (cluster metadata). The scheduler places work by a resource contract (logical, not enforced), prefers data-locality but spills back to keep nodes busy — and the locality-vs-utilization choice is decided by the compute-to-bytes ratio, not a rule. The Plasma object store shares immutable values zero-copy via mmap on a node, reference-counts them across the cluster, and pins each object's lineage to its owner — so lost objects are rebuilt by re-running their task, but an object dies with its owner. Object-store memory is a separate ~30% budget; exceed it and you hit the disk-spill latency cliff. Co-located bundles go in a placement group. Name where work runs, where the bytes are, and what is scarce, and Ray performance becomes arithmetic.

Interview prompts

Connective tissue

The object store is a shared-memory cache with eviction and a disk-spill tier — the same storage-engine ideas (write-once immutable values, LRU eviction, spilling to a slower tier) that databases formalize. And the locality-vs-transfer math is the cluster interconnect from the systems-ML view: 10 GbE vs NVLink changes every break-even in this lesson.

Storage enginesWrite-once immutable values, LRU eviction, and spill-to-a-slower-tier are the same ideas a database formalizes — see data-intensive systems · storage engines.
InterconnectEvery locality-vs-transfer break-even in this lesson is set by the wire: 10 GbE vs NVLink changes the numbers — see system_ml · interconnect.