6.1 The Anatomy of an RL-for-LLM System¶
In Part V you learned the algorithms of post-training: SFT, reward modeling, PPO, DPO, GRPO, RLVR. Those chapters answered “what loss do we optimize, and why?” This part answers a different, harder question: “what machine actually computes that loss, at scale, without falling over?” That machine — the distributed system that runs reinforcement learning on a large language model — is one of the most demanding pieces of infrastructure in all of ML. It is the only common training workload that must, inside a single training step, run a high-throughput autoregressive inference engine and a memory-hungry training engine, keep their weights in lockstep, and pipe gigabytes of freshly generated tokens between them every few seconds.
This chapter is the map for Part VI. Before we dive into specific frameworks (TRL, veRL, OpenRLHF, Prime-RL) and specific problems (weight sync, colocation, advantage estimation, reward sandboxes), we need a shared mental model: the components of an RL-for-LLM system, how data flows between them, and the structural reasons this is uniquely hard. If you internalize this one chapter, every later chapter becomes “here is a better way to build component X” or “here is how framework Y wires the components together.”
We will assume you already know the math of policy-gradient RL for LLMs from Policy Gradients & PPO for Language Models and GRPO, RLOO & Critic-Free RL. Here we treat those algorithms as a fixed contract and ask how to serve them. We will end this chapter able to draw the full system diagram from memory, account for every GPU in a cluster, and explain to an interviewer exactly why “RL is just SFT with a weird loss” is wrong.
The one-loop problem: why RL infra is its own discipline¶
Start by contrasting RL post-training with the two workloads you already understand.
Pretraining and SFT are one-engine workloads. You have a fixed dataset on disk. Each step: load a batch of tokens, forward, backward, optimizer step. The data never changes in response to the model. The entire system is a single dataflow — a training engine plus a data loader — and the hard problems are parallelism and throughput (Distributed Training I: Data Parallelism, DDP, ZeRO & FSDP) and stability (Training Stability, Loss Spikes & Debugging Large Runs). DPO and offline preference methods inherit this simplicity: the preference pairs are fixed on disk, so DPO is “SFT with a contrastive loss” and runs on a vanilla trainer.
Inference serving is also one-engine. You have a fixed set of weights and a stream of requests. The hard problems are batching, KV-cache memory, and latency (The Anatomy of LLM Inference: Prefill, Decode & The KV Cache). The weights never change.
Online RL is two engines in one loop. The training data does not exist on disk — it is generated by the model being trained, then scored, then consumed to update that same model, which changes what data it generates next. The loop is:
┌──────────────────────────────────────────────────────────────────┐
│ │
│ prompts ──► [GENERATE responses] │
│ │ (autoregressive decode: an INFERENCE engine) │
│ ▼ │
│ [SCORE responses] ──► rewards │
│ │ (reward model / verifier / sandbox) │
│ ▼ │
│ [LEARN from (response, reward)] │
│ │ (forward+backward+optimizer: a TRAIN engine) │
│ ▼ │
│ new weights ──────────────► (sync back to generator) │
│ │ │
└────────────────────┘ repeat │
This single fact — generation and training live in the same loop, on the same (or freshly-copied) weights — is the source of essentially every hard problem in Part VI. It forces four things that no other ML workload forces simultaneously:
-
Two fundamentally different compute profiles share a cluster. Generation is memory-bandwidth-bound, latency-sensitive autoregressive decode: tiny matmuls, huge KV cache, terrible arithmetic intensity. Training is compute-bound, throughput-oriented: big fused matmuls, full activation graphs, optimizer state. The same GPUs (or two pools of GPUs) must do both well. We analyze this tension formally with the roofline model in The Roofline Model & Performance Engineering; the practical upshot is that the best inference stack (vLLM/SGLang) and the best training stack (FSDP/Megatron) are different code with incompatible memory layouts, and an RL system must run both.
-
Weights must be synchronized between the two engines, fast, every step. After each optimizer step the generator is stale. Re-syncing tens to hundreds of GB of parameters over the network, possibly reshaping from a training parallelism layout to an inference parallelism layout, is a first-class system problem with its own chapter (Colocated vs Disaggregated RL & Weight Synchronization).
-
The workload is bursty and heterogeneous in time. A step is “generate for 40 seconds, then train for 8 seconds.” Naively, half your expensive training GPUs sit idle during generation and your inference GPUs sit idle during training. Squeezing out that idle time — via colocation, async pipelines, or disaggregation — is most of what “scaling RL” means (Scaling RL: Throughput, Load Balancing & The Latest Tricks).
-
Up to four copies of the model may coexist. Policy (trained), reference (frozen, for KL), reward model (frozen, if learned), and a separate inference replica of the policy. On a 70B model that is potentially 280B+ parameters resident across the cluster before you count optimizer state. Memory accounting is not an afterthought; it is the design.
Aside: ‘RL infra’ vs ‘RL algorithm’
People say “we use GRPO” as if that names the system. It names the loss. Two teams both “using GRPO” can have wildly different systems: one colocated on 8 GPUs with synchronous vLLM rollouts, one disaggregated across 512 GPUs with a fully async actor pool and a Ray controller. Part V is about the loss; Part VI is about the system around it. The loss is maybe 200 lines; the system is the other 50,000.
The six components¶
Every RL-for-LLM system, from a 100-line TRL script to a production cluster, is built from the same six logical components. Frameworks differ in where these run (same process? same GPU? same node? separate clusters?) and who coordinates them (a single Python driver? a Ray controller? a message queue?), but the components are invariant. Learn them once.
Let us take them one at a time. For each, we give the job, the interface (what goes in, what comes out), and the systems characteristics that make it interesting.
1. The actor (policy)¶
The actor, or policy, is the model you are training: the parameters \(\theta\) of the language model \(\pi_\theta\). It is the only component whose weights change. Conceptually it is one thing, but physically it appears in two places at once that you must keep distinct in your head:
- as the generation weights inside the rollout engine (used to sample responses), and
- as the training weights inside the learner (used to compute gradients and apply the optimizer step).
In the colocated design these are the same bytes time-sliced on the same GPUs; in the disaggregated design they are two separate copies on two pools of GPUs that must be synchronized. Either way, the policy is the hub: generation reads it, training writes it, and the gap between “the weights that generated this data” (\(\theta_{\text{old}}\), the behavior policy) and “the weights we are updating now” (\(\theta\)) is what makes the math off-policy and forces importance ratios and clipping (recall the ratio \(r_{i,t}=\pi_\theta/\pi_{\theta_{\text{old}}}\) from GRPO, RLOO & Critic-Free RL).
2. The rollout / generation engine¶
The rollout engine turns prompts into experience: it samples one or more responses per prompt by autoregressive decoding under the current policy. This is the inference half of the loop, and it is almost always the dominant cost of an RL step — typically 60–80% of wall-clock — because long-CoT responses are thousands of decode steps each, and decode is memory-bandwidth-bound.
Its interface: in come prompts (and a sampling config: temperature, top-p, max tokens, group size \(G\)); out come, per response, the token ids, the per-token log-probs under the behavior policy \(\log \pi_{\theta_{\text{old}}}(o_t\mid\cdot)\), and stop/length metadata. Those behavior log-probs are not optional decoration — they are the denominator of the importance ratio and must come from the same forward pass that sampled the tokens, or you introduce subtle bias (more on this below and in The Generation–Training Loop & Rollout Engines).
In a toy script the rollout engine is model.generate(). In a real system it is a dedicated inference server — vLLM (vLLM: Architecture, PagedAttention & Internals) or SGLang (SGLang: RadixAttention & Structured Programs) — because those give you continuous batching (Continuous Batching & Request Scheduling), PagedAttention KV management (PagedAttention & KV-Cache Memory Management), and 10–30× the throughput of naive HuggingFace generation. The price you pay is that this engine has its own copy of the weights in its own memory layout, which is exactly the weight-sync problem.
3. The reward / verifier¶
The reward model assigns a scalar (or vector) score to each response. This is the component with the most variety in the whole system, because “reward” can be almost anything that returns a number:
- A learned reward model (RM): a transformer with a scalar head trained on human preferences, run as a frozen forward pass over each response. This is classic RLHF (The RLHF Pipeline & Reward Modeling). It is a fourth large model on the cluster.
- A rule-based verifier: parse the boxed answer, compare to gold, return 0/1. Cheap, CPU-bound, uncheatable in the usual RM sense. This is the heart of RLVR (RL with Verifiable Rewards (RLVR) & The Reasoning Recipe). In practice you rarely write the parser yourself: HuggingFace’s
math-verifyhandles LaTeX/answer-equivalence checking for math, and environment libraries such asverifierspackage prompt sets, parsers and rubrics into reusable, framework-agnostic reward environments. - A code sandbox: actually execute the generated program against unit tests in an isolated container and return pass-rate. Now your reward path includes process isolation, timeouts, and security — a whole subsystem (Reward Engineering, Verifiers & Sandboxes).
- An LLM-as-judge: another model grading the response (LLM-as-a-Judge & Automated Evaluation).
From the system’s point of view the reward is a function reward(prompt, response) -> float that may be a GPU forward pass, a CPU regex, or a 5-second Docker run. Its latency and placement vary by 4 orders of magnitude across these cases, which is why a good RL framework treats the reward as a pluggable, possibly-remote, possibly-batched, possibly-async stage rather than baking it in.
4. The learner / trainer¶
The learner consumes scored experience and updates \(\theta\). It is a standard distributed training engine — FSDP or Megatron, mixed precision (Mixed Precision, bf16 & FP8 Training), gradient clipping, an AdamW step — with one twist: its loss is the policy-gradient surrogate, not next-token cross-entropy. Per step it:
- recomputes the current-policy per-token log-probs \(\log\pi_\theta(o_t\mid\cdot)\) with a forward pass (this is the numerator of the ratio),
- queries the reference model for \(\log\pi_{\text{ref}}(o_t\mid\cdot)\) if a KL term is used,
- forms the advantage (from rewards + the chosen estimator: GAE with a critic, or group-relative for GRPO/RLOO),
- computes the clipped surrogate loss, backpropagates, and steps the optimizer.
Crucially, the learner is compute-bound and uses training parallelism (sharded params, sharded optimizer state, activation checkpointing — Memory-Efficient Training: Checkpointing, Offloading & LoRA Math). If the algorithm uses a value network/critic (PPO), the learner also trains that — a second transformer with its own forward/backward — which is a big reason critic-free methods (GRPO/RLOO) took over for reasoning RL.
5. The reference model¶
The reference model \(\pi_{\text{ref}}\) is a frozen copy of the policy (usually the SFT checkpoint you started RL from). Its only job is to provide \(\log\pi_{\text{ref}}(o_t\mid\cdot)\) so the learner can compute the KL-divergence penalty that keeps the policy from drifting too far and reward-hacking (Reward Hacking, Over-Optimization & Alignment Failures). It is forward-only (no gradients, no optimizer state) so it is cheap in compute but still costs a full set of weights in memory. Some recipes — notably R1-Zero — drop the KL term entirely and therefore drop the reference model, saving that memory. When present, it is usually colocated with the learner (it needs the same tokenized batch and the same log-prob machinery) and is a prime candidate for offload-to-CPU or quantization since it is never updated.
6. The experience / replay buffer¶
The experience buffer is the dataset that does not exist on disk. It holds the rollouts produced this iteration: for each response, the token ids, behavior log-probs, reward, computed advantage, prompt/response masks. The learner draws minibatches from it.
In LLM RL the buffer is usually small and short-lived compared to classic deep-RL replay buffers (DQN-style). Most algorithms are “near-on-policy”: you generate a batch, take a handful of gradient epochs on that batch (PPO’s ppo_epochs, GRPO’s reuse), then throw it away and generate fresh, because the data goes stale as \(\theta\) moves away from \(\theta_{\text{old}}\). The buffer is more of a staging area than a long-term memory. The interesting design choices are: how many gradient steps before data is too off-policy to trust (the “staleness budget”); whether to keep a small mix of older data (off-policy RL, async RL — Prime-RL, Async RL & Decentralized Training); and how to lay the buffer out across the cluster so the learner’s data-parallel ranks each get a balanced shard.
These six roles are not merely pedagogical: they are, almost one-for-one, the objects you configure in real frameworks. Reading a strange RL config becomes easy once you can point at which component each key belongs to (names below are for the current major releases — always check against your installed version):
| Component | TRL (GRPOTrainer) |
veRL | OpenRLHF |
|---|---|---|---|
| Actor / policy | model= arg |
actor_rollout_ref.model |
--pretrain |
| Rollout engine | use_vllm=True, vllm_mode |
actor_rollout_ref.rollout (name: vllm or sglang) |
--vllm_num_engines |
| Reward / verifier | reward_funcs=[fn, ...] |
reward_model.* or a custom reward function |
--reward_pretrain, or a remote reward endpoint |
| Learner | GRPOConfig + Accelerate/DeepSpeed backend |
actor_rollout_ref.actor (FSDP or Megatron strategy) |
DeepSpeed --zero_stage |
| Reference model | implicit; disabled by beta=0.0 |
actor_rollout_ref.ref |
implicit; disabled by zero KL coefficient |
| Experience buffer | in-memory, per generation batch | Ray DataProto batches passed between worker pools |
Experience / replay-buffer objects |
Notice the shape of the table: TRL keeps everything inside one trainer object (multi-controller, one process per GPU running the same script); veRL gives each component its own named config subtree because each is a separately-placed Ray worker pool; OpenRLHF exposes them as CLI flags over Ray actors. Same six nouns, three different placements. We dissect each framework in TRL: HuggingFace’s RL Library, veRL: HybridFlow & The Single-Controller Architecture, and OpenRLHF, NeMo-Aligner & Ray-Based Systems.
Practitioner tip: name the four model copies in your config
A recurring source of confusion (and OOMs) is losing track of which models are resident. Write your config so it is explicit: policy (trainable, FSDP-sharded), policy_inference (the vLLM replica), reference (frozen, maybe CPU-offloaded), reward (frozen, or null if rule-based). If you can’t point at each on a GPU memory map, you don’t yet understand your own system — and you will discover this the hard way at step 1 when CUDA reports out-of-memory.
The data flow, end to end¶
Components are nouns; the system is the verb that connects them. Here is the canonical synchronous on-policy step, the one every framework implements as its baseline before adding async/colocation tricks. We trace one outer iteration for a GRPO-style critic-free setup (the most common modern case).
Two arrows in this loop are the entire reason Part VI exists, and neither has any analogue in SFT or serving:
- (b)→©→(f): the data is manufactured, scored, and consumed in one pass. There is no
dataset.parquet. If generation is slow, the whole step is slow; if the reward sandbox flakes, your training signal is corrupted. The data pipeline is the model. - (g): the weight-sync arrow. After every update, the inference engine holds stale weights. You must copy the new \(\theta\) from the training layout into the inference layout, possibly across the network, possibly across a parallelism-layout change (FSDP shards ≠ tensor-parallel shards). This arrow is invisible in the math and dominant in the engineering.
Here is a single, runnable, deliberately-minimal driver that makes the six components and the dataflow concrete. It is colocated and synchronous — the simplest real design — and uses HuggingFace generation as a stand-in for a real rollout engine so it runs on one GPU. Every later framework in Part VI is an elaboration of this skeleton.
import torch
import torch.nn.functional as F
from transformers import AutoModelForCausalLM, AutoTokenizer
# ===========================================================================
# A minimal, COLOCATED, SYNCHRONOUS RL-for-LLM loop that exhibits all six
# components and the full dataflow. Toy model + rule reward so it runs on a
# laptop GPU. This is the mental-model reference for the whole of Part VI.
# ===========================================================================
MODEL = "Qwen/Qwen2.5-0.5B-Instruct"
device = "cuda" if torch.cuda.is_available() else "cpu"
tok = AutoTokenizer.from_pretrained(MODEL)
# --- COMPONENT 2: the ACTOR/POLICY (trainable θ). In a colocated design the
# SAME object serves both generation and training (time-sliced). ---------
policy = AutoModelForCausalLM.from_pretrained(MODEL, torch_dtype=torch.bfloat16).to(device)
# --- COMPONENT 6: the REFERENCE model (frozen θ_ref, for the KL term). -------
reference = AutoModelForCausalLM.from_pretrained(MODEL, torch_dtype=torch.bfloat16).to(device)
reference.eval()
for p in reference.parameters():
p.requires_grad_(False)
# --- COMPONENT 4 belongs to the LEARNER: the optimizer over θ. ---------------
opt = torch.optim.AdamW(policy.parameters(), lr=1e-6)
G = 8 # group size: responses sampled per prompt
KL_BETA = 0.02 # KL penalty weight (0.0 to drop the reference entirely)
CLIP_EPS = 0.2 # PPO/GRPO clip
PPO_EPOCHS = 2 # gradient epochs reusing the SAME rollouts (off-policy reuse)
MAX_NEW = 256
# ------------------- COMPONENT 3: the REWARD / VERIFIER ---------------------
def reward_fn(response_text: str, gold: str) -> float:
"""Rule-based verifier: 1.0 if correct, +0.2 format bonus. CPU, no model."""
r = 0.0
if f"<answer>{gold}</answer>" in response_text:
r += 1.0
if "<answer>" in response_text and "</answer>" in response_text:
r += 0.2
return r
# ------------- COMPONENT 1: the ROLLOUT / GENERATION ENGINE -----------------
@torch.no_grad()
def rollout(prompts, golds):
"""Sample G responses per prompt under θ_old. Returns the EXPERIENCE BUFFER
fields: padded token ids, response mask, behavior log-probs, rewards."""
policy.eval()
seqs, plens, rewards = [], [], []
for q, gold in zip(prompts, golds):
ids = tok.apply_chat_template([{"role": "user", "content": q}],
add_generation_prompt=True,
return_tensors="pt").to(device)
plen = ids.shape[1]
# In production this call is a vLLM/SGLang server, not model.generate.
out = policy.generate(ids, do_sample=True, temperature=1.0, top_p=1.0,
max_new_tokens=MAX_NEW, num_return_sequences=G,
pad_token_id=tok.eos_token_id)
for g in range(G):
full = out[g]
text = tok.decode(full[plen:], skip_special_tokens=True)
rewards.append(reward_fn(text, gold)) # <-- COMPONENT 3 invoked
seqs.append(full); plens.append(plen)
policy.train()
# Pad into a rectangular batch and build the response mask.
B, maxlen = len(seqs), max(s.shape[0] for s in seqs)
input_ids = torch.full((B, maxlen), tok.eos_token_id, dtype=torch.long, device=device)
resp_mask = torch.zeros((B, maxlen), device=device)
for i, (s, plen) in enumerate(zip(seqs, plens)):
input_ids[i, :s.shape[0]] = s
resp_mask[i, plen:s.shape[0]] = 1.0 # 1 on generated tokens only
rewards = torch.tensor(rewards, device=device)
# Behavior log-probs logπ_old: in a colocated design we recompute them with
# the current weights (which == θ_old until the first optimizer step).
old_lp, mask = token_logprobs(policy, input_ids, resp_mask)
return input_ids, mask, old_lp.detach(), rewards
def token_logprobs(model, input_ids, resp_mask=None):
"""Per-token logπ of the SAMPLED tokens under `model`, plus the shifted mask.
Pass resp_mask=None when the caller already holds the shifted mask."""
logits = model(input_ids).logits[:, :-1, :] # predict t+1 from t
lp = F.log_softmax(logits.float(), dim=-1)
tgt = input_ids[:, 1:]
tok_lp = lp.gather(-1, tgt.unsqueeze(-1)).squeeze(-1) # (B, T-1)
return tok_lp, (None if resp_mask is None else resp_mask[:, 1:])
# ------------------- ADVANTAGE: group-relative (GRPO) ----------------------
def grpo_advantage(rewards, group_size):
g = rewards.view(-1, group_size)
adv = (g - g.mean(dim=1, keepdim=True)) / (g.std(dim=1, keepdim=True) + 1e-4)
return adv.reshape(-1) # (B,) one per response
# --------------------- COMPONENT 4: the LEARNER ----------------------------
def learner_step(input_ids, mask, old_lp, advantage, ref_lp):
new_lp, _ = token_logprobs(policy, input_ids) # current θ: ratio numerator
A = advantage.unsqueeze(1) # broadcast over tokens
ratio = (new_lp - old_lp).exp() # π_θ / π_old
surr = -torch.min(ratio * A,
torch.clamp(ratio, 1 - CLIP_EPS, 1 + CLIP_EPS) * A)
if KL_BETA > 0: # COMPONENT 6 used here
log_r = ref_lp - new_lp
kl = log_r.exp() - log_r - 1.0 # Schulman k3, >= 0
surr = surr + KL_BETA * kl
loss = (surr * mask).sum() / mask.sum().clamp(min=1.0) # token-level mean
opt.zero_grad(); loss.backward()
torch.nn.utils.clip_grad_norm_(policy.parameters(), 1.0)
opt.step()
return loss.item()
# --------------------- THE OUTER LOOP (the CONTROLLER) ----------------------
def rl_iteration(prompts, golds):
# (b) rollout + (c) reward -> experience buffer
input_ids, mask, old_lp, rewards = rollout(prompts, golds)
# (d) advantage
advantage = grpo_advantage(rewards, G)
# reference log-probs are fixed for this batch; cache once
with torch.no_grad():
ref_lp, _ = token_logprobs(reference, input_ids) if KL_BETA > 0 \
else (torch.zeros_like(old_lp), None)
# (f) learn: E epochs over the same rollouts
for _ in range(PPO_EPOCHS):
loss = learner_step(input_ids, mask, old_lp, advantage, ref_lp)
# (g) weight sync is a NO-OP here because generation and training share θ
# (colocated). In a disaggregated system this is where you'd push θ
# into the vLLM engine. See chapter 6.7.
return rewards.mean().item(), loss
# prompts = ["What is 17 + 26? Put the final number in <answer></answer>."] * 4
# golds = ["43"] * 4
# for it in range(200):
# mean_r, loss = rl_iteration(prompts, golds)
# print(it, "mean_reward", round(mean_r, 3), "loss", round(loss, 4))
Read that loop until the six components and the seven dataflow stages are obvious. The single most important thing the toy hides is stage (g): here weight sync is free because generation and training share one set of weights on one GPU. That is the colocated, synchronous design — perfect for understanding, and genuinely used for small models. At the ~100M scale of this book’s capstone it is not a simplification at all but the correct production choice: policy + reference + inference replica + optimizer state together fit in a couple of GB, so one GPU holds all four copies and the weight-sync arrow really is a no-op — see Post-Training: SFT, DPO, and Narrow RLVR (GRPO) That Works at 100M, which runs essentially this loop with a real verifier and a real prompt set. Everything in Part VI past this chapter is about what happens to this loop when you can no longer afford that simplicity: when the model is too big to colocate, when generation idle time is too expensive, when the reward is a remote sandbox, when you have 512 GPUs and a Ray cluster.
Aside: the controller is a real component too
We listed six components, but the controller — the thing running the outer loop — is the seventh, and frameworks differ most here. In TRL it is a plain Python for loop in your training script (the “multi-controller” style: every GPU runs the same program). In veRL it is a single-controller Ray driver that issues commands to worker pools (veRL: HybridFlow & The Single-Controller Architecture). In async systems it is a scheduler juggling overlapping generate/train stages. Who holds the loop, and whether that holder is one process or every process, is the central architectural axis of Part VI.
Three batch sizes, and the staleness they buy¶
Newcomers reading a real RL config are ambushed by the fact that there is no single “batch size.” There are three nested ones, and conflating them is how people accidentally train far more off-policy than they intended.
- Rollout batch — how many prompts the controller hands to the generation engine per outer iteration (veRL
data.train_batch_size, OpenRLHF--rollout_batch_size, TRLgeneration_batch_size). Multiplied by the group size \(G\) (veRLactor_rollout_ref.rollout.n, OpenRLHF--n_samples_per_prompt, TRLnum_generations) it gives the number of responses generated before any weight update. Larger is better for generation throughput (more concurrency for continuous batching) and lowers advantage variance, but stretches the interval between updates. - Mini-batch — how much of that rollout batch is consumed per optimizer step (veRL
actor_rollout_ref.actor.ppo_mini_batch_size, OpenRLHF--train_batch_size). This is the knob that decides on-policyness. If mini-batch equals rollout batch you take exactly one optimizer step per rollout and the update is fully on-policy (\(r_{i,t}\equiv 1\), clipping never fires). If it is smaller you take several steps on data generated by weights that are already stale by the time you reach the last one. - Micro-batch — how much fits in GPU memory at once (veRL
ppo_micro_batch_size_per_gpu, OpenRLHF--micro_train_batch_size, TRLper_device_train_batch_sizewithgradient_accumulation_steps). Micro-batches are gradient-accumulated into one mini-batch, so this is a pure memory knob with no effect on the math — changing it must not change your loss curve, which makes it a good sanity check on an implementation.
Combine these with the number of gradient epochs over the same rollouts (PPO’s ppo_epochs, TRL’s num_iterations, PPO_EPOCHS in the toy code above) and you get an explicit staleness budget: the final gradient step of an iteration is
optimizer steps removed from the policy that produced its data. When \(n_{\text{stale}}=0\) the importance ratio and clip are decoration; when it is 8 or 16 they are the only things holding the run together. That number — not the algorithm’s name — tells you how off-policy your “on-policy” method really is.
Why this is uniquely hard: three structural tensions¶
We can now state precisely why RL infra is its own discipline. Three tensions fall directly out of the one-loop structure. Every framework in Part VI is a different point in the trade-off space these tensions define.
Tension 1: generation and training want opposite things¶
Generation (decode) and training have nearly opposite hardware appetites. Make this quantitative with the roofline lens (The Roofline Model & Performance Engineering):
| Property | Generation (decode) | Training (forward+backward) |
|---|---|---|
| Bottleneck | memory bandwidth | compute (tensor cores) |
| Arithmetic intensity | very low (≈1 token/forward) | high (big batched matmuls) |
| Batch shape | many short steps, growing KV | few large fused steps |
| Memory hog | KV cache | activations + optimizer state |
| Best parallelism | tensor-parallel, paged KV | FSDP / pipeline / sequence |
| Best software | vLLM, SGLang, TRT-LLM | FSDP, Megatron, DeepSpeed |
| Precision | often weight-only quantized (INT8/FP8) | bf16 master + fp32 optim state |
These columns are so different that the best code for each is mutually incompatible: vLLM lays weights out for paged tensor-parallel inference; FSDP shards them for sharded-optimizer training. You cannot, in general, hand vLLM an FSDP-sharded parameter and have it serve. So an RL system runs both stacks and bridges them — and the bridge is the weight-sync machinery. This is why “just call model.generate() inside your training loop” works for a 0.5B toy and collapses for a 70B model: naive generation is 10–30× too slow, so you need vLLM, which means you need two weight layouts, which means you need sync.
Tension 2: idle time — the loop is a relay race, not a team sport¶
In the synchronous loop, stages run in series: generate, then score, then learn. While the inference engine generates, the training engine’s GPUs (with their fat optimizer state) sit idle. While the learner trains, the inference engine sits idle. If generation is 70% of the step and training 20%, then under a strict disaggregated split your training GPUs are idle 70% of the time and your inference GPUs idle 20–30% of the time. On a cluster costing thousands of USD/hour, that idle time is the headline cost of RL.
There are three escapes, each a later chapter:
- Colocation — put generation and training on the same GPUs, time-sliced, so there is no second idle pool. You pay by stopping training to free memory for the KV cache and back, plus the local weight reshape. This is the default for small/medium models (Colocated vs Disaggregated RL & Weight Synchronization).
- Asynchrony — let generation for step \(k{+}1\) run while training for step \(k\) proceeds, accepting that the data is now slightly off-policy (generated by weights a step or two stale). This trades a little algorithmic correctness for a lot of hardware utilization and is the basis of async RL (Prime-RL, Async RL & Decentralized Training).
- Disaggregation done right — separate inference and training pools but pipeline them and size each pool so neither starves (Scaling RL: Throughput, Load Balancing & The Latest Tricks).
A second, sneakier source of idle time is the long-tail of generation: in a batch of \(N{\times}G\) responses, a few will run to the full max_tokens while most finish early. Synchronous designs must wait for the slowest response before scoring and training. This “straggler” problem motivates continuous batching in the rollout engine and partial/streaming rollout consumption — see The Generation–Training Loop & Rollout Engines.
Tension 3: weight sync — keeping two copies of a moving target consistent¶
After every optimizer step, the policy moves. The inference engine now holds stale weights. To keep the loop on-policy you must propagate the new \(\theta\) to the generator before the next rollout. For a 70B model in bf16 that is ~140 GB of parameters to move, every step. The hard parts:
- The layout mismatch. Training weights are FSDP-sharded across data-parallel ranks (or tensor/pipeline-sharded under Megatron). Inference weights are tensor-parallel-sharded for vLLM. Syncing means gathering the full parameter (or the right shard) from the training layout and scattering it into the inference layout — a collective-communication problem (Parallel Computing & Collective Communication).
- The transport. Same node? Use NVLink / CUDA IPC and stay on-GPU. Different nodes? You are on the InfiniBand/RoCE fabric, and a 140 GB broadcast at, say, 100 GB/s is over a second of pure communication per step — often comparable to the training time itself.
- The freshness/throughput trade-off. Sync every step → maximally on-policy but maximally stalling. Sync every few steps → faster but more off-policy drift, which the importance ratio and clip must absorb (and which, past a point, destabilizes training).
The cleanest production approaches use a collective broadcast directly from training GPUs into the inference engine’s GPU buffers (no CPU round-trip, no disk), overlapped with the next generation where the algorithm tolerates a step of staleness. The whole of Colocated vs Disaggregated RL & Weight Synchronization is devoted to doing this well.
Common pitfall: log-prob mismatch between sampler and trainer
A treacherous, near-invisible bug: the rollout engine (vLLM, FP8 or INT8 weights, fused kernels) and the learner (bf16, different kernels, different attention implementation) compute slightly different log-probs for the same tokens. The importance ratio \(r=\exp(\log\pi_\theta - \log\pi_{\theta_{\text{old}}})\) then has a systematic offset even at step 0 before any update, because \(\pi_{\theta_{\text{old}}}\) came from vLLM and \(\pi_\theta\) from the trainer. Ratios that should be exactly \(1.0\) on fresh data instead sit at, say, \(0.9\) or \(1.1\), biasing the gradient and silently degrading the run. The fixes: recompute behavior log-probs in the trainer’s numerics, or correct with an explicit importance ratio between sampler and trainer (the “truncated importance sampling” trick), or at minimum monitor the sampler-vs-trainer log-prob gap as a health metric. This is the single most common “my RL run looks fine but won’t improve” bug. See The Generation–Training Loop & Rollout Engines.
A worked example: sizing a 7B GRPO run¶
Abstract tensions become concrete the moment you try to put a real run on real GPUs. Let us size a synchronous GRPO run for a 7B model and see where the time and memory go. We will use round, illustrative numbers (the point is the method and the magnitudes, not vendor-exact figures).
Worked example: time and memory budget for a 7B GRPO step
Setup. Policy = 7B params. We start from an SFT checkpoint, KL on (so a reference model is resident). Reward is a rule-based verifier (no reward model, no critic — GRPO). We sample \(G=8\) responses for each of \(N=64\) prompts ⇒ \(512\) responses per step. Mean response length ≈ 1{,}000 generated tokens; prompts ≈ 200 tokens.
Memory accounting (per the four-copies rule). In bf16, 7B params ≈ \(7\times10^9 \times 2\text{ B} = 14\) GB just for one copy of the weights. Tally the resident copies:
| Copy | What it costs | Approx |
|---|---|---|
| Policy weights (training) | 14 GB params | 14 GB |
| Policy optimizer (AdamW: fp32 master + 2 moments ≈ 12 B/param) | \(7\text{e}9\times12\) | 84 GB |
| Policy gradients (bf16) | 14 GB | 14 GB |
| Reference weights (frozen, bf16) | 14 GB | 14 GB |
| Inference replica of policy (vLLM) | 14 GB weights + KV cache | 14 GB + KV |
That is ~140 GB before activations and KV cache — already more than a single 80 GB GPU. So even a “small” 7B GRPO run is multi-GPU: you shard the policy + optimizer with FSDP across several GPUs (Distributed Training I: Data Parallelism, DDP, ZeRO & FSDP), CPU-offload or shard the reference, and give the vLLM replica its own slice of the same GPUs (colocated) or its own GPUs (disaggregated). The optimizer state (84 GB) is the single biggest line item — which is exactly why critic-free GRPO (no second model’s optimizer state) and LoRA-style RL (PEFT I: LoRA, QLoRA, DoRA & The Adapter Family, which trains tiny adapters and slashes the optimizer-state term) are so attractive for RL.
KV cache for the rollout. During generation, peak concurrent sequences each hold a KV cache of (layers × 2 × kv-heads × head-dim × seqlen × 2 B). For a 7B model that is on the order of ~0.5 MB per token; at 512 concurrent sequences of ~1{,}200 tokens that is roughly \(512\times1200\times0.5\text{ MB}\approx 300\) GB of KV if fully concurrent — which is why the rollout engine uses PagedAttention and continuous batching to bound concurrency rather than holding all 512 at once (PagedAttention & KV-Cache Memory Management).
Time accounting (the relay race). Suppose on this cluster the rollout engine decodes the batch in ~40 s (dominated by the 1{,}000-token mean length × 512 responses, mitigated by batching), the verifier scores in ~1 s (CPU, parallel), advantage is ~0 s, and the learner does \(E=2\) epochs over 512 sequences in ~10 s. Plus weight sync. The step looks like:
Generation is ~78% of the step even before sync. If this is disaggregated with a separate training pool, those training GPUs are idle for the 41 s of generate+reward — idle 80% of the step. That single number is why colocation and async RL exist. And if weight sync is a cross-node 14 GB broadcast (×, say, several DP ranks gathering shards), \(T_{\text{sync}}\) can be a few seconds — non-trivial against a 10 s training phase.
The lever. To cut wall-clock you attack the 40 s first: faster/quantized rollout engine, more inference parallelism, shorter responses (or remove the length-inflating GRPO biases — see GRPO, RLOO & Critic-Free RL), or overlap generation of step \(k{+}1\) with training of step \(k\) (async). Optimizing the 10 s training phase is almost pointless until generation is handled. In RL, generation is the budget.
The example crystallizes the part’s whole thesis: in RL-for-LLM, memory is dominated by the multiplicity of model copies, and time is dominated by generation. Optimize for those two facts and you have understood 80% of RL infrastructure.
The mental model for Part VI¶
You now have the scaffolding for everything that follows. Here is how the rest of Part VI hangs off this chapter, so you can read it as a coherent argument rather than a list of tools:
Three load-bearing ideas to carry forward:
-
The six components are invariant; the architecture is where they run and who coordinates them. When you meet a new framework, your first questions are: where does generation run relative to training (colocated/disaggregated)? Who holds the outer loop (single- vs multi-controller)? Is the loop synchronous or async? How does weight sync happen? Those four questions fully locate any system in the design space.
-
Generation is the cost center. Memory is dominated by model-copy multiplicity; wall-clock is dominated by autoregressive decode. Every serious optimization in Part VI is ultimately about making generation cheaper, hiding it behind training, or reusing the GPUs it leaves idle.
-
The off-policy gap is the price of the loop. The instant you let \(\theta\) (trainer) drift from \(\theta_{\text{old}}\) (generator) — via multi-epoch reuse, async generation, or stale weight sync — you are off-policy, and the importance ratio + clip are what keep you honest. Most stability tricks in Advantage Estimation, KL Control & Stability Tricks are about managing exactly this gap.
Interview Corner
Q: Someone says “RL fine-tuning is basically just SFT with a different loss function — why does it need its own infrastructure?” How do you respond?
A: Because SFT is a one-engine workload and online RL is a two-engine loop. In SFT the data is fixed on disk and you only ever do forward+backward+optimizer — a standard distributed trainer. Online RL has no dataset on disk: the data is generated by the model being trained, so every step must run a high-throughput autoregressive inference engine (to sample responses), then a reward/verifier (which may be a model, a rule, or a code sandbox), then a training engine (to update on the scored rollouts), then synchronize the updated weights back into the inference engine. That forces three things SFT never faces: (1) you must co-run two stacks with opposite hardware profiles — decode is memory-bandwidth-bound and best served by vLLM/SGLang, training is compute-bound and best served by FSDP/Megatron, and their weight layouts are incompatible; (2) generation is 60–80% of the step, so the loop is a relay race with large GPU idle time unless you colocate or go async; (3) the generator’s weights go stale after every optimizer step, so you must move tens-to-hundreds of GB of parameters between two parallelism layouts every step — the weight-sync problem. There are also up to four resident model copies (policy, its inference replica, reference, reward), so memory accounting is part of the design. None of that exists in SFT. The loss is ~200 lines; the system — generation engine, reward pipeline, experience buffer, weight sync, controller — is the discipline of Part VI.
Interview Corner
Q: In a synchronous on-policy RL step, where does the wall-clock time go, and what’s the first thing you’d optimize?
A: It goes overwhelmingly into generation — typically 60–80% of step time — because each response is thousands of memory-bandwidth-bound autoregressive decode steps, and you sample many (\(N\times G\)) of them. Reward scoring is usually cheap (a CPU rule) unless it’s a learned RM or a code sandbox; advantage is negligible; the training forward+backward is maybe 15–25%; weight sync can be a non-trivial tail if it’s a cross-node broadcast of the full parameters. So the first lever is always generation: switch from model.generate to a real rollout engine (vLLM/SGLang) for continuous batching and PagedAttention, increase inference parallelism, quantize the inference weights, cut response length (or remove length-inflating loss biases), and — the big one — overlap generation of the next batch with training of the current one (async RL) so the training GPUs aren’t idle while generation runs. Optimizing the trainer before the generator is premature; in RL, generation is the budget.
Key Takeaways
- Online RL is a two-engine loop, not a one-engine job. Unlike SFT (fixed data, one trainer) and serving (fixed weights, one server), RL runs an inference engine and a training engine in the same loop, on weights that change every step. This is the root of every hard problem in Part VI.
- Six invariant components: the actor/policy (the trainable \(\theta\), living in two places), the rollout/generation engine (inference, the cost center), the reward/verifier (RM, rule, or sandbox), the learner/trainer (forward+backward+optimizer with a policy-gradient loss), the reference model (frozen, for the KL term), and the experience/replay buffer (the dataset that exists only in flight). A seventh, the controller, holds the outer loop.
- The dataflow is sample → rollout → reward → advantage → buffer → learn (E epochs) → weight sync. The two arrows with no SFT analogue — manufacturing the data in-loop and syncing weights back into the generator — are why this part exists.
- Three structural tensions. (1) Generation and training want opposite hardware (bandwidth-bound decode vs compute-bound training) and use incompatible software/weight layouts. (2) Serial stages create large GPU idle time (a relay race), fixed by colocation or asynchrony. (3) Weight sync must move tens-to-hundreds of GB between two parallelism layouts every step.
- Memory is dominated by model-copy multiplicity (policy + optimizer state + reference + inference replica + maybe reward + maybe critic). The optimizer state is often the single largest line item — a reason GRPO (no critic) and LoRA-RL (tiny optimizer state) win.
- Wall-clock is dominated by generation (often ~70–80% of a step). In RL, generation is the budget; optimize it (or hide it behind training) before anything else.
- The off-policy gap is the price of the loop. Multi-epoch reuse, async generation, and stale sync all make the trainer’s \(\theta\) drift from the generator’s \(\theta_{\text{old}}\); the importance ratio and clip are what keep that honest, and a sampler-vs-trainer log-prob mismatch is the classic silent bug. The rollout-batch / mini-batch / micro-batch hierarchy makes the gap quantifiable: \(n_{\text{stale}} = \text{epochs}\times(\text{rollout}/\text{mini}) - 1\) optimizer steps.
- To locate any framework in the design space, ask four questions: colocated or disaggregated? single- or multi-controller? synchronous or async? how is weight sync done? Everything else is detail.
State of the Art & Resources (2026)
RL infrastructure for LLMs has matured rapidly since 2023: purpose-built frameworks (veRL, OpenRLHF, TRL, Prime-RL, and newer SGLang-native systems like slime and AReaL) have replaced ad-hoc training loops, and async/colocated designs now routinely train 70B+ models at scale. The central unsolved tensions — generation cost, weight-sync overhead, and off-policy drift — remain active engineering frontiers.
Foundational work
- Ouyang et al., Training Language Models to Follow Instructions with Human Feedback (2022) — the InstructGPT paper that defined the policy/reward/reference three-component structure still used by every RL-for-LLM system.
- Schulman et al., Proximal Policy Optimization Algorithms (2017) — the clipped surrogate loss and actor-critic structure that the learner component implements.
- Shao et al., DeepSeekMath (2024) — introduced GRPO, eliminating the critic and reducing memory/optimizer overhead; the dominant RL-for-LLM algorithm today.
Recent advances (2023–2026)
- DeepSeek-AI, DeepSeek-R1 (2025) — rule-based verifier reward + GRPO at scale; showed that critic-free RL with zero KL reference can produce frontier reasoning, reshaping production RL-infra priorities.
- Sheng et al., HybridFlow: A Flexible and Efficient RLHF Framework (2024) — single-controller Ray architecture with 3D-HybridEngine for zero-redundancy weight resharding between training and inference layouts; 1.5–20× throughput gains.
- Fu et al., AReaL: A Large-Scale Asynchronous Reinforcement Learning System for Language Reasoning (2025) — fully async decoupled generation/training achieving up to 2.77× speedup over synchronous baselines.
- Jaghouar et al., INTELLECT-2 (2025) — first globally decentralized RL run of a 32B model, introducing TOPLOC rollout verification and SHARDCAST weight broadcast for untrusted inference workers.
Open-source & tools
- verl-project/verl — open-source HybridFlow; flexible single-controller RLHF framework integrating FSDP, Megatron-LM, vLLM, and SGLang with pluggable reward backends.
- OpenRLHF/OpenRLHF — Ray-native RL framework designed for 70B+ scale; separates model pools across nodes with vLLM rollout engine.
- huggingface/trl — HuggingFace’s TRL; the simplest multi-controller baseline, best starting point for experiments up to ~30B with GRPOTrainer/PPOTrainer.
- PrimeIntellect-ai/prime-rl — async agentic RL framework (FSDP2 + vLLM), scales to 1000+ GPUs with support for decentralized and multi-turn setups.
- THUDM/slime — Megatron + SGLang RL post-training framework; the system behind the GLM-4.5–5.x model family, emphasizing large-scale agentic and long-horizon rollout workflows.
Go deeper
- Kwon et al., Efficient Memory Management for LLM Serving with PagedAttention (2023) — the vLLM paper; understanding PagedAttention is prerequisite to understanding why rollout engines and training engines have incompatible weight layouts.
- Lilian Weng, Why We Think (2025) — comprehensive survey of test-time compute and chain-of-thought RL, situating the system design choices in this chapter within the broader scaling picture.
Further reading¶
- Ouyang, Wu, Jiang, et al., Training Language Models to Follow Instructions with Human Feedback (InstructGPT, 2022) — the canonical RLHF pipeline that defines the policy/reward/reference component structure.
- Schulman, Wolski, Dhariwal, Radford, Klimov, Proximal Policy Optimization Algorithms (2017) — the clipped surrogate and the actor-critic structure the learner implements.
- Shao, Wang, Zhu, et al., DeepSeekMath (2024) and DeepSeek-AI, DeepSeek-R1 (2025) — the critic-free GRPO loop and the rule-reward verifier that reshaped modern RL infra.
- Sheng, Zhang, Ye, et al., HybridFlow: A Flexible and Efficient RLHF Framework (veRL, 2024) — the single-controller architecture and the placement abstractions that motivate much of this part; see veRL: HybridFlow & The Single-Controller Architecture.
- Hu, et al., OpenRLHF: An Easy-to-use, Scalable and High-performance RLHF Framework — a Ray-native reference design for the components in this chapter.
- Kwon, Li, Zhuang, et al., Efficient Memory Management for Large Language Model Serving with PagedAttention (vLLM, 2023) — the inference engine that powers most rollout components; see vLLM: Architecture, PagedAttention & Internals.
- HuggingFace TRL and the veRL, OpenRLHF, and NeMo-Aligner repositories — production implementations of the six components, studied in TRL: HuggingFace’s RL Library through OpenRLHF, NeMo-Aligner & Ray-Based Systems.
Exercises¶
1. (Conceptual.) The chapter’s “four-copies rule” says up to four copies of the model may coexist on the cluster: the trainable policy, its inference replica, the reference, and a learned reward model. For each of the first three, state (a) whether it carries gradients and optimizer state, (b) whether its weights ever change during the run, and © roughly how many bytes-per-parameter of resident state it costs. Then explain why the R1-Zero recipe can delete one of these copies entirely, and which one.
Solution
| Copy | Gradients + optimizer state? | Weights change? | Resident state per param |
|---|---|---|---|
| Policy (training) | Yes | Yes (updated every step) | bf16 params (2 B) + gradients (2 B) + AdamW state (fp32 master + 2 moments ~ 12 B) ~ 16 B/param |
| Policy inference replica | No | Yes, but only by being overwritten at weight sync (no local optimizer) | bf16 params (2 B) + KV cache (workload-dependent) |
| Reference | No | No (frozen SFT checkpoint) | bf16 params (2 B), forward-only |
(a)/(b)/©: Only the policy is trainable, so only it carries gradients and AdamW state – the dominant memory line item. The inference replica has no optimizer and never updates itself; its parameters change only because the weight-sync arrow (stage (g)) copies fresh \(\theta\) into it. The reference is fully frozen: no gradients, no optimizer, weights never move, so it costs just one set of bf16 weights and is a prime candidate for CPU-offload or quantization.
R1-Zero drops the reference model. The reference exists only to supply \(\log\pi_{\text{ref}}\) for the KL-divergence penalty \(\beta\,\mathrm{KL}(\pi_\theta \Vert \pi_{\text{ref}})\). R1-Zero sets the KL term to zero (KL_BETA = 0.0 in the toy code), so nothing ever queries the reference; it can be removed, saving a full set of weights in memory.
2. (Quantitative.) Redo the chapter’s memory accounting for a 13B policy instead of 7B: KL on (reference resident), critic-free GRPO (no critic), rule-based reward (no reward model). Use bf16 = 2 B/param for weights and gradients, and AdamW = 12 B/param for optimizer state. Ignore activations and KV cache. (a) Give the size of each resident copy and the total. (b) Which single line item is largest? © What is the minimum number of 80 GB GPUs just to hold this static footprint?
Solution
Let \(P = 13\times10^9\).
| Copy | Formula | Size |
|---|---|---|
| Policy weights (bf16) | \(P \times 2\text{ B}\) | 26 GB |
| Policy optimizer (AdamW, 12 B/param) | \(P \times 12\text{ B}\) | 156 GB |
| Policy gradients (bf16) | \(P \times 2\text{ B}\) | 26 GB |
| Reference weights (bf16) | \(P \times 2\text{ B}\) | 26 GB |
| Inference replica (bf16, KV ignored) | \(P \times 2\text{ B}\) | 26 GB |
(a) Total \(= 26 + 156 + 26 + 26 + 26 = 260\) GB.
(b) The optimizer state, 156 GB, dwarfs everything else – it is 60% of the static footprint. This is exactly why the chapter stresses critic-free GRPO (no second model’s optimizer state) and LoRA-RL (tiny adapter optimizer state).
© \(260 / 80 = 3.25\), so you need at least 4 GPUs just for the static tensors – and in practice more, because this ignores activations and the KV cache, both of which are substantial during the forward/backward and rollout phases. Even a “small” 13B GRPO run is inherently multi-GPU.
3. (Quantitative.) On some cluster a synchronous GRPO step measures: generate \(= 45\) s, reward \(= 2\) s, train \(= 12\) s, weight sync \(= 3\) s. (a) What fraction of the step is generation? (b) In a disaggregated layout (separate training pool), what fraction of the step are the training GPUs idle, if they are busy only during train + sync? © If you go fully async and overlap generation of step \(k{+}1\) with the train+sync of step \(k\), estimate the pipelined per-step wall-clock and the resulting speedup over the synchronous step.
Solution
Synchronous step time: \(T = 45 + 2 + 12 + 3 = 62\) s.
(a) Generation fraction \(= 45 / 62 = 0.726\), i.e. ~73% of the step – consistent with the chapter’s “60-80%, generation is the budget.”
(b) Training GPUs are busy for train + sync \(= 12 + 3 = 15\) s and idle for generate + reward \(= 45 + 2 = 47\) s. Idle fraction \(= 47 / 62 = 0.758\), i.e. the expensive training pool sits ~76% idle every step. That single number is the motivation for colocation and async RL.
© In a two-stage pipeline the per-step wall-clock approaches the longer of the two overlapping stages: $$ T_{\text{async}} \approx \max\big(\underbrace{45 + 2}{\text{generate+reward}}, \underbrace{12 + 3}. $$ Speedup }}\big) = \max(47, 15) = 47\text{ s\(= 62 / 47 \approx \mathbf{1.32\times}\). Note the async floor is generation (47 s): once you hide training behind generation, the only way to go faster is to attack the generation phase itself – again, generation is the budget.
4. (Conceptual.) On a fresh batch, before any optimizer step, the importance ratio \(r = \exp(\log\pi_\theta - \log\pi_{\theta_{\text{old}}})\) should equal exactly \(1.0\) for every token. In a real disaggregated system it often does not – it sits systematically at, say, \(0.9\) or \(1.1\). Explain the mechanism, why it biases the gradient, and give three ways to handle it.
Solution
Mechanism. \(\pi_{\theta_{\text{old}}}\) (the denominator / behavior log-probs) is computed by the rollout engine – vLLM or SGLang, possibly with FP8/INT8 weight-only quantization, fused kernels, and a different attention implementation. \(\pi_\theta\) (the numerator) is recomputed by the learner in bf16 with different kernels. On fresh data the underlying weights are identical, but the two stacks evaluate the same tokens with different numerics, so their per-token log-probs differ slightly. The ratio therefore has a systematic offset even though no learning has happened – ratios that should be \(1.0\) instead cluster around \(0.9\) or \(1.1\).
Why it biases the gradient. The policy-gradient surrogate weights each token by \(r \cdot A\). If \(r\) is systematically off from \(1.0\) due to a numerics gap rather than a genuine policy change, every token’s contribution is mis-scaled, so the gradient is biased away from the true policy gradient. Because it looks numerically fine (no NaNs, loss is finite), the run “looks healthy but won’t improve” – the chapter calls this the single most common silent RL bug.
Three fixes (from the warning box):
- Recompute behavior log-probs in the trainer’s numerics – discard vLLM’s log-probs and take \(\log\pi_{\theta_{\text{old}}}\) from a trainer forward pass, so numerator and denominator share numerics and \(r = 1\) exactly at step 0. (The toy code does exactly this: it recomputes
old_lpwithtoken_logprobs(policy, ...).) - Explicit importance-sampling correction between sampler and trainer (truncated importance sampling), i.e. carry a correction ratio for the sampler-vs-trainer distribution gap rather than assuming it is \(1\).
- Monitor the sampler-vs-trainer log-prob gap as a first-class health metric so a growing mismatch is caught before it silently corrupts a long run.
5. (Implementation.) In GRPO a group of \(G\) responses whose rewards are all identical (all solved, or all failed) produces a zero advantage for every token in the group, and therefore no learning signal – pure wasted generation. Modify the chapter’s grpo_advantage into a version that (a) still returns the per-response advantages, and (b) also returns the fraction of groups that were degenerate (zero reward variance), so you can log it as a health/curriculum metric. Show the code and explain, using the surrogate loss, why such groups contribute no gradient.
Solution
def grpo_advantage_monitored(rewards, group_size, var_eps=1e-8):
"""Group-relative advantage plus a diagnostic: the fraction of groups
with no reward variance (all responses scored identically). Those groups
yield zero advantage -> zero gradient -> wasted rollout compute."""
g = rewards.view(-1, group_size)
std = g.std(dim=1, keepdim=True)
adv = (g - g.mean(dim=1, keepdim=True)) / (std + 1e-4) # same as chapter
degenerate = (std <= var_eps).float() # 1.0 per dead group
frac_degenerate = degenerate.mean().item()
return adv.reshape(-1), frac_degenerate
Usage in the outer loop mirrors the chapter’s rl_iteration:
advantage, frac_dead = grpo_advantage_monitored(rewards, G)
# ... learner epochs as before ...
print("frac_degenerate_groups", round(frac_dead, 3))
Why degenerate groups give no gradient. GRPO’s advantage for response \(i\) in a group is
$$
A_i = \frac{r_i - \mathrm{mean}(r_{1..G})}{\mathrm{std}(r_{1..G}) + \epsilon}.
$$
If every reward in the group is identical, then \(r_i = \mathrm{mean}(r_{1..G})\) for all \(i\), so the numerator is exactly \(0\) and \(A_i = 0\) for every response in the group. In the learner the token loss is
$$
-\min!\big(r_{i,t} A_i, \mathrm{clip}(r_{i,t}, 1-\epsilon, 1+\epsilon)\, A_i\big) + \beta\,\mathrm{KL},
$$
and with \(A_i = 0\) the entire surrogate term vanishes for all those tokens; only the KL term (if any) survives, which pulls toward the reference rather than teaching the task. So every token generated for a degenerate group cost a full decode but produced no task-learning signal. A high frac_degenerate is an actionable curriculum signal: the prompts are too easy (all-solved) or too hard (all-failed) for the current policy, and the rollout budget is being wasted – motivating harder/easier prompt selection or filtering those groups out of the learner batch to reclaim compute.