The LLM StackFrom Silicon to Agents
Part VI — RL Infrastructure (Deep Dive)
37 min read·Updated ·▶ Run the code (Colab)

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

\[ r_{i,t}(\theta) = \frac{\pi_\theta(o_{i,t}\mid q, o_{i,<t})}{\pi_{\theta_{\text{behavior}}}(o_{i,t}\mid q, o_{i,<t})}, \]

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.

STALENESS s = optimizer steps the sampler weights lag the trainer

<– more on-policy (cleaner gradient) more off-policy (biased, clip works harder) –>

s = 0

strictly on-policy generate, take exactly ONE grad step, regenerate throughput disaster

s = k k-step stale sampler weights lag trainer by k updates practical operating zone

s -> inf fully off-policy train on a fixed dataset of old rollouts (~= offline) rarely used in LLM RL

where real LLM-RL lives – small, BOUNDED staleness, absorbed by the PPO/GRPO clip

On-policy vs off-policy is a continuous staleness dial, not a binary choice. Staleness s counts how many optimizer steps old the sampler weights were when they produced the training data. Real LLM-RL systems stay in the shaded band — small, bounded staleness that the PPO/GRPO importance-ratio clip is specifically designed to absorb, giving throughput gains without sacrificing gradient validity.

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_minibatches gradient 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’s PPOTrainer and 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.

ONE RL OUTER STEP prompts in (P prompts x G samples = B sequences) 1 ROLLOUT / GENERATION sample G completions/prompt, batched autoregressive decode dominates wall-clock: 60-80% of step (memory-bandwidth-bound) inference engine (vLLM/SGLang) token ids + sampling logprobs 2 REWARD / SCORING run each completion through reward fn(s), get scalar R per response sandbox / verifier / RM forward pass -- cheap unless rate-limited verifier / RM / sandbox completions + scalar rewards R 3 EXPERIENCE PREP recompute logprobs (policy + reference); compute advantages (group baseline / GAE); build flat training batch 2nd-largest cost: ~10-20% of step (no-grad forwards over B*Lg tokens) trainer-side fwd passes (ids, resp_mask, old_lp, ref_lp, advantages) 4 LEARN / MINIBATCH UPDATES ppo_epochs x minibatches of the clipped surrogate loss only grad-bearing phase -- often the fastest of the five FSDP / Megatron fwd+bwd updated theta 5 WEIGHT SYNC push new theta into the generator for the next rollout ~14 GB for 7B bf16 -- in-process copy (colocated) or NCCL broadcast theta -> generator next outer step -- generator samples from new theta
Anatomy of one RL outer step: five sequential phases, each owned by a different system. Phase 1 (Rollout) dominates wall-clock at 60-80% due to memory-bandwidth-bound autoregressive decoding — despite having fewer FLOPs than the backward pass. The dashed wrap-around arrow from Phase 5 back to Phase 1 closes the loop: every step ends by pushing fresh weights into the generator so the next rollout samples from the updated policy.

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:

  1. 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.
  2. 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.
  3. 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:

\[ C_{\text{train}} \approx 6N \cdot B \cdot L_g \cdot E. \]

Generation compute (Phase 1). Decoding is \(2N\) FLOPs per generated token (forward only), and we generate \(B \cdot L_g\) tokens:

\[ C_{\text{gen,FLOPs}} \approx 2N \cdot B \cdot L_g. \]

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:

\[ T_{\text{decode-step}} \approx \frac{N \cdot b_{\text{param}} + B \cdot \text{KV}_{\text{per-seq-step}}}{\text{HBM bandwidth}}, \]

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.

One weight read, two very different payoffs decode: memory-bandwidth-bound (one step) vs. training fwd+bwd: compute-bound (one step) x L_g steps DECODE (one step) memory-bandwidth-bound HBM all N weights (~14 GB, 7B model, bf16) compute unit B tokens out (one per sequence in the batch) all from that SINGLE ~N x b_param-byte read the batch amortizes ONE weight read -- bigger batch is the only real lever useful FLOPs per byte read tiny arithmetic intensity ~ 0 MFU collapses to single-digit % clock set by HBM bandwidth, not FLOPs C_gen ~ 2N x B x L_g (forward only) fewer FLOPs, but pays the bandwidth tax L_g times TRAINING fwd+bwd (one step) compute-bound HBM all N weights (~14 GB, 7B model, bf16) -- SAME ONE read, reused below compute unit weight reused across every B x L_g token position dense grid = fwd + bwd over every token position, every sequence one weight fans out to a whole batch's gradient useful FLOPs per byte read huge high arithmetic intensity 40-55% MFU clock set by compute, not bandwidth C_train ~ 6N x B x L_g x E (forward + backward, E epochs) more FLOPs, but each byte read buys far more work Same weight-read tax on both sides -- but decode extracts ONE token per read, while training extracts a whole batch's gradient from that same read. That is why the FLOP-CHEAPER phase (2N vs 6N per token) is the wall-clock-DOMINANT one. Decode pays the same ~N x b_param byte tax L_g times over, at near-zero arithmetic intensity -- training pays it once per step and turns it into a full backward pass's worth of useful FLOPs.
Decode and training read the same weights from HBM, but get wildly different work out of that read. One decode step streams all N parameters to emit a single token per sequence — tiny arithmetic intensity, bandwidth-bound, MFU in the low single digits — and repeats this L_g times. One training forward+backward reuses that same weight read across every token position in the batch — high arithmetic intensity, compute-bound, 40-55% MFU. That gap in useful work per byte, not the raw FLOP count, is why the FLOP-cheaper phase (2N vs 6N) dominates the wall clock.

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:

Share of one RL outer step (typical single shared GPU pool) rew ~3% sync ~2% generation ~70% exp-prep ~15% bwd ~10% 0% 20% 50% 100% generation 60-80% autoregressive decode, memory-BW-bound, terrible MFU experience-prep fwds 10-20% recompute old + ref logprobs over all B*Lg tokens backward / update 5-15% fwd+bwd passes; ONLY grad-bearing phase reward few % verifier / RM / sandbox, unless rate-limited weight-sync few % copy new theta to engine; bigger if cross-node Why generation dominates despite FEWER FLOPs than the backward: Training (Phase 4): 6N FLOPs/token, compute-bound, ~40-55% MFU on FSDP/Megatron Decode (Phase 1): 2N FLOPs/token, memory-BW-bound, <5% MFU (each decode step reads all N params from HBM -- bandwidth, not FLOPs, is the bottleneck) -> Doubling generation throughput ~halves step time. Doubling backward speed barely moves it. Optimize the rollout, not the gradient. Lg hits you twice: more decode steps + slowest sequence gates the step (long-tail problem). Continuous batching, overlong filtering, and async (pipelined) RL all target this dominant cost.
Generation dominates RL step time (~60-80%), despite having fewer FLOPs than the backward pass. The disconnect is MFU: autoregressive decode is memory-bandwidth-bound and runs at under 5% MFU, while the backward pass reaches 40-55% MFU on a well-tuned FSDP setup. Doubling generation throughput nearly halves total step time; halving the backward barely registers.

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
the SAME response, the SAME policy weights theta nothing about the policy differs below -- only the arithmetic pipeline that scores it o_1 o_2 o_3 ... o_t ... o_T PANEL A -- the bug (trusting the sampler's logprobs) SAMPLER pipeline inference engine (vLLM / SGLang) attn kernel * fp8 / quantized path * its own reduction order emits log pi_behavior (DENOMINATOR) different numerics -> TRAINER pipeline trainer (FSDP, full bf16) different attention kernel * different dtype path emits log pi_theta (NUMERATOR) r = exp(new_lp - old_lp) ratio r 1+eps 1-eps 1.0 clip engages here (phantom drift) t=1 t=T token position within the response -> step 0, policy has NOT moved -> ratio SHOULD be exactly 1.0, but numerics make it drift. Clip engages on phantom off-policyness. Silent bug. PANEL B -- the fix (recompute old logprobs on the trainer) TRAINER pipeline (shared) trainer (FSDP, full bf16) -- one set of kernels, one dtype no_grad forward -> old_lp (DENOMINATOR) grad forward -> new_lp (NUMERATOR) sampler logprobs (demoted) -- diagnostics / off-policy term only r = exp(new_lp - old_lp) ratio r 1+eps 1-eps 1.0 flat at 1.0 -- clip fraction ~ 0 t=1 t=T token position within the response -> numerator and denominator share the SAME kernels + dtype -> r = exp(new_lp - old_lp) = 1.0 exactly, at step 0. It is not the POLICIES that differ -- it is the ARITHMETIC. Make the ratio's top and bottom come from the same pipeline.
The importance-ratio drift bug: numerator and denominator must share one numeric pipeline. If the ratio's denominator (old log-probs) comes from the inference engine's kernels while the numerator (new log-probs) comes from the trainer's, the ratio wanders away from 1.0 purely from floating-point and kernel differences &mdash; even at step 0, before the policy has moved at all &mdash; and the clip starts firing on phantom off-policyness. The fix is to recompute the old log-probs with a 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 0 · Synchronous, colocated, serial one GPU pool; phases strictly serial — each idles the other GPUs GENERATE train GENERATE train GENERATE train ... engine busy, trainer idle trainer busy, engine idle strictly on-policy, lots of idle GPUs Rung 2 · Pipelined / overlapped (one-step-stale) generation of batch k+1 overlaps training on batch k gen train GEN k GEN k+1 GEN k+2 GEN k+3 ... TR k TR k+1 TR k+2 ... overlap: train on k while gen produces k+1 weights k-1 staleness s = 1 (bounded, clip-absorbed) time
Synchronous-serial (Rung 0) vs pipelined one-step-stale (Rung 2): the time-cursor highlights the key difference. In the top panel, the GPU pool alternates between generation and training with each idle while the other runs. In the bottom panel, generation of batch k+1 runs concurrently with training on batch k, eliminating idle time at the cost of exactly one step of staleness — which the PPO/GRPO importance-ratio clip is designed to absorb.

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_epochs and 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

Recent advances (2023–2026)

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/trlGRPOTrainer and PPOTrainer; the accessible entry point for the loop described in this chapter.

Go deeper

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

\[ r_{i,t}(\theta) = \frac{\pi_\theta(o_{i,t}\mid\cdot)}{\pi_{\theta_{\text{behavior}}}(o_{i,t}\mid\cdot)} = 1 \quad\text{exactly, for every token.} \]

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:

\[ T_{\text{decode-step}} \approx \frac{2.6\times10^{10}\ \text{bytes}}{3.35\times10^{12}\ \text{bytes/s}} \approx 7.76\ \text{ms}. \]

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:

\[ C_{\text{train}} = 6N \cdot B \cdot L_g \cdot E = 6 \times 13\text{e}9 \times 512 \times 512 \times 2 \approx 4.09\times10^{16}\ \text{FLOPs}. \]

Effective throughput at 45% MFU: \(0.45 \times 990\text{e}12 = 4.455\times10^{14}\) FLOP/s. Time on one H100:

\[ T_{\text{train}} \approx \frac{4.09\times10^{16}}{4.455\times10^{14}} \approx 91.8\ \text{s}. \]

© 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\):

\[ \hat A = [\,1-0.5,\ 0-0.5,\ 1-0.5,\ 0-0.5\,] = [\,0.5,\ -0.5,\ 0.5,\ -0.5\,]. \]

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\):

\[ \hat A = [\,0.5/0.5001,\ -0.5/0.5001,\ \ldots\,] \approx [\,0.9998,\ -0.9998,\ 0.9998,\ -0.9998\,]. \]

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.