all_lessons/ray/03lesson 4 / 17

Part I - Core mental model

Actors, State, and Services

Lesson 02 gave you the stateless task: a function shipped to a worker, returning an ObjectRef immediately so the driver can fan out thousands of calls. But a task is born empty and dies with its return value — it cannot remember anything between calls, cannot hold a model that took 30 seconds to load, and cannot be a long-lived endpoint other code talks to. Some of the most common ML shapes — a parameter server, a model replica, a simulator, a connection pool — are defined by the state they carry. This lesson adds Ray's second primitive, the actor: a stateful worker that owns a Python object for its whole lifetime, and whose single-threaded execution model is both a correctness guarantee and the throughput ceiling you will spend the rest of the lesson designing around.

Book source
Chapter 2 Core API plus Chapter 3 distributed application design - stateful components and simulation workers. Calibrated to current Ray: @ray.remote on a class, .remote() method calls returning ObjectRefs, and the max_restarts / max_concurrency / async-actor options the modern API exposes for fault tolerance and concurrency.
Linear position
Prerequisite: Lesson 02 (Tasks, Objects, and Futures) — stateless @ray.remote functions, .remote() returning an ObjectRef, ray.get/ray.put, and the dependency graph that builds itself.
New capability: decide when a workload needs identity rather than parallelism, build a stateful actor, reason quantitatively about its single-threaded throughput ceiling, and shard hot state across an actor pool instead of bottlenecking on one process.
The plan
Five moves. (1) Show the three things a task structurally cannot do, and define the actor as the fix. (2) Build a counter / parameter-server actor and a model-serving actor that loads weights once in __init__, naming "actor handle" and the method-call mechanics. (3) Make the concurrency model precise — one method at a time on one thread — and put a number on the throughput ceiling that creates. (4) Break that ceiling with an actor pool, shard state by key, and weigh async/threaded actors for I/O-bound work. (5) Confront the two costs you bought: an actor is a single point of failure and a serialization point — what max_restarts and checkpointing do and do not save.

1 · What a task cannot do

A task is a function: it receives arguments, runs, returns a value, and the worker that ran it is free to run anyone else's task next. That statelessness is exactly what makes tasks easy to parallelize and retry — Ray can re-run a failed task anywhere because it depends only on its inputs (lineage, lesson 04). But three real needs fall outside what a stateless function can express:

mutable stateA running counter, an accumulating set of gradients, an RL replay buffer that grows across calls. A task cannot hold a value that persists from one call to the next — every call starts from nothing.
expensive initA 13B-parameter model takes ~30 s to load from disk into GPU memory. If a task loads it on every call, you pay 30 s per inference. You want to load once and reuse the warm object across thousands of calls.
a service endpointA long-lived addressable thing other code sends requests to — a feature server, a tokenizer, a connection pool to a database. Tasks have no identity; you cannot "call the same one again."

An actor is a class marked @ray.remote. Instantiating it (Counter.remote()) creates one long-lived worker process that constructs the object and holds it. That instance lives on one worker, owns a Python object, and survives between calls. Method calls — counter.increment.remote() — are shipped to that specific process and return ObjectRefs exactly like task calls, so actors compose into the same dependency graph tasks do. The reference you get back from .remote() on the class is an actor handle: a first-class value you can pass to other tasks and actors so they can call the same instance.

The mental model: a task is a function call shipped to whatever worker is free; an actor is a remote object with a mailbox. Methods queue in that mailbox and run on the actor's process, one at a time, against state that lives there. Everything good and everything painful about actors follows from "one process owns the state."

2 · Two actors: a parameter server and a model replica

The smallest useful actor is a shared mutable counter — the seed of a parameter server. State lives in self; methods read and mutate it; the single-threaded execution model (next section) means the increments cannot race, so you get an atomic counter across the whole cluster for free:

import ray
ray.init()

@ray.remote
class ParameterServer:
    def __init__(self):
        self.params = 0.0            # mutable state, owned by THIS process

    def apply_gradient(self, grad):  # called concurrently by many workers
        self.params -= 0.01 * grad   # cannot race: methods run one at a time
        return self.params

    def get(self):
        return self.params

ps = ParameterServer.remote()        # 'ps' is the ACTOR HANDLE
refs = [ps.apply_gradient.remote(g) for g in gradients]  # queue many updates
ray.get(refs)                        # each ran serially against shared state
final = ray.get(ps.get.remote())     # read the converged value back

The second canonical actor solves the expensive init problem. The model is loaded once in __init__ — which runs exactly once, when the actor is created — and every predict call reuses the warm, already-on-GPU object. @ray.remote(num_gpus=1) reserves one GPU for this actor's entire lifetime, so the weights stay resident:

@ray.remote(num_gpus=1)              # reserve a GPU for this actor's lifetime
class ModelReplica:
    def __init__(self, weights_path):
        self.model = load_model(weights_path)   # ~30 s, paid ONCE at creation
        self.model.eval()

    def predict(self, batch):
        return self.model(batch)     # warm: no reload, just the forward pass

replica = ModelReplica.remote("s3://bucket/model.pt")
# the 30 s load happens in the background while we prepare inputs
preds = ray.get(replica.predict.remote(batch))
Worked number — why init-once is the whole point
Say loading the model is 30 s and one inference is 40 ms. Scoring 10,000 batches with a task that loads the model each call costs 10,000 × (30 s + 0.04 s) = 300,400 s ≈ 83.4 hours — the load dominates by ~750×. With a model-replica actor that loads once, you pay 30 s + 10,000 × 0.04 s = 30 s + 400 s = 430 s ≈ 7.2 minutes. Same hardware, same model — the actor is ~700× faster purely by not throwing away the warm object. This is the structural reason model serving, simulators, and connection pools are actors, not tasks.

3 · The concurrency model is a single thread

A plain actor processes one method call at a time, on a single thread. Calls arrive in the mailbox, run to completion in submission order per caller, and the next one starts only when the current one returns. This is the source of the consistency guarantee in §2 — no two apply_gradient calls interleave, so you never need a lock — but it is equally the source of a hard throughput ceiling.

The real contract: one actor is one thread
An actor's throughput is bounded by 1 / (per-call time), on one core, no matter how large the cluster. Adding 200 nodes does not make a single actor faster; it still runs its methods one after another on its one process. An actor is therefore simultaneously a serialization point (good for correctness) and a bottleneck (bad for scale). The job of the next section is to know when you have hit that ceiling and how to break past it.

Put a number on it. If a method takes 20 ms, a single actor can serve at most 1 / 0.020 s = 50 calls/s — and that is the ceiling on a 1-node laptop and on a 500-node cluster alike. Suppose 40 worker tasks each want to push 100 gradients/s to one parameter server: that is 40 × 100 = 4,000 calls/s of demand against a 50 calls/s actor. The mailbox grows without bound, latency climbs, and the "fast" cluster is gated by one serialized process. You have rediscovered that a shared mutable object is a global lock.

Two escape hatches exist within a single actor for the case where the method is I/O-bound (waiting on network/disk, not burning CPU):

Async actor
Define methods as async def; Ray runs an asyncio event loop in the actor. While one method awaits a network reply, others run. Great for an actor that mostly waits on a database or remote API — concurrency without true parallelism (still one thread).
Threaded actor
@ray.remote(max_concurrency=N) runs up to N methods on N threads. max_concurrency is the cap on simultaneously executing methods. Useful for blocking I/O, but the GIL still serializes CPU-bound Python — and you give up the free no-lock guarantee, so now you must protect shared state yourself.
Neither helps CPU work
If the method is CPU-bound Python, the GIL means async and threads do not raise throughput. The only real fix for CPU-bound demand is more processes — i.e. more actors. That is the pool pattern, §4.

4 · Break the ceiling: pools and sharded state

When one actor cannot keep up with CPU-bound demand, run a fixed pool of N actors and spread calls across them. Each actor is its own process on its own core, so N actors give you roughly N× the throughput — turning the 50 calls/s ceiling into ≈ N × 50 calls/s. The simplest pool is round-robin over a list of handles:

# N independent model-replica actors, each on its own GPU/core
N = 8
pool = [ModelReplica.remote("s3://bucket/model.pt") for _ in range(N)]

# round-robin: call i goes to actor i % N — work spreads across N processes
refs = [pool[i % N].predict.remote(b) for i, b in enumerate(batches)]
preds = ray.get(refs)

Ray also ships ray.util.ActorPool, which load-balances by handing each new task to whichever actor is free rather than blind round-robin — better when call durations vary, since a slow actor does not stall its share of the queue:

from ray.util import ActorPool
pool = ActorPool([ModelReplica.remote(path) for _ in range(N)])
# map_unordered assigns each input to the next AVAILABLE actor
results = list(pool.map_unordered(lambda a, b: a.predict.remote(b), batches))
Worked number — single actor vs pool, and where it still breaks
Back to the 20 ms method (50 calls/s per actor) against 4,000 calls/s of demand. One actor: 50 calls/s served, the rest queues — utterly bottlenecked. A pool of N = 8: 8 × 50 = 400 calls/s — still short. N = 80: 80 × 50 = 4,000 calls/s — now you match demand, at the cost of 80 processes (and 80 GPUs if each holds a replica). The pool only helps because the work shards cleanly: each predict is independent. The thing that does not shard is the single mutable counter in §2 — if every call must update one shared total, a pool cannot help, because the state itself is the serialization point.

That last point is the design pivot. When the bottleneck is state, you do not pool identical actors; you shard the state by a natural key so that each actor owns a disjoint slice and calls for different keys run in parallel. A single global counter becomes K counter shards keyed by, say, user_id % K:

K = 16
shards = [ParameterServer.remote() for _ in range(K)]

def update(user_id, grad):
    shard = shards[hash(user_id) % K]     # this user's state lives on ONE shard
    return shard.apply_gradient.remote(grad)
# distinct users hit distinct shards → up to K× the single-actor throughput,
# while each user's own updates stay serialized (still race-free per key)

Sharding buys throughput but costs you global atomicity: a sum across all shards is now a fan-in (read all K, add them), and you can no longer take a single lock across the whole state. That is the recurring actor trade-off — centralize for correctness, shard for scale, and you cannot have both for the same piece of state.

SituationUseWhy
Pure parallel compute, no shared stateTaskTrivially parallel, retried anywhere by lineage, no lifecycle to manage.
Expensive init reused across calls (model, connection)ActorLoad/connect once in __init__, keep the warm object resident.
Mutable state that must stay consistentOne actorSingle-thread execution serializes mutations — no locks needed.
One actor can't keep up, work shards cleanlyActor pool / ActorPoolN processes ≈ N× throughput for independent calls.
Hot shared state is the bottleneckSharded actorsPartition state by key; disjoint keys run in parallel.
Method waits on I/O, not CPUAsync / threaded actormax_concurrency overlaps waiting; GIL still blocks CPU work.

5 · The two costs: a single point of failure and a serialization point

You bought capabilities in §1; here is the bill. An actor concentrates state in one process, which means two things break in ways tasks do not.

State dies with the process. A task's output can be reconstructed by re-running it (lineage). An actor's accumulated state — the replay buffer, the converged parameters, the warmed cache — cannot, because re-running __init__ gives you a fresh empty object, not the state you had. If the node holding the actor dies, that state is gone. max_restarts tells Ray how many times to restart a crashed actor (e.g. @ray.remote(max_restarts=3)), but a restart re-runs __init__ and starts empty unless you persist and reload state. Durability is your job:

@ray.remote(max_restarts=3)          # Ray will recreate the actor up to 3 times
class ReplayBuffer:
    def __init__(self, ckpt="s3://b/buf.pkl"):
        self.ckpt = ckpt
        self.data = load_if_exists(ckpt) or []   # reload on restart, else empty

    def add(self, item):
        self.data.append(item)

    def checkpoint(self):                # call periodically from the driver
        save(self.ckpt, self.data)       # durability is YOUR responsibility
Worked number — checkpoint interval is a real trade-off
Suppose a learner actor accumulates 5,000 experiences/min and checkpointing the buffer costs 2 s of stalled work. Checkpoint every 1 min and you lose at most 5,000 experiences on a crash, paying 2 s / 60 s ≈ 3.3% overhead. Checkpoint every 10 min and overhead drops to 2 s / 600 s ≈ 0.33%, but a crash now loses up to 50,000 experiences (~10 min of work). The interval trades steady-state overhead against worst-case loss — there is no free durability, only a knob, and §14 (failure semantics) makes the cluster-wide version of this argument.

It is still a serialization point. Even fault-tolerant, one actor is one thread (§3). The two costs compound: the actor you made durable with checkpointing is the same actor whose 50 calls/s ceiling you must respect. Production designs therefore reach for a pool of restartable, periodically-checkpointed actors sharded by key — fault tolerance and throughput at once — which is precisely the shape Ray Serve (lesson 11) and RLlib (lesson 07) package so you do not assemble it by hand each time.

Failure modes & checklist

Failure modes

  • The accidental global lock. Routing all coordination through one actor turns a 50 calls/s method into the cluster's bottleneck. Measure mailbox depth; shard or pool before it saturates.
  • Actors for stateless work. Wrapping a pure function in an actor adds lifecycle, placement, and failure-handling complexity for zero benefit — use a task.
  • Assuming durability. Actor-local state is not persisted. A node failure with no checkpoint loses every gradient, every buffered experience, every cache entry.
  • Threaded actor without locks. Setting max_concurrency>1 revokes the free no-race guarantee; shared self state now needs explicit locking, and CPU work still won't speed up under the GIL.
  • Too-fine methods. A 100 µs method pays more in per-call RPC than it computes — same granularity trap as tasks (lesson 02). Batch work per call.

Implementation checklist

  • Does this work have memory (state, expensive init, or an endpoint)? If not, it is a task.
  • What is one method's latency, and therefore this actor's 1 / t throughput ceiling?
  • Does the work shard cleanly (→ pool) or is the shared state itself the bottleneck (→ shard by key)?
  • Is the bottleneck CPU (needs more actors) or I/O (async / max_concurrency)?
  • If this actor dies, what happens — rebuild from checkpoint, restart empty, or fail the job? Is max_restarts set?
  • What is the checkpoint interval, and what worst-case loss does it imply?

Checkpoint exercise

Try it
You serve a model whose predict takes 20 ms and you must handle 1,200 requests/s at p99 < 100 ms. Compute the minimum pool size from the single-actor ceiling (50 calls/s → 24 actors just to match throughput; add headroom for queueing and you land near 30+). Now flip one assumption: the method drops to 5 ms because you added request batching. Recompute — the ceiling becomes 200 calls/s, so 6 actors match throughput. The "right answer" (pool size) moved 4× from one knob (per-call time), which is exactly why §11's dynamic batching exists. Then add a per-user feature cache: does it live in the replica actors (fast but duplicated K×, lost on restart) or one sharded cache actor (consistent but a new bottleneck)? Justify which, and where the checkpoint goes.

Where this points next

You now have both primitives — stateless task, stateful actor — and the handle that lets them call each other. But we have been hand-waving the machinery underneath: when you fire predict.remote(batch), which node runs it, how does the 4 MB batch get there without copying it through the driver, where does the model replica's GPU reservation actually come from, and what reconstructs a lost object? Lesson 04 opens the runtime: the driver/worker/raylet/GCS layout, the resource-and-locality-aware scheduler that placed your actors, and the plasma object store whose ownership and lineage model is what made ObjectRef work in the first place.

Takeaway
An actor is Ray's answer to everything a stateless task structurally can't be: a class marked @ray.remote whose instance lives on one worker, owns mutable Python state, and serves method calls that return ObjectRefs like tasks do — the actor handle is a passable, callable reference to that one instance. Its single-threaded execution is a double-edged contract: it serializes method calls so shared state stays race-free without locks, but it caps throughput at 1 / (per-call time) on one core regardless of cluster size. You break that ceiling with a pool of N actors (≈ N× throughput when work shards cleanly) or by sharding hot state by key (disjoint keys run in parallel, at the cost of global atomicity); for I/O-bound methods, async / max_concurrency overlaps the waiting. The price of concentrating state is that an actor is a single point of failure whose state dies with the process — max_restarts recreates it but re-runs __init__ empty, so durability means you checkpoint, trading steady-state overhead against worst-case loss. Decide by the one question: does the work have memory? If yes, actor; if no, task.

Interview prompts