all_lessons/ray/07lesson 8 / 17

Part II - Applications and libraries

RLlib: Scalable Reinforcement Learning

In lesson 06 you built a reinforcement-learning loop by hand and watched four roles emerge: an environment to simulate, a policy to choose actions, rollout workers to collect experience, and a learner to update the policy from that experience. You also watched the bottleneck migrate — from compute, to coordination, to the single learner — as you added simulators. RLlib is that exact graph, hardened. This lesson maps each hand-built role onto an RLlib component, and then spends its weight on the one engineering fact that makes RL a Ray problem rather than a single-box problem: the two halves of the loop want completely different hardware, and reconciling that is the whole game.

Book source
Chapter 4, "Reinforcement Learning with Ray RLlib" — Gym/Gymnasium environments, the CLI/Python API, experiment configuration, multi-agent, policy clients, curriculum, and offline data. API wording is calibrated to current Ray: the modern AlgorithmConfig builder uses .env_runners(num_env_runners=N) where older releases said .rollouts(num_rollout_workers=N).
Linear position
Prerequisite: Lesson 06 (Your First Distributed Application) — the hand-built rollout/learner loop, and the observation that the learner becomes the bottleneck once rollout throughput is plentiful.
New capability: after this lesson you can take an RL problem, express it as an AlgorithmConfig, and reason quantitatively about how many cheap CPU rollout workers it takes to keep one expensive GPU learner busy — the CPU/GPU ratio that decides whether your cluster is well-spent or idling.
The plan
Five moves. (1) Map the four hand-built roles onto RLlib's EnvRunners, learner, sample batch, and the Gym env contract. (2) Write the minimal PPOConfig + algo.train() loop and read what one iteration actually does. (3) Make resource heterogeneity quantitative — why many cheap CPU samplers feed a few expensive GPU learners, with worked throughput and utilization numbers. (4) On-policy vs off-policy, and why that single choice decides how stale a collected sample is allowed to be — the throughput-vs-staleness trade-off. (5) The four advanced modes (multi-agent, offline, curriculum, external clients) — what each is for — then failure modes and a checklist.

1 · The four roles, now named by RLlib

In lesson 06 you wrote the roles yourself. RLlib gives each one a component and a contract, so the code you keep writing is the part that is actually specific to your problem — the environment and the reward — while the distributed plumbing is configured, not rebuilt.

Environment
A Gymnasium env: an object exposing reset() → (observation, info) and step(action) → (observation, reward, terminated, truncated, info), plus declared observation_space and action_space. This is the only contract between your world and RLlib. Get it right and almost no custom distributed code is needed.
EnvRunner (rollout worker)
A Ray actor (lesson 03) that owns one or more env copies and a local policy replica. It runs the env forward, choosing actions from the current policy, and emits experience. A rollout is one such stretch of stepping the env; the worker that does it is the rollout worker. Count is set by num_env_runners.
Learner
The component that consumes collected experience and applies the gradient update to the policy parameters. This is where the GPU lives. One learner is common; num_learners > 1 shards the gradient step across several GPUs (data-parallel, the lesson-10 shape).
Sample batch
The unit of data flowing runner → learner: a bundle of transitions (observation, action, reward, next-observation, done) plus bookkeeping like value estimates and log-probs. EnvRunners produce sample batches; the learner trains on a train batch assembled from them.

The data path is exactly the fan-out / object-store / fan-in shape lesson 00 promised: EnvRunners fan out across cheap CPU nodes, each producing sample batches that travel through Ray's object store to the learner, which fans them in, computes one gradient step, and then broadcasts the updated weights back out to every EnvRunner so the next rollouts use the fresh policy. That last broadcast — weights flowing back the other way — is the detail lesson 06 had you write by hand and is the source of the staleness question in §4.

2 · The minimal loop, and what one train() does

RLlib's modern API is a builder: you chain configuration methods onto an AlgorithmConfig subclass (here PPOConfig), then .build_algo() it into a running Algorithm and call .train() in a loop. Each call to train() is one full iteration of the loop you built in lesson 06: collect a train batch from the EnvRunners, do one (or several) gradient updates on the learner, broadcast new weights, and return metrics.

from ray.rllib.algorithms.ppo import PPOConfig

config = (
    PPOConfig()
    .environment("CartPole-v1")          # the Gym/Gymnasium env contract
    .env_runners(num_env_runners=4)       # 4 CPU rollout-worker actors
    .training(train_batch_size=4000)      # transitions per learner update
    .resources(num_gpus=0)                # CartPole is tiny; no GPU needed
)

algo = config.build_algo()                # spins up the EnvRunner actors + learner
for i in range(20):
    result = algo.train()                 # ONE iteration: collect → update → broadcast
    print(i, result["env_runners"]["episode_return_mean"])
algo.save("checkpoints/cartpole")         # checkpoint = resumable policy + optimizer

Read the contract in that loop. train_batch_size=4000 means each train() waits until the four EnvRunners have collectively produced 4,000 environment transitions before the learner updates. So iteration time is dominated by whichever side is slower: if the four runners take 0.8 s to make 4,000 steps and the learner takes 0.2 s to consume them, you are sampling-bound and the GPU (if any) sits idle 80% of the time. That ratio is the entire subject of §3. The older API spelled the runner line .rollouts(num_rollout_workers=4); same actors, renamed — if you read old examples, translate rolloutsenv_runners and num_rollout_workersnum_env_runners.

3 · Resource heterogeneity is the whole point

Here is why RL maps onto Ray specifically and not onto a simpler tool. The two halves of the loop want opposite hardware. Sampling is stepping an environment — usually CPU-bound Python/physics with little arithmetic, and embarrassingly parallel across env copies. Learning is a dense neural-net gradient step — the thing GPUs exist for. A single homogeneous pool of machines is wrong for both: a box of GPUs wastes them on env stepping; a box of CPUs starves the gradient step. The right cluster is heterogeneous — many cheap CPU EnvRunners feeding a few expensive GPU learners — and configuring exactly that asymmetric placement is what Ray's resource model (lesson 04) is for.

Worked number — how many CPU samplers keep one GPU learner busy?
Say one EnvRunner steps the env at 2,000 transitions/second (a moderate simulator), and the GPU learner can consume 30,000 transitions/second of gradient work for this network. To stop the learner waiting, sampling must match consumption:
30,000 ÷ 2,000 = 15 EnvRunners to keep one learner saturated.
Run only 4 runners → you produce 8,000 transitions/s, the learner is busy 8,000 ÷ 30,000 ≈ 27% of the time and your expensive GPU idles 73% of wall-clock. Run 15 → the learner is the bottleneck (the good place to be: the scarce resource is the busy one). Run 30 → you produce 60,000/s but the learner still only eats 30,000/s, so half your sampling is wasted unless the algorithm can use the surplus (it sometimes can — see off-policy, §4). The lesson: size the cheap side to the expensive side, then stop.
Worked number — cost of the wrong ratio
Suppose a c-class CPU EnvRunner core costs ~$0.04/hr and a GPU learner instance costs ~$3.00/hr. With the 15:1 ratio above, the cluster is 15 × $0.04 + 1 × $3.00 = $3.60/hr and the GPU runs near 100%. Now make the mistake of running rollouts on the GPU box to "keep it simple": you need ~15 CPU-equivalents of sampling, but each GPU-instance hour is $3.00, so matching throughput on GPU boxes costs many multiples of $3.60/hr while the GPUs spend most cycles stepping CartPole. The heterogeneous split is not a micro-optimization — it is often a 5–10× cost difference at the same training throughput. This is precisely the asymmetry Ray's per-actor resource requests (num_cpus, num_gpus) let you express.
config = (
    PPOConfig()
    .environment("MyHeavyEnv-v0")
    .env_runners(
        num_env_runners=16,               # 16 cheap CPU rollout actors
        num_envs_per_env_runner=4,         # 4 env copies each → 64 envs total, vectorized
    )
    .learners(num_learners=1, num_gpus_per_learner=1)   # one expensive GPU learner
    .training(train_batch_size=32000)
)

That config is the heterogeneous topology in code: 64 environment copies stepping on CPU actors, one GPU doing the gradient. num_envs_per_env_runner lets a single actor vectorize several envs in one process, amortizing the per-actor overhead (lesson 02's granularity point — don't pay actor scheduling cost for one tiny env).

CPU EnvRunners vs one GPU learner — find the ratio that stops the GPU idling
One GPU learner consumes a fixed 30,000 transitions/s. Each CPU EnvRunner produces a sampling rate you set below. Drag the number of EnvRunners and the per-runner sample rate, and watch where the system becomes learner-bound (good: the scarce GPU is busy) versus sampling-bound (bad: the GPU idles). The bar is GPU learner utilization.
Sampling throughput
Learner capacity
30,000 / s
GPU learner utilization
Bottleneck
Show the core JS
const LEARNER_CAP = 30000;                 // transitions/s the GPU can consume
const throughput  = nRunners * perRunnerRate;
// if sampling < capacity, learner waits on data → utilization = throughput/capacity
// if sampling >= capacity, learner is the bottleneck → utilization = 100%, surplus wasted
const util = Math.min(1, throughput / LEARNER_CAP);
const bottleneck = throughput < LEARNER_CAP ? "sampling (GPU idle)" : "learner (good)";

4 · On-policy vs off-policy: how stale may a sample be?

The §3 surplus question — what if you sample faster than the learner can consume? — is answered differently by two families of algorithm, and the difference is the most important conceptual knob in RL systems.

A policy update changes the policy. The samples an EnvRunner collected a moment ago were generated by the old policy. On-policy means a sample is only valid for updating the exact policy that produced it — the moment you take one gradient step, every un-consumed sample is stale and must be discarded. PPO (the default above) is on-policy. Off-policy means samples from older policies remain usable: you store transitions in a replay buffer and the learner trains on a mix of fresh and old. DQN and SAC are off-policy.

PropertyOn-policy (PPO, A2C)Off-policy (DQN, SAC)
Sample reuseOnce. Stale after the next update.Many times, from a replay buffer.
Sample efficiencyLower — needs fresh data per step.Higher — squeezes more learning per transition.
Tolerance for surplus samplingLow — extra runners past the learner rate are wasted (must sync per step).High — surplus samples land in the buffer and get reused, so more runners still help.
Sync requirementTight — runners need fresh weights each iteration; async collection introduces a staleness bias the algorithm must correct (importance sampling).Loose — staleness is expected and absorbed by the buffer.
Failure when abusedTrain on stale on-policy data → biased gradients, unstable/divergent learning.Replay buffer drifts far from current policy → distribution shift, value overestimation.
The real contract: throughput vs staleness
This is the central systems trade-off of distributed RL. To raise throughput you want EnvRunners collecting asynchronously, never blocking on the learner. But an on-policy algorithm requires that samples were generated by the current policy. So you cannot have both maximal throughput and zero staleness — every distributed RL system picks a point on that line. Synchronous collection (runners pause, learner updates, weights broadcast, runners resume) gives zero staleness but leaves the GPU idle during collection and the CPUs idle during the update. Asynchronous collection (IMPALA/APPO style) keeps both sides busy but trains on slightly-stale samples and must correct the bias with importance weighting. The decision flows directly from §3: if you are learner-bound, sync is fine; if you are sampling-bound and the GPU idles, asynchrony buys real utilization at the cost of a staleness correction.
Worked number — what asynchrony recovers
Take the synchronous loop where collection is 0.8 s and the update is 0.2 s per iteration. Synchronous wall-clock per iteration is 0.8 + 0.2 = 1.0 s, and the GPU is busy only 0.2 ÷ 1.0 = 20%. Overlap them asynchronously — runners collect the next batch while the learner updates on the current one — and per-iteration wall-clock drops toward max(0.8, 0.2) = 0.8 s, a 1.25× speedup here, with GPU utilization rising to 0.2 ÷ 0.8 = 25%. The gain grows as the two sides come into balance: at 0.5 s / 0.5 s, async nearly doubles throughput. The price is that the learner now consumes samples generated one iteration ago — acceptable for off-policy, a correction-requiring approximation for on-policy.

5 · Four modes that change the loop's shape

Everything above is single-agent online RL. RLlib packages four variants; you do not need their full API here, only what each is for and how it bends the loop.

multi-agentMore than one agent acts in the same environment (traffic, markets, games). You define a policy_mapping_fn that routes each agent to a policy — agents can share one policy or each own theirs. The systems change: the sample batch now carries per-agent streams, and learners may update several policies. Treat the mapping as architecture, not config trivia — it decides what learns from what.
offline RLLearn from a fixed log of previously-recorded transitions instead of live rollouts — for when interacting with the real system is costly or unsafe (recommendation, healthcare, robotics from logs). EnvRunners are replaced by a data reader. The central risk shifts from sampling throughput to distribution shift: the logged behavior policy differs from the one you are learning, so the data never covers what your new policy would do.
curriculum learningChange the environment's difficulty as the policy improves — start easy, ramp up. Implemented with a callback that mutates the env (e.g. larger maze, faster opponents) based on training progress. It is a sample-efficiency lever: a flat hard task may give near-zero reward signal forever, while a curriculum keeps the gradient informative.
external policy clientsThe environment lives outside Ray — a game server, a real robot, a web app — and connects to an RLlib policy server over the network to get actions and ship back experience. Inverts control: the env drives, RLlib serves the policy and learns in the background. For when you cannot wrap the world in a Python step() call.

6 · Failure modes & checklist

Failure modes

  • Blaming the algorithm for a broken reward. A reward that leaks, saturates, or rewards the wrong behavior produces a policy that optimizes exactly that — and it looks like "PPO doesn't work." Validate the env with random actions and inspect the reward distribution before training.
  • Scaling runners past the learner. Adding EnvRunners past the learner's consumption rate (§3) buys nothing on-policy — the surplus samples are discarded — while inflating cost. The fix is to size the cheap side to the expensive side, then stop, or switch to an off-policy/async algorithm that can use the surplus.
  • GPU starvation. Too few or too slow EnvRunners leave the GPU learner idle most of wall-clock; you pay for a GPU that waits on CPUs. Watch utilization, not just reward.
  • Treating offline data as on-policy. Feeding logged transitions to an on-policy algorithm, or ignoring that the logging policy differs from the learned one, yields biased, overconfident value estimates.
  • Stale on-policy training. Turning on async collection for an on-policy algorithm without the importance-sampling correction trains on stale samples → biased gradients and unstable learning.

Implementation checklist

  • Does the env satisfy the Gymnasium contract, and does it run with random actions before any learning?
  • What is the sampling throughput per EnvRunner, and the learner's consumption rate — and does the runner count match them (§3)?
  • Is the GPU learner utilization being tracked, not just episode reward?
  • Is the algorithm on-policy or off-policy, and does the collection mode (sync vs async) match that choice (§4)?
  • For multi-agent, is the policy_mapping_fn a deliberate architecture decision?
  • For offline RL, is distribution shift between the logging policy and the learned policy being measured?
  • Are checkpoints saved often enough to resume, and is the experiment run under Ray Tune (lesson 08) rather than by hand?

Checkpoint exercise

Try it
You have one GPU learner that consumes 40,000 transitions/s and EnvRunners that each step at 2,500 transitions/s on a $0.05/hr CPU core; the GPU instance is $3.20/hr. (a) How many EnvRunners keep the learner saturated, and what is the hourly cluster cost? (b) Your reward is improving but GPU utilization is stuck at 30% — is the system sampling-bound or learner-bound, and what is the cheapest fix? (c) Now the algorithm is switched from PPO to SAC (off-policy). Which of your answers change, and why does adding runners past the saturation point now still help?

Where this points next

You can now configure a single RL experiment and reason about its CPU/GPU ratio. But one config is one point in a space — learning rate, batch size, runner count, network size all interact, and RL is notoriously sensitive to them. Running configs one at a time by hand is exactly the waste lesson 00 warned about. Lesson 08 introduces Ray Tune: it treats each config as a trial (a Ray task/actor), runs many in parallel, and — crucially — uses a scheduler to kill losing trials early and reallocate their compute. The algo.train() loop you just wrote becomes the inner body that Tune drives, and the question shifts from "how do I run this config" to "how do I search the space without burning the whole GPU budget."

Takeaway
RLlib is the hand-built lesson-06 loop, hardened: EnvRunners (rollout-worker actors) collect sample batches from Gym/Gymnasium env copies, a learner consumes a train batch and applies the gradient, and updated weights broadcast back. The engineering core is resource heterogeneity — sampling is cheap CPU work and learning is expensive GPU work, so the right cluster is many cheap EnvRunners feeding a few GPU learners, sized so the scarce GPU stays busy (worked: ~15 CPU samplers per GPU learner, often a 5–10× cost difference versus a homogeneous box). The decisive conceptual knob is on-policy vs off-policy: it sets how stale a collected sample may be, which fixes the throughput-vs-staleness trade-off between synchronous (zero staleness, idle hardware) and asynchronous (busy hardware, corrected staleness) collection. Multi-agent, offline, curriculum, and external-client modes each bend the loop's shape without changing that core.

Interview prompts