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.
@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.@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.
__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:
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))
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.
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 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).@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.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))
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.
| Situation | Use | Why |
|---|---|---|
| Pure parallel compute, no shared state | Task | Trivially parallel, retried anywhere by lineage, no lifecycle to manage. |
| Expensive init reused across calls (model, connection) | Actor | Load/connect once in __init__, keep the warm object resident. |
| Mutable state that must stay consistent | One actor | Single-thread execution serializes mutations — no locks needed. |
| One actor can't keep up, work shards cleanly | Actor pool / ActorPool | N processes ≈ N× throughput for independent calls. |
| Hot shared state is the bottleneck | Sharded actors | Partition state by key; disjoint keys run in parallel. |
| Method waits on I/O, not CPU | Async / threaded actor | max_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
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>1revokes the free no-race guarantee; sharedselfstate 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_restartsset? - What is the checkpoint interval, and what worst-case loss does it imply?
Checkpoint exercise
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.
@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
- What can an actor do that a task cannot, and what does it cost you? (§1, §5 — hold mutable state, reuse expensive init, be a service endpoint; in exchange it's a single point of failure whose state isn't durable and a single-threaded serialization point.)
- An actor method takes 20 ms — what's the max throughput of one actor on a 500-node cluster? (§3 — 1/0.020 = 50 calls/s; cluster size is irrelevant because one actor runs methods serially on one process.)
- When does an actor pool help and when does it not? (§4 — N actors give ≈ N× throughput when calls are independent; it does NOT help when the bottleneck is one shared mutable state, which stays serialized — then shard the state by key instead.)
- Why doesn't a plain actor need a lock around shared state? (§2, §3 — single-threaded execution runs one method at a time, so mutations can't interleave; this guarantee is lost the moment you set max_concurrency>1.)
- What does
max_restartsactually recover, and what does it not? (§5 — it recreates a crashed actor up to N times, but re-runs__init__from scratch; accumulated state is lost unless you checkpoint and reload it yourself.) - Async actor vs threaded actor vs more actors — when each? (§3, §4 — async/threaded (max_concurrency) overlap I/O-bound waiting on one process but the GIL blocks CPU work; CPU-bound demand needs more actor processes, i.e. a pool.)
- How do you choose a checkpoint interval for a stateful learner? (§5 — trade steady-state overhead (checkpoint cost / interval) against worst-case loss (work done since last checkpoint); shorter = safer but slower, longer = cheaper but loses more on a crash.)