6.2 The Generation–Training Loop & Rollout Engines¶
In The Anatomy of an RL-for-LLM System we drew the system as four boxes — a generator that samples responses, a reward that scores them, a learner that updates weights, and a controller that orchestrates the cycle. This chapter zooms into the beating heart of that system: the loop that alternates between generating experience and training on it. Everything else in RL infrastructure — Ray placement groups, weight-sync protocols, disaggregation — exists to make this one loop go faster without breaking its math.
The loop is deceptively simple to state. Sample a batch of prompts. Generate completions. Score them. Compute advantages. Take a few gradient steps. Repeat. But each of those verbs hides a different system: generation is an autoregressive, memory-bound inference workload best served by a purpose-built engine like vLLM or SGLang; training is a compute-bound, gradient-accumulating workload best served by FSDP or Megatron. RL for LLMs is the awkward marriage of these two opposite regimes inside a single training step, and almost every interesting engineering decision in the field — colocate or disaggregate, sync or async, on-policy or off — is about managing the seam between them.
The single most important empirical fact to anchor on: generation usually dominates the wall-clock. In a typical GRPO or PPO run, sampling the rollouts can eat 60–80% of each step’s time, with the actual backward pass a relative afterthought. If you do not understand why, you will optimize the wrong thing. So we start there.
On-policy, off-policy, and the “staleness” that RL infra actually fights¶
The definition that matters for systems¶
A learning algorithm is on-policy if the data it trains on was generated by the same policy it is updating, and off-policy if the data came from some other (older or different) policy. In the LLM RL setting this distinction is not abstract — it is literally a question of which version of the weights produced these tokens.
Let \(\pi_\theta\) be the policy we are training and \(\pi_{\theta_{\text{behavior}}}\) be the policy that actually sampled the rollouts. Strictly on-policy means \(\theta_{\text{behavior}} = \theta\) at the moment of every gradient computation. The instant you take one optimizer step, your weights move, and any rollouts you have not yet consumed become off-policy — they were drawn from the now-stale \(\theta_{\text{behavior}}\).
This is why PPO and GRPO carry an importance sampling ratio. The policy-gradient objective is an expectation under \(\pi_\theta\), but we have samples from \(\pi_{\theta_{\text{behavior}}}\). The correction is the per-token ratio
and the clipped surrogate \(\min(r_{i,t}\hat A_i,\ \operatorname{clip}(r_{i,t},1-\epsilon,1+\epsilon)\hat A_i)\) is what keeps the off-policy correction from exploding when \(\theta\) has drifted far from \(\theta_{\text{behavior}}\). The full derivation lives in Policy Gradients & PPO for Language Models; here we care about the systems consequence: the clip exists precisely so that we are allowed to be a little off-policy, which in turn is what lets us reuse a batch of rollouts for several gradient steps instead of regenerating after every single one.
The staleness knob¶
In practice “on-policy vs off-policy” is not binary; it is a dial we call staleness \(s\) — how many optimizer steps old the weights were when they generated the data we are now training on.
Three regimes appear in real systems:
- Synchronous on-policy-ish (\(s\) small, bounded by PPO epochs). Generate a big batch, then take
ppo_epochs × num_minibatchesgradient steps on it before regenerating. Within those steps \(\theta\) drifts, so the later minibatches are mildly off-policy — the clip absorbs it. This is what TRL’sPPOTrainerand most GRPO recipes do by default. Staleness is small and bounded. - Asynchronous (\(s\) moderate, e.g. 1–4). The generator runs continuously on its own copy of the weights while the trainer updates; weights are pushed to the generator every few steps. The generator is therefore always a bounded number of steps behind. This trades a little policy-gradient bias for a large throughput win (we overlap generation and training). Covered in depth in Prime-RL, Async RL & Decentralized Training.
- Off-policy / replay (\(s\) large). You keep a buffer of old rollouts and replay them. This is rare in modern LLM RL because the importance weights get huge and the clip throws away most of the gradient — but it shows up in some sample-efficiency-focused research.
Aside: why classic deep-RL replay buffers mostly don’t transfer
DQN and SAC lean heavily on large replay buffers because environment interaction is expensive and the per-step reward is dense. LLM RL is the opposite: generation is internal (you control the simulator — it’s your own model on a GPU) and the reward is terminal (one scalar per full response). So the value of replaying old data is low, while the cost of a wrong importance weight on a 4,000-token trajectory is high. Modern LLM RL therefore stays close to on-policy and spends its engineering budget on making fresh generation fast, rather than on squeezing more learning out of stale data.
Why we tolerate any staleness at all¶
If on-policy is statistically cleaner, why not always run \(s=0\)? Because \(s=0\) is a throughput disaster. Strictly on-policy means: generate a batch, do one gradient step, throw the batch away, generate again. You pay the full (dominant) generation cost to extract a single update. Allowing a handful of minibatch updates per rollout batch — and allowing the sampler to lag slightly — is how RL infra reclaims an order of magnitude of throughput. The art is keeping \(s\) small enough that the policy gradient stays approximately valid (clip fraction reasonable, KL controlled) while large enough that the expensive generators are never idle.
Anatomy of one RL step: the five phases¶
Let us make the loop concrete. One outer step (sometimes called an iteration or a “rollout step”) consists of five phases. We will time each one in the worked example later; first, what they are.
Phase 1 — Rollout / generation¶
We draw \(G\) completions per prompt (the group, for GRPO/RLOO — see GRPO, RLOO & Critic-Free RL) for a batch of \(P\) prompts, so \(B = P \cdot G\) sequences. This is pure autoregressive decoding: prefill the prompt, then emit one token at a time until EOS or a length cap. It is memory-bandwidth-bound (each decode step reads the whole model and KV cache to produce one token) and the dominant cost. We discuss the engines that do this well in the next section.
Critically, the generator returns not just token ids but the sampling log-probabilities \(\log \pi_{\theta_{\text{behavior}}}(o_{i,t}\mid\cdot)\) — these are the behavior-policy log-probs that go in the denominator of the importance ratio. Getting these to agree with the trainer’s recomputed log-probs is the single subtlest correctness issue in the whole loop (Section on numerics below).
Phase 2 — Reward / scoring¶
Each completion is scored. For RL with verifiable rewards this is a math checker, a code sandbox, or an exact-match rule (Reward Engineering, Verifiers & Sandboxes); for RLHF it is a reward-model forward pass (The RLHF Pipeline & Reward Modeling). Reward is usually cheap relative to generation — a sandbox call is milliseconds, a 7B RM forward is one prefill — but it can become a tail-latency problem if the sandbox is slow or rate-limited, which is why it is often run asynchronously, per-sample, as completions finish rather than as a blocking batch step.
Phase 3 — Experience preparation¶
Now we move to the trainer’s GPUs. We run forward passes to obtain, for every token of every completion:
- \(\log \pi_\theta(o_t\mid\cdot)\) — the current policy’s log-probs (the “old” log-probs used as the ratio denominator if you trust them, or to re-derive a consistent behavior baseline);
- \(\log \pi_{\text{ref}}(o_t\mid\cdot)\) — the frozen reference model’s log-probs, for the KL penalty (skip if \(\beta=0\));
- optionally \(V(s_t)\) — the critic’s value estimates, for PPO/GAE (skip for GRPO/RLOO).
Then we compute advantages. For GRPO this is the group z-score; for PPO it is GAE over the critic’s values. We assemble everything into a flat training batch: (input_ids, response_mask, old_logprobs, ref_logprobs, advantages).
Phase 4 — Learn / minibatch updates¶
We iterate ppo_epochs times over the rollout batch, each time shuffling and splitting it into minibatches, computing the clipped surrogate loss, and stepping the optimizer with gradient accumulation. This is the only phase that consumes gradients, and (perhaps surprisingly) it is often the fastest of the five — a handful of forward+backward passes versus thousands of autoregressive decode steps.
Phase 5 — Weight synchronization¶
After updating \(\theta\), the inference engine still holds the old weights. We must copy the new \(\theta\) into the generator before (or during) the next rollout. In a colocated system this is an in-process tensor copy; in a disaggregated system it is a cross-process or cross-node transfer (NCCL broadcast, RDMA, or a serialized checkpoint). For a 7B model in bf16 that is ~14 GB to move every step — non-trivial, and the subject of Colocated vs Disaggregated RL & Weight Synchronization.
Rollout engines: why you don’t use model.generate in production¶
The toy GRPO code in GRPO, RLOO & Critic-Free RL called model.generate(). That is pedagogically honest and catastrophically slow for real RL. The reason is the same reason production inference uses vLLM or SGLang: HuggingFace’s generate does static batching with a dense KV cache, so the whole batch runs at the speed of its longest sequence and most of the KV memory is wasted on padding.
What a real rollout engine buys you¶
A purpose-built engine (vLLM: Architecture, PagedAttention & Internals, SGLang: RadixAttention & Structured Programs) gives the rollout phase three things that matter enormously for RL:
- Continuous (in-flight) batching. As soon as one sequence finishes, its slot is filled by a waiting sequence — no waiting for the longest member of a static batch (Continuous Batching & Request Scheduling). In RL, completion lengths vary wildly (some prompts get solved in 50 tokens, some ramble for 4,000), so this is a 2–5× win on its own.
- PagedAttention. The KV cache is stored in non-contiguous “pages” so memory is allocated on demand instead of pre-padded to max length (PagedAttention & KV-Cache Memory Management). This lets you fit a far larger effective batch and keep the GPU saturated.
- Prefix sharing. All \(G\) completions in a group share the same prompt prefix. RadixAttention (SGLang) and vLLM’s prefix caching (Prefix Caching & KV-Cache Reuse) compute that shared prefix’s KV once and reuse it across the group — a direct, large saving that is almost unique to the RL access pattern, where you deliberately sample many continuations of one prompt.
Calling vLLM from inside a trainer¶
In production RL, the trainer and a vLLM engine live in the same process (or adjacent processes) and the trainer drives generation through vLLM’s LLM API or its async engine. The crucial extra requirements over plain serving are: (a) you need the sampling log-probs back for the importance ratio, and (b) you need to swap the weights every step without restarting the engine.
# Sketch of the rollout call inside an RL trainer using vLLM.
# (Real code in TRL/veRL is more involved; this shows the load-bearing parts.)
from vllm import LLM, SamplingParams
# One persistent engine, created ONCE. We will hot-swap weights into it each step.
engine = LLM(
model="Qwen/Qwen2.5-7B",
dtype="bfloat16",
gpu_memory_utilization=0.5, # leave room for the trainer if COLOCATED
enable_prefix_caching=True, # reuse the shared prompt prefix across the group
enable_sleep_mode=True, # lets us free the engine's KV cache during training
max_model_len=4096,
)
sampling = SamplingParams(
n=8, # G: sample 8 completions per prompt in ONE call
temperature=1.0,
top_p=1.0,
max_tokens=2048,
logprobs=0, # return the logprob of the SAMPLED token (behavior logprob)
# ^ logprobs=0 means "0 extra besides the chosen token" -> we still get the
# chosen token's logprob, which is exactly the behavior-policy logprob we need.
)
def rollout(prompts):
"""Return, per prompt, G completions + their per-token sampling logprobs."""
outs = engine.generate(prompts, sampling) # batched, continuous-batched internally
batch = []
for req in outs: # one req per prompt
for comp in req.outputs: # G completions
token_ids = comp.token_ids
# vLLM gives a list of {token_id: Logprob} dicts, one per position.
behavior_logprobs = [
lp_dict[tid].logprob
for tid, lp_dict in zip(token_ids, comp.logprobs)
]
batch.append({
"prompt": req.prompt,
"response_ids": token_ids,
"behavior_logprobs": behavior_logprobs, # log pi_behavior(o_t|.)
})
return batch
The weight swap (Phase 5) typically goes through vLLM’s worker API. Conceptually:
def sync_weights_to_engine(engine, state_dict):
"""
Push freshly-trained weights into the live vLLM engine without a restart.
`state_dict` maps param name -> bf16 tensor (e.g. policy.state_dict()).
In current vLLM the engine core runs in its own process, so you do NOT reach
into internals; the supported entry point is the public `collective_rpc`,
which invokes a named method on every worker. You make `load_weights`
callable by passing `worker_extension_cls="mypkg.MyWorkerExt"` when
constructing the LLM (see vLLM's RLHF example under
`examples/offline_inference/`). veRL/OpenRLHF wrap this so the named tensors
map correctly onto vLLM's (possibly tensor-parallel-sharded) layout, and for
multi-node they broadcast the tensors over NCCL rather than pickling them.
"""
engine.collective_rpc("load_weights", args=(list(state_dict.items()),))
# After this, the next engine.generate() samples from the NEW policy.
In a colocated setup (engine and trainer on the same GPUs) there is one more move that matters as much as the transfer itself: the engine’s KV cache is a large, statically reserved pool, and it is dead weight while the trainer runs. vLLM’s sleep mode releases it — engine.sleep(level=1) before Phase ¾ frees the KV blocks (level 2 also offloads the weights) and engine.wake_up() re-allocates before the next rollout — which is what lets a single 80 GB GPU host both an inference engine and an FSDP trainer without either being memory-starved.
The details (NCCL broadcast for multi-GPU engines, parameter name remapping, handling tensor-parallel sharding) are exactly what libraries like veRL (veRL: HybridFlow & The Single-Controller Architecture) and OpenRLHF (OpenRLHF, NeMo-Aligner & Ray-Based Systems) exist to handle robustly. The conceptual point is unchanged: one persistent inference engine, weights hot-swapped each step. Tearing down and rebuilding the engine per step would cost more than the rollout itself.
The same five phases, in a real library¶
You would not hand-write the above for a production run. TRL’s GRPOTrainer is the shortest path to exactly the loop of this chapter, and its config surface is a useful glossary of the knobs we have been naming (TRL: HuggingFace’s RL Library):
from trl import GRPOTrainer, GRPOConfig
def reward_correct(completions, answer, **kwargs): # Phase 2, verifiable
return [1.0 if extract_final(c) == a else 0.0 for c, a in zip(completions, answer)]
cfg = GRPOConfig(
num_generations=8, # G: group size for the group-relative baseline
max_completion_length=1024, # caps the L_g long tail that gates a sync step
num_iterations=1, # "ppo_epochs": gradient passes per rollout batch
beta=0.0, # KL-to-reference coefficient; 0 => skip the ref forward
epsilon=0.2, epsilon_high=0.28, # asymmetric ("decoupled") clip, DAPO-style
use_vllm=True, # Phase 1 goes through vLLM, not model.generate
vllm_mode="colocate", # engine shares the trainer's GPUs ("server" = disaggregated)
per_device_train_batch_size=4, gradient_accumulation_steps=8,
)
GRPOTrainer(model="Qwen/Qwen2.5-1.5B-Instruct", reward_funcs=reward_correct,
args=cfg, train_dataset=ds).train()
TRL handles Phases 1–5 internally: it drives vLLM for the rollout, calls your reward functions, recomputes the old/reference log-probs, runs the clipped-surrogate minibatch loop, and syncs weights back into the engine each step. Reading trl/trainer/grpo_trainer.py alongside this chapter is the single best way to see the abstractions land on real code. vllm_mode="server" (backed by the trl vllm-serve CLI) is the disaggregated variant, where the engine lives on separate GPUs and the trainer talks to it over HTTP.
Common pitfall: trusting the sampler’s logprobs blindly
The behavior log-probs returned by vLLM are computed in the inference engine’s numerics (its kernels, its attention implementation, possibly a different dtype or a quantized path). The trainer recomputes log-probs in its numerics (FSDP, full bf16, a different attention kernel). These will not be bitwise identical, and on long sequences the discrepancy compounds. If you feed the sampler’s logprobs as the ratio denominator and the trainer’s as the numerator, even at step 0 (where they “should” both be the current policy and the ratio should be exactly 1) you will see ratios drifting from 1.0. The robust fix most libraries use: recompute the “old” logprobs on the trainer with a torch.no_grad() forward pass and use those as the denominator, so numerator and denominator share numerics. The sampler’s logprobs are then used only for diagnostics or for a true off-policy correction term. This is a favorite source of silent RL bugs.
The throughput bottleneck: why generation dominates, with the numbers¶
This is the central quantitative fact of RL infra. Let us derive why generation dominates and then put real numbers on it.
A FLOP and bandwidth accounting¶
Consider one outer step with \(B = P \cdot G\) sequences, prompt length \(L_p\), mean generation length \(L_g\), a model with \(N\) parameters, and ppo_epochs = E with the whole batch reused.
Training compute (Phase 4). A forward+backward pass costs about \(6N\) FLOPs per token (the classic \(2N\) forward, \(4N\) backward; see Scaling Laws: Kaplan, Chinchilla & Beyond). We train on the response tokens of all \(B\) sequences, \(E\) times:
Generation compute (Phase 1). Decoding is \(2N\) FLOPs per generated token (forward only), and we generate \(B \cdot L_g\) tokens:
By raw FLOPs, generation looks cheaper than training (\(2N\) vs \(6NE\)). So why does it dominate the clock? Because the two phases run in completely different efficiency regimes:
- Training is compute-bound and runs at high Model FLOPs Utilization (MFU), often 40–55% of peak on a good FSDP/Megatron setup.
- Decode generation is memory-bandwidth-bound: each decode step must stream all \(N\) parameters (and the growing KV cache) from HBM to produce a single token per sequence. Arithmetic intensity is tiny, so MFU collapses to low single-digit percent. The wall-clock per token is set not by FLOPs but by how fast you can read the weights from memory.
The decode time is better modeled by bandwidth. Per decode step across a batch of \(B\) sequences, you read the weights once (amortized over the batch) plus each sequence’s KV:
and total decode time is roughly \(L_g\) times that. The key term is the weight read: even with a large batch, you pay \(\sim N \cdot b_{\text{param}}\) bytes of weight traffic per decode step, and there are \(L_g\) steps. That is the tax that makes generation slow.
Worked example: where does an RL step’s time go?
Take a 7B model in bf16 (\(N=7\times10^9\), \(b_{\text{param}}=2\) bytes) on a single H100 (peak bf16 ≈ 990 TFLOP/s dense; HBM bandwidth ≈ 3.35 TB/s). Batch: \(P=64\) prompts, \(G=8\) → \(B=512\) sequences. Lengths: \(L_p=512\), \(L_g=1024\). PPO epochs \(E=2\).
Generation time (Phase 1), bandwidth-bound. Weight traffic per decode step ≈ \(N\cdot b_{\text{param}} = 7\text{e}9\cdot2 = 1.4\times10^{10}\) bytes = 14 GB. Time to read that once: \(14\text{ GB} / 3.35\text{ TB/s} \approx 4.2\) ms per decode step (ignoring KV traffic, which adds more). Over \(L_g=1024\) steps: \(\approx 4.3\) s of weight-read-bound decode — and this is amortized across the whole batch, because all 512 sequences decode their next token together. With realistic KV traffic and imperfect batching, call it ~6–10 s per outer step for generation. Prefill of the 512 prompts (×512 tokens) is compute-bound and comparatively quick, a second or two.
Training time (Phase 4), compute-bound. \(C_{\text{train}} = 6N\cdot B\cdot L_g\cdot E = 6\cdot7\text{e}9\cdot512\cdot1024\cdot2 \approx 4.4\times10^{16}\) FLOPs. At 45% MFU on an H100: effective throughput \(\approx 0.45\cdot 990\text{e}12 = 4.5\times10^{14}\) FLOP/s. Time \(\approx 4.4\text{e}16 / 4.5\text{e}14 \approx\) ~98 s… but wait — that is on one GPU. The point of FSDP is to spread this across, say, 8 GPUs, giving ~12 s. Meanwhile the generation above was also on the available GPUs.
The honest takeaway from real runs (not this back-of-envelope, which is sensitive to batch and parallelism): with a single shared GPU pool, generation is typically 60–80% of the step because of its terrible MFU, the long \(L_g\), and the fact that you regenerate every outer step but only train a couple of epochs. The experience-prep forward passes (Phase 3 — old-logprob + ref-logprob over all \(B\cdot L_g\) tokens) add another meaningful chunk, often 10–20%. Reward (Phase 2) and weight sync (Phase 5) are usually a few percent each, unless the sandbox or the cross-node transfer is slow.
The qualitative ranking is robust across model sizes and clusters:
This is the reason RL-for-LLMs research is obsessed with rollout throughput. Doubling your backward-pass speed barely moves the needle; doubling your generation throughput nearly halves your step time.
Practitioner tip: long generations are doubly expensive
Generation length \(L_g\) hits you twice. First, decode time scales roughly linearly in \(L_g\) (more steps). Second, the slowest sequence in a batch gates a synchronous step — if one prompt generates 4,000 tokens while the rest finish at 300, your step waits for that one tail. This long-tail problem is why continuous batching (refill finished slots) and overlong-filtering (cap and mask runaway generations) matter so much, and why async RL — where the trainer doesn’t block on the slowest rollout — gets such a large win. Watching the distribution of generation lengths, not just the mean, is essential.
Experience collection and advantage computation in detail¶
Between “we have scored completions” and “we can take a gradient step” sits a surprising amount of bookkeeping. Getting it right is mostly about masking and alignment.
Building the experience batch¶
Each rollout produces a variable-length response. We pad to a common length and build a response mask that is 1 on generated tokens and 0 on prompt tokens and padding — every loss term, every advantage, every KL must be masked to the response, never the prompt. Prompt tokens were given, not chosen, so they carry no policy gradient.
import torch
import torch.nn.functional as F
def build_experience_batch(rollouts, pad_id, device):
"""
rollouts: list of dicts with 'prompt_ids' (Lp,), 'response_ids' (Lg,),
'behavior_logprobs' (Lg,), and 'reward' (scalar).
Returns padded tensors + a response mask aligned to the next-token targets.
"""
seqs, masks, beh_lp, rewards = [], [], [], []
for r in rollouts:
ids = torch.cat([r["prompt_ids"], r["response_ids"]]) # (Lp+Lg,)
m = torch.zeros_like(ids, dtype=torch.float)
m[len(r["prompt_ids"]):] = 1.0 # mask = 1 on response
seqs.append(ids); masks.append(m)
# behavior logprobs are defined only on response tokens; left-pad with 0s
beh = torch.zeros_like(ids, dtype=torch.float)
beh[len(r["prompt_ids"]):] = torch.tensor(r["behavior_logprobs"])
beh_lp.append(beh); rewards.append(r["reward"])
maxlen = max(s.numel() for s in seqs)
def pad(x, val): # right-pad to maxlen
return torch.stack([F.pad(t, (0, maxlen - t.numel()), value=val) for t in x])
input_ids = pad(seqs, pad_id).to(device) # (B, L)
response_mask = pad(masks, 0.0).to(device) # (B, L)
behavior_lp = pad(beh_lp, 0.0).to(device) # (B, L)
rewards = torch.tensor(rewards, device=device) # (B,)
# IMPORTANT: logprobs/loss are computed on NEXT-token prediction, so when we
# gather log pi(o_t | o_<t) from logits[:, :-1], the aligned mask/targets are
# shifted by one. Keep the response_mask and shift it where you compute loss.
return input_ids, response_mask, behavior_lp, rewards
Recomputing log-probs (the “old” forward) and the reference¶
As warned above, we recompute the behavior/old log-probs on the trainer so the importance ratio is numerically consistent. We also run the frozen reference for the KL term. Both are no_grad forwards over the entire batch — this is Phase 3, and it costs roughly one (or two, with the reference) generation-length forward pass over all \(B\) sequences. That is why experience-prep is the second-biggest time sink.
@torch.no_grad()
def token_logprobs(model, input_ids):
"""log pi(o_t | o_<t) for the actually-present next tokens. (B, L-1)."""
logits = model(input_ids).logits[:, :-1, :] # predict token t+1 from t
logp = F.log_softmax(logits.float(), dim=-1)
targets = input_ids[:, 1:]
return logp.gather(-1, targets.unsqueeze(-1)).squeeze(-1)
# Phase 3 — experience prep:
old_lp = token_logprobs(policy, input_ids) # consistent ratio denominator
ref_lp = token_logprobs(ref, input_ids) # for KL penalty (if beta>0)
resp_mask = response_mask[:, 1:] # shift to align with targets
no_grad forward through the same trainer pipeline that computes the new log-probs, demoting the sampler's own log-probs to a diagnostic signal.Advantage computation¶
For GRPO the advantage is the per-group z-score of the reward, broadcast to every token of the response (derived in GRPO, RLOO & Critic-Free RL). For PPO it is GAE over the critic’s value estimates. The infrastructure-level subtleties — whitening, KL-in-reward vs KL-in-loss, clipping the advantage — live in Advantage Estimation, KL Control & Stability Tricks. Here is the GRPO path, which needs no critic:
def grpo_advantages(rewards, group_size, normalize_std=True, eps=1e-4):
"""Group-relative advantage; one scalar per response, then broadcast to tokens."""
g = rewards.view(-1, group_size) # (n_prompts, G)
adv = g - g.mean(dim=1, keepdim=True) # baseline = group mean
if normalize_std:
adv = adv / (g.std(dim=1, keepdim=True) + eps) # (the contested std norm)
return adv.reshape(-1) # (B,)
Now notice what happens when every completion in a group earns the same reward — all \(G\) correct on an easy prompt, or all \(G\) wrong on an impossible one. The centered advantage is identically zero, so that group contributes no gradient at all, having burned \(G\) full generations: the most expensive resource in the loop. On a mismatched prompt set this can silently void a large fraction of every batch. DAPO’s dynamic sampling fixes it at the loop level: over-sample prompts, discard the degenerate groups, and keep drawing until the batch is full of groups that actually carry signal — an effective-batch-size guarantee bought with a variable number of generations per step.
def informative_groups(rewards, group_size, min_std=1e-6):
"""Boolean mask over GROUPS: True where the group's rewards have variance."""
g = rewards.view(-1, group_size)
return g.std(dim=1) > min_std # False => advantage is 0 => no gradient
Logging the surviving fraction (the “effective group rate”) is one of the most informative curves in an RLVR run: if it collapses toward zero your prompts have been solved and the curriculum needs harder problems (RL Data, Curriculum & Replay Management).
The result is a flat experience batch: input_ids, resp_mask, old_lp, ref_lp, advantages. Everything downstream is the minibatch loop.
Minibatch updates: the inner loop¶
Now we consume the batch. The outer batch (\(B\) sequences) is split into minibatches, and we do ppo_epochs passes. This is where staleness enters within a step: after the first minibatch updates \(\theta\), the second minibatch’s ratio is no longer exactly 1, and the clip starts doing real work.
def minibatch_update_loop(input_ids, resp_mask, old_lp, ref_lp, advantages,
policy, opt, *, ppo_epochs=2, n_minibatches=4,
eps_low=0.2, eps_high=0.28, kl_beta=0.0, grad_accum=1):
"""The Phase-4 inner loop: ppo_epochs x n_minibatches clipped-surrogate steps."""
B = input_ids.size(0)
A = advantages.unsqueeze(1) # (B,1), broadcast over tokens
micro = 0 # counts minibatches for grad accum
for epoch in range(ppo_epochs):
perm = torch.randperm(B, device=input_ids.device) # reshuffle each epoch
for mb in perm.chunk(n_minibatches):
# --- forward WITH grad on this minibatch ---
logits = policy(input_ids[mb]).logits[:, :-1, :]
logp = F.log_softmax(logits.float(), dim=-1)
tgt = input_ids[mb, 1:]
new_lp = logp.gather(-1, tgt.unsqueeze(-1)).squeeze(-1) # (m, L-1)
mask = resp_mask[mb]
# --- clipped surrogate (PPO/GRPO) ---
ratio = (new_lp - old_lp[mb]).exp() # pi_theta / pi_behavior
unclipped = ratio * A[mb]
clipped = torch.clamp(ratio, 1 - eps_low, 1 + eps_high) * A[mb]
pg_loss = -torch.min(unclipped, clipped) # we minimize the negative
if kl_beta > 0: # k3 KL to reference
log_r = ref_lp[mb] - new_lp # log(pi_ref/pi_theta)
kl = log_r.exp() - log_r - 1.0
pg_loss = pg_loss + kl_beta * kl
# token-level reduction (Dr.GRPO/DAPO style): sum over tokens / num tokens
loss = (pg_loss * mask).sum() / mask.sum().clamp(min=1.0)
(loss / grad_accum).backward()
# --- diagnostics you SHOULD log every minibatch ---
with torch.no_grad():
clip_frac = ((ratio - 1.0).abs() > eps_low).float().mean().item()
approx_kl = (old_lp[mb] - new_lp).mul(mask).sum() / mask.sum()
# if clip_frac is huge or approx_kl spikes -> you're too off-policy.
# Step every `grad_accum` minibatches. NOTE the placement: the optimizer
# must step INSIDE the minibatch loop, not once per epoch. That is what
# makes the *later* minibatches genuinely off-policy w.r.t. `old_lp` --
# i.e. what gives the clip something to do. Stepping once per epoch would
# turn `n_minibatches` into plain gradient accumulation over one big
# on-policy batch, which is a different (and much weaker) algorithm.
micro += 1
if micro % grad_accum == 0:
torch.nn.utils.clip_grad_norm_(policy.parameters(), 1.0)
opt.step(); opt.zero_grad(set_to_none=True)
return loss.item(), clip_frac, approx_kl.item()
What to watch in the inner loop¶
Three diagnostics tell you whether your staleness is under control:
- Clip fraction — the share of tokens whose ratio left \([1-\epsilon,1+\epsilon]\). A few percent is healthy; 30%+ means the policy is moving too far per batch (lower the LR, fewer ppo_epochs, smaller batch reuse).
- Approximate KL between old and new policy — how far \(\theta\) drifted within the step. If this blows up across ppo_epochs, your reuse is too aggressive.
- Ratio at the first minibatch of epoch 0. If old_lp was recomputed consistently, this should be ≈ 1.0 with clip_frac ≈ 0. If it isn’t, you have the numerics bug from the warning above.
Aside: ppo_epochs is a throughput-vs-onpolicyness knob
Each extra ppo_epoch extracts more gradient signal from the same (expensive) rollouts — so it improves sample efficiency and amortizes the dominant generation cost. But each epoch also pushes you further off-policy (larger clip fraction, more bias). Typical choices are 1–4. Many GRPO recipes use just 1 (effectively on-policy, the clip never engages and GRPO reduces to a clean group-baseline REINFORCE); PPO RLHF often uses 2–4. There is no universal answer — it is a dial you tune against the clip fraction.
Overlapping generation and training: from synchronous to async¶
We have established that generation dominates and that the trainer’s GPUs are idle while the engine decodes (and vice versa). The whole frontier of RL infra is about filling those idle windows. There is a ladder of designs.
Rung 0 — Synchronous, colocated, serial¶
The simplest correct system. One pool of GPUs. Generate (GPUs busy with the engine), then train (GPUs busy with FSDP), strictly serial. Each phase idles the other’s machinery. Easiest to reason about, strictly on-policy up to ppo_epochs, but leaves a lot of utilization on the table — when the trainer runs, the inference engine’s memory and SMs sit unused, and vice versa. Colocation details (sharing GPU memory between engine and trainer, gpu_memory_utilization budgeting, sleeping the engine during training) are in Colocated vs Disaggregated RL & Weight Synchronization.
Rung 1 — Disaggregated, synchronous¶
Put generation on one set of GPUs (inference cluster) and training on another (training cluster). Each is now always doing its own job. But if you still gate each training step on a complete fresh rollout, the training GPUs idle during generation and the inference GPUs idle during training — you have moved the bubble, not removed it, unless you pipeline.
Rung 2 — Pipelined / overlapped (one-step-stale)¶
This is the first real win. While the trainer updates on rollout batch \(k\), the generator is already producing rollout batch \(k+1\) using the weights from step \(k-1\) (or \(k\)). Generation of the next batch overlaps with training on the current one. The trainer is never blocked on generation; the generator is never blocked on training. The cost is exactly one step of staleness — the rollouts the trainer consumes were generated by weights one update old — which the PPO/GRPO clip is built to absorb.
Rung 3 — Fully asynchronous¶
Decouple completely. Generators run continuously, dumping completed rollouts into a queue. The trainer pulls from the queue, updates, and periodically (every few steps) pushes new weights to the generators. Staleness is whatever the queue depth and sync interval make it — bounded but variable. This maximizes utilization of both clusters and is how systems like Prime-RL operate (Prime-RL, Async RL & Decentralized Training). The price is the most off-policy of all the rungs; you manage it by bounding the staleness (drop rollouts older than \(s_{\max}\) steps) and by relying on the clip plus, sometimes, an explicit importance-weight correction for the lag.
# Skeleton of an async RL loop (single trainer, N generator workers, a queue).
# This is intentionally minimal; production uses Ray actors + bounded buffers.
import queue, threading, copy
rollout_q = queue.Queue(maxsize=256) # bounded: backpressure if trainer lags
weight_version = {"v": 0}
shared_weights = {"sd": None, "lock": threading.Lock()}
def generator_worker(engine, prompt_stream):
while True:
# 1) refresh local weights if the trainer pushed a newer version
with shared_weights["lock"]:
local_v = weight_version["v"]
if shared_weights["sd"] is not None:
sync_weights_to_engine(engine, shared_weights["sd"])
# 2) generate a group, tag it with the weight version that produced it
prompts = next(prompt_stream)
for r in rollout(prompts): # uses the engine (continuous-batched)
r["gen_version"] = local_v
rollout_q.put(r) # blocks if queue full -> backpressure
def trainer_loop(policy, opt, ref, max_staleness=4, batch_size=512):
step = 0
while True:
# pull a batch of FRESH-ENOUGH rollouts
batch = []
while len(batch) < batch_size:
r = rollout_q.get()
if step - r["gen_version"] <= max_staleness: # drop stale rollouts
batch.append(r)
# standard experience-prep + minibatch update (Phases 3-4)
input_ids, resp_mask, beh_lp, rewards = build_experience_batch(batch, ...)
# ... recompute old_lp/ref_lp, compute advantages, minibatch_update_loop ...
step += 1
# push new weights to generators every few steps (Phase 5)
if step % 2 == 0:
with shared_weights["lock"]:
shared_weights["sd"] = copy.deepcopy(policy.state_dict())
weight_version["v"] = step
The async ladder is the single biggest lever on RL throughput, and choosing a rung is the defining architectural decision of an RL system. Synchronous-colocated is simplest and most on-policy; fully-async is fastest and most off-policy. Most production systems live at rung 2 or a bounded rung 3 — overlapping generation and training with a small, capped staleness, so the clip stays valid while the expensive generators are never idle.
Common pitfall: unbounded staleness silently poisons the gradient
In a naive async loop with no max_staleness cap and a large queue, a slow generator (or a transient stall) can let rollouts age many steps before they’re consumed. The importance ratios for those rollouts are then far from 1, the clip discards most of their gradient (so they contribute almost nothing useful) while still consuming a full training step’s compute, and the KL between the behavior and current policy creeps up unmonitored. The result is a run that looks busy but learns slowly and may destabilize. Always (1) cap staleness and drop over-age rollouts, (2) tag every rollout with its weight version, and (3) log the staleness distribution as a first-class metric — not just the mean.
Tying it together: the full synchronous loop¶
Here is the whole synchronous loop assembled from the pieces, so the five phases are visible end to end. This is essentially what a single-controller trainer (veRL-style) executes per step, minus the distributed plumbing.
def rl_outer_step(prompts, golds, *, engine, policy, ref, opt, tok, group_size=8):
# ---- Phase 1: ROLLOUT (inference engine, continuous-batched) -------------
rollouts = rollout(prompts) # G completions/prompt + beh logprobs
# ---- Phase 2: REWARD (verifier / RM / sandbox) ---------------------------
for r, gold in zip(rollouts, expand(golds, group_size)):
response_text = tok.decode(r["response_ids"], skip_special_tokens=True)
r["reward"] = reward_fn(r["prompt"], response_text, gold)
# ---- Phase 3: EXPERIENCE PREP (trainer-side no_grad forwards) -------------
input_ids, resp_mask, beh_lp, rewards = build_experience_batch(rollouts, pad_id, dev)
old_lp = token_logprobs(policy, input_ids) # consistent ratio denominator
ref_lp = token_logprobs(ref, input_ids) # for KL (if used)
advantages = grpo_advantages(rewards, group_size, normalize_std=False) # Dr.GRPO
# ---- Phase 4: LEARN (ppo_epochs x minibatches, the only grad-bearing part) -
loss, clip_frac, approx_kl = minibatch_update_loop(
input_ids, resp_mask[:, 1:], old_lp, ref_lp, advantages, policy, opt)
# ---- Phase 5: WEIGHT SYNC (push new theta into the engine) ----------------
sync_weights_to_engine(engine, policy.state_dict())
return {"reward": rewards.mean().item(), "loss": loss,
"clip_frac": clip_frac, "kl": approx_kl,
"gen_len_mean": resp_mask.sum(1).mean().item()}
Read top to bottom, this is the entire RL-for-LLM training loop. This is also, almost line for line, the loop that gives Stack-100M its narrow RLVR stage: at 100M parameters the whole thing is colocated on a single GPU — one vLLM engine at gpu_memory_utilization≈0.3 with sleep mode, \(G=8\), short \(L_g\), num_iterations=1, \(\beta=0\), and a synchronous rung-0 schedule, because the model is small enough that generation is cheap and simplicity beats overlap (Post-Training: SFT, DPO, and Narrow RLVR (GRPO) That Works at 100M).
Every other chapter in this Part is an elaboration of one of these five lines: which engine serves Phase 1 (vLLM/SGLang), how Phase 2’s verifiers are built (Reward Engineering, Verifiers & Sandboxes), how Phase 4’s advantages and KL are stabilized (Advantage Estimation, KL Control & Stability Tricks), how Phase 5’s weight sync works across nodes (Colocated vs Disaggregated RL), and how to overlap them all for throughput (Scaling RL: Throughput, Load Balancing & The Latest Tricks).
Interview Corner
Q: You profile a GRPO run on a 7B model and find each step takes 30 seconds: 22 s in rollout generation, 5 s recomputing old/reference log-probs, 2 s in the backward/update, and 1 s in reward + weight sync. Your manager asks you to cut step time in half. Where do you look, in what order, and what are the correctness tradeoffs?
A: Generation is 73% of the step, so that is where the leverage is. In order: (1) Overlap generation with training (rung ⅔). While the trainer does Phases 3–5 (8 s of work), the generator should already be producing the next batch. This alone can hide most of training behind generation and costs only one step of bounded staleness, which the GRPO clip absorbs. (2) Speed up generation itself: confirm continuous batching and prefix caching are on (all \(G\) completions share the prompt — that prefix should be computed once); raise the inference batch / gpu_memory_utilization; and crucially attack the length long-tail — a few runaway 4k-token generations gate a synchronous step, so cap max tokens and use overlong filtering, or go async so the slowest rollout doesn’t block. (3) The 5 s of log-prob recompute is a forward pass over all tokens for both policy and reference; you can fuse the old-logprob computation into the first training forward, drop the reference entirely if running KL-free (R1-style \(\beta=0\)), or shard it across more GPUs. (4) Quantize or use a smaller dtype for the generator only (e.g. fp8/int8 inference) to speed decode, accepting a small numerics gap — but then you must recompute old-logprobs on the full-precision trainer to keep the ratio consistent. The backward (2 s) is the last thing to touch; halving it saves under 7%. The headline: in RL, optimize the rollout, not the gradient.
Key Takeaways
- The loop is five phases: rollout (generate) → reward → experience prep (recompute logprobs, advantages) → minibatch updates → weight sync. Each phase is a different system glued at the seam.
- Generation dominates wall-clock (typically 60–80%) not because it has more FLOPs — it has fewer than the backward — but because autoregressive decode is memory-bandwidth-bound and runs at terrible MFU, with cost scaling in generation length. Optimize the rollout, not the gradient.
- On-policy vs off-policy is really a “staleness” dial. Strictly on-policy (\(s=0\)) wastes the dominant generation cost on a single update; the PPO/GRPO clip exists so we can tolerate bounded staleness — reuse rollouts for a few
ppo_epochsand let the sampler lag the trainer. - Use a real rollout engine (vLLM/SGLang), never
model.generate. Continuous batching, PagedAttention, and prefix sharing across the group are 2–5× wins each; the engine is persistent and weights are hot-swapped every step. - Numerics consistency is the subtle correctness bug: recompute the “old”/behavior log-probs on the trainer so the importance ratio’s numerator and denominator share kernels and dtype — don’t blindly trust the sampler’s logprobs as the denominator.
- Mask everything to response tokens, align the response mask to next-token targets (shift by one), and broadcast the per-response advantage to its tokens.
- Overlapping generation and training is the biggest throughput lever. The ladder runs synchronous-colocated → disaggregated → pipelined (one-step-stale) → fully async; production lives at bounded staleness, keeping the clip valid while the expensive generators never idle.
- Watch the right diagnostics: clip fraction, old-vs-new approx KL, generation-length distribution (not just mean), and — in async — the staleness distribution. Cap and drop over-age rollouts.
State of the Art & Resources (2026)
The generation–training loop is the operational core of modern LLM RL, and the field has standardized on a small set of production-grade rollout engines (vLLM, SGLang) paired with a fast-growing ecosystem of async-overlap frameworks — veRL and OpenRLHF alongside newer entrants such as AReaL, slime, and NeMo-RL — that pipeline generation against gradient updates to keep expensive inference hardware from sitting idle. Asynchronous and disaggregated architectures—where generators run continuously while trainers update on bounded-stale rollouts—are now the dominant production pattern, made all but mandatory by long chain-of-thought reasoning traces where a single batch of rollouts can take minutes to hours while the training GPUs would otherwise idle.
Foundational work
- Schulman et al., Proximal Policy Optimization Algorithms (2017) — the clipped-surrogate objective that licenses bounded off-policy reuse and is at the core of every PPO/GRPO loop.
- Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention (2023) — the vLLM paper; continuous batching + PagedAttention turn the rollout phase from a throughput disaster into a first-class workload.
Recent advances (2023–2026)
- Sheng et al., HybridFlow: A Flexible and Efficient RLHF Framework (2024) — veRL’s single-controller architecture and 3D-HybridEngine for zero-redundancy weight resharding between generation and training phases.
- Hu et al., OpenRLHF: An Easy-to-use, Scalable and High-performance RLHF Framework (2024) — Ray + vLLM disaggregated design; first open framework to scale PPO/GRPO beyond 70B.
- Noukhovitch et al., Asynchronous RLHF: Faster and More Efficient Off-Policy RL for Language Models (2024) — rigorous measurement of staleness-vs-throughput tradeoff; ~40–70% wall-clock speedup from overlapping generation and training (ICLR 2025).
- Fu et al., AReaL: A Large-Scale Asynchronous Reinforcement Learning System for Language Reasoning (2025) — fully decoupled generation and training with a staleness-enhanced PPO variant; up to 2.77× faster training on math/code while matching final quality — a systems-level realization of this chapter’s fully-async Rung 3.
- DeepSeek-AI, DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via Reinforcement Learning (2025) — the GRPO-based loop this chapter dissects; pure RL without SFT achieving o1-level reasoning.
- Yu et al., DAPO: An Open-Source LLM Reinforcement Learning System at Scale (2025) — production recipe for stable large-scale GRPO (decoupled clip, dynamic sampling); 50 pts on AIME 2024 with Qwen2.5-32B.
- Zheng et al., SGLang: Efficient Execution of Structured Language Model Programs (2024) — RadixAttention for prefix sharing across the group; direct 2–6× win on the rollout phase’s dominant cost (NeurIPS 2024).
Open-source & tools
- verl-project/verl — production RL post-training library (PPO, GRPO, FSDP/Megatron); reference implementation of the HybridFlow generation–training overlap.
- OpenRLHF/OpenRLHF — Ray-based actor placement, vLLM-driven rollouts, and weight-sync protocols at scale.
- huggingface/trl —
GRPOTrainerandPPOTrainer; the accessible entry point for the loop described in this chapter.
Go deeper
- vLLM docs: Async RL & Weight Transfer — official reference for
pause_generation/resume_generationand pluggable weight-sync used in the Phase 5 weight-swap sketched in this chapter. - Hugging Face Blog, Keep the Tokens Flowing: Lessons from 16 Open-Source RL Libraries (2026) — seven-axis comparison of async rollout architectures (staleness management, weight-sync protocols, rollout buffer designs) across the full open-source ecosystem.
Further reading¶
- Sheng, Zhang, Ye, et al., HybridFlow (veRL): A Flexible and Efficient RLHF Framework (2024) — the single-controller architecture and the generation/training overlap that this chapter abstracts.
- Hu, et al., OpenRLHF: An Easy-to-Use, Scalable and High-Performance RLHF Framework (2024) — Ray-based actor placement, vLLM-driven rollouts, weight synchronization in practice.
- Kwon, Li, Zhuang, et al., Efficient Memory Management for Large Language Model Serving with PagedAttention (vLLM) (2023) — the rollout engine that makes Phase 1 fast.
- Zheng, Yin, Xie, et al., SGLang: Efficient Execution of Structured Language Model Programs (2024) — RadixAttention and prefix sharing, ideal for group sampling.
- Schulman, Wolski, Dhariwal, Radford, Klimov, Proximal Policy Optimization Algorithms (2017) — the clipped surrogate that licenses bounded off-policy reuse.
- DeepSeek-AI, DeepSeek-R1 (2025) and Shao et al., DeepSeekMath (2024) — the GRPO loop whose five phases this chapter dissects.
- Noukhovitch, et al., Asynchronous RLHF: Faster and More Efficient Off-Policy RL for Language Models (2024) — quantifies the staleness-vs-throughput tradeoff of overlapping generation and training.
- HuggingFace TRL (
PPOTrainer,GRPOTrainer) and the veRL / OpenRLHF repositories — production implementations of the loop; see TRL: HuggingFace’s RL Library, veRL: HybridFlow & The Single-Controller Architecture, and OpenRLHF, NeMo-Aligner & Ray-Based Systems.
Exercises¶
1. (Conceptual) The chapter’s warning box insists you should recompute the “old”/behavior log-probs on the trainer rather than feed vLLM’s sampling log-probs straight into the importance ratio’s denominator. Suppose you ignore this advice and use the sampler’s log-probs as the denominator and the trainer’s recomputed log-probs as the numerator. At the very first minibatch of epoch 0 — before any optimizer step has moved \(\theta\) — what value should the ratio \(r_{i,t}\) take, what will you actually observe, and why? What downstream metric will most visibly reveal the bug?
Solution
Before any optimizer step, the numerator policy \(\pi_\theta\) and the behavior policy \(\pi_{\theta_{\text{behavior}}}\) are the same weights — the generator sampled with exactly the \(\theta\) the trainer now holds (Phase 5 pushed those weights before the rollout). So mathematically
But the two log-probs are computed in different numerics: vLLM’s kernels/attention/dtype (possibly a quantized decode path) for the denominator, the trainer’s FSDP full-bf16 forward for the numerator. They are not bitwise identical, and the tiny per-token discrepancies compound over a long sequence. So instead of \(r=1\) you observe ratios scattered around 1 — some tokens at 1.02, some at 0.97, and a growing spread the longer the response.
The most visible symptom is the clip fraction at the first minibatch of epoch 0: it should be \(\approx 0\) (no token has drifted, nothing to clip), but with mismatched numerics a nontrivial fraction of tokens already fall outside \([1-\epsilon, 1+\epsilon]\). The chapter lists exactly this check — “Ratio at the first minibatch of epoch 0 … should be \(\approx 1.0\) with clip_frac \(\approx 0\). If it isn’t, you have the numerics bug.” The fix is to recompute the denominator with the trainer’s own no_grad forward so numerator and denominator share kernels and dtype; the ratio is then \(1.0\) by construction at step 0.
2. (Conceptual) Rung 2 of the async ladder (“pipelined / one-step-stale”) is described as costing “exactly one step of staleness.” Explain concretely which weights generated the rollouts the trainer consumes, why one step of staleness is tolerable here, and what specific piece of machinery in the PPO/GRPO objective makes it safe. Then explain why the fully-async Rung 3 needs an explicit max_staleness cap when Rung 2 does not.
Solution
In Rung 2, while the trainer updates on rollout batch \(k\), the generator is already producing batch \(k+1\) using weights from step \(k-1\) or \(k\). So when the trainer finally consumes batch \(k+1\), those tokens were sampled by weights exactly one optimizer step behind the current \(\theta\). The behavior policy \(\pi_{\theta_{\text{behavior}}}\) is therefore \(\theta\) from one step ago, and the importance ratio \(r_{i,t} = \pi_\theta / \pi_{\theta_{\text{behavior}}}\) is close to but no longer exactly 1.
This is safe because of the clipped surrogate \(\min(r\hat A,\ \operatorname{clip}(r,1-\epsilon,1+\epsilon)\hat A)\). The clip bounds how much any single off-policy token can contribute, so a bounded drift of the behavior policy from the current policy produces a bounded, well-behaved gradient. One step of drift keeps the ratio near 1 and the clip fraction low — exactly the regime PPO/GRPO were designed for. This is the same reason the chapter gives for tolerating ppo_epochs > 1: the clip “exists precisely so that we are allowed to be a little off-policy.”
Rung 2 needs no explicit cap because the staleness is structurally fixed at exactly one step by the pipeline schedule — there is no way for a rollout to age further. Rung 3 decouples generators and trainer through a queue: staleness is “whatever the queue depth and sync interval make it — bounded but variable.” A slow generator or a transient stall can let a rollout sit in the queue for many steps, so its ratio drifts far from 1, the clip discards most of its gradient (it contributes almost nothing useful while still burning a full training step), and the behavior-vs-current KL creeps up. Hence Rung 3 must explicitly tag each rollout with its gen_version and drop any rollout older than max_staleness, as in the chapter’s async skeleton.
3. (Quantitative) Using the chapter’s FLOP/bandwidth accounting, work an RL step for a 13B model in bf16 on a single H100 (HBM bandwidth \(= 3.35\) TB/s; peak bf16 \(= 990\) TFLOP/s dense; assume 45% MFU for training). Batch: \(P = 32\) prompts, \(G = 16\) (so \(B = 512\)), prompt length \(L_p = 256\), mean generation length \(L_g = 512\), ppo_epochs \(E = 2\). Compute: (a) the weight-read time per decode step and the total weight-read-bound decode time over \(L_g\) steps; (b) the training FLOPs \(C_{\text{train}}\) and the training time on a single H100; © comment on the ratio and on what changes if the whole training pass is sharded across 8 GPUs.
Solution
(a) Decode (Phase 1), bandwidth-bound. Weight traffic per decode step is \(N \cdot b_{\text{param}} = 13\text{e}9 \times 2 = 2.6\times10^{10}\) bytes \(= 26\) GB. This read is amortized across the whole batch — all 512 sequences advance one token together, reading the weights once. Time per step:
Over \(L_g = 512\) decode steps: \(512 \times 7.76\ \text{ms} \approx 3.97\) s of weight-read-bound decode (ignoring the KV-cache traffic, which only adds more, and prefill of the prompts).
(b) Training (Phase 4), compute-bound. Train on response tokens of all \(B\) sequences, \(E\) times, at \(6N\) FLOPs/token:
Effective throughput at 45% MFU: \(0.45 \times 990\text{e}12 = 4.455\times10^{14}\) FLOP/s. Time on one H100:
© Comment. On a single GPU the training pass (\(\approx 92\) s) looks far larger than the decode weight-read floor (\(\approx 4\) s) — but this is exactly the trap the chapter warns about: training is compute-bound and parallelizes, whereas decode runs at terrible MFU. Shard the training pass across 8 GPUs and it drops to \(\approx 92/8 \approx 11.5\) s, while decode does not shrink the same way (it stays memory-bandwidth-bound and, with realistic KV traffic and imperfect batching, is more like 6-10 s scaled to this size, and it gates a fresh rollout every outer step while training runs only \(E=2\) epochs). Once training is spread across the pool, generation reclaims its usual 60-80% share of wall-clock. The headline stands: doubling backward-pass speed barely moves the step; doubling generation throughput nearly halves it.
4. (Quantitative) A GRPO group has \(G = 4\) completions for one prompt, with rewards \(\mathbf{r} = [1, 0, 1, 0]\) (verifiable pass/fail). (a) Compute the group-relative advantage for each completion without std normalization (Dr.GRPO style, normalize_std=False). (b) Recompute with std normalization (normalize_std=True, eps = 1e-4), using the population std that torch.std(..., unbiased=False) would give. © A second prompt’s group returns \(\mathbf{r} = [1, 1, 1, 1]\) (all correct). What advantage does each member get, with and without std normalization, and why does this case motivate the eps term and the “contested std norm” comment in the code?
Solution
(a) No std normalization. Baseline is the group mean \(\bar r = (1+0+1+0)/4 = 0.5\). Advantage \(= r_i - \bar r\):
Passing completions get \(+0.5\), failing ones \(-0.5\). This scalar is then broadcast to every response token.
(b) With std normalization. Population std (unbiased=False): variance \(= \frac14\sum (r_i - 0.5)^2 = \frac14(0.25+0.25+0.25+0.25) = 0.25\), so \(\sigma = 0.5\). Divide the centered values by \(\sigma + \text{eps} = 0.5 + 10^{-4} = 0.5001\):
The normalization rescales the advantages to roughly unit magnitude (\(\pm 1\)). (Note that the chapter’s grpo_advantages calls g.std(...), which uses PyTorch’s default unbiased=True — the Bessel-corrected sample std that divides by \(G-1=3\). That gives \(\sigma = \sqrt{1/3} \approx 0.577\) and thus \(\hat A \approx \pm 0.5/0.5774 \approx \pm 0.866\). We use the population std (\(\div G\)) here for cleaner arithmetic; the qualitative point — rescaling to order-unity magnitude — is identical either way.)
© The all-correct group \(\mathbf{r} = [1,1,1,1]\). Mean \(\bar r = 1\), so without std norm every member gets \(\hat A = 1 - 1 = 0\) — the group provides no learning signal (all completions are equally good relative to the baseline; nothing to prefer). With std norm, the std is \(\sigma = 0\), so the code divides \(0 / (0 + \text{eps}) = 0 / 10^{-4} = 0\) — still zero, but the eps is what prevents a divide-by-zero (a \(0/0 \to\) NaN) that would poison the whole batch. This is why eps exists. The “contested std norm” comment flags a known issue: dividing by the group std up-weights low-variance (nearly-solved or nearly-failed) groups relative to high-variance ones, introducing a difficulty-dependent bias into the gradient. Dr.GRPO/DAPO recipes therefore often drop the std division entirely (normalize_std=False), keeping only the mean baseline — which is why the chapter’s assembled rl_outer_step calls grpo_advantages(..., normalize_std=False).
5. (Implementation) The chapter’s minibatch_update_loop computes clip_frac as ((ratio - 1.0).abs() > eps_low).float().mean() — but this is unmasked (it averages over prompt/padding tokens too) and it uses the symmetric eps_low even though the loop supports an asymmetric clip range [1 - eps_low, 1 + eps_high] (the DAPO “decoupled clip”). Write a corrected masked_clip_frac(ratio, mask, eps_low, eps_high) that (a) counts a token as clipped only if ratio < 1 - eps_low or ratio > 1 + eps_high, and (b) averages only over response tokens using mask. Then show the one-line change to the diagnostics block. Keep the chapter’s style.
Solution
The existing diagnostic has two flaws: it uses eps_low on both sides (wrong when eps_high != eps_low, the decoupled-clip case the loop explicitly supports with eps_high=0.28), and .mean() divides by all tokens including masked prompt/padding positions, diluting the true response-token clip fraction. A masked, asymmetric version:
def masked_clip_frac(ratio, mask, eps_low, eps_high):
"""Fraction of RESPONSE tokens whose ratio left [1-eps_low, 1+eps_high].
ratio, mask: (m, L-1) aligned to next-token targets. mask is 1 on response.
"""
clipped = (ratio < 1.0 - eps_low) | (ratio > 1.0 + eps_high) # (m, L-1) bool
clipped = clipped.float() * mask # zero out non-response
return clipped.sum() / mask.sum().clamp(min=1.0) # masked mean
The corresponding change inside minibatch_update_loop’s diagnostics block (replacing the single clip_frac = ... line):
with torch.no_grad():
clip_frac = masked_clip_frac(ratio, mask, eps_low, eps_high).item()
approx_kl = (old_lp[mb] - new_lp).mul(mask).sum() / mask.sum()
Note the symmetry with the rest of the loop: ratio and mask here are the same (m, L-1) tensors already used to form the masked loss (pg_loss * mask).sum() / mask.sum(), so the clip-fraction denominator now matches the loss denominator (response-token count) exactly. This makes the diagnostic directly comparable to the “few percent healthy, 30%+ too off-policy” guidance in the chapter, and correctly attributes clipping to the high side (eps_high) versus the low side when the two thresholds differ.