all_lessons/ray/06lesson 7 / 17

Part II - Applications and libraries

Your First Distributed Application

Part I gave you tasks, actors, the object store, and the map-shuffle-reduce pattern (lesson 05). You have never yet wired them into one running application. This lesson does — and deliberately picks reinforcement learning, because RL forces every primitive into the open at once: a stateful simulator (actor), a read-mostly policy (shared object), a parallel fan-out (rollout collection), and a reduce with a feedback loop (the learner). We build it by hand so that when lesson 07 hands you RLlib, you recognize the machine inside the box instead of trusting a black one.

Book source
Chapter 3, "Building Your First Distributed Application" - a simple maze, the simulation, an RL model, the distributed Ray app, and the first look behind the scenes with Ray tooling. We keep the maze but calibrate the code to current Ray (@ray.remote actors, ray.put, ray.wait) and push past the book into the bottleneck-migration argument the chapter only hints at.
Linear position
Prerequisite: Lesson 05 (map → shuffle → reduce) — you can fan work out as tasks and aggregate it without routing every byte through the driver; and lesson 03 (actors) — a stateful worker that owns a Python object.
New capability: You can decompose a stateful, iterative workload into the four roles Ray serves, place each on the right primitive, and predict which role becomes the bottleneck as you scale — before you write the cluster a cheque.
The plan
Five moves. (1) Name the four roles RL exposes and define every RL term as it appears. (2) Build the single-process maze first — the correctness oracle. (3) Distribute it: simulator actors fan out rollouts, a parameter-server actor holds the policy, the driver reduces and broadcasts. (4) Show, with worked numbers at 1, 8, and 64 rollout actors, how the bottleneck migrates from simulation compute to policy-sync and the learner — an Amdahl argument tying back to lesson 00. (5) Read the Ray dashboard to see it, then hand off to RLlib.

1 · The four roles RL drags into the open

Reinforcement learning is a loop: an agent acts in an environment, the environment returns a reward, and over many tries the agent learns to act so as to maximize cumulative reward. The vocabulary, defined once:

environmentThe world. It has internal state, a reset() that returns a fresh start state, and a step(action) that returns (next_state, reward, done). Our maze: state = the cell you stand in; actions = up/down/left/right; reward = -1 per move, +0 at the goal; done = you reached the goal.
policyThe decision rule: a function state → action. Ours is a Q-table, a lookup of one value per (state, action) pair; the policy picks the action with the highest Q-value. It is read-mostly: every simulator reads it constantly, the learner writes it occasionally.
episode / trajectoryAn episode is one run from reset() until done. The trajectory it produces is the recorded list of (state, action, reward) tuples — the raw experience the learner consumes.
rolloutRunning episodes with the current policy to collect trajectories. This is the embarrassingly parallel part: many simulators roll out independently from the same policy snapshot.
learnerConsumes pooled trajectories and updates the policy (here, a Q-learning update), then the new policy is broadcast back to the simulators. This is the reduce — plus the feedback edge that makes RL iterative rather than one-shot.

Hold those last two against lesson 05. Rollout is the map (fan out independent work), pooling trajectories is the shuffle/reduce (aggregate experience), and the learner update is a reduce whose output feeds back to seed the next map. RL is map-shuffle-reduce with a loop closed around it. Once you see that, on-policy vs off-policy is just a question about that loop: on-policy means the learner trains only on data generated by the very latest policy (each round's data is thrown away after one update); off-policy means it may train on data generated by older policies (data can be reused from a buffer). We will lean lightly off-policy — simulators run a slightly stale policy snapshot while the learner updates — because that is exactly the staleness/utilization trade-off Ray makes you confront.

The decomposition contract
Four roles, two primitives. The policy and the environment state are mutable and long-lived → actors. The rollout is a burst of independent work → it could be tasks, but the environment is expensive to construct, so we make each simulator an actor that holds its env and is called repeatedly. The learner is a reduce → it can live on the driver (simple, becomes the bottleneck) or in its own actor (scales further). Picking the wrong primitive here is the single most common way a first distributed app stalls.

2 · The single-process maze — your correctness oracle

Before any @ray.remote, write the thing that has no parallelism and no chance of a race. It is slow, but it is right, and every distributed version must reproduce its learning curve. This is the same discipline as lesson 00's "Ray is not a speed button": correctness first, then parallelize the part that is actually the bottleneck.

import numpy as np

class MazeEnv:
    def __init__(self, n=8):
        self.n, self.goal = n, n*n - 1          # 8x8 grid, goal = bottom-right
    def reset(self):
        self.s = 0; return self.s               # start top-left
    def step(self, a):                          # a in {0:up,1:down,2:left,3:right}
        r, c = divmod(self.s, self.n)
        if   a == 0: r = max(0, r-1)
        elif a == 1: r = min(self.n-1, r+1)
        elif a == 2: c = max(0, c-1)
        else:        c = min(self.n-1, c+1)
        self.s = r*self.n + c
        done = (self.s == self.goal)
        return self.s, (0.0 if done else -1.0), done   # -1 per move; reach goal to stop

def run_episode(env, Q, eps=0.1, max_steps=200):
    s, traj = env.reset(), []
    for _ in range(max_steps):
        a = np.random.randint(4) if np.random.rand() < eps else int(Q[s].argmax())
        s2, r, done = env.step(a)
        traj.append((s, a, r, s2)); s = s2
        if done: break
    return traj                                  # the trajectory

# single-process training loop: rollout -> learn -> repeat
Q = np.zeros((64, 4))                            # the policy: one row per state
for it in range(500):
    env = MazeEnv()
    batch = [t for _ in range(16) for t in run_episode(env, Q)]   # 16 episodes
    for (s, a, r, s2) in batch:                  # Q-learning update (the learner)
        Q[s, a] += 0.1 * (r + 0.95*Q[s2].max() - Q[s, a])

The eps knob is exploration: 10% of the time the agent acts randomly instead of greedily, so it discovers paths the current Q-table would never try. Everything distributed below keeps this exact update; we only change who runs the rollouts and where the Q-table lives.

3 · Distribute it — actors for state, the driver for the reduce

Two facts decide the layout. First, the maze env here is cheap, but in any realistic problem the environment is expensive to build (load a simulator, open a connection, allocate a board), so we do not want to recreate it per rollout — that argues for a long-lived simulator actor that constructs its env once and is called many times. Second, the policy is read by every simulator and written by one learner — classic read-mostly shared state — so we host it in a parameter-server actor whose only jobs are get_weights() and apply_update().

import ray
ray.init()

@ray.remote
class RolloutWorker:                             # one stateful simulator
    def __init__(self, n=8):
        self.env = MazeEnv(n)                    # built ONCE, reused every call
    def sample(self, Q, episodes=16):            # Q arrives auto-dereferenced
        return [t for _ in range(episodes) for t in run_episode(self.env, Q)]

@ray.remote
class ParameterServer:                           # holds the read-mostly policy
    def __init__(self, n_states=64, n_actions=4):
        self.Q = np.zeros((n_states, n_actions))
    def get_weights(self):  return self.Q        # served zero-copy on-node
    def apply(self, batch, lr=0.1, gamma=0.95):  # the learner / reduce step
        for (s, a, r, s2) in batch:
            self.Q[s, a] += lr*(r + gamma*self.Q[s2].max() - self.Q[s, a])
        return self.Q

ps = ParameterServer.remote()
workers = [RolloutWorker.remote() for _ in range(8)]

for it in range(500):
    w_ref = ps.get_weights.remote()              # ObjectRef to the current policy
    w_ref = ray.put(ray.get(w_ref))              # pin one snapshot, share its ref
    futures = [wk.sample.remote(w_ref) for wk in workers]   # fan out: the MAP
    # collect as they finish instead of blocking on the slowest straggler:
    batch, pending = [], futures
    while pending:
        done, pending = ray.wait(pending, num_returns=1)    # first-done streaming
        batch += ray.get(done[0])
    ray.get(ps.apply.remote(batch))              # the REDUCE + feedback edge

Three things to notice, each a lesson-05 idea made real. We ray.put the policy once per iteration and pass the ref to all 8 workers, so the Q-table is serialized one time, not eight (lesson 00's "big objects through small tasks" anti-pattern, avoided). We use ray.wait rather than ray.get(futures) so a slow worker does not stall the whole batch — we fold in trajectories as they arrive. And the learner update is the reduce, whose output (the new Q) becomes the input to the next round: the feedback edge.

Worked number — does the fan-out even pay off?
One rollout-worker iteration here is ~16 episodes × ~30 steps × ~50 µs/step ≈ 24 ms of real compute. Ray's per-call actor-task overhead is ~0.5 ms (lesson 00). Overhead fraction = 0.5 / 24 ≈ 2% — comfortably coarse, so parallelism is worth it. If instead each worker ran a single episode per call (~1.5 ms compute), overhead would be 0.5 / 1.5 ≈ 33% and the parallel version would barely beat the serial loop. That is why sample() takes an episodes batch: it amortizes the call so simulation, not scheduling, dominates.

4 · The bottleneck migrates as you scale — the real engineering point

Here is the lesson the book only gestures at. Each training iteration has a parallel part (rollout, which divides across workers) and a serial part that does not shrink no matter how many workers you add: getting the policy snapshot, the ray.put broadcast, and the learner's apply over the pooled batch. Call the serial part the coordination tax. This is Amdahl's law (lesson 00) wearing an RL costume.

Put numbers on it. Suppose total rollout work per iteration is fixed at W = 8 × 24 ms = 192 ms of simulation, split across the workers. The serial coordination tax per iteration is roughly: policy fetch + put ≈ 2 ms, plus the learner update, which grows with batch size — say the pooled batch is ~480 transitions per worker and the learner does ~0.6 µs per transition, so learner time ≈ workers × 480 × 0.6 µs. Walk it up:

Rollout actorsParallel rollout timeCoordination tax (put + learner)Iteration timeWhere the time goes
1192 ms2 + 0.3 ≈ 2.3 ms≈ 194 ms99% simulation — compute-bound
824 ms2 + 2.3 ≈ 4.3 ms≈ 28 ms85% simulation — still healthy
643 ms2 + 18.4 ≈ 20.4 ms≈ 23 ms~89% coordination — the learner is now the wall

From 1 → 8 workers, iteration time drops 194 → 28 ms (a 6.9× speedup on 8× the workers — near-linear, simulation dominates). From 8 → 64 workers, you added 8× more workers but iteration time barely moves, 28 → 23 ms (a measly 1.2×), because the single learner now consumes 8× the pooled batch every round and the ray.put broadcast fans out to 64 readers. The bottleneck migrated from simulation compute to coordination. The Amdahl ceiling is set by that ~20 ms serial floor: even with infinite rollout workers, 192 / (≈20) ≈ 9.6× is the best total speedup you can ever get from this design.

What the table tells you to do next
The fix is not "more rollout workers" — they are already starved for serial budget. It is to attack the serial floor: (a) make the learner its own actor (or several sharded learners) so the update overlaps the next rollout instead of blocking it; (b) shard or tree-broadcast the policy instead of a single ray.put read by 64 workers; (c) accept controlled staleness — let workers roll out on policy version k while the learner produces k+1, turning the synchronous barrier into a pipeline. Each is exactly what RLlib productionizes (lesson 07). The widget below lets you find the crossover for yourself.
Rollout scaling — watch the bottleneck migrate from compute to coordination
Drag the number of rollout actors. The green region is parallel simulation time (shrinks as you add workers); the purple region is the coordination tax — policy broadcast plus the single learner's update, which grows with the pooled batch. Total iteration time is their sum. Speedup is measured against the 1-actor baseline. Watch it bend over: the learner becomes the wall.
Simulation time
Coordination tax
Iteration time
Speedup vs 1 actor
Show the core JS
// fixed total rollout work W (ms); per-worker batch feeds one serial learner.
// learner cost per worker = 480 transitions x 0.6us = 0.288 ms (see the §4 table).
var W = 192, putMs = 2, learnerPerWorker = 0.288;   // ms
function model(n){
  var sim   = W / n;                  // rollout divides across workers
  var coord = putMs + learnerPerWorker * n;  // broadcast + single learner grows
  var iter  = sim + coord;
  return { sim: sim, coord: coord, iter: iter, speedup: (W + putMs + learnerPerWorker) / iter };
}

5 · Look behind the scenes — the Ray dashboard

The table above is a prediction; the dashboard is how you confirm it on a real run. ray.init() prints a dashboard URL (default http://127.0.0.1:8265). Three views answer the question "where is my time going?" directly:

Actors / Tasks view
Shows each RolloutWorker and the ParameterServer, their state (RUNNING / IDLE), and per-method call counts. If workers sit IDLE while the learner runs, you are watching the synchronous barrier from §4 — they finished sampling and are waiting on apply.
Timeline (trace)
ray timeline dumps a Chrome-trace of every task. You will literally see the rollout bars shrink and the learner bar grow as you scale workers — the bottleneck migration rendered as geometry, not a guess.
Object store / memory
Tracks the policy snapshots you ray.put. If you forget to drop old refs, you will see object-store memory climb each iteration — the precursor to spilling (lesson 04). One snapshot per iteration should stay flat.

The discipline from lesson 00 returns: never optimize by intuition. Measure the split between sampling and learning, then attack whichever the timeline says is winning. The dashboard turns the Amdahl argument from arithmetic into something you can point at.

6 · Failure modes & checklist

Failure modes

  • Parallelizing before it's correct. Distributing a maze whose reward or done logic is wrong just produces a wrong answer faster, on more machines. Keep §2's single-process loop as the oracle and diff the learning curve.
  • Re-serializing the policy per worker. Calling wk.sample.remote(ray.get(w_ref)) sends a fresh copy to each worker. Pass the ref (ray.put once) so the snapshot is serialized one time, not N.
  • Tasks too fine. One episode per remote call makes scheduling overhead ~33% of runtime (§3 worked number). Batch episodes per sample() call so simulation dominates.
  • Unowned shared state. Letting every worker mutate a global Q-table is a race with no owner. The ParameterServer actor is the owner; all writes go through its single thread.
  • Blocking on the straggler. ray.get(futures) waits for the slowest worker. Use ray.wait to fold in trajectories as they land.
  • Measuring reward only. Reward going up while the cluster spends 89% of wall-clock in the learner (§4) means you are paying for 64 idle simulators. Watch sample throughput and learner throughput together.

Implementation checklist

  • Which of the four roles is each line of my code — environment, policy, rollout, learner?
  • Is each role on the right primitive: actor for env/policy state, batched actor calls for rollout, an actor for the learner once it's the bottleneck?
  • Is the policy ray.put once per iteration and passed by ref?
  • At my target worker count, is simulation or coordination dominating — and what does the timeline say?
  • What is my policy-staleness budget if I let workers run ahead of the learner?
  • Does the distributed learning curve match the single-process oracle?

Checkpoint exercise

Try it
Take the §3 design and change exactly one assumption: the environment takes 20 seconds to construct (a heavy simulator). (1) Does a per-rollout task still make sense, or must the simulator be an actor — and why does the 20 s setup cost decide it? (2) Now flip a second knob: the learner update gets 100× heavier (a neural-net policy, not a Q-table). Recompute the §4 table's crossover — at how many rollout actors does coordination overtake simulation now, and which fix (own-actor learner, sharded broadcast, or staleness) buys you the most? The "right" decomposition should change when you flip each assumption.

Where this points next

You have built the RL graph by hand and found its ceiling: a synchronous barrier and a single learner that cap speedup no matter how many simulators you add. The obvious moves — make the learner its own actor, broadcast the policy as a tree, pipeline stale rollouts against fresh learning, put the learner on a GPU while rollouts stay on cheap CPUs — are exactly the design of RLlib. Lesson 07 shows RLlib's EnvRunners and learner as the productionized version of your RolloutWorker and ParameterServer, and makes the heterogeneous-resource argument (many cheap CPU samplers, few expensive GPU learners) that this hand-built version was begging for.

Takeaway
RL is map-shuffle-reduce with a feedback loop, and it forces all four Ray roles into the open: a stateful environment and read-mostly policy (actors), a parallel rollout (batched actor calls / the map), and a learner that reduces pooled experience and broadcasts the new policy back (the reduce + feedback edge). Build the single-process version first as a correctness oracle, then distribute with ray.put for the policy snapshot and ray.wait for streaming collection. The decisive engineering fact is that the bottleneck migrates: from 1 → 8 workers you get near-linear speedup because simulation dominates, but by 64 workers the single learner and the policy broadcast — the un-parallelizable coordination tax — cap you at an Amdahl ceiling. The fix is never more rollout workers; it is shrinking the serial floor (own-actor learner, tree broadcast, controlled staleness) — which is precisely what RLlib gives you next.

Interview prompts